Merge branch 'dev' into bun

This commit is contained in:
thororen1234 2026-04-09 18:13:01 -04:00
commit f5e5b12876
No known key found for this signature in database
380 changed files with 27348 additions and 12852 deletions

View file

@ -43,7 +43,7 @@ jobs:
cp browser/browser.* release
cp Equicord.user.{js,js.LEGAL.txt} release
# copy the plugin data jsons, the extension zips and the desktop/vesktop asars
# copy the plugin data jsons, the extension zips and the desktop/equibop asars
cp *.{json,zip,asar} release
# legacy un-asared files
cp desktop/* release

89
.github/workflows/buildDev.yml vendored Normal file
View file

@ -0,0 +1,89 @@
name: Build
on:
push:
branches:
- "**"
workflow_dispatch:
inputs:
branch:
type: choice
description: "Branch to build and upload"
options:
- main
- dev
- translation
default: "dev"
env:
FORCE_COLOR: true
GITHUB_TOKEN: ${{ secrets.ETOKEN }}
REPO: Equicord/Equibuilds
USERNAME: GitHub-Actions
permissions:
contents: write
jobs:
Build:
name: Build Equicord
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.branch || github.ref }}
- uses: pnpm/action-setup@v3
- name: Use Node.js 20
uses: actions/setup-node@v4
with:
node-version: 20
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build web
run: pnpm buildWebStandalone
- name: Build
run: pnpm buildStandalone
- name: Generate plugin lists
run: |
pnpm generatePluginJson dist/plugins.json
pnpm generateEquicordPluginJson dist/equicordplugins.json
pnpm generateVencordPluginJson dist/vencordplugins.json
pnpm generateDevsList dist/devs.json
- name: Upload to Equibuilds repo
run: |
BRANCH="${{ github.event.inputs.branch || github.ref_name }}"
COMMIT_SHA=$(git rev-parse --short HEAD)
git config --global user.name "GitHub-Actions"
git config --global user.email actions@github.com
git clone https://$USERNAME:$GITHUB_TOKEN@github.com/$REPO.git equibuilds
cd equibuilds
if git ls-remote --exit-code --heads origin "$BRANCH" > /dev/null 2>&1; then
git checkout "$BRANCH"
else
git checkout --orphan "$BRANCH"
fi
cp -r ../dist/* .
find . -size 0 -delete
mkdir -p userscript
mv Equicord.user.* userscript/
mv desktop.asar desktop/
mv equibop.asar equibop/
mv extension-*.zip browser/
git add -A
git commit -m "Builds for https://github.com/$GITHUB_REPOSITORY/commit/$COMMIT_SHA"
git push --force https://$USERNAME:$GITHUB_TOKEN@github.com/$REPO.git "$BRANCH"

View file

@ -1,7 +1,8 @@
# NavIDs
If you have `ConsoleShortcuts` enabled you can run loadLazyChunks()
to get every navId after it loads everything with the section below
after it says Finished loading all chunks! in console
you can run the snippet below to list and copy all the navIds currently available
```js
const wp = webpackChunkdiscord_app.push([[Symbol()], {}, r => r]);
@ -16,7 +17,7 @@ console.log(navIds);
copy(navIds);
```
Updated: 3/1/2026
Updated: 4/7/2026
```md
ChannelNotificationCustomSettingsItems
@ -38,6 +39,7 @@ channel-summaries-context-menu
clips-context
clips-filters-context
clips-more-options
collectibles-game-shops-menu
collectibles-index-page-menu
collectibles-shop-tabs-overflow-menu
command-list-sort
@ -51,11 +53,13 @@ edit-profile-popout
emoji-studio-context-menu
exit-options
expression-picker
favorite-server-context
favorite-guild-header-add-context
favorites-channel-list-context
favorites-header-popout
forum-tag
friend-row
game-context
game-profile-add-to-profile
game-profile-context
game-shop-context
game_server-popout-context-menu
@ -116,6 +120,7 @@ overlay-video-widget-context-menu
overlay-voice-widget-context-menu
pip-menu
plaintext-preview-overflow-menu
play-on-distributor-menu
playground-settings-menu
poll-media-edit-menu
progress-bar-context
@ -134,6 +139,8 @@ set-image-for-action
set-status-submenu
set-status-submenu-mobile-web
settings-footer-more-menu
settings-menu
social-layer-storefront-card-context
sort-and-view
sound-button-context
staff-help-popout
@ -142,11 +149,10 @@ stream-context
stream-options
subscription-context
switch-accounts-submenu
test-skus
test-store-listing
text-context
textarea-context
thread-context
unapplied-boost-actions
unknown-user-context
user-bot-profile-add-app
user-bot-profile-overflow-menu
@ -155,7 +161,6 @@ user-profile-friend-request-buttons
user-profile-overflow-menu
user-profile-widget-context-menu
user-settings-change-avatar
user-settings-cog
video-background-context
video-device-context
webauthn-credential-actions

View file

@ -1,7 +1,7 @@
{
"name": "equicord",
"private": "true",
"version": "1.14.4.0",
"version": "1.14.6.2",
"description": "The other cutest Discord client mod",
"homepage": "https://github.com/Equicord/Equicord#readme",
"bugs": {
@ -18,9 +18,9 @@
"packages/vencord-types"
],
"scripts": {
"build": "bun run scripts/build/build.mjs",
"build": "bun run depCheck && bun run scripts/build/build.mjs",
"buildStandalone": "bun run build --standalone",
"buildWeb": "bun run scripts/build/buildWeb.mjs",
"buildWeb": "bun run depCheck && bun run scripts/build/buildWeb.mjs",
"buildWebStandalone": "bun run buildWeb --standalone",
"buildReporter": "bun run buildWebStandalone --reporter --skip-extension",
"buildReporterDesktop": "bun run build --reporter",
@ -41,13 +41,14 @@
"test": "bun run buildStandalone && bun run testTsc && bun run lint:fix && bun run lint-styles && bun run generatePluginJson",
"testWeb": "bun run lint:fix && bun run buildWeb && bun run testTsc",
"testTsc": "tsc --noEmit",
"postinstall": "bun run scripts/applyPatches.mjs"
"postinstall": "bun run scripts/applyPatches.mjs",
"depCheck": "bun run scripts/depCheck.mjs"
},
"dependencies": {
"@ffmpeg/ffmpeg": "^0.12.10",
"@ffmpeg/util": "^0.12.1",
"@intrnl/xxhash64": "^0.1.2",
"@song-spotlight/api": "^2.0.1",
"@song-spotlight/api": "^2.1.3",
"@streamparser/json": "^0.0.22",
"@types/less": "^3.0.8",
"@types/stylus": "^0.48.42",
@ -88,6 +89,7 @@
"highlight.js": "11.11.1",
"html-minifier-terser": "^7.2.0",
"moment": "^2.22.2",
"p-limit": "^7.3.0",
"puppeteer-core": "^24.30.0",
"standalone-electron-types": "^34.2.0",
"stylelint": "^16.25.0",

View file

@ -9,15 +9,19 @@ export const enum ActivityType {
}
export const enum ActivityFlags {
INSTANCE = 1,
JOIN = 2,
SYNC = 16,
PLAY = 32,
PARTY_PRIVACY_FRIENDS = 64,
PARTY_PRIVACY_VOICE_CHANNEL = 128,
EMBEDDED = 256,
CONTEXTLESS = 512,
SUPPORTS_GATEWAY_ACTIVITY_ACTION_JOIN = 1024,
INSTANCE = 1 << 0,
JOIN = 1 << 1,
/** @deprecated */
SPECTATE = 1 << 2,
/** @deprecated */
JOIN_REQUEST = 1 << 3,
SYNC = 1 << 4,
PLAY = 1 << 5,
PARTY_PRIVACY_FRIENDS = 1 << 6,
PARTY_PRIVACY_VOICE_CHANNEL = 1 << 7,
EMBEDDED = 1 << 8,
CONTEXTLESS = 1 << 9,
SUPPORTS_GATEWAY_ACTIVITY_ACTION_JOIN = 1 << 10,
}
export const enum ActivityStatusDisplayType {

View file

@ -19,25 +19,23 @@ export const enum CarouselItemType {
}
export const enum ApplicationFlags {
EMBEDDED_RELEASED = 2,
EMBEDDED_IAP = 8,
APPLICATION_AUTO_MODERATION_RULE_CREATE_BADGE = 64,
GAME_PROFILE_DISABLED = 128,
CONTEXTLESS_ACTIVITY = 512,
SOCIAL_LAYER_INTEGRATION_LIMITED = 1024,
CLOUD_GAMING_DEMO = 2048,
GATEWAY_PRESENCE = 4096,
GATEWAY_PRESENCE_LIMITED = 8192,
GATEWAY_GUILD_MEMBERS = 16384,
GATEWAY_GUILD_MEMBERS_LIMITED = 32768,
EMBEDDED = 131072,
GATEWAY_MESSAGE_CONTENT = 262144,
GATEWAY_MESSAGE_CONTENT_LIMITED = 524288,
EMBEDDED_FIRST_PARTY = 1048576,
APPLICATION_COMMAND_BADGE = 8388608,
SOCIAL_LAYER_INTEGRATION = 134217728,
PROMOTED = 536870912,
PARTNER = 1073741824,
PARENT = 8589934592,
DISABLE_RELATIONSHIPS_ACCESS = 17179869184,
EMBEDDED_RELEASED = 1 << 1,
EMBEDDED_IAP = 1 << 3,
APPLICATION_AUTO_MODERATION_RULE_CREATE_BADGE = 1 << 6,
GAME_PROFILE_DISABLED = 1 << 7,
CONTEXTLESS_ACTIVITY = 1 << 9,
SOCIAL_LAYER_INTEGRATION_LIMITED = 1 << 10,
CLOUD_GAMING_DEMO = 1 << 11,
GATEWAY_PRESENCE = 1 << 12,
GATEWAY_PRESENCE_LIMITED = 1 << 13,
GATEWAY_GUILD_MEMBERS = 1 << 14,
GATEWAY_GUILD_MEMBERS_LIMITED = 1 << 15,
EMBEDDED = 1 << 17,
GATEWAY_MESSAGE_CONTENT = 1 << 18,
GATEWAY_MESSAGE_CONTENT_LIMITED = 1 << 19,
EMBEDDED_FIRST_PARTY = 1 << 20,
APPLICATION_COMMAND_BADGE = 1 << 23,
SOCIAL_LAYER_INTEGRATION = 1 << 27,
PROMOTED = 1 << 29,
PARTNER = 1 << 30,
}

View file

@ -28,28 +28,30 @@ export const enum ReadStateType {
}
export const enum UserFlags {
STAFF = 1,
PARTNER = 2,
HYPESQUAD = 4,
BUG_HUNTER_LEVEL_1 = 8,
MFA_SMS = 16,
PREMIUM_PROMO_DISMISSED = 32,
HYPESQUAD_ONLINE_HOUSE_1 = 64,
HYPESQUAD_ONLINE_HOUSE_2 = 128,
HYPESQUAD_ONLINE_HOUSE_3 = 256,
PREMIUM_EARLY_SUPPORTER = 512,
HAS_UNREAD_URGENT_MESSAGES = 8192,
BUG_HUNTER_LEVEL_2 = 16384,
VERIFIED_BOT = 65536,
VERIFIED_DEVELOPER = 131072,
CERTIFIED_MODERATOR = 262144,
BOT_HTTP_INTERACTIONS = 524288,
SPAMMER = 1048576,
DISABLE_PREMIUM = 2097152,
PROVISIONAL_ACCOUNT = 8388608,
QUARANTINED = 17592186044416,
COLLABORATOR = 1125899906842624,
RESTRICTED_COLLABORATOR = 2251799813685248,
STAFF = 1 << 0,
PARTNER = 1 << 1,
HYPESQUAD = 1 << 2,
BUG_HUNTER_LEVEL_1 = 1 << 3,
MFA_SMS = 1 << 4,
PREMIUM_PROMO_DISMISSED = 1 << 5,
HYPESQUAD_ONLINE_HOUSE_1 = 1 << 6,
HYPESQUAD_ONLINE_HOUSE_2 = 1 << 7,
HYPESQUAD_ONLINE_HOUSE_3 = 1 << 8,
PREMIUM_EARLY_SUPPORTER = 1 << 9,
TEAM_PSEUDO_USER = 1 << 10,
IS_HUBSPOT_CONTACT = 1 << 11,
SYSTEM = 1 << 12,
HAS_UNREAD_URGENT_MESSAGES = 1 << 13,
BUG_HUNTER_LEVEL_2 = 1 << 14,
UNDERAGE_DELETED = 1 << 15,
VERIFIED_BOT = 1 << 16,
VERIFIED_DEVELOPER = 1 << 17,
CERTIFIED_MODERATOR = 1 << 18,
BOT_HTTP_INTERACTIONS = 1 << 19,
SPAMMER = 1 << 20,
DISABLE_PREMIUM = 1 << 21,
ACTIVE_DEVELOPER = 1 << 22,
PROVISIONAL_ACCOUNT = 1 << 23,
}
export const enum StandingState {

View file

@ -3,7 +3,7 @@
"author": "Equicord & Vencord Contributors",
"private": false,
"description": "Typescript definitions for the webpack modules of the Discord Web app",
"version": "1.0.3",
"version": "1.0.4",
"license": "LGPL-3.0-or-later",
"types": "src/index.d.ts",
"type": "module",

View file

@ -20,6 +20,11 @@ export class Guild extends DiscordRecord {
constructor(guild: object);
afkChannelId: string | undefined;
afkTimeout: number;
applicationCommandCounts: {
0: number;
1: number;
2: number;
};
application_id: string | null;
banner: string | undefined;
defaultMessageNotifications: GuildDefaultMessageNotifications;

View file

@ -13,11 +13,11 @@ export interface Role {
secondary_color: number | undefined;
tertiary_color: number | undefined;
};
description: string | null;
description: string | undefined;
flags: RoleFlags;
guildId: string;
hoist: boolean;
icon: string | null;
icon: string | undefined;
id: string;
managed: boolean;
mentionable: boolean;
@ -27,11 +27,11 @@ export interface Role {
tags: {
bot_id?: string;
integration_id?: string;
premium_subscriber?: null;
premium_subscriber?: unknown;
subscription_listing_id?: string;
available_for_purchase?: null;
guild_connections?: null;
available_for_purchase?: unknown;
guild_connections?: unknown;
} | undefined;
unicodeEmoji: string | null;
unicodeEmoji: string | undefined;
version: number | undefined;
}

View file

@ -291,6 +291,8 @@ export interface MessageAttachment {
content_type?: string;
width?: number;
height?: number;
title?: string;
description?: string;
}
export interface ReactionEmoji {

View file

@ -1,5 +1,42 @@
import { GuildMemberFlags } from "../enums";
import { VoiceState } from "./stores";
import { FluxStore } from "./stores/FluxStore";
export interface PassiveUpdateState {
type: string;
guildId: string;
members?: ({
user: {
avatar: string | null;
communication_disabled_until: string | null;
deaf: boolean;
flags: GuildMemberFlags;
joined_at: string;
mute: boolean;
nick: string;
pending: boolean;
premium_since: string | null;
};
roles: string[];
premium_since: string | null;
pending: boolean;
nick: string | null;
mute: boolean;
joined_at: string;
flags: GuildMemberFlags;
deaf: boolean;
communication_disabled_until: string | null;
avatar: string | null;
})[];
channels: ({
lastPinTimestamp?: string;
lastMessageId: string;
id: string;
})[];
voiceStates?: VoiceState[];
}
export class FluxEmitter {
constructor();

File diff suppressed because one or more lines are too long

View file

@ -5,6 +5,5 @@ export * from "./fluxEvents";
export * from "./menu";
export * from "./modules";
export * from "./stores";
export * from "./passiveupdatestate";
export * from "./utils";
export * as Webpack from "../webpack";

View file

@ -1,44 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { GuildMemberFlags } from "../enums";
import { VoiceState } from "./stores";
export type PassiveUpdateType = "PASSIVE_UPDATE_V2";
export interface PassiveUpdateState {
type: PassiveUpdateType;
guildId: string;
members?: ({
user: {
avatar: null | string;
communication_disabled_until: null | string;
deaf: boolean;
flags: GuildMemberFlags;
joined_at: string;
mute: boolean;
nick: string;
pending: boolean;
premium_since: null | string;
};
roles: (string)[];
premium_since: null | string;
pending: boolean;
nick: string | null;
mute: boolean;
joined_at: string;
flags: GuildMemberFlags;
deaf: boolean;
communication_disabled_until: null | string;
avatar: null | string;
})[];
channels: ({
lastPinTimestamp?: string;
lastMessageId: string;
id: string;
})[];
voiceStates?: VoiceState[];
}

View file

@ -0,0 +1,7 @@
import { FluxStore } from "..";
export interface ApplicationStreamPreviewStore extends FluxStore {
getIsPreviewLoading: (guildId: string | bigint | null, channelId: string | bigint, ownerId: string | bigint) => boolean;
getPreviewURL: (guildId: string | bigint | null, channelId: string | bigint, ownerId: string | bigint) => Promise<string | null>;
getPreviewURLForStreamKey: (streamKey: string) => ReturnType<ApplicationStreamPreviewStore["getPreviewURL"]>;
}

View file

@ -1,39 +1,4 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { FluxStore } from "@vencord/discord-types";
export interface ApplicationStreamPreviewStore extends FluxStore {
getIsPreviewLoading: (guildId: string | bigint | null, channelId: string | bigint, ownerId: string | bigint) => boolean;
getPreviewURL: (guildId: string | bigint | null, channelId: string | bigint, ownerId: string | bigint) => Promise<string | null>;
getPreviewURLForStreamKey: (streamKey: string) => ReturnType<ApplicationStreamPreviewStore["getPreviewURL"]>;
}
export interface ApplicationStream {
streamType: string;
guildId: string | null;
channelId: string;
ownerId: string;
}
export interface Stream extends ApplicationStream {
state: string;
}
import { ApplicationStream, FluxStore, Stream } from "..";
export interface RTCStream {
region: string,
@ -54,9 +19,6 @@ export interface StreamingStoreState {
streamsByUserAndGuild: { [key: string]: { [key: string]: ApplicationStream; }; };
}
/**
* example how a stream key could look like: `call(type of connection):1116549917987192913(channelId):305238513941667851(ownerId)`
*/
export interface ApplicationStreamingStore extends FluxStore {
getActiveStreamForApplicationStream: (stream: ApplicationStream) => Stream | null;
getActiveStreamForStreamKey: (streamKey: string) => Stream | null;

View file

@ -0,0 +1,101 @@
import { FluxStore } from "..";
interface ExperimentAssignmentOverride {
type: "user" | "guild";
revision: number;
population?: number;
bucket: number;
override: boolean;
}
interface UserExperimentAssignment {
aaMode: boolean;
assignmentSource: string;
bucket: number;
fingerprint: string | null;
hashResult: number;
holdoutBucket: number | null;
holdoutName: string | null;
holdoutRevision: number | null;
loadedFromCache: boolean;
override: boolean;
population: number;
revision: number;
sessionId: string;
triggerDebuggingEnabled: boolean;
type: string;
}
interface GuildExperimentAssignment {
aaMode: boolean;
assignmentSource: string;
fingerprint: string | null;
hashKey: string | null;
holdoutControlBucket: number | null;
holdoutName: string | null;
loadedFromCache: boolean;
overrides: Record<string, number>;
overridesFormatted: unknown[];
populations: unknown[];
revision: number;
sessionId: string;
triggerDebuggingEnabled: boolean;
}
interface ExperimentDescriptor {
buckets: number[];
commonTriggerPoint?: string;
description: string[];
title: string;
type: "user" | "guild";
}
interface ExperimentExposureEntry {
time: number;
hash: number;
}
type GuildExposureKey = `guild|${string}|${string}`;
type UserExposureKey = `user|${string}`;
interface TrackedExposures {
[key: UserExposureKey]: ExperimentExposureEntry;
[key: GuildExposureKey]: ExperimentExposureEntry;
}
interface ExperimentState {
assignmentFingerprint: string | null;
assignmentSessionId: string;
assignmentSource: string;
cookieOverrides: unknown;
guildExperimentOverrides: ExperimentAssignmentOverride;
hasLoadedExperiments: boolean;
loadedGuildExperiments: GuildExperimentAssignment;
loadedUserExperiments: UserExperimentAssignment;
trackedExposureExperiments: TrackedExposures;
userExperimentOverrides: ExperimentAssignmentOverride;
}
export class ExperimentStore extends FluxStore {
hasLoadedExperiments: boolean;
persistKey: string;
getAllExperimentAssignments(): Record<string, number>;
getAllExperimentOverrideDescriptors(): ExperimentAssignmentOverride;
getAllUserExperimentDescriptors(): Record<number, UserExperimentAssignment>;
getExperimentOverrideDescriptor(experimentId: string): ExperimentAssignmentOverride;
getGuildExperimentBucket(experimentId: string, guildId: string): number;
getGuildExperimentDescriptor(experimentId: string, guildId: string): ExperimentAssignmentOverride;
getGuildExperiments(): Record<number, GuildExperimentAssignment>;
getLoadedGuildExperiment(experimentId: string): GuildExperimentAssignment;
getLoadedUserExperiment(experimentId: string): UserExperimentAssignment;
getRegisteredExperiments(): ExperimentDescriptor;
getSerializedState(): ExperimentState;
getUserExperimentBucket(experimentId: string): number;
getUserExperimentDescriptor(experimentId: string): ExperimentAssignmentOverride;
hasRegisteredExperiment(experimentId: string): boolean;
// has two args
getRecentExposures(): unknown;
// has four args
hasExperimentTrackedExposure(): unknown;
}

View file

@ -1,26 +1,22 @@
import { Channel, FluxStore, Message } from "..";
import { Channel, Message } from "../common";
import { FluxStore } from "./FluxStore";
/** Full pending reply state object */
interface PendingReply {
/** The channel in which the pending reply was created */
export interface PendingReply {
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;
/** Discord doesn't use this method. Also seems to always return undefined */
getPendingReplyActionSource(channelId: string): unknown;
}

View file

@ -6,7 +6,7 @@ export interface UserAndActivity {
activity: Activity;
}
export type DiscordPlatform = "desktop" | "mobile" | "web" | "embedded";
export type DiscordPlatform = "desktop" | "mobile" | "web" | "embedded" | "vr";
export interface PresenceStoreState {
presencesForGuilds: Record<string, Record<string, { status: OnlineStatus; activities: Activity[]; clientStatus: Partial<Record<DiscordPlatform, OnlineStatus>>; }>>;

View file

@ -60,7 +60,7 @@ export interface SpotifyTrack {
export interface SpotifyPlayerState {
track: SpotifyTrack;
startTime: number;
context: { uri: string } | null;
context: { uri: string; } | null;
}
export interface SpotifyActivity {

View file

@ -0,0 +1,33 @@
import { FluxStore } from "..";
interface UserAffinity {
otherUserId: string;
userSegment: string;
otherUserSegment: string;
isFriend: boolean;
dmProbability: number;
dmRank: number;
vcProbability: number;
vcRank: number;
serverMessageProbability: number;
serverMessageRank: number;
communicationProbability: number;
communicationRank: number;
}
interface UserAffinitiesResponse {
lastFetched: number;
userAffinities: UserAffinity[];
}
export class UserAffinitiesStore extends FluxStore {
compare(firstUserId: string, secondUserId: string): number;
compareByDmProbability(firstUserId: string, secondUserId: string): number;
getState(): UserAffinitiesResponse;
getUserAffinities(): UserAffinity[];
getUserAffinitiesMap(): Map<string, UserAffinity>;
getUserAffinity(userId: string): UserAffinity;
isFetching(): boolean;
isHighlyAffinedVCUser(userId: string): boolean;
shouldFetch(): boolean;
}

View file

@ -3,6 +3,8 @@ export * from "./AccessibilityStore";
export * from "./ActiveJoinedThreadsStore";
export * from "./ApplicationCommandIndexStore";
export * from "./ApplicationStore";
export * from "./ApplicationStreamingStore";
export * from "./ApplicationStreamPreviewStore";
export * from "./AuthenticationStore";
export * from "./CallStore";
export * from "./ChannelRTCStore";
@ -10,6 +12,7 @@ export * from "./ChannelStore";
export * from "./DraftStore";
export * from "./EditMessageStore";
export * from "./EmojiStore";
export * from "./ExperimentStore";
export * from "./FluxStore";
export * from "./FriendsStore";
export * from "./GuildChannelStore";
@ -44,6 +47,7 @@ export * from "./StreamerModeStore";
export * from "./ThemeStore";
export * from "./TypingStore";
export * from "./UploadAttachmentStore";
export * from "./UserAffinitiesStore";
export * from "./UserGuildSettingsStore";
export * from "./UserProfileStore";
export * from "./UserSettingsProtoStore";

View file

@ -31,6 +31,7 @@ export type Parser = Record<
| "parseGuildEventDescription"
| "parseAutoModerationSystemMessage"
| "parseForumPostGuidelines"
| "parseForumPostMostRecentMessage"
| "parseVoiceChannelStatus",
(content: string, inline?: boolean, state?: Record<string, any>) => ReactNode[]
> & Record<
@ -202,7 +203,7 @@ export interface IconUtils {
getUserAvatarURL(user: User, canAnimate?: boolean, size?: number, format?: string): string;
getDefaultAvatarURL(id: string, discriminator?: string): string;
getUserBannerURL(data: { id: string, banner: string, canAnimate?: boolean, size: number; }): string | undefined;
getAvatarDecorationURL(dara: { avatarDecoration: string, size: number; canCanimate?: boolean; }): string | undefined;
getAvatarDecorationURL(data: { avatarDecoration: string, size: number; canCanimate?: boolean; }): string | undefined;
getGuildMemberAvatarURL(member: GuildMember, canAnimate?: string): string | null;
getGuildMemberAvatarURLSimple(data: { guildId: string, userId: string, avatar: string, canAnimate?: boolean; size?: number; }): string;

View file

@ -1,7 +1,7 @@
{
"name": "@vencord/types",
"private": false,
"version": "1.14.1",
"version": "1.14.2",
"description": "",
"types": "index.d.ts",
"scripts": {
@ -26,7 +26,7 @@
"type-fest": "^4.35.0"
},
"peerDependencies": {
"@types/react": "18.3.1",
"@types/react-dom": "18.3.1"
"@types/react": "^19.0.10",
"@types/react-dom": "19.0.4"
}
}

View file

@ -75,11 +75,12 @@ export async function buildOrWatchAll(buildConfigs) {
context(cfg).then(ctx => ctx.watch())
));
} else {
await Promise.all(buildConfigs.map(cfg => build(cfg)))
.catch(error => {
for (const cfg of buildConfigs) {
await build(cfg).catch(error => {
console.error(error.message);
process.exit(1); // exit immediately to skip the rest of the builds
process.exit(1);
});
}
}
}

33
scripts/depCheck.mjs Normal file
View file

@ -0,0 +1,33 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { execSync } from "child_process";
import { existsSync, readFileSync } from "fs";
import { createInterface } from "readline/promises";
import { dirname, join } from "path";
import { fileURLToPath } from "url";
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const PackageJSON = JSON.parse(readFileSync(join(root, "package.json"), "utf-8"));
const allDeps = { ...PackageJSON.dependencies, ...PackageJSON.devDependencies };
const requiredDeps = Object.keys(allDeps);
const missing = requiredDeps.filter(d => {
const v = allDeps[d];
return !v.startsWith("link:") && !v.startsWith("workspace:") && !existsSync(join(root, "node_modules", d, "package.json"));
});
if (missing.length) {
console.error(`\x1b[31mMissing ${missing.length} package(s): ${missing.join(", ")}\x1b[0m`);
const rl = createInterface({ input: process.stdin, output: process.stderr });
rl.on("SIGINT", () => { rl.close(); process.exit(1); });
try {
const ans = await rl.question("\x1b[33mRun 'pnpm install'? [Y/n]: \x1b[0m");
if (!ans || /^y/i.test(ans)) execSync("pnpm install --frozen-lockfile", { cwd: root, stdio: "inherit" });
} finally { rl.close(); }
} else {
console.log("\x1b[32mAll dependencies are installed.\x1b[0m");
}

View file

@ -18,31 +18,97 @@
* limitations under the License.
*/
import { Logger } from "@utils/Logger";
const logger = new Logger("DataStore");
let loggedFailure = false;
const memoryDatabases = new Map<string, Map<string, Map<IDBValidKey, unknown>>>();
interface MemoryStore {
__memoryStore: true;
get<T>(key: IDBValidKey): T | undefined;
put(value: unknown, key: IDBValidKey): void;
delete(key: IDBValidKey): void;
clear(): void;
getAllKeys<KeyType extends IDBValidKey>(): KeyType[];
getAll<T>(): T[];
entries<KeyType extends IDBValidKey, ValueType>(): [KeyType, ValueType][];
}
type StoreLike = IDBObjectStore | MemoryStore;
function handleFailure(error: unknown) {
if (loggedFailure) return;
loggedFailure = true;
logger.error("IndexedDB unavailable, using empty storage.", error);
}
function withFailureFallback<T>(promise: Promise<T>, fallback: T): Promise<T> {
return promise.catch(error => {
handleFailure(error);
return fallback;
});
}
function getMemoryStore(dbName: string, storeName: string): MemoryStore {
let database = memoryDatabases.get(dbName);
if (!database) {
database = new Map();
memoryDatabases.set(dbName, database);
}
let data = database.get(storeName);
if (!data) {
data = new Map<IDBValidKey, unknown>();
database.set(storeName, data);
}
return {
__memoryStore: true,
get: <T>(key: IDBValidKey) => data.get(key) as T | undefined,
put: (value, key) => { data.set(key, value); },
delete: key => { data.delete(key); },
clear: () => { data.clear(); },
getAllKeys: <KeyType extends IDBValidKey>() => Array.from(data.keys()) as KeyType[],
getAll: <T>() => Array.from(data.values()) as T[],
entries: <KeyType extends IDBValidKey, ValueType>() => Array.from(data.entries()) as [KeyType, ValueType][]
};
}
function isMemoryStore(store: StoreLike): store is MemoryStore {
return "__memoryStore" in store;
}
export function promisifyRequest<T = undefined>(
request: IDBRequest<T> | IDBTransaction,
): Promise<T> {
return new Promise<T>((resolve, reject) => {
// @ts-expect-error - file size hacks
request.oncomplete = request.onsuccess = () => resolve(request.result);
// @ts-expect-error - file size hacks
request.onabort = request.onerror = () => reject(request.error);
const target = request as IDBRequest<T> & IDBTransaction;
target.oncomplete = target.onsuccess = () => resolve(target.result);
target.onabort = target.onerror = () => reject(target.error);
});
}
export function createStore(dbName: string, storeName: string): UseStore {
const request = indexedDB.open(dbName);
request.onupgradeneeded = () => request.result.createObjectStore(storeName);
const dbp = promisifyRequest(request);
const dbp = promisifyRequest(request).catch(error => {
handleFailure(error);
return null;
});
const memoryStore = getMemoryStore(dbName, storeName);
return (txMode, callback) =>
dbp.then(db =>
callback(db.transaction(storeName, txMode).objectStore(storeName)),
);
return async (txMode, callback) => {
const db = await dbp;
if (!db) return callback(memoryStore);
return callback(db.transaction(storeName, txMode).objectStore(storeName));
};
}
export type UseStore = <T>(
txMode: IDBTransactionMode,
callback: (store: IDBObjectStore) => T | PromiseLike<T>,
callback: (store: StoreLike) => T | PromiseLike<T>,
) => Promise<T>;
let defaultGetStoreFunc: UseStore | undefined;
@ -54,88 +120,68 @@ function defaultGetStore() {
return defaultGetStoreFunc;
}
/**
* Get a value by its key.
*
* @param key
* @param customStore Method to get a custom store. Use with caution (see the docs).
*/
export function get<T = any>(
key: IDBValidKey,
customStore = defaultGetStore(),
): Promise<T | undefined> {
return customStore("readonly", store => promisifyRequest(store.get(key)));
return withFailureFallback(customStore("readonly", store => {
if (isMemoryStore(store)) return store.get(key) as T | undefined;
return promisifyRequest(store.get(key));
}), undefined);
}
/**
* Set a value with a key.
*
* @param key
* @param value
* @param customStore Method to get a custom store. Use with caution (see the docs).
*/
export function set(
key: IDBValidKey,
value: any,
customStore = defaultGetStore(),
): Promise<void> {
return customStore("readwrite", store => {
return withFailureFallback(customStore("readwrite", store => {
store.put(value, key);
if (isMemoryStore(store)) return;
return promisifyRequest(store.transaction);
});
}), undefined);
}
/**
* Set multiple values at once. This is faster than calling set() multiple times.
* It's also atomic – if one of the pairs can't be added, none will be added.
*
* @param entries Array of entries, where each entry is an array of `[key, value]`.
* @param customStore Method to get a custom store. Use with caution (see the docs).
*/
export function setMany(
entries: [IDBValidKey, any][],
customStore = defaultGetStore(),
): Promise<void> {
return customStore("readwrite", store => {
return withFailureFallback(customStore("readwrite", store => {
entries.forEach(entry => store.put(entry[1], entry[0]));
if (isMemoryStore(store)) return;
return promisifyRequest(store.transaction);
});
}), undefined);
}
/**
* Get multiple values by their keys
*
* @param keys
* @param customStore Method to get a custom store. Use with caution (see the docs).
*/
export function getMany<T = any>(
keys: IDBValidKey[],
customStore = defaultGetStore(),
): Promise<T[]> {
return customStore("readonly", store =>
Promise.all(keys.map(key => promisifyRequest(store.get(key)))),
);
return withFailureFallback(customStore("readonly", store => {
if (isMemoryStore(store)) {
return keys.map(key => store.get(key) as T | undefined);
}
return Promise.all(keys.map(key => promisifyRequest(store.get(key))));
}), []);
}
/**
* Update a value. This lets you see the old value and update it as an atomic operation.
*
* @param key
* @param updater A callback that takes the old value and returns a new value.
* @param customStore Method to get a custom store. Use with caution (see the docs).
*/
export function update<T = any>(
key: IDBValidKey,
updater: (oldValue: T | undefined) => T,
customStore = defaultGetStore(),
): Promise<void> {
return customStore(
return withFailureFallback(customStore(
"readwrite",
store =>
// Need to create the promise manually.
// If I try to chain promises, the transaction closes in browsers
// that use a promise polyfill (IE10/11).
new Promise((resolve, reject) => {
store => {
if (isMemoryStore(store)) {
store.put(updater(store.get(key) as T | undefined), key);
return;
}
return new Promise((resolve, reject) => {
store.get(key).onsuccess = function () {
try {
store.put(updater(this.result), key);
@ -144,52 +190,42 @@ export function update<T = any>(
reject(err);
}
};
}),
);
});
},
), undefined);
}
/**
* Delete a particular key from the store.
*
* @param key
* @param customStore Method to get a custom store. Use with caution (see the docs).
*/
export function del(
key: IDBValidKey,
customStore = defaultGetStore(),
): Promise<void> {
return customStore("readwrite", store => {
return withFailureFallback(customStore("readwrite", store => {
store.delete(key);
if (isMemoryStore(store)) return;
return promisifyRequest(store.transaction);
});
}), undefined);
}
/**
* Delete multiple keys at once.
*
* @param keys List of keys to delete.
* @param customStore Method to get a custom store. Use with caution (see the docs).
*/
export function delMany(
keys: IDBValidKey[],
customStore = defaultGetStore(),
): Promise<void> {
return customStore("readwrite", (store: IDBObjectStore) => {
keys.forEach((key: IDBValidKey) => store.delete(key));
return withFailureFallback(customStore("readwrite", store => {
keys.forEach(key => store.delete(key));
if (isMemoryStore(store)) return;
return promisifyRequest(store.transaction);
});
}), undefined);
}
/**
* Clear all values in the store.
*
* @param customStore Method to get a custom store. Use with caution (see the docs).
*/
export function clear(customStore = defaultGetStore()): Promise<void> {
return customStore("readwrite", store => {
return withFailureFallback(customStore("readwrite", store => {
store.clear();
if (isMemoryStore(store)) return;
return promisifyRequest(store.transaction);
});
}), undefined);
}
function eachCursor(
@ -204,16 +240,12 @@ function eachCursor(
return promisifyRequest(store.transaction);
}
/**
* Get all keys in the store.
*
* @param customStore Method to get a custom store. Use with caution (see the docs).
*/
export function keys<KeyType extends IDBValidKey>(
customStore = defaultGetStore(),
): Promise<KeyType[]> {
return customStore("readonly", store => {
// Fast path for modern browsers
return withFailureFallback(customStore("readonly", store => {
if (isMemoryStore(store)) return store.getAllKeys<KeyType>();
if (store.getAllKeys) {
return promisifyRequest(
store.getAllKeys() as unknown as IDBRequest<KeyType[]>,
@ -221,59 +253,45 @@ export function keys<KeyType extends IDBValidKey>(
}
const items: KeyType[] = [];
return eachCursor(store, cursor =>
items.push(cursor.key as KeyType),
).then(() => items);
});
}), [] as KeyType[]);
}
/**
* Get all values in the store.
*
* @param customStore Method to get a custom store. Use with caution (see the docs).
*/
export function values<T = any>(customStore = defaultGetStore()): Promise<T[]> {
return customStore("readonly", store => {
// Fast path for modern browsers
return withFailureFallback(customStore("readonly", store => {
if (isMemoryStore(store)) return store.getAll<T>();
if (store.getAll) {
return promisifyRequest(store.getAll() as IDBRequest<T[]>);
}
const items: T[] = [];
return eachCursor(store, cursor => items.push(cursor.value as T)).then(
() => items,
);
});
}), [] as T[]);
}
/**
* Get all entries in the store. Each entry is an array of `[key, value]`.
*
* @param customStore Method to get a custom store. Use with caution (see the docs).
*/
export function entries<KeyType extends IDBValidKey, ValueType = any>(
customStore = defaultGetStore(),
): Promise<[KeyType, ValueType][]> {
return customStore("readonly", store => {
// Fast path for modern browsers
// (although, hopefully we'll get a simpler path some day)
return withFailureFallback(customStore("readonly", store => {
if (isMemoryStore(store)) return store.entries<KeyType, ValueType>();
if (store.getAll && store.getAllKeys) {
return Promise.all([
promisifyRequest(
store.getAllKeys() as unknown as IDBRequest<KeyType[]>,
),
promisifyRequest(store.getAll() as IDBRequest<ValueType[]>),
]).then(([keys, values]) => keys.map((key, i) => [key, values[i]]));
]).then(([keys, values]) => keys.map((key, i) => [key, values[i]] as [KeyType, ValueType]));
}
const items: [KeyType, ValueType][] = [];
return customStore("readonly", store =>
eachCursor(store, cursor =>
items.push([cursor.key as KeyType, cursor.value]),
).then(() => items),
);
});
return eachCursor(store, cursor =>
items.push([cursor.key as KeyType, cursor.value]),
).then(() => items);
}), [] as [KeyType, ValueType][]);
}

View file

@ -13,7 +13,7 @@ import type { ComponentType, JSX, MouseEventHandler, ReactNode } from "react";
const logger = new Logger("HeaderBarAPI");
const HeaderBarClasses = findCssClassesLazy("clickable", "withHighlight");
const HeaderBarClasses = findCssClassesLazy("clickable", "selected", "badge", "badgeContainer");
const HeaderBarIcon = findComponentByCodeLazy(".HEADER_BAR_BADGE_TOP:", '"aria-haspopup":') as ComponentType<ChannelToolbarButtonProps>;
export interface HeaderBarButtonProps {
@ -101,7 +101,7 @@ export function HeaderBarButton(props: HeaderBarButtonProps & { ref?: React.RefO
{({ onMouseEnter, onMouseLeave }) => (
<Clickable
{...{ innerRef: ref } as any}
className={classes(HeaderBarClasses.clickable, HeaderBarClasses.withHighlight, className)}
className={classes(HeaderBarClasses.clickable, className)}
style={{ width: iconSize, boxSizing: "content-box", justifyContent: "center" }}
onClick={onClick}
onContextMenu={onContextMenu}

View file

@ -42,6 +42,7 @@ export { Plugins as plugins };
import { traceFunction } from "../debug/Tracer";
import { addAudioProcessor, removeAudioProcessor } from "./AudioPlayer";
import { addChannelToolbarButton, addHeaderBarButton, removeChannelToolbarButton, removeHeaderBarButton } from "./HeaderBar";
import { addProfileCollection, removeProfileCollection } from "./ProfileCollections";
import { addUserAreaButton, removeUserAreaButton } from "./UserArea";
const logger = new Logger("PluginManager", "#a6d189");
@ -194,9 +195,9 @@ export const startPlugin = traceFunction("startPlugin", function startPlugin(p:
const {
name, commands, contextMenus, managedStyle, userProfileBadges,
onBeforeMessageEdit, onBeforeMessageSend, onMessageClick,
renderChatBarButton, chatBarButton, renderMemberListDecorator, renderMessageAccessory, renderMessageDecoration, renderMessagePopoverButton, messagePopoverButton,
chatBarButton, renderMemberListDecorator, renderMessageAccessory, renderMessageDecoration, messagePopoverButton,
// Custom
renderNicknameIcon, headerBarButton, audioProcessor, userAreaButton
renderNicknameIcon, headerBarButton, audioProcessor, userAreaButton, renderProfileCollection
} = p;
if (p.start) {
@ -247,14 +248,10 @@ export const startPlugin = traceFunction("startPlugin", function startPlugin(p:
if (onMessageClick) addMessageClickListener(onMessageClick);
if (chatBarButton) addChatBarButton(name, chatBarButton.render, chatBarButton.icon);
// @ts-expect-error: legacy code doesn't have icon
else if (renderChatBarButton) addChatBarButton(name, renderChatBarButton);
if (renderMemberListDecorator) addMemberListDecorator(name, renderMemberListDecorator);
if (renderMessageDecoration) addMessageDecoration(name, renderMessageDecoration);
if (renderMessageAccessory) addMessageAccessory(name, renderMessageAccessory);
if (messagePopoverButton) addMessagePopoverButton(name, messagePopoverButton.render, messagePopoverButton.icon);
// @ts-expect-error: legacy code doesn't have icon
else if (renderMessagePopoverButton) addMessagePopoverButton(name, renderMessagePopoverButton);
// Custom
if (renderNicknameIcon) addNicknameIcon(name, renderNicknameIcon);
@ -267,6 +264,7 @@ export const startPlugin = traceFunction("startPlugin", function startPlugin(p:
}
if (audioProcessor) addAudioProcessor(name, audioProcessor);
if (userAreaButton) addUserAreaButton(name, userAreaButton.render, userAreaButton.priority);
if (renderProfileCollection) addProfileCollection(name, renderProfileCollection);
return true;
}, p => `startPlugin ${p.name}`);
@ -275,9 +273,9 @@ export const stopPlugin = traceFunction("stopPlugin", function stopPlugin(p: Plu
const {
name, commands, contextMenus, managedStyle, userProfileBadges,
onBeforeMessageEdit, onBeforeMessageSend, onMessageClick,
renderChatBarButton, chatBarButton, renderMemberListDecorator, renderMessageAccessory, renderMessageDecoration, renderMessagePopoverButton, messagePopoverButton,
chatBarButton, renderMemberListDecorator, renderMessageAccessory, renderMessageDecoration, messagePopoverButton,
// Custom
renderNicknameIcon, headerBarButton, audioProcessor, userAreaButton
renderNicknameIcon, headerBarButton, audioProcessor, userAreaButton, renderProfileCollection
} = p;
if (p.stop) {
@ -325,11 +323,11 @@ export const stopPlugin = traceFunction("stopPlugin", function stopPlugin(p: Plu
if (onBeforeMessageSend) removeMessagePreSendListener(onBeforeMessageSend);
if (onMessageClick) removeMessageClickListener(onMessageClick);
if (chatBarButton || renderChatBarButton) removeChatBarButton(name);
if (chatBarButton) removeChatBarButton(name);
if (renderMemberListDecorator) removeMemberListDecorator(name);
if (renderMessageDecoration) removeMessageDecoration(name);
if (renderMessageAccessory) removeMessageAccessory(name);
if (messagePopoverButton || renderMessagePopoverButton) removeMessagePopoverButton(name);
if (messagePopoverButton) removeMessagePopoverButton(name);
// Custom
if (renderNicknameIcon) removeNicknameIcon(name);
@ -342,6 +340,7 @@ export const stopPlugin = traceFunction("stopPlugin", function stopPlugin(p: Plu
}
if (audioProcessor) removeAudioProcessor(name);
if (userAreaButton) removeUserAreaButton(name);
if (renderProfileCollection) removeProfileCollection(name);
return true;
}, p => `stopPlugin ${p.name}`);
@ -352,9 +351,9 @@ export const initPluginManager = onlyOnce(function init() {
const pluginKeysToBind: Array<keyof PluginDef & `${"on" | "render"}${string}`> = [
"onBeforeMessageEdit", "onBeforeMessageSend", "onMessageClick",
"renderChatBarButton", "renderMemberListDecorator", "renderMessageAccessory", "renderMessageDecoration", "renderMessagePopoverButton",
"renderMemberListDecorator", "renderMessageAccessory", "renderMessageDecoration",
// Custom
"renderNicknameIcon"
"renderNicknameIcon", "renderProfileCollection"
];
const neededApiPlugins = new Set<string>();
@ -384,11 +383,11 @@ export const initPluginManager = onlyOnce(function init() {
if (p.commands?.length) neededApiPlugins.add("CommandsAPI");
if (p.onBeforeMessageEdit || p.onBeforeMessageSend || p.onMessageClick) neededApiPlugins.add("MessageEventsAPI");
if (p.chatBarButton || p.renderChatBarButton) neededApiPlugins.add("ChatInputButtonAPI");
if (p.chatBarButton) neededApiPlugins.add("ChatInputButtonAPI");
if (p.renderMemberListDecorator) neededApiPlugins.add("MemberListDecoratorsAPI");
if (p.renderMessageAccessory) neededApiPlugins.add("MessageAccessoriesAPI");
if (p.renderMessageDecoration) neededApiPlugins.add("MessageDecorationsAPI");
if (p.messagePopoverButton || p.renderMessagePopoverButton) neededApiPlugins.add("MessagePopoverAPI");
if (p.messagePopoverButton) neededApiPlugins.add("MessagePopoverAPI");
if (p.userProfileBadge) neededApiPlugins.add("BadgeAPI");
// Custom
@ -396,6 +395,7 @@ export const initPluginManager = onlyOnce(function init() {
if (p.headerBarButton) neededApiPlugins.add("HeaderBarAPI");
if (p.audioProcessor) neededApiPlugins.add("AudioPlayerAPI");
if (p.userAreaButton) neededApiPlugins.add("UserAreaAPI");
if (p.renderProfileCollection) neededApiPlugins.add("ProfileCollectionsAPI");
for (const key of pluginKeysToBind) {
p[key] &&= p[key].bind(p) as any;

View file

@ -0,0 +1,70 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import ErrorBoundary from "@components/ErrorBoundary";
import { Logger } from "@utils/Logger";
import { useEffect, useState } from "@webpack/common";
import type { ReactNode } from "react";
const logger = new Logger("ProfileCollectionAPI");
export type ProfileCollectionFactory = (props: any) => ReactNode | null;
interface CollectionEntry {
render: ProfileCollectionFactory;
}
const profileCollections = new Map<string, CollectionEntry>();
const profileCollectionListeners = new Set<() => void>();
/**
* Adds a collection to the user profile panel.
*
* @param id - Unique identifier for the collection (e.g., "my-plugin-profile-collection")
* @param render - Function that returns the collection JSX. Receives the full profile props object.
* @param priority - Higher values appear first. Default: 0
*
* @example
* addProfileCollection("my-collection", (props) => (
* <div>Custom content for {props.user.id}</div>
* ));
*/
export function addProfileCollection(id: string, render: ProfileCollectionFactory, priority = 0) {
profileCollections.set(id, { render });
profileCollectionListeners.forEach(listener => listener());
}
/**
* Removes a collection from the user profile panel.
*
* @param id - The identifier used when adding the collection
*/
export function removeProfileCollection(id: string) {
profileCollections.delete(id);
profileCollectionListeners.forEach(listener => listener());
}
function ProfileCollections({ props }: { props: any; }) {
const [, forceUpdate] = useState(0);
useEffect(() => {
const listener = () => forceUpdate(n => n + 1);
profileCollectionListeners.add(listener);
return () => { profileCollectionListeners.delete(listener); };
}, []);
return Array.from(profileCollections)
.map(([id, { render: Collection }]) => (
<ErrorBoundary noop key={id} onError={e => logger.error(`Failed to render profile collection: ${id}`, e.error)}>
<Collection {...props} user={props.user ?? props.currentUser} />
</ErrorBoundary>
));
}
/** @internal Injected by ProfileCollectionAPI patch (do NOT call directly) */
export function renderProfileCollections(props: any) {
return <ProfileCollections props={props} />;
}

View file

@ -26,6 +26,8 @@ import plugins from "~plugins";
const logger = new Logger("Settings");
export type ThemeActivationMode = "always" | "light" | "dark";
export interface SettingsPluginUiElement {
enabled: boolean;
// TODO
@ -47,6 +49,7 @@ export interface Settings {
enableOnlineThemes: boolean;
pinnedThemes: string[];
themeNames: Record<string, string>;
themeActivationModes: Partial<Record<string, ThemeActivationMode>>;
enableReactDevtools: boolean;
themeLinks: string[];
mainWindowFrameless: boolean;
@ -116,6 +119,7 @@ const DefaultSettings: Settings = {
enableOnlineThemes: true,
pinnedThemes: [],
themeNames: {},
themeActivationModes: {},
enableReactDevtools: false,
mainWindowFrameless: false,
frameless: false,
@ -297,9 +301,21 @@ export function migratePluginToSettings(deleteOldSettings: boolean, newName: str
}
}
export function migrateSettingToPlugin(newName: string, oldName: string, settingName: string) {
const { plugins } = SettingsStore.plain;
const newPlugin = plugins[newName];
const oldPlugin = plugins[oldName];
if (newPlugin && oldPlugin?.enabled && oldPlugin?.[settingName]) {
logger.info(`Migrating setting ${settingName} from ${oldName} to seperate plugin ${newName}`);
delete oldPlugin[settingName];
newPlugin.enabled = true;
SettingsStore.markAsChanged();
}
}
export function migrateSettingsFromPlugin(newPlugin: string, oldPlugin: string, ...settings: string[]) {
const { plugins } = SettingsStore.plain;
const oldSettings = plugins[oldPlugin];
const newSettings = plugins[newPlugin];
if (!oldSettings || !newSettings) return;
@ -317,6 +333,21 @@ export function migrateSettingsFromPlugin(newPlugin: string, oldPlugin: string,
SettingsStore.markAsChanged();
}
export function migrateOldSettingToNewPlugin(newPlugin: string, newSetting: string, oldPlugin: string, oldSetting: string,) {
const { plugins } = SettingsStore.plain;
const oldSettings = plugins[oldPlugin];
const newSettings = plugins[newPlugin];
if (!oldSettings || !newSettings) return;
if (!Object.hasOwn(oldSettings, oldSetting) || Object.hasOwn(newSettings, newSetting)) return;
logger.info(`Migrating plugin setting "${oldSetting}" from ${oldPlugin} to "${newSetting}" on ${newPlugin}`);
newSettings[newSetting] = oldSettings[oldSetting];
delete oldSettings[oldSetting];
SettingsStore.markAsChanged();
}
export function definePluginSettings<
Def extends SettingsDefinition,
Checks extends SettingsChecks<Def>,

View file

@ -96,19 +96,22 @@ export async function authorizeCloud() {
const res = await fetch(location, {
headers: { Accept: "application/json" }
});
const { secret } = await res.json();
if (secret) {
logger.info("Authorized with secret");
await setAuthorization(secret);
const data = await res.json();
if (data.secret) {
logger.info("Authorized with cloud");
await setAuthorization(data.secret);
showNotification({
title: "Cloud Integration",
body: "Cloud integrations enabled!"
});
Settings.cloud.authenticated = true;
} else {
logger.error("OAuth callback returned no secret", data);
showNotification({
title: "Cloud Integration",
body: "Setup failed (no secret returned?)."
body: data.error
? `Setup failed: ${data.error}`
: "Setup failed (no secret returned)."
});
Settings.cloud.authenticated = false;
}

View file

@ -4,6 +4,7 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import * as DataStore from "@api/DataStore";
import { showNotification } from "@api/Notifications";
import { PlainSettings, Settings } from "@api/Settings";
import { localStorage } from "@utils/localStorage";
@ -14,196 +15,486 @@ import { deflateSync, inflateSync } from "fflate";
import { deauthorizeCloud, getCloudAuth, getCloudUrl } from "./cloudSetup";
import { exportSettings, importSettings } from "./offline";
import { ManifestEntry, SyncRequest, SyncResponse } from "./types";
const logger = new Logger("SettingsSync:Cloud", "#39b7e0");
const MANIFEST_STORE_KEY = "Vencord_cloudManifest";
type ApiVersion = "v2" | "v1";
let apiVersion: ApiVersion = "v2";
async function getApiVersion(): Promise<ApiVersion> {
return apiVersion;
}
async function setApiVersion(version: ApiVersion) {
apiVersion = version;
}
function toBase64(data: Uint8Array): string {
let binary = "";
for (let i = 0; i < data.length; i++)
binary += String.fromCharCode(data[i]);
return btoa(binary);
}
function fromBase64(b64: string): Uint8Array {
const binary = atob(b64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++)
bytes[i] = binary.charCodeAt(i);
return bytes;
}
async function computeChecksum(data: Uint8Array): Promise<string> {
const hash = await crypto.subtle.digest("SHA-256", new Uint8Array(data));
const bytes = new Uint8Array(hash, 0, 8);
return Array.from(bytes, b => b.toString(16).padStart(2, "0")).join("");
}
async function getLocalManifest(): Promise<ManifestEntry[]> {
return await DataStore.get<ManifestEntry[]>(MANIFEST_STORE_KEY) ?? [];
}
async function saveLocalManifest(manifest: ManifestEntry[]) {
await DataStore.set(MANIFEST_STORE_KEY, manifest);
}
async function buildLocalData(): Promise<Map<string, Uint8Array>> {
const encoder = new TextEncoder();
const data = new Map<string, Uint8Array>();
data.set("settings", encoder.encode(JSON.stringify(VencordNative.settings.get())));
const quickCss = await VencordNative.quickCss.get();
if (quickCss) data.set("quickCss", encoder.encode(quickCss));
const dataStoreEntries = await DataStore.entries();
if (dataStoreEntries) data.set("dataStore", encoder.encode(JSON.stringify(dataStoreEntries)));
return data;
}
async function applyDownloads(downloads: SyncResponse["downloads"]) {
if (downloads.length === 0) return false;
let settingsChanged = false;
const decoder = new TextDecoder();
for (const dl of downloads) {
const text = decoder.decode(fromBase64(dl.value));
if (dl.key === "settings") {
try {
await importSettings(JSON.stringify({ settings: JSON.parse(text) }), "all", true);
settingsChanged = true;
} catch (e) {
logger.error("Failed to apply settings download", e);
}
} else if (dl.key === "quickCss") {
await VencordNative.quickCss.set(text);
settingsChanged = true;
} else if (dl.key.startsWith("dataStore/")) {
const dsKey = dl.key.slice("dataStore/".length);
try {
await DataStore.set(dsKey, JSON.parse(text));
} catch (e) {
logger.error(`Failed to apply dataStore download for ${dsKey}`, e);
}
}
}
return settingsChanged;
}
function handleAuthFailure() {
showNotification({
title: "Cloud Settings",
body: "Cloud sync was disabled because this account isn't connected. Reconnect in Cloud Settings.",
color: "var(--yellow-360)",
onClick: () => SettingsRouter.openUserSettings("equicord_cloud_panel"),
});
Settings.cloud.authenticated = false;
}
async function doSyncV2(uploads: SyncRequest["uploads"], clientManifest: ManifestEntry[]): Promise<SyncResponse | null> {
let res: Response;
try {
res = await fetch(new URL("/v2/sync", getCloudUrl()), {
method: "POST",
headers: {
Authorization: await getCloudAuth(),
"Content-Type": "application/json",
},
body: JSON.stringify({ client_manifest: clientManifest, uploads } satisfies SyncRequest),
});
} catch (e) {
logger.error("v2 sync network error, will retry next sync", e);
return null;
}
if (res.status === 404) {
logger.info("Server does not support v2, falling back to v1");
await setApiVersion("v1");
return null;
}
if (res.status === 401) {
handleAuthFailure();
return null;
}
if (!res.ok) {
logger.error(`Sync failed, API returned ${res.status}`);
showNotification({
title: "Cloud Settings",
body: `Could not synchronize settings (API returned ${res.status}).`,
color: "var(--red-360)",
});
return null;
}
return await res.json();
}
async function putV2(manual?: boolean) {
const localManifest = await getLocalManifest();
const manifestMap = new Map(localManifest.map(e => [e.key, e]));
const localData = await buildLocalData();
const uploads: SyncRequest["uploads"] = [];
for (const [key, value] of localData) {
const checksum = await computeChecksum(value);
const existing = manifestMap.get(key);
if (!existing || existing.checksum !== checksum)
uploads.push({ key, value: toBase64(value), checksum });
}
if (uploads.length === 0 && !manual) {
logger.info("No changes to push");
delete localStorage.Vencord_settingsDirty;
return;
}
const response = await doSyncV2(uploads, localManifest);
if (!response) return;
for (const err of response.errors)
logger.error(`Sync error for ${err.key}: ${err.error}`);
const hadDownloads = await applyDownloads(response.downloads);
await saveLocalManifest(response.server_manifest);
PlainSettings.cloud.settingsSyncVersion = Date.now();
await VencordNative.settings.set(PlainSettings);
logger.info(`Sync complete: ${response.uploaded.length} uploaded, ${response.downloads.length} downloaded`);
if (manual) {
showNotification({
title: "Cloud Settings",
body: hadDownloads
? "Settings synced! Click here to restart to fully apply changes."
: "Settings synchronized to the cloud!",
color: "var(--green-360)",
onClick: hadDownloads ? (IS_WEB ? () => location.reload() : relaunch) : undefined,
noPersist: true,
});
}
delete localStorage.Vencord_settingsDirty;
}
async function getV2(shouldNotify: boolean, force: boolean) {
const localManifest = force ? [] : await getLocalManifest();
const response = await doSyncV2([], localManifest);
if (!response) return false;
for (const err of response.errors)
logger.error(`Sync error for ${err.key}: ${err.error}`);
if (response.downloads.length === 0) {
logger.info("Settings up to date");
if (shouldNotify)
showNotification({
title: "Cloud Settings",
body: "Your settings are up to date.",
noPersist: true,
});
return false;
}
const settingsChanged = await applyDownloads(response.downloads);
await saveLocalManifest(response.server_manifest);
PlainSettings.cloud.settingsSyncVersion = Date.now();
await VencordNative.settings.set(PlainSettings);
logger.info(`Pulled ${response.downloads.length} keys from cloud`);
if (shouldNotify)
showNotification({
title: "Cloud Settings",
body: settingsChanged
? "Your settings have been updated! Click here to restart to fully apply changes!"
: "Cloud data synchronized.",
color: "var(--green-360)",
onClick: settingsChanged ? (IS_WEB ? () => location.reload() : relaunch) : undefined,
noPersist: true,
});
delete localStorage.Vencord_settingsDirty;
return true;
}
async function deleteV2() {
const auth = await getCloudAuth();
const manifestRes = await fetch(new URL("/v2/manifest", getCloudUrl()), {
headers: { Authorization: auth },
});
if (!manifestRes.ok) {
showNotification({
title: "Cloud Settings",
body: `Could not fetch manifest for deletion (API returned ${manifestRes.status}).`,
color: "var(--red-360)",
});
return;
}
const { entries }: { entries: ManifestEntry[]; } = await manifestRes.json();
await Promise.all(entries.map(async entry => {
const res = await fetch(new URL(`/v2/data/${encodeURIComponent(entry.key)}`, getCloudUrl()), {
method: "DELETE",
headers: { Authorization: auth },
});
if (!res.ok && res.status !== 404)
logger.error(`Failed to delete key ${entry.key}: ${res.status}`);
}));
await saveLocalManifest([]);
PlainSettings.cloud.settingsSyncVersion = 0;
await VencordNative.settings.set(PlainSettings);
logger.info("Settings deleted from cloud successfully");
showNotification({
title: "Cloud Settings",
body: "Settings deleted from cloud!",
color: "var(--green-360)",
});
}
async function putV1(manual?: boolean) {
const settings = await exportSettings({ syncDataStore: false, minify: true });
const res = await fetch(new URL("/v1/settings", getCloudUrl()), {
method: "PUT",
headers: {
Authorization: await getCloudAuth(),
"Content-Type": "application/octet-stream",
},
body: deflateSync(new TextEncoder().encode(settings)) as Uint8Array<ArrayBuffer>,
});
if (!res.ok) {
logger.error(`Failed to sync up, API returned ${res.status}`);
showNotification({
title: "Cloud Settings",
body: `Could not synchronize settings to cloud (API returned ${res.status}).`,
color: "var(--red-360)",
});
return;
}
const { written } = await res.json();
PlainSettings.cloud.settingsSyncVersion = written;
VencordNative.settings.set(PlainSettings);
logger.info("Settings uploaded to cloud successfully");
if (manual) {
showNotification({
title: "Cloud Settings",
body: "Synchronized settings to the cloud!",
noPersist: true,
});
}
delete localStorage.Vencord_settingsDirty;
}
async function getV1(shouldNotify: boolean, force: boolean) {
const res = await fetch(new URL("/v1/settings", getCloudUrl()), {
method: "GET",
headers: {
Authorization: await getCloudAuth(),
Accept: "application/octet-stream",
"If-None-Match": Settings.cloud.settingsSyncVersion.toString(),
},
});
if (res.status === 401) {
handleAuthFailure();
return false;
}
if (res.status === 404) {
logger.info("No settings on the cloud");
if (shouldNotify)
showNotification({
title: "Cloud Settings",
body: "There are no settings in the cloud.",
noPersist: true,
});
return false;
}
if (res.status === 304) {
logger.info("Settings up to date");
if (shouldNotify)
showNotification({
title: "Cloud Settings",
body: "Your settings are up to date.",
noPersist: true,
});
return false;
}
if (!res.ok) {
logger.error(`Failed to sync down, API returned ${res.status}`);
showNotification({
title: "Cloud Settings",
body: `Could not synchronize settings from the cloud (API returned ${res.status}).`,
color: "var(--red-360)",
});
return false;
}
const written = Number(res.headers.get("etag")!);
const localWritten = Settings.cloud.settingsSyncVersion;
if (!force && written < localWritten) {
if (shouldNotify)
showNotification({
title: "Cloud Settings",
body: "Your local settings are newer than the cloud ones.",
noPersist: true,
});
return false;
}
const data = await res.arrayBuffer();
const settings = new TextDecoder().decode(inflateSync(new Uint8Array(data)));
await importSettings(settings, "all", true);
PlainSettings.cloud.settingsSyncVersion = written;
VencordNative.settings.set(PlainSettings);
logger.info("Settings loaded from cloud successfully");
if (shouldNotify)
showNotification({
title: "Cloud Settings",
body: "Your settings have been updated! Click here to restart to fully apply changes!",
color: "var(--green-360)",
onClick: IS_WEB ? () => location.reload() : relaunch,
noPersist: true,
});
delete localStorage.Vencord_settingsDirty;
return true;
}
async function deleteV1() {
const res = await fetch(new URL("/v1/settings", getCloudUrl()), {
method: "DELETE",
headers: { Authorization: await getCloudAuth() },
});
if (!res.ok) {
logger.error(`Failed to delete, API returned ${res.status}`);
showNotification({
title: "Cloud Settings",
body: `Could not delete settings (API returned ${res.status}).`,
color: "var(--red-360)",
});
return;
}
logger.info("Settings deleted from cloud successfully");
showNotification({
title: "Cloud Settings",
body: "Settings deleted from cloud!",
color: "var(--green-360)",
});
}
export function shouldCloudSync(direction: "push" | "pull") {
const localDirection = localStorage.Vencord_cloudSyncDirection;
return localDirection === direction || localDirection === "both";
}
export async function putCloudSettings(manual?: boolean) {
const settings = await exportSettings({ syncDataStore: false, minify: true });
try {
const res = await fetch(new URL("/v1/settings", getCloudUrl()), {
method: "PUT",
headers: {
Authorization: await getCloudAuth(),
"Content-Type": "application/octet-stream"
},
body: deflateSync(new TextEncoder().encode(settings)) as Uint8Array<ArrayBuffer>
});
if (!res.ok) {
logger.error(`Failed to sync up, API returned ${res.status}`);
showNotification({
title: "Cloud Settings",
body: `Could not synchronize settings to cloud (API returned ${res.status}).`,
color: "var(--red-360)"
});
return;
const version = await getApiVersion();
if (version === "v2") {
await putV2(manual);
if (await getApiVersion() === "v1")
await putV1(manual);
} else {
await putV1(manual);
}
const { written } = await res.json();
PlainSettings.cloud.settingsSyncVersion = written;
VencordNative.settings.set(PlainSettings);
logger.info("Settings uploaded to cloud successfully");
if (manual) {
showNotification({
title: "Cloud Settings",
body: "Synchronized settings to the cloud!",
noPersist: true,
});
}
delete localStorage.Vencord_settingsDirty;
} catch (e: any) {
logger.error("Failed to sync up", e);
showNotification({
title: "Cloud Settings",
body: `Could not synchronize settings to the cloud (${e.toString()}).`,
color: "var(--red-360)"
color: "var(--red-360)",
});
}
}
export async function getCloudSettings(shouldNotify = true, force = false) {
try {
const res = await fetch(new URL("/v1/settings", getCloudUrl()), {
method: "GET",
headers: {
Authorization: await getCloudAuth(),
Accept: "application/octet-stream",
"If-None-Match": Settings.cloud.settingsSyncVersion.toString()
},
});
if (res.status === 401) {
// User switched to an account that isn't connected to cloud
showNotification({
title: "Cloud Settings",
body: "Cloud sync was disabled because this account isn't connected to the cloud App. You can enable it again by connecting this account in Cloud Settings. (note: it will store your preferences separately)",
color: "var(--yellow-360)",
onClick: () => SettingsRouter.openUserSettings("equicord_cloud_panel")
});
// Disable cloud sync globally
Settings.cloud.authenticated = false;
return false;
const version = await getApiVersion();
if (version === "v2") {
const result = await getV2(shouldNotify, force);
if (await getApiVersion() === "v1")
return await getV1(shouldNotify, force);
return result;
}
if (res.status === 404) {
logger.info("No settings on the cloud");
if (shouldNotify)
showNotification({
title: "Cloud Settings",
body: "There are no settings in the cloud.",
noPersist: true
});
return false;
}
if (res.status === 304) {
logger.info("Settings up to date");
if (shouldNotify)
showNotification({
title: "Cloud Settings",
body: "Your settings are up to date.",
noPersist: true
});
return false;
}
if (!res.ok) {
logger.error(`Failed to sync down, API returned ${res.status}`);
showNotification({
title: "Cloud Settings",
body: `Could not synchronize settings from the cloud (API returned ${res.status}).`,
color: "var(--red-360)"
});
return false;
}
const written = Number(res.headers.get("etag")!);
const localWritten = Settings.cloud.settingsSyncVersion;
// don't need to check for written > localWritten because the server will return 304 due to if-none-match
if (!force && written < localWritten) {
if (shouldNotify)
showNotification({
title: "Cloud Settings",
body: "Your local settings are newer than the cloud ones.",
noPersist: true,
});
return;
}
const data = await res.arrayBuffer();
const settings = new TextDecoder().decode(inflateSync(new Uint8Array(data)));
await importSettings(settings, "all", true);
// sync with server timestamp instead of local one
PlainSettings.cloud.settingsSyncVersion = written;
VencordNative.settings.set(PlainSettings);
logger.info("Settings loaded from cloud successfully");
if (shouldNotify)
showNotification({
title: "Cloud Settings",
body: "Your settings have been updated! Click here to restart to fully apply changes!",
color: "var(--green-360)",
onClick: IS_WEB ? () => location.reload() : relaunch,
noPersist: true
});
delete localStorage.Vencord_settingsDirty;
return true;
return await getV1(shouldNotify, force);
} catch (e: any) {
logger.error("Failed to sync down", e);
showNotification({
title: "Cloud Settings",
body: `Could not synchronize settings from the cloud (${e.toString()}).`,
color: "var(--red-360)"
color: "var(--red-360)",
});
return false;
}
}
export async function deleteCloudSettings() {
try {
const res = await fetch(new URL("/v1/settings", getCloudUrl()), {
method: "DELETE",
headers: { Authorization: await getCloudAuth() },
});
if (!res.ok) {
logger.error(`Failed to delete, API returned ${res.status}`);
showNotification({
title: "Cloud Settings",
body: `Could not delete settings (API returned ${res.status}).`,
color: "var(--red-360)"
});
return;
}
logger.info("Settings deleted from cloud successfully");
showNotification({
title: "Cloud Settings",
body: "Settings deleted from cloud!",
color: "var(--green-360)"
});
const version = await getApiVersion();
if (version === "v2")
await deleteV2();
else
await deleteV1();
} catch (e: any) {
logger.error("Failed to delete", e);
showNotification({
title: "Cloud Settings",
body: `Could not delete settings (${e.toString()}).`,
color: "var(--red-360)"
color: "var(--red-360)",
});
}
}
@ -211,7 +502,7 @@ export async function deleteCloudSettings() {
export async function eraseAllCloudData() {
const res = await fetch(new URL("/v1/", getCloudUrl()), {
method: "DELETE",
headers: { Authorization: await getCloudAuth() }
headers: { Authorization: await getCloudAuth() },
});
if (!res.ok) {
@ -219,17 +510,18 @@ export async function eraseAllCloudData() {
showNotification({
title: "Cloud Integrations",
body: `Could not erase all data (API returned ${res.status}), please contact support.`,
color: "var(--red-360)"
color: "var(--red-360)",
});
return;
}
Settings.cloud.authenticated = false;
await deauthorizeCloud();
await saveLocalManifest([]);
showNotification({
title: "Cloud Integrations",
body: "Successfully erased all data.",
color: "var(--green-360)"
color: "var(--green-360)",
});
}

View file

@ -0,0 +1,47 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
export interface ManifestEntry {
key: string;
version: number;
checksum: string;
}
export interface SyncUpload {
key: string;
value: string;
checksum?: string;
}
export interface SyncRequest {
client_manifest: ManifestEntry[];
uploads: SyncUpload[];
}
export interface SyncDownload {
key: string;
value: string;
version: number;
checksum: string;
}
export interface SyncUploadResult {
key: string;
version: number;
checksum: string;
}
export interface SyncError {
key: string;
error: string;
}
export interface SyncResponse {
server_manifest: ManifestEntry[];
downloads: SyncDownload[];
uploaded: SyncUploadResult[];
errors: SyncError[];
}

View file

@ -16,16 +16,26 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { Settings, SettingsStore } from "@api/Settings";
import { Settings, SettingsStore, type ThemeActivationMode } from "@api/Settings";
import { createAndAppendStyle } from "@utils/css";
import { ThemeStore } from "@vencord/discord-types";
import { PopoutWindowStore } from "@webpack/common";
import { userStyleRootNode, vencordRootNode } from "./Styles";
import { coreStyleRootNode, managedStyleRootNode, userStyleRootNode, vencordRootNode } from "./Styles";
let style: HTMLStyleElement;
let themesStyle: HTMLStyleElement;
function getThemeActivationMode(themeId: string) {
return Settings.themeActivationModes?.[themeId] ?? "always";
}
function shouldApplyTheme(mode: ThemeActivationMode, activeTheme?: "light" | "dark") {
if (mode === "always") return true;
if (!activeTheme) return false;
return mode === activeTheme;
}
async function toggle(isEnabled: boolean) {
if (!style) {
if (isEnabled) {
@ -55,42 +65,60 @@ async function initThemes() {
? undefined
: ThemeStore.theme === "light" ? "light" : "dark";
const links = enabledThemeLinks
.map(rawLink => {
const match = /^@(light|dark) (.*)/.exec(rawLink);
if (!match) return rawLink;
const links = new Set<string>();
const [, mode, link] = match;
return mode === activeTheme ? link : null;
})
.filter(link => link !== null);
for (const rawLink of enabledThemeLinks) {
const match = /^@(light|dark) (.*)/.exec(rawLink);
const link = match?.[2] ?? rawLink;
const mode = getThemeActivationMode(rawLink);
if (shouldApplyTheme(mode, activeTheme)) {
links.add(link);
}
}
if (IS_WEB) {
for (const theme of enabledThemes) {
const mode = getThemeActivationMode(theme);
if (!shouldApplyTheme(mode, activeTheme)) continue;
const themeData = await VencordNative.themes.getThemeData(theme);
if (!themeData) continue;
const blob = new Blob([themeData], { type: "text/css" });
links.push(URL.createObjectURL(blob));
links.add(URL.createObjectURL(blob));
}
} else {
const localThemes = enabledThemes.map(theme => `vencord:///themes/${theme}?v=${Date.now()}`);
links.push(...localThemes);
const version = Date.now();
for (const theme of enabledThemes) {
const mode = getThemeActivationMode(theme);
if (!shouldApplyTheme(mode, activeTheme)) continue;
links.add(`vencord:///themes/${theme}?v=${version}`);
}
}
themesStyle.textContent = links.map(link => `@import url("${link.trim()}");`).join("\n");
themesStyle.textContent = Array.from(links).map(link => `@import url("${link.trim()}");`).join("\n");
updatePopoutWindows();
}
function applyToPopout(popoutWindow: Window | undefined, key: string) {
if (!popoutWindow?.document) return;
// skip game overlay cuz it needs to stay transparent, themes broke it
if (key === "DISCORD_OutOfProcessOverlay") return;
const doc = popoutWindow.document;
doc.querySelector("vencord-root")?.remove();
doc.documentElement.appendChild(vencordRootNode.cloneNode(true));
const clonedRoot = vencordRootNode.cloneNode(false) as HTMLElement;
clonedRoot.append(
coreStyleRootNode.cloneNode(true),
managedStyleRootNode.cloneNode(true)
);
if (key !== "DISCORD_OutOfProcessOverlay") {
clonedRoot.append(userStyleRootNode.cloneNode(true));
}
doc.documentElement.appendChild(clonedRoot);
}
function updatePopoutWindows() {
@ -111,6 +139,7 @@ document.addEventListener("DOMContentLoaded", () => {
SettingsStore.addChangeListener("enabledThemeLinks", initThemes);
SettingsStore.addChangeListener("enabledThemes", initThemes);
SettingsStore.addChangeListener("themeActivationModes", initThemes);
window.addEventListener("message", event => {
const { discordPopoutEvent } = event.data || {};

View file

@ -34,6 +34,7 @@ import * as $Notices from "./Notices";
import * as $Notifications from "./Notifications";
import * as $UserArea from "./UserArea";
export * as PluginManager from "./PluginManager";
import * as $ProfileCollections from "./ProfileCollections";
import * as $ServerList from "./ServerList";
import * as $Settings from "./Settings";
import * as $Styles from "./Styles";
@ -170,3 +171,8 @@ export const UserArea = $UserArea;
* Just used to identify if user is on Equicord as Vencord doesn't have this.
*/
export const isEquicord = true;
/**
* An API allowing you to add other collections where discords game collection is.
*/
export const ProfileCollections = $ProfileCollections;

View file

@ -1,6 +1,8 @@
.vc-text-base {
font-family: var(--font-primary);
color: var(--text-default);
line-height: normal;
/* Discord puts an insane default margin on p tags, so reset that here */
margin: 0;
}
@ -24,3 +26,7 @@
.vc-text-tabular-numbers {
font-variant-numeric: tabular-nums;
}
.vc-text-defaultColor {
color: var(--text-default);
}

View file

@ -82,6 +82,7 @@ export type BaseTextProps<Tag extends TextTag = "div"> = ComponentPropsWithoutRe
selectable?: boolean;
lineClamp?: number;
tabularNumbers?: boolean;
defaultColor?: boolean;
};
export function BaseText<T extends TextTag = "div">(props: BaseTextProps<T>): ReactNode {
@ -93,6 +94,7 @@ export function BaseText<T extends TextTag = "div">(props: BaseTextProps<T>): Re
selectable = false,
lineClamp,
tabularNumbers = false,
defaultColor = true,
children,
className,
style,
@ -107,6 +109,7 @@ export function BaseText<T extends TextTag = "div">(props: BaseTextProps<T>): Re
lineClamp === 1 && textCls("line-clamp-1"),
lineClamp != null && lineClamp > 1 && textCls("line-clamp"),
tabularNumbers && textCls("tabular-numbers"),
defaultColor && textCls("defaultColor"),
className
)}
style={{

View file

@ -0,0 +1,23 @@
.vc-expandable-card {
color: var(--text-default);
}
.vc-expandable-card-header {
display: flex;
cursor: pointer;
padding: .75em 1em;
margin: 4px;
border-radius: var(--radius-xs);
.vc-expandable-card[data-expanded="true"] & {
background: var(--card-secondary-bg);
}
}
.vc-expandable-card-icon {
margin-left: auto;
}
.vc-expandable-card-content {
padding: 0.5em 1em;
}

View file

@ -0,0 +1,45 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import "./ExpandableCard.css";
import { classes } from "@utils/misc";
import { Clickable, useState } from "@webpack/common";
import { PropsWithChildren } from "react";
import { Card } from "./Card";
import { DownArrow, RightArrow } from "./Icons";
export type ExpandableSectionProps = PropsWithChildren<{
renderContent: () => React.ReactNode;
className?: string;
initialExpanded?: boolean;
}>;
/**
* A card component that can expand and collapse to show/hide content. The header (props.children) is always visible, and the content (props.renderContent) is only visible when expanded.
*/
export function ExpandableSection({ children, renderContent: Content, className, initialExpanded = false }: ExpandableSectionProps) {
const [expanded, setExpanded] = useState(initialExpanded);
const Icon = expanded ? DownArrow : RightArrow;
return (
<Card data-expanded={expanded} className={classes("vc-expandable-card", className)}>
<Clickable className="vc-expandable-card-header" onClick={() => setExpanded(c => !c)} >
{children}
<Icon className="vc-expandable-card-icon" />
</Clickable>
{expanded
? <div className="vc-expandable-card-content">
<Content />
</div>
: null
}
</Card>
);
}

View file

@ -21,16 +21,18 @@ import type { CSSProperties, HTMLAttributes } from "react";
export interface FlexProps extends HTMLAttributes<HTMLDivElement> {
flexDirection?: CSSProperties["flexDirection"];
gap?: CSSProperties["gap"];
alignContent?: CSSProperties["alignContent"];
justifyContent?: CSSProperties["justifyContent"];
alignItems?: CSSProperties["alignItems"];
flexWrap?: CSSProperties["flexWrap"];
}
export function Flex({ flexDirection, gap = "1em", justifyContent, alignItems, flexWrap, children, style, ...restProps }: FlexProps) {
export function Flex({ flexDirection, gap = "1em", alignContent, justifyContent, alignItems, flexWrap, children, style, ...restProps }: FlexProps) {
style = {
display: "flex",
flexDirection,
gap,
alignContent,
justifyContent,
alignItems,
flexWrap,

View file

@ -714,7 +714,7 @@ export function CloudUploadIcon(props: IconProps) {
);
}
export const ClockIcon = (props?: any) => {
export function ClockIcon(props: IconProps) {
return (
<Icon
{...props}
@ -728,7 +728,35 @@ export const ClockIcon = (props?: any) => {
/>
</Icon>
);
};
}
export function DownArrow(props: IconProps) {
return (
<Icon
{...props}
viewBox="0 0 24 24"
>
<path
fill={props.fill || "currentColor"}
d="M5.3 9.3a1 1 0 0 1 1.4 0l5.3 5.29 5.3-5.3a1 1 0 1 1 1.4 1.42l-6 6a1 1 0 0 1-1.4 0l-6-6a1 1 0 0 1 0-1.42Z"
/>
</Icon>
);
}
export function RightArrow(props: IconProps) {
return (
<Icon
{...props}
viewBox="0 0 24 24"
>
<path
fill={props.fill || "currentColor"}
d="M9.3 5.3a1 1 0 0 0 0 1.4l5.29 5.3-5.3 5.3a1 1 0 1 0 1.42 1.4l6-6a1 1 0 0 0 0-1.4l-6-6a1 1 0 0 0-1.42 0Z"
/>
</Icon>
);
}
export const QrCodeIcon = (props?: any) => {
return (

View file

@ -19,7 +19,7 @@
import "./themesStyles.css";
import { isPluginEnabled } from "@api/PluginManager";
import { Settings, useSettings } from "@api/Settings";
import { Settings, type ThemeActivationMode, useSettings } from "@api/Settings";
import { Button } from "@components/Button";
import { Divider } from "@components/Divider";
import { ErrorCard } from "@components/ErrorCard";
@ -59,6 +59,74 @@ const LinkIcon = findComponentByCodeLazy("M16.32 14.72a1 1 0 0 1 0-1.41l2.51-2.5
const DiscordIcon = findComponentByCodeLazy("1.6 5.64-2.87");
const DownloadIcon = findComponentByCodeLazy("1.42l3.3 3.3V3a1");
const themeActivationModeOptions: { value: ThemeActivationMode; label: string; }[] = [
{ value: "always", label: "Always on" },
{ value: "light", label: "Light only" },
{ value: "dark", label: "Dark only" }
];
function getThemeActivationModeLabel(mode: ThemeActivationMode) {
return themeActivationModeOptions.find(option => option.value === mode)?.label ?? "Always on";
}
function inferThemeActivationMode(css: string) {
let text = css.replace(/^\uFEFF/, "");
while (true) {
const trimmed = text.trimStart();
if (trimmed !== text) text = trimmed;
const comment = /^\/\*[\s\S]*?\*\/\s*/.exec(text);
if (!comment) break;
text = text.slice(comment[0].length);
}
const match = /^@(light|dark)\b/i.exec(text);
return match?.[1].toLowerCase() as ThemeActivationMode | undefined;
}
function inferAndStoreThemeActivationMode(themeId: string, css: string) {
const activationMode = Settings.themeActivationModes?.[themeId] ?? inferThemeActivationMode(css);
if (!activationMode || themeId in (Settings.themeActivationModes ?? {})) return;
Settings.themeActivationModes = {
...(Settings.themeActivationModes ?? {}),
[themeId]: activationMode,
};
}
function ThemeActivationMenu({ themeId, activationMode, onActivationModeChange, children }: {
themeId: string;
activationMode: ThemeActivationMode;
onActivationModeChange?: (mode: ThemeActivationMode) => void;
children?: React.ReactNode;
}) {
const [selectedMode, setSelectedMode] = useState(activationMode);
return (
<Menu.Menu navId={`theme-card-menu-${themeId}`} onClose={ContextMenuApi.closeContextMenu}>
{onActivationModeChange && (
<Menu.MenuItem id={`theme-activation-${themeId}`} label="Theme activation">
{themeActivationModeOptions.map(option => (
<Menu.MenuRadioItem
key={option.value}
id={`theme-activation-${themeId}-${option.value}`}
group={`theme-activation-${themeId}`}
label={option.label}
checked={selectedMode === option.value}
action={() => {
setSelectedMode(option.value);
onActivationModeChange(option.value);
}}
/>
))}
</Menu.MenuItem>
)}
{children}
</Menu.Menu>
);
}
function LocalThemeIcon({ size }: { size?: string; }) {
const sizeVal = size === "sm" ? 16 : 24;
return (
@ -145,6 +213,8 @@ interface OtherThemeCardProps {
onDownload?: () => void;
themeLink?: string;
isLocal?: boolean;
activationMode?: ThemeActivationMode;
onActivationModeChange?: (mode: ThemeActivationMode) => void;
}
interface UserCSSCardProps {
@ -153,12 +223,48 @@ interface UserCSSCardProps {
onChange: (enabled: boolean) => void;
onDelete: () => void;
onSettingsReset: () => void;
activationMode?: ThemeActivationMode;
onActivationModeChange?: (mode: ThemeActivationMode) => void;
}
function UserCSSThemeCard({ theme, enabled, onChange, onDelete, onSettingsReset }: UserCSSCardProps) {
function UserCSSThemeCard({ theme, enabled, onChange, onDelete, onSettingsReset, activationMode = "always", onActivationModeChange }: UserCSSCardProps) {
const missingPlugins = useMemo(() =>
theme.requiredPlugins?.filter(p => !isPluginEnabled(p)), [theme]);
const openThemeMenu = (e: React.MouseEvent) => {
ContextMenuApi.openContextMenu(e, () => (
<ThemeActivationMenu
themeId={theme.id}
activationMode={activationMode}
onActivationModeChange={onActivationModeChange}
>
{theme.vars && (
<Menu.MenuItem
id="edit-theme-settings"
label="Edit Settings"
action={() => openModal(modalProps =>
<UserCSSSettingsModal modalProps={modalProps} theme={theme} onSettingsReset={onSettingsReset} />)
}
/>
)}
{IS_WEB && (
<>
<Menu.MenuSeparator />
<Menu.MenuItem
id="delete-theme"
label="Delete"
color="danger"
icon={DeleteIcon}
action={onDelete}
/>
</>
)}
</ThemeActivationMenu>
));
};
const canOpenThemeMenu = !!theme.vars || !!onActivationModeChange || IS_WEB;
return (
<AddonCard
name={theme.name ?? "Unknown"}
@ -182,19 +288,11 @@ function UserCSSThemeCard({ theme, enabled, onChange, onDelete, onSettingsReset
)}
</Tooltip>
)}
{theme.vars && (
<div style={{ cursor: "pointer" }} onClick={
() => openModal(modalProps =>
<UserCSSSettingsModal modalProps={modalProps} theme={theme} onSettingsReset={onSettingsReset} />)
}>
{canOpenThemeMenu && (
<div style={{ cursor: "pointer" }} onClick={openThemeMenu}>
<CogWheel />
</div>
)}
{IS_WEB && (
<div style={{ cursor: "pointer", color: "var(--status-danger" }} onClick={onDelete}>
<DeleteIcon />
</div>
)}
</>
}
footer={
@ -204,16 +302,26 @@ function UserCSSThemeCard({ theme, enabled, onChange, onDelete, onSettingsReset
<span style={{ color: "var(--text-muted)" }}>•</span>
)}
{!!theme.supportURL && <Link href={theme.supportURL}>Support</Link>}
{activationMode !== "always" && (
<>
{!!(theme.homepageURL || theme.supportURL) && <span style={{ color: "var(--text-muted)" }}>•</span>}
<span style={{ color: "var(--text-muted)" }}>{getThemeActivationModeLabel(activationMode)}</span>
</>
)}
</Flex>
}
/>
);
}
function OtherThemeCard({ theme, enabled, onChange, onDelete, showDeleteButton, onEditName, disabled, onPin, isPinned, onRefresh, onOpenFolder, onCopyUrl, onDownload, themeLink, isLocal }: OtherThemeCardProps) {
function OtherThemeCard({ theme, enabled, onChange, onDelete, showDeleteButton, onEditName, disabled, onPin, isPinned, onRefresh, onOpenFolder, onCopyUrl, onDownload, themeLink, isLocal, activationMode = "always", onActivationModeChange }: OtherThemeCardProps) {
const openThemeMenu = (e: React.MouseEvent) => {
ContextMenuApi.openContextMenu(e, () => (
<Menu.Menu navId="theme-card-menu" onClose={ContextMenuApi.closeContextMenu}>
<ThemeActivationMenu
themeId={themeLink ?? theme.fileName}
activationMode={activationMode}
onActivationModeChange={onActivationModeChange}
>
{onPin && (
<Menu.MenuItem
id="pin-theme"
@ -286,7 +394,7 @@ function OtherThemeCard({ theme, enabled, onChange, onDelete, showDeleteButton,
/>
</>
)}
</Menu.Menu>
</ThemeActivationMenu>
));
};
@ -353,6 +461,12 @@ function OtherThemeCard({ theme, enabled, onChange, onDelete, showDeleteButton,
Discord Server
</Link>
)}
{activationMode !== "always" && (
<>
{!!(theme.website || theme.invite) && <span style={{ color: "var(--text-muted)" }}>•</span>}
<span style={{ color: "var(--text-muted)" }}>{getThemeActivationModeLabel(activationMode)}</span>
</>
)}
</Flex>
}
@ -368,10 +482,11 @@ interface UnifiedTheme {
enabled: boolean;
header: UserThemeHeader | UserstyleHeader;
link?: string;
activationMode: ThemeActivationMode;
}
function ThemesTab() {
const settings = useSettings(["themeLinks", "enabledThemeLinks", "enabledThemes", "enableOnlineThemes", "pinnedThemes"]);
const settings = useSettings(["themeLinks", "enabledThemeLinks", "enabledThemes", "enableOnlineThemes", "pinnedThemes", "themeActivationModes.*"]);
const fileInputRef = useState<HTMLInputElement | null>(null)[1];
const [currentThemeLink, setCurrentThemeLink] = useState("");
@ -386,16 +501,15 @@ function ThemesTab() {
const [filter, setFilter] = useState(ThemeFilter.All);
useEffect(() => {
updateThemes();
void updateThemes();
}, []);
async function updateThemes() {
await changeThemeLibraryURLs();
refreshLocalThemes();
refreshOnlineThemes();
changeThemeLibraryURLs();
await Promise.allSettled([refreshLocalThemes(), refreshOnlineThemes()]);
}
async function changeThemeLibraryURLs() {
function changeThemeLibraryURLs() {
settings.themeLinks = settings.themeLinks.map(link => {
if (link.startsWith("https://discord-themes.com/api")) {
return link.replace("https://discord-themes.com/api", "https://themes.equicord.org/api");
@ -411,43 +525,49 @@ function ThemesTab() {
for (const { fileName, content } of themes) {
if (!fileName.endsWith(".css")) continue;
if ((!IS_WEB || "legcord" in window) && fileName.endsWith(".user.css")) {
const header = await usercssParse(content, fileName);
try {
inferAndStoreThemeActivationMode(fileName, content);
themeInfo.push({
type: "usercss",
header
});
if ((!IS_WEB || "legcord" in window) && fileName.endsWith(".user.css")) {
const header = await usercssParse(content, fileName);
Settings.userCssVars[header.id] ??= {};
themeInfo.push({
type: "usercss",
header
});
for (const [name, varInfo] of Object.entries(header.vars ?? {})) {
let normalizedValue = "";
Settings.userCssVars[header.id] ??= {};
switch (varInfo.type) {
case "text":
case "color":
case "checkbox":
normalizedValue = varInfo.default;
break;
case "select":
normalizedValue = varInfo.options.find(v => v.name === varInfo.default)!.value;
break;
case "range":
normalizedValue = `${varInfo.default}${varInfo.units}`;
break;
case "number":
normalizedValue = String(varInfo.default);
break;
for (const [name, varInfo] of Object.entries(header.vars ?? {})) {
let normalizedValue = "";
switch (varInfo.type) {
case "text":
case "color":
case "checkbox":
normalizedValue = varInfo.default;
break;
case "select":
normalizedValue = varInfo.options.find(v => v.name === varInfo.default)!.value;
break;
case "range":
normalizedValue = `${varInfo.default}${varInfo.units}`;
break;
case "number":
normalizedValue = String(varInfo.default);
break;
}
Settings.userCssVars[header.id][name] ??= normalizedValue;
}
Settings.userCssVars[header.id][name] ??= normalizedValue;
} else {
themeInfo.push({
type: "other",
header: getThemeInfo(stripBOM(content), fileName)
});
}
} else {
themeInfo.push({
type: "other",
header: getThemeInfo(stripBOM(content), fileName)
});
} catch {
continue;
}
}
@ -504,9 +624,10 @@ function ThemesTab() {
const res = await fetch(link);
if (!res.ok) throw new Error(`Failed to fetch ${link}`);
const css = await res.text();
inferAndStoreThemeActivationMode(link, css);
return { ...getThemeInfo(css, link), link };
} catch (err) {
console.warn(`Theme fetch failed for ${link}:`, err);
} catch {
return null;
}
})
@ -523,12 +644,35 @@ function ThemesTab() {
}
}
function clearThemeState(themeId: string) {
settings.pinnedThemes = settings.pinnedThemes.filter(f => f !== themeId);
settings.enabledThemes = settings.enabledThemes.filter(f => f !== themeId);
settings.enabledThemeLinks = settings.enabledThemeLinks.filter(f => f !== themeId);
settings.themeNames = Object.fromEntries(Object.entries(settings.themeNames).filter(([key]) => key !== themeId));
const themeActivationModes = { ...(settings.themeActivationModes ?? {}) };
delete themeActivationModes[themeId];
settings.themeActivationModes = themeActivationModes;
}
function deleteThemeLink(link: string) {
settings.themeLinks = settings.themeLinks.filter(f => f !== link);
settings.pinnedThemes = settings.pinnedThemes.filter(f => f !== link);
clearThemeState(link);
refreshOnlineThemes();
}
function setThemeActivationMode(themeId: string, mode: ThemeActivationMode) {
const themeActivationModes = { ...(settings.themeActivationModes ?? {}) };
if (mode === "always") {
delete themeActivationModes[themeId];
} else {
themeActivationModes[themeId] = mode;
}
settings.themeActivationModes = themeActivationModes;
}
function togglePinTheme(themeId: string) {
if (settings.pinnedThemes.includes(themeId)) {
settings.pinnedThemes = settings.pinnedThemes.filter(f => f !== themeId);
@ -542,6 +686,8 @@ function ThemesTab() {
const res = await fetch(link);
if (!res.ok) throw new Error(`Failed to fetch ${link}`);
const css = await res.text();
inferAndStoreThemeActivationMode(link, css);
const updatedTheme = { ...getThemeInfo(css, link), link };
setOnlineThemes(prev =>
@ -587,7 +733,8 @@ function ThemesTab() {
name: customName ?? theme.name ?? theme.fileName,
enabled: settings.enabledThemeLinks.includes(theme.link),
header: { ...theme, customName },
link: theme.link
link: theme.link,
activationMode: settings.themeActivationModes?.[theme.link] ?? "always",
});
}
@ -601,12 +748,13 @@ function ThemesTab() {
themeType: type,
name,
enabled: settings.enabledThemes.includes(header.fileName),
header
header,
activationMode: settings.themeActivationModes?.[header.fileName] ?? "always",
});
}
return themes;
}, [onlineThemes, userThemes, themeNames, settings.enabledThemeLinks, settings.enabledThemes]);
}, [onlineThemes, userThemes, themeNames, settings.enabledThemeLinks, settings.enabledThemes, settings.themeActivationModes]);
const filteredThemes = useMemo(() => {
let themes = allThemes;
@ -808,6 +956,8 @@ function ThemesTab() {
onRefresh={() => refreshOnlineTheme(onlineTheme.link)}
onDownload={() => downloadTheme(onlineTheme.link, onlineTheme.name ?? "theme")}
isLocal={false}
activationMode={theme.activationMode}
onActivationModeChange={mode => setThemeActivationMode(onlineTheme.link, mode)}
onEditName={newName => {
const updatedNames = { ...themeNames, [onlineTheme.link]: newName };
setThemeNames(updatedNames);
@ -829,11 +979,14 @@ function ThemesTab() {
onChange={enabled => onLocalThemeChange(usercssTheme.fileName, enabled)}
onDelete={async () => {
onLocalThemeChange(usercssTheme.fileName, false);
clearThemeState(usercssTheme.fileName);
await VencordNative.themes.deleteTheme(usercssTheme.fileName);
refreshLocalThemes();
}}
onSettingsReset={refreshLocalThemes}
theme={usercssTheme}
activationMode={theme.activationMode}
onActivationModeChange={mode => setThemeActivationMode(usercssTheme.fileName, mode)}
/>
);
}
@ -846,6 +999,7 @@ function ThemesTab() {
onChange={enabled => onLocalThemeChange(localTheme.fileName, enabled)}
onDelete={async () => {
onLocalThemeChange(localTheme.fileName, false);
clearThemeState(localTheme.fileName);
await VencordNative.themes.deleteTheme(localTheme.fileName);
refreshLocalThemes();
}}
@ -856,6 +1010,8 @@ function ThemesTab() {
onRefresh={refreshLocalThemes}
isLocal
theme={localTheme}
activationMode={theme.activationMode}
onActivationModeChange={mode => setThemeActivationMode(localTheme.fileName, mode)}
/>
);
})}

View file

@ -81,7 +81,6 @@ export function Updatable(props: CommonProps) {
<>
<Flex className={Margins.bottom8} gap="8px">
<Button
size="small"
disabled={isUpdating || isChecking}
onClick={runWithDispatch(setIsChecking, async () => {
const outdated = await checkForUpdates();

View file

@ -17,12 +17,15 @@
*/
import { useSettings } from "@api/Settings";
import { Button } from "@components/Button";
import { Card } from "@components/Card";
import { Divider } from "@components/Divider";
import { Flex } from "@components/Flex";
import { FormSwitch } from "@components/FormSwitch";
import { Heading } from "@components/Heading";
import { Heading, HeadingSecondary } from "@components/Heading";
import { Link } from "@components/Link";
import { Paragraph } from "@components/Paragraph";
import { SettingsTab, wrapTab } from "@components/settings";
import { SettingsTab, wrapTab } from "@components/settings/tabs/BaseTab";
import { Margins } from "@utils/margins";
import { useAwaiter } from "@utils/react";
import { getRepo, isNewer, UpdateLogger } from "@utils/updater";
@ -37,6 +40,34 @@ interface CommonProps {
repoPending: boolean;
}
function EquibopSection() {
if (!IS_EQUIBOP) return null;
const [isEquibopOutdated] = useAwaiter<boolean>(VesktopNative.app.isOutdated, { fallbackValue: false });
return (
<Flex className={Margins.bottom20} flexDirection="column" gap="1em">
<Card variant="brand">
<HeadingSecondary>Equibop & Equicord</HeadingSecondary>
<Paragraph>Equibop and Equicord are two separate things. This updater is for Equicord.</Paragraph>
<Paragraph className={Margins.top8}>
You receive separate popups for Equibop updates. You can also manually update by installing the <Link href="https://equibop.org/install">latest version</Link>.
</Paragraph>
</Card>
{isEquibopOutdated && (
<Card variant="warning">
<HeadingSecondary>Equibop Outdated</HeadingSecondary>
<Flex flexDirection="column" gap="0.5em">
<Paragraph>Your version of Equibop is outdated!</Paragraph>
<Button variant="link" onClick={() => VesktopNative.app.openUpdater()}>Open Equibop Updater</Button>
</Flex>
</Card>
)}
</Flex>
);
}
function Updater() {
const settings = useSettings(["autoUpdate", "autoUpdateNotification"]);
@ -54,6 +85,7 @@ function Updater() {
return (
<SettingsTab>
<EquibopSection />
<Heading className={Margins.top16}>Update Preferences</Heading>
<Paragraph className={Margins.bottom20}>
Control how Equicord keeps itself up to date. You can choose to update automatically in the background or be notified when new updates are available.

View file

@ -9,6 +9,7 @@ import { canonicalizeMatch } from "@utils/patches";
import { ModuleFactory } from "@vencord/discord-types/webpack";
import * as Webpack from "@webpack";
import { wreq } from "@webpack";
import pLimit from "p-limit";
import { AnyModuleFactory } from "webpack";
function getWebpackChunkMap() {
@ -29,20 +30,9 @@ function getWebpackChunkMap() {
return v;
}
async function promiseAllSettledBatched<T>(tasks: (() => Promise<T>)[], batchSize = 8): Promise<T[]> {
const results: T[] = [];
for (let i = 0; i < tasks.length; i += batchSize) {
const batch = tasks.slice(i, i + batchSize);
const settled = await Promise.allSettled(batch.map(t => t()));
for (const r of settled) {
if (r.status === "fulfilled") results.push(r.value);
}
}
return results;
}
export async function loadLazyChunks() {
const LazyChunkLoaderLogger = new Logger("LazyChunkLoader");
const queue = pLimit(50);
try {
LazyChunkLoaderLogger.log("Loading all chunks...");
@ -71,11 +61,15 @@ export async function loadLazyChunks() {
const shouldForceDefer = false;
await promiseAllSettledBatched(Array.from(lazyChunks).map(([, rawChunkIds, entryPoint]) => async () => {
const chunkIds = rawChunkIds ? Array.from(rawChunkIds.matchAll(Webpack.ChunkIdsRegex)).map(m => {
const numChunkId = Number(m[1]);
return Number.isNaN(numChunkId) ? m[1] : numChunkId;
}) : [];
await Promise.all(Array.from(lazyChunks).map(async ([, rawChunkIds, entryPoint]) => {
const chunkIds = rawChunkIds
?.matchAll(Webpack.ChunkIdsRegex)
.map(m => {
const numChunkId = Number(m[1]);
return Number.isNaN(numChunkId) ? m[1] : numChunkId;
})
.toArray()
?? [];
if (chunkIds.length === 0) {
return;
@ -96,9 +90,11 @@ export async function loadLazyChunks() {
if (wreq.u(id) == null || wreq.u(id) === "undefined.js") continue;
const isWorkerAsset = await fetch(wreq.p + wreq.u(id))
.then(r => r.text())
.then(t => /importScripts\(|self\.postMessage/.test(t));
const isWorkerAsset = await queue(() =>
fetch(wreq.p + wreq.u(id))
.then(r => r.text())
.then(t => /importScripts\(|self\.postMessage/.test(t))
);
if (isWorkerAsset) {
invalidChunks.add(id);
@ -116,9 +112,9 @@ export async function loadLazyChunks() {
}));
// Loads all found valid chunk groups
await promiseAllSettledBatched(
await Promise.all(
Array.from(validChunkGroups)
.map(([chunkIds]) => () =>
.map(([chunkIds]) =>
Promise.all(chunkIds.map(id => wreq.e(id)))
)
);
@ -194,7 +190,7 @@ export async function loadLazyChunks() {
return !(validChunks.has(id) || invalidChunks.has(id));
});
await promiseAllSettledBatched(chunksLeft.map(id => async () => {
await Promise.all(chunksLeft.map(async id => queue(async () => {
const isWorkerAsset = await fetch(wreq.p + wreq.u(id))
.then(r => r.text())
.then(t => /importScripts\(|self\.postMessage/.test(t));
@ -203,7 +199,7 @@ export async function loadLazyChunks() {
if (!isWorkerAsset) {
await wreq.e(id);
}
}));
})));
LazyChunkLoaderLogger.log("Finished loading all chunks!");
} catch (e) {

View file

@ -16,7 +16,7 @@ export default definePlugin({
{
find: '?"BACK_FORWARD_NAVIGATION":',
replacement: {
match: /(?<="HELP"===.{0,150}className:\i\.\i\}\))(?=\])/,
match: /(?<="HELP"===.{0,75}\{\}\))(?=\])/,
replace: ",...Vencord.Api.HeaderBar._addHeaderBarButtons()"
}
},

View file

@ -0,0 +1,37 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { Devs } from "@utils/constants";
import definePlugin from "@utils/types";
export default definePlugin({
name: "ProfileCollectionsAPI",
description: "API to add collections to the user profile panel like discords game collection.",
authors: [Devs.thororen],
patches: [
{
find: /\.POPOUT,onClose:\i}\),nicknameIcons:.+?\.isProvisional/,
replacement: {
match: /user:\i,widgets:.{0,100}?\}\),/,
replace: "$&Vencord.Api.ProfileCollections.renderProfileCollections(arguments[0]),",
}
},
{
find: '"UserProfileAccountPopout"',
replacement: {
match: /user:\i,widgets:.{0,100}}\),/,
replace: "$&Vencord.Api.ProfileCollections.renderProfileCollections(arguments[0]),",
},
},
{
find: ".SIDEBAR,disableToolbar:",
replacement: {
match: /user:(\i),widgets:.{0,100}?\}\),/,
replace: "$&Vencord.Api.ProfileCollections.renderProfileCollections({...arguments[0],isSideBar:true}),"
}
}
]
});

View file

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

View file

@ -1,251 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
// alot of the code is from JellyfinRPC
import { definePluginSettings } from "@api/Settings";
import { HeadingSecondary } from "@components/Heading";
import { Paragraph } from "@components/Paragraph";
import { EquicordDevs } from "@utils/constants";
import { Logger } from "@utils/Logger";
import definePlugin, { OptionType } from "@utils/types";
import { ApplicationAssetUtils, FluxDispatcher, showToast } from "@webpack/common";
interface ActivityAssets {
large_image?: string;
large_text?: string;
small_image?: string;
small_text?: string;
}
interface Activity {
state: string;
details?: string;
timestamps?: {
start?: number;
};
assets?: ActivityAssets;
name: string;
application_id: string;
metadata?: {
button_urls?: Array<string>;
};
type: number;
flags: number;
}
interface MediaData {
name: string;
type: string;
author?: string;
series?: string;
duration?: number;
currentTime?: number;
progress?: number;
url?: string;
imageUrl?: string;
isFinished?: boolean;
}
const settings = definePluginSettings({
serverUrl: {
description: "AudioBookShelf server URL (e.g., https://abs.example.com)",
type: OptionType.STRING,
},
username: {
description: "AudioBookShelf username",
type: OptionType.STRING,
},
password: {
description: "AudioBookShelf password",
type: OptionType.STRING,
},
});
const applicationId = "1381423044907503636";
const logger = new Logger("AudioBookShelfRichPresence");
let authToken: string | null = null;
let updateInterval: NodeJS.Timeout | undefined;
async function getApplicationAsset(key: string): Promise<string> {
return (await ApplicationAssetUtils.fetchAssetIds(applicationId, [key]))[0];
}
function setActivity(activity: Activity | null) {
FluxDispatcher.dispatch({
type: "LOCAL_ACTIVITY_UPDATE",
activity,
socketId: "ABSRPC",
});
}
export default definePlugin({
name: "AudioBookShelfRichPresence",
description: "Rich presence for AudioBookShelf media server",
authors: [EquicordDevs.vmohammad],
settingsAboutComponent: () => (
<>
<HeadingSecondary>How to connect to AudioBookShelf</HeadingSecondary>
<Paragraph>
Enter your AudioBookShelf server URL, username, and password to display your currently playing audiobooks as Discord Rich Presence.
<br /><br />
The plugin will automatically authenticate and fetch your listening progress.
</Paragraph>
</>
),
settings,
start() {
this.updatePresence();
updateInterval = setInterval(() => { this.updatePresence(); }, 10000);
},
stop() {
clearInterval(updateInterval);
updateInterval = undefined;
},
async authenticate(): Promise<boolean> {
if (!settings.store.serverUrl || !settings.store.username || !settings.store.password) {
logger.warn("AudioBookShelf server URL, username, or password is not set in settings.");
showToast("AudioBookShelf RPC is not configured.", "failure", {
duration: 15000,
});
return false;
}
try {
const baseUrl = settings.store.serverUrl.replace(/\/$/, "");
const url = `${baseUrl}/login`;
const res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
username: settings.store.username,
password: settings.store.password,
}),
});
if (!res.ok) throw `${res.status} ${res.statusText}`;
const data = await res.json();
authToken = data.user?.token;
return !!authToken;
} catch (e) {
logger.error("Failed to authenticate with AudioBookShelf", e);
authToken = null;
return false;
}
},
async fetchMediaData(): Promise<MediaData | null> {
if (!authToken && !(await this.authenticate())) {
return null;
}
const isPlayingNow = session => {
const now = Date.now();
const lastUpdate = session.updatedAt;
const diffSeconds = (now - lastUpdate) / 1000;
return diffSeconds <= 30;
};
try {
const baseUrl = settings.store.serverUrl!.replace(/\/$/, "");
const url = `${baseUrl}/api/me/listening-sessions`;
const res = await fetch(url, {
headers: {
"Authorization": `Bearer ${authToken}`,
},
});
if (!res.ok) {
if (res.status === 401) {
authToken = null;
if (await this.authenticate()) {
return this.fetchMediaData();
}
}
throw `${res.status} ${res.statusText}`;
}
const { sessions } = await res.json();
const activeSession = sessions.find((session: any) =>
session.updatedAt && !session.isFinished
);
if (!activeSession || !isPlayingNow(activeSession)) return null;
const { mediaMetadata: media, mediaType, duration, currentTime, libraryItemId } = activeSession;
if (!media) return null;
console.log(media);
return {
name: media.title || "Unknown",
type: mediaType || "book",
author: media.author || media.publisher,
series: media.series[0]?.name,
duration,
currentTime,
imageUrl: libraryItemId ? `${baseUrl}/api/items/${libraryItemId}/cover` : undefined,
isFinished: activeSession.isFinished || false,
};
} catch (e) {
logger.error("Failed to query AudioBookShelf API", e);
return null;
}
},
async updatePresence() {
setActivity(await this.getActivity());
},
async getActivity(): Promise<Activity | null> {
const mediaData = await this.fetchMediaData();
if (!mediaData || mediaData.isFinished) return null;
const largeImage = mediaData.imageUrl;
console.log("Large Image URL:", largeImage);
const assets: ActivityAssets = {
large_image: largeImage ? await getApplicationAsset(largeImage) : await getApplicationAsset("audiobookshelf"),
large_text: mediaData.series || mediaData.author || undefined,
};
const getDetails = () => {
return mediaData.name;
};
const getState = () => {
if (mediaData.series && mediaData.author) {
return `${mediaData.series} • ${mediaData.author}`;
}
return mediaData.author || "AudioBook";
};
const timestamps = mediaData.currentTime && mediaData.duration ? {
start: Date.now() - (mediaData.currentTime * 1000),
end: Date.now() + ((mediaData.duration - mediaData.currentTime) * 1000)
} : undefined;
return {
application_id: applicationId,
name: "AudioBookShelf",
details: getDetails(),
state: getState(),
assets,
timestamps,
type: 2,
flags: 1,
};
}
});

View file

@ -4,316 +4,291 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import "./style.css";
import "./styles.css";
import { definePluginSettings } from "@api/Settings";
import { EquicordDevs } from "@utils/constants";
import { classNameFactory } from "@utils/css";
import definePlugin, { OptionType } from "@utils/types";
import { showToast, Toasts } from "@webpack/common";
import { ColorUtils, React, showToast, Toasts } from "@webpack/common";
const fileSizeLimit = 12e6;
const cl = classNameFactory("vc-better-audio-player-");
const CORS_PROXY = "https://cors.keiran0.workers.dev?url=";
const MAX_FILE_SIZE = 12e6;
function parseFileSize(size: string) {
const [value, unit] = size.split(" ");
const multiplier = {
B: 1,
KB: 1024,
MB: 1024 ** 2,
GB: 1024 ** 3,
TB: 1024 ** 4,
}[unit];
if (!multiplier) return;
return parseFloat(value) * multiplier;
interface PlayerInstance {
mediaRef: React.RefObject<HTMLAudioElement>;
props: { src: string; type: string; };
}
function getMetadata(audioElement: HTMLElement) {
const metadataElement = audioElement.querySelector("[class*='metadataContent']");
const nameElement = metadataElement?.querySelector("a");
const sizeElement = audioElement.querySelector("[class*='metadataContent'] [class*='metadataSize']");
const url = nameElement?.getAttribute("href");
const audioElementLink = audioElement.querySelector("audio");
function validateColor(value: string, key: string, fallback: string) {
if (/^\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}$/.test(value)) return;
if (!sizeElement?.textContent || !nameElement?.textContent || !url || !audioElementLink) return false;
const name = nameElement.textContent;
const size = parseFileSize(sizeElement.textContent);
if (size && size > fileSizeLimit) {
return false;
}
const elements = [metadataElement?.parentElement, audioElement.querySelector("[class*='audioControls']")];
const computedStyle = getComputedStyle(audioElement);
const parentBorderRadius = computedStyle.borderRadius;
if (settings.store.forceMoveBelow) {
elements.forEach(element => {
if (element) (element as HTMLElement).style.zIndex = "2";
});
}
return {
name,
size,
url,
audio: audioElementLink,
parentBorderRadius: parentBorderRadius,
};
}
async function addListeners(audioElement: HTMLAudioElement, url: string, parentBorderRadius: string) {
const madeURL = new URL(url);
madeURL.searchParams.set("t", Date.now().toString());
const corsProxyUrl = "https://cors.keiran0.workers.dev?url=" + encodeURIComponent(madeURL.href);
const response = await fetch(corsProxyUrl);
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
const audioContext = new AudioContext();
const analyser = audioContext.createAnalyser();
analyser.fftSize = 2048;
const bufferLength = analyser.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
const frequencyData = new Uint8Array(bufferLength);
const source = audioContext.createMediaElementSource(audioElement);
source.connect(analyser);
analyser.connect(audioContext.destination);
const canvas = document.createElement("canvas");
const canvasContext = canvas.getContext("2d");
if (!canvasContext) return;
canvas.classList.add("better-audio-visualizer");
audioElement.parentElement?.appendChild(canvas);
if (parentBorderRadius) canvas.style.borderRadius = parentBorderRadius;
function drawVisualizer() {
if (!audioElement.paused) {
requestAnimationFrame(drawVisualizer);
try {
const rgb = ColorUtils.hexToRgb(value.replace("#", ""));
if (rgb) {
settings.store[key] = `${rgb.r}, ${rgb.g}, ${rgb.b}`;
return;
}
} catch { /* invalid hex */ }
analyser.getByteTimeDomainData(dataArray);
analyser.getByteFrequencyData(frequencyData);
if (!canvasContext) return;
canvasContext.clearRect(0, 0, canvas.width, canvas.height);
if (settings.store.oscilloscope) drawOscilloscope(canvasContext, canvas, dataArray, bufferLength);
if (settings.store.spectrograph) drawSpectrograph(canvasContext, canvas, frequencyData, bufferLength);
}
audioElement.src = blobUrl;
audioElement.addEventListener("play", () => {
if (audioContext.state === "suspended") {
audioContext.resume();
}
drawVisualizer();
});
audioElement.addEventListener("pause", () => {
audioContext.suspend();
});
showToast(`Invalid color format for ${key}, use "R, G, B" or "#RRGGBB"`, Toasts.Type.FAILURE);
settings.store[key] = fallback;
}
function drawOscilloscope(canvasContext, canvas, dataArray, bufferLength) {
const sliceWidth = canvas.width / bufferLength;
function maxTypedArray(arr: Uint8Array<ArrayBufferLike>): number {
let max = 0;
for (let i = 0; i < arr.length; i++) {
if (arr[i] > max) max = arr[i];
}
return max;
}
function drawOscilloscope(ctx: CanvasRenderingContext2D, w: number, h: number, dataArray: Uint8Array<ArrayBufferLike>, bufferLength: number) {
const sliceWidth = w / bufferLength;
const [r, g, b] = settings.store.oscilloscopeColor.split(",").map(Number);
const amp = 3;
let x = 0;
const { oscilloscopeSolidColor, oscilloscopeColor } = settings.store;
const [r, g, b] = oscilloscopeColor.split(",").map(Number);
canvasContext.lineWidth = 2;
canvasContext.clearRect(0, 0, canvas.width, canvas.height);
canvasContext.beginPath();
ctx.lineWidth = 2;
ctx.beginPath();
for (let i = 0; i < bufferLength; i++) {
const v = dataArray[i] / 128.0;
const y = (v * canvas.height) / 2;
const v = (dataArray[i] - 128) / 128;
const y = (h / 2) - (v * amp * h / 2);
if (oscilloscopeSolidColor) {
canvasContext.strokeStyle = `rgb(${r}, ${g}, ${b})`;
if (settings.store.oscilloscopeSolidColor) {
ctx.strokeStyle = `rgb(${r}, ${g}, ${b})`;
} else {
const red = Math.min(r + (v * 100) + (i / bufferLength) * 155, 255);
const green = Math.min(g + (v * 50) + (i / bufferLength) * 155, 255);
const blue = Math.min(b + (v * 150) + (i / bufferLength) * 155, 255);
canvasContext.strokeStyle = `rgb(${red}, ${green}, ${blue})`;
}
if (i === 0) {
canvasContext.moveTo(x, y);
} else {
canvasContext.lineTo(x, y);
const absV = Math.abs(v);
ctx.strokeStyle = `rgb(${Math.min(r + absV * 100 + (i / bufferLength) * 155, 255)}, ${Math.min(g + absV * 50 + (i / bufferLength) * 155, 255)}, ${Math.min(b + absV * 150 + (i / bufferLength) * 155, 255)})`;
}
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
x += sliceWidth;
}
canvasContext.stroke();
ctx.stroke();
}
function drawSpectrograph(canvasContext, canvas, frequencyData, bufferLength) {
const { spectrographSolidColor, spectrographColor } = settings.store;
const maxHeight = canvas.height;
const barWidth = canvas.width / bufferLength;
function drawSpectrograph(ctx: CanvasRenderingContext2D, w: number, h: number, frequencyData: Uint8Array<ArrayBufferLike>, bufferLength: number) {
const barWidth = w / bufferLength;
const maxVal = maxTypedArray(frequencyData);
if (maxVal === 0) return;
const [r, g, b] = settings.store.spectrographColor.split(",").map(Number);
let x = 0;
const maxFrequencyValue = Math.max(...frequencyData);
if (maxFrequencyValue === 0 || !isFinite(maxFrequencyValue)) {
return;
}
for (let i = 0; i < bufferLength; i++) {
const normalizedHeight = (frequencyData[i] / maxFrequencyValue) * maxHeight;
const barH = (frequencyData[i] / maxVal) * h;
if (spectrographSolidColor) {
canvasContext.fillStyle = `rgb(${spectrographColor})`;
if (settings.store.spectrographSolidColor) {
ctx.fillStyle = `rgb(${r}, ${g}, ${b})`;
} else {
const [r, g, b] = spectrographColor.split(",").map(Number);
const red = Math.min(r + (i / bufferLength) * 155, 255);
const green = Math.min(g + (i / bufferLength) * 155, 255);
const blue = Math.min(b + (i / bufferLength) * 155, 255);
const gradient = canvasContext.createLinearGradient(x, canvas.height - normalizedHeight, x, canvas.height);
const gradient = ctx.createLinearGradient(x, h - barH, x, h);
gradient.addColorStop(0, `rgb(${red}, ${green}, ${blue})`);
const darkerColor = `rgb(${Math.max(red - 50, 0)},${Math.max(green - 50, 0)},${Math.max(blue - 50, 0)})`;
gradient.addColorStop(1, darkerColor);
canvasContext.fillStyle = gradient;
gradient.addColorStop(1, `rgb(${Math.max(red - 50, 0)}, ${Math.max(green - 50, 0)}, ${Math.max(blue - 50, 0)})`);
ctx.fillStyle = gradient;
}
canvasContext.fillRect(x, canvas.height - normalizedHeight, barWidth, normalizedHeight);
ctx.fillRect(x, h - barH, barWidth, barH);
x += barWidth + 0.5;
}
}
function scanForAudioElements(element: HTMLElement) {
element.querySelectorAll("[class*='wrapperAudio']:not([data-better-audio-processed])").forEach(audioElement => {
(audioElement as HTMLElement).dataset.betterAudioProcessed = "true";
const metadata = getMetadata(audioElement as HTMLElement);
async function fetchAudioBlob(src: string): Promise<string | null> {
const url = new URL(src);
url.searchParams.set("t", Date.now().toString());
if (!metadata) return;
const response = await fetch(CORS_PROXY + encodeURIComponent(url.href));
const contentLength = response.headers.get("content-length");
if (contentLength && Number(contentLength) > MAX_FILE_SIZE) return null;
addListeners(metadata.audio, metadata.url, metadata.parentBorderRadius);
});
const blob = await response.blob();
return URL.createObjectURL(blob);
}
function createObserver(targetNode: HTMLElement) {
const observer = new MutationObserver(mutations => {
mutations.forEach(mutation => {
if (mutation.type === "childList") {
mutation.addedNodes.forEach(addedNode => {
if (addedNode instanceof HTMLElement) {
scanForAudioElements(addedNode);
}
});
function Visualizer({ playerRef, src }: { playerRef: React.RefObject<HTMLAudioElement>; src: string; }) {
const canvasRef = React.useRef<HTMLCanvasElement>(null);
const audioCtxRef = React.useRef<AudioContext | null>(null);
const analyserRef = React.useRef<AnalyserNode | null>(null);
const animFrameRef = React.useRef(0);
const setupDoneRef = React.useRef(false);
const blobUrlRef = React.useRef<string | null>(null);
React.useEffect(() => {
const audio = playerRef.current;
const canvas = canvasRef.current;
if (!audio || !canvas) return () => { };
let cancelled = false;
const init = async () => {
const blobUrl = await fetchAudioBlob(src).catch(() => null);
if (cancelled || !blobUrl) return;
blobUrlRef.current = blobUrl;
const wasPlaying = !audio.paused;
const { currentTime } = audio;
audio.src = blobUrl;
audio.currentTime = currentTime;
const audioCtx = new AudioContext();
const analyser = audioCtx.createAnalyser();
analyser.fftSize = 2048;
const source = audioCtx.createMediaElementSource(audio);
source.connect(analyser);
analyser.connect(audioCtx.destination);
audioCtxRef.current = audioCtx;
analyserRef.current = analyser;
setupDoneRef.current = true;
if (wasPlaying) {
audio.play().catch(() => { });
}
});
});
observer.observe(targetNode, {
childList: true,
subtree: true,
});
return observer;
}
};
function tryHexToRgb(hex) {
if (hex.startsWith("#")) {
const hexMatch = hex.match(/\w\w/g);
if (hexMatch) {
const [r, g, b] = hexMatch.map(x => parseInt(x, 16));
return `${r}, ${g}, ${b}`;
}
}
return hex;
}
const canvasCtx = canvas.getContext("2d");
let dataArray: Uint8Array<ArrayBuffer> | null = null;
let frequencyData: Uint8Array<ArrayBuffer> | null = null;
function handleColorChange(value, settingKey, defaultValue) {
const rgbPattern = /^(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})$/;
const draw = () => {
const analyser = analyserRef.current;
if (!canvasCtx || !analyser) return;
if (!value.match(rgbPattern)) {
const rgb = tryHexToRgb(value);
if (!dataArray || !frequencyData) {
const bufferLength = analyser.frequencyBinCount;
dataArray = new Uint8Array(bufferLength);
frequencyData = new Uint8Array(bufferLength);
}
if (rgb.match(rgbPattern)) {
settings.store[settingKey] = rgb;
} else {
showToast(`Invalid color format for ${settingKey}, make sure it's in the format 'R, G, B' or '#RRGGBB'`, Toasts.Type.FAILURE);
settings.store[settingKey] = defaultValue;
}
} else {
settings.store[settingKey] = value;
}
if (!audio.paused) animFrameRef.current = requestAnimationFrame(draw);
analyser.getByteTimeDomainData(dataArray);
analyser.getByteFrequencyData(frequencyData);
const { width, height } = canvas.getBoundingClientRect();
canvasCtx.clearRect(0, 0, width, height);
if (settings.store.oscilloscope) drawOscilloscope(canvasCtx, width, height, dataArray, dataArray.length);
if (settings.store.spectrograph) drawSpectrograph(canvasCtx, width, height, frequencyData, frequencyData.length);
};
const onPlay = () => {
if (!setupDoneRef.current) return;
if (audioCtxRef.current?.state === "suspended") {
audioCtxRef.current.resume();
}
draw();
};
const onPause = () => {
audioCtxRef.current?.suspend();
cancelAnimationFrame(animFrameRef.current);
};
audio.addEventListener("play", onPlay);
audio.addEventListener("pause", onPause);
init();
return () => {
cancelled = true;
audio.removeEventListener("play", onPlay);
audio.removeEventListener("pause", onPause);
cancelAnimationFrame(animFrameRef.current);
audioCtxRef.current?.close();
audioCtxRef.current = null;
analyserRef.current = null;
setupDoneRef.current = false;
if (blobUrlRef.current) {
URL.revokeObjectURL(blobUrlRef.current);
blobUrlRef.current = null;
}
};
}, [playerRef]);
React.useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return () => { };
const resize = () => {
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * window.devicePixelRatio;
canvas.height = rect.height * window.devicePixelRatio;
const ctx = canvas.getContext("2d");
ctx?.scale(window.devicePixelRatio, window.devicePixelRatio);
};
resize();
const observer = new ResizeObserver(resize);
observer.observe(canvas);
return () => observer.disconnect();
}, []);
return (
<canvas
className={cl("canvas")}
ref={canvasRef}
/>
);
}
const settings = definePluginSettings({
oscilloscope: {
type: OptionType.BOOLEAN,
description: "Enable oscilloscope visualizer",
description: "Enable oscilloscope visualizer.",
default: true,
},
spectrograph: {
type: OptionType.BOOLEAN,
description: "Enable spectrograph visualizer",
description: "Enable spectrograph visualizer.",
default: true,
},
oscilloscopeSolidColor: {
type: OptionType.BOOLEAN,
description: "Use solid color for oscilloscope",
description: "Use a solid color for the oscilloscope.",
default: false,
},
oscilloscopeColor: {
type: OptionType.STRING,
description: "Color for oscilloscope",
description: "Color for the oscilloscope (R, G, B or #hex).",
default: "255, 255, 255",
onChange: value => handleColorChange(value, "oscilloscopeColor", "255, 255, 255"),
onChange: value => validateColor(value, "oscilloscopeColor", "255, 255, 255"),
},
spectrographSolidColor: {
type: OptionType.BOOLEAN,
description: "Use solid color for spectrograph",
description: "Use a solid color for the spectrograph.",
default: false,
},
spectrographColor: {
type: OptionType.STRING,
description: "Color for spectrograph",
description: "Color for the spectrograph (R, G, B or #hex).",
default: "33, 150, 243",
onChange: value => handleColorChange(value, "spectrographColor", "33, 150, 243"),
},
forceMoveBelow: {
type: OptionType.BOOLEAN,
description: "Force the visualizer below the audio player",
default: true,
onChange: value => validateColor(value, "spectrographColor", "33, 150, 243"),
},
});
let observer: MutationObserver | null = null;
export default definePlugin({
name: "BetterAudioPlayer",
description: "Adds a spectrograph and oscilloscope visualizer to audio attachment players",
description: "Adds a spectrograph and oscilloscope visualizer to audio attachment players.",
authors: [EquicordDevs.creations],
settings,
start() {
const waitForContent = () => {
const targetNode = document.querySelector("[class*='content']");
if (targetNode) {
scanForAudioElements(targetNode as HTMLElement);
observer = createObserver(targetNode as HTMLElement);
} else {
requestAnimationFrame(waitForContent);
}
};
waitForContent();
},
stop() {
observer?.disconnect();
observer = null;
patches: [
{
find: "}renderPlayIcon(){",
replacement: {
match: /this\.renderAudio\(\):this\.renderVideo\(\)/,
replace: "$&,$self.renderVisualizer(this)",
},
},
],
renderVisualizer(player: PlayerInstance) {
if (player.props.type !== "AUDIO") return null;
return <Visualizer playerRef={player.mediaRef} src={player.props.src} key={player.props.src} />;
},
});

View file

@ -1,4 +1,4 @@
.better-audio-visualizer {
.vc-better-audio-player-canvas {
position: absolute;
top: 0;
left: 0;
@ -7,4 +7,5 @@
pointer-events: none;
z-index: 1;
border: none;
}
border-radius: inherit;
}

View file

@ -70,7 +70,7 @@ export default definePlugin({
commands: [
{
name: "refresh",
description: "Refreshes Discord application commands locally",
description: "Refreshes the specified applications commands locally",
options: [
{
name: "user",

View file

@ -46,7 +46,7 @@ export default definePlugin({
]
},
{
find: "onlineCount})})]",
find: "isBannerVisible:!1})},",
replacement: [
{
// make the button clickable

View file

@ -5,14 +5,14 @@
*/
import { BaseText } from "@components/BaseText";
import { BasicChannelTabsProps, Bookmark, BookmarkFolder, BookmarkProps, isBookmarkFolder, isTabSelected, navigateToBookmark, openedTabs, settings, switchChannel, useBookmarks } from "@equicordplugins/channelTabs/util";
import { BasicChannelTabsProps, Bookmark, BookmarkFolder, BookmarkProps, getDiscordFolderIcon, isBookmarkFolder, isTabSelected, navigateToBookmark, openedTabs, settings, switchChannel, UseBookmarkMethods, useBookmarks } from "@equicordplugins/channelTabs/util";
import { CircleQuestionIcon, DiscoveryIcon, EnvelopeIcon, FriendsIcon, NitroIcon, QuestIcon, ShopIcon } from "@equicordplugins/channelTabs/util/icons";
import { classNameFactory } from "@utils/css";
import { getGuildAcronym, getIntlMessage } from "@utils/discord";
import { classes } from "@utils/misc";
import { closeModal, openModal } from "@utils/modal";
import { findComponentByCodeLazy } from "@webpack";
import { Avatar, ChannelStore, ContextMenuApi, FluxDispatcher, GuildStore, Menu, React, ReadStateStore, ReadStateUtils, SelectedChannelStore, SelectedGuildStore, Tooltip, useDrag, useDrop, useEffect, useRef, UserStore, useState } from "@webpack/common";
import { Avatar, ChannelStore, ContextMenuApi, FluxDispatcher, GuildStore, Menu, React, ReadStateStore, ReadStateUtils, SelectedChannelStore, SelectedGuildStore, TextInput, Tooltip, useDrag, useDrop, useEffect, useRef, UserStore, useState } from "@webpack/common";
import { NotificationDot } from "./ChannelTab";
import { BookmarkContextMenu, EditModal } from "./ContextMenus";
@ -44,15 +44,28 @@ function FolderIcon({ fill }: { fill: string; }) {
}
function BookmarkIcon({ bookmark }: { bookmark: Bookmark | BookmarkFolder; }) {
if (isBookmarkFolder(bookmark)) return (
<svg
height={16}
width={16}
viewBox="0 0 24 24"
>
<FolderIcon fill={bookmark.iconColor} />
</svg>
);
if (isBookmarkFolder(bookmark)) {
const IconComponent = getDiscordFolderIcon(bookmark.iconName);
const fill = bookmark.iconName === "CircleShieldIcon" ? "var(--background-base-low)" : bookmark.iconColor;
const customIconSize = 16;
return IconComponent
? <IconComponent
height={customIconSize}
width={customIconSize}
color={bookmark.iconColor}
fill={fill}
style={{ transform: "scale(0.6666667)", transformOrigin: "center" }}
/>
: <svg
height={16}
width={16}
viewBox="0 0 24 24"
>
<FolderIcon fill={bookmark.iconColor} />
</svg>;
}
// Handle special synthetic pages
const { channelId } = bookmark;
@ -205,19 +218,22 @@ function BookmarkFolderOpenMenu(props: BookmarkProps) {
);
}
function FolderBookmarkItem({ bookmark, folderIndex, bookmarkIndex, methods, isCurrentChannel }: {
function FolderBookmarkItem({ bookmark, bookmarks, folderIndex, bookmarkIndex, methods, isCurrentChannel, searchActive }: {
bookmark: Bookmark;
bookmarks: (Bookmark | BookmarkFolder)[];
folderIndex: number;
bookmarkIndex: number;
methods: any;
methods: UseBookmarkMethods;
isCurrentChannel?: boolean;
searchActive?: boolean;
}) {
const { bookmarkNotificationDot } = settings.use(["bookmarkNotificationDot"]);
const ref = useRef<HTMLDivElement>(null);
// drag implementation for folder items
const [, drag] = useDrag(() => ({
const [{ isDragging }, drag] = useDrag(() => ({
type: "vc_Bookmark",
canDrag: () => !searchActive,
item: () => ({
index: bookmarkIndex, // index within folder
folderIndex: folderIndex, // the folder its in
@ -227,17 +243,68 @@ function FolderBookmarkItem({ bookmark, folderIndex, bookmarkIndex, methods, isC
collect: monitor => ({
isDragging: !!monitor.isDragging()
}),
}), [bookmark, folderIndex, bookmarkIndex]);
}), [bookmark, folderIndex, bookmarkIndex, searchActive]);
// combine drag ref
drag(ref);
const [dropSide, setDropSide] = useState<"before" | "after" | null>(null);
const [{ isOver: isReorderOver, canDrop: canReorderDrop }, drop] = useDrop(() => ({
accept: "vc_Bookmark",
canDrop: item => !searchActive && item.isFromFolder && item.folderIndex === folderIndex,
hover: (item, monitor) => {
if (!ref.current || searchActive || !item.isFromFolder || item.folderIndex !== folderIndex) return;
const dragIndex = item.index;
const hoverIndex = bookmarkIndex;
if (dragIndex === hoverIndex) {
setDropSide(null);
return;
}
const hoverBoundingRect = ref.current.getBoundingClientRect();
const hoverMiddleX = (hoverBoundingRect.right - hoverBoundingRect.left) / 2;
const clientOffset = monitor.getClientOffset();
if (!clientOffset) return;
const hoverClientX = clientOffset.x - hoverBoundingRect.left;
const nextDropSide = hoverClientX < hoverMiddleX ? "before" : "after";
setDropSide(nextDropSide);
if (dragIndex < hoverIndex && hoverClientX < hoverMiddleX
|| dragIndex > hoverIndex && hoverClientX > hoverMiddleX) {
return;
}
const folder = bookmarks[folderIndex];
if (!isBookmarkFolder(folder)) return;
const newBookmarks = [...folder.bookmarks];
const moved = newBookmarks.splice(dragIndex, 1)[0];
newBookmarks.splice(hoverIndex, 0, moved);
methods.editBookmark(folderIndex, { bookmarks: newBookmarks });
item.index = hoverIndex;
},
collect: monitor => ({
isOver: monitor.isOver({ shallow: true }),
canDrop: monitor.canDrop()
})
}), [bookmarkIndex, bookmarks, folderIndex, methods, searchActive]);
useEffect(() => {
if (!isReorderOver && dropSide) setDropSide(null);
}, [dropSide, isReorderOver]);
drag(drop(ref));
return (
<div
ref={ref}
className={classes(
cl("bookmark", "folder-item", "hoverable"),
isCurrentChannel && cl("bookmark-active")
isCurrentChannel && cl("bookmark-active"),
isDragging && cl("bookmark-dragging"),
isReorderOver && canReorderDrop && cl("bookmark-drop-target"),
isReorderOver && canReorderDrop && dropSide === "before" && cl("bookmark-drop-before"),
isReorderOver && canReorderDrop && dropSide === "after" && cl("bookmark-drop-after")
)}
onClick={() => navigateToBookmark(bookmark)}
onContextMenu={e => {
@ -258,7 +325,9 @@ function FolderBookmarkItem({ bookmark, folderIndex, bookmarkIndex, methods, isC
modalKey={key}
bookmark={bookmark}
onSave={name => {
const folder = methods.bookmarks[folderIndex] as BookmarkFolder;
const folder = bookmarks[folderIndex];
if (!isBookmarkFolder(folder)) return;
const newBookmarks = [...folder.bookmarks];
newBookmarks[bookmarkIndex].name = name;
methods.editBookmark(folderIndex, { bookmarks: newBookmarks });
@ -307,16 +376,17 @@ function FolderBookmarkItem({ bookmark, folderIndex, bookmarkIndex, methods, isC
);
}
function Bookmark(props: BookmarkProps & { isExpanded?: boolean; onToggleFolder?: () => void; isCurrentChannel?: boolean; }) {
const { bookmarks, index, methods, isExpanded, onToggleFolder, isCurrentChannel } = props;
function Bookmark(props: BookmarkProps & { isExpanded?: boolean; onToggleFolder?: () => void; isCurrentChannel?: boolean; searchActive?: boolean; }) {
const { bookmarks, index, methods, isExpanded, onToggleFolder, isCurrentChannel, searchActive } = props;
const bookmark = bookmarks[index];
const { bookmarkNotificationDot } = settings.use(["bookmarkNotificationDot"]);
const ref = useRef<HTMLDivElement>(null);
const [dropSide, setDropSide] = useState<"before" | "after" | null>(null);
const [, drag] = useDrag(() => ({
const [{ isDragging }, drag] = useDrag(() => ({
type: "vc_Bookmark",
canDrag: () => !isBookmarkFolder(bookmark), // Don't allow dragging folders
canDrag: () => !isBookmarkFolder(bookmark) && !searchActive,
item: () => {
// find current index dynamically instead of using stale closure
const currentIndex = bookmarks.findIndex(b =>
@ -334,22 +404,30 @@ function Bookmark(props: BookmarkProps & { isExpanded?: boolean; onToggleFolder?
collect: monitor => ({
isDragging: !!monitor.isDragging()
}),
}), [bookmarks, bookmark, index]);
}), [bookmarks, bookmark, index, searchActive]);
const [, drop] = useDrop(() => ({
const [{ isOver: isReorderOver, canDrop: canReorderDrop }, drop] = useDrop(() => ({
accept: "vc_Bookmark",
canDrop: item => !searchActive && !item.isFromFolder,
hover: (item, monitor) => {
if (!ref.current) return;
if (!ref.current || searchActive || item.isFromFolder) return;
const dragIndex = item.index;
const hoverIndex = index;
if (dragIndex === hoverIndex) return;
if (dragIndex === hoverIndex) {
setDropSide(null);
return;
}
const hoverBoundingRect = ref.current?.getBoundingClientRect();
const hoverMiddleX =
(hoverBoundingRect.right - hoverBoundingRect.left) / 2;
const hoverBoundingRect = ref.current.getBoundingClientRect();
const hoverMiddleX = (hoverBoundingRect.right - hoverBoundingRect.left) / 2;
const clientOffset = monitor.getClientOffset();
if (!clientOffset) return;
const hoverClientX = clientOffset.x - hoverBoundingRect.left;
const nextDropSide = hoverClientX < hoverMiddleX ? "before" : "after";
setDropSide(nextDropSide);
if (dragIndex < hoverIndex && hoverClientX < hoverMiddleX
|| dragIndex > hoverIndex && hoverClientX > hoverMiddleX) {
return;
@ -358,7 +436,15 @@ function Bookmark(props: BookmarkProps & { isExpanded?: boolean; onToggleFolder?
methods.moveDraggedBookmarks(dragIndex, hoverIndex);
item.index = hoverIndex;
},
}), []);
collect: monitor => ({
isOver: monitor.isOver({ shallow: true }),
canDrop: monitor.canDrop()
}),
}), [index, methods, searchActive]);
useEffect(() => {
if (!isReorderOver && dropSide) setDropSide(null);
}, [dropSide, isReorderOver]);
// separate drop zone for folders to accept tabs and bookmarks (prevents duplicates)
const [{ isOver: isFolderDropTarget }, folderDrop] = useDrop(() => ({
@ -416,9 +502,14 @@ function Bookmark(props: BookmarkProps & { isExpanded?: boolean; onToggleFolder?
<div
className={classes(
cl("bookmark", "hoverable", { wider: settings.store.widerTabsAndBookmarks }),
isBookmarkFolder(bookmark) && cl("bookmark-folder"),
isFolderDropTarget && cl("folder-drop-target"),
isExpanded && cl("folder-expanded"),
isCurrentChannel && cl("bookmark-active")
isCurrentChannel && cl("bookmark-active"),
isDragging && cl("bookmark-dragging"),
isReorderOver && canReorderDrop && cl("bookmark-drop-target"),
isReorderOver && canReorderDrop && dropSide === "before" && cl("bookmark-drop-before"),
isReorderOver && canReorderDrop && dropSide === "after" && cl("bookmark-drop-after")
)}
ref={ref}
onClick={e => {
@ -485,6 +576,9 @@ export default function BookmarkContainer(props: BasicChannelTabsProps & { userI
const { guildId, channelId, userId } = props;
const [bookmarks, methods] = useBookmarks(userId);
const [expandedFolders, setExpandedFolders] = useState<Set<number>>(new Set());
const [isSearchOpen, setIsSearchOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const searchInputRef = useRef<HTMLInputElement>(null);
// make setting reactive to changes
const { bookmarksIndependentFromTabs } = settings.use(["bookmarksIndependentFromTabs"]);
@ -498,6 +592,14 @@ export default function BookmarkContainer(props: BasicChannelTabsProps & { userI
activeTab.channelId === currentChannelId &&
(activeTab.guildId || "@me") === currentGuildId;
const normalizedSearchQuery = searchQuery.trim().toLowerCase();
const searchActive = normalizedSearchQuery.length > 0;
useEffect(() => {
if (!isSearchOpen) return;
searchInputRef.current?.focus();
}, [isSearchOpen]);
const toggleFolder = (folderIndex: number) => {
setExpandedFolders(prev => {
const next = new Set(prev);
@ -523,8 +625,9 @@ export default function BookmarkContainer(props: BasicChannelTabsProps & { userI
const [{ isOver }, dropRef] = useDrop(() => ({
accept: ["vc_ChannelTab", "vc_Bookmark"],
canDrop: () => !searchActive,
drop: (item: any, monitor) => {
if (!bookmarks) return;
if (!bookmarks || searchActive) return;
// check if a nested drop zone (like a folder) already handled this
if (monitor.didDrop()) {
@ -557,7 +660,9 @@ export default function BookmarkContainer(props: BasicChannelTabsProps & { userI
collect: monitor => ({
isOver: monitor.isOver({ shallow: true })
})
}), [bookmarks, methods]);
}), [bookmarks, methods, searchActive]);
const matchesSearch = (value?: string) => value?.toLowerCase().includes(normalizedSearchQuery) ?? false;
return (
<div className={cl("bookmark-container")}>
@ -570,52 +675,121 @@ export default function BookmarkContainer(props: BasicChannelTabsProps & { userI
</BaseText>}
{Array.isArray(bookmarks) && bookmarks.length > 0 &&
bookmarks.flatMap((bookmark, i) => {
const items = [
if (!isBookmarkFolder(bookmark)) {
if (searchActive && !matchesSearch(bookmark.name)) return [];
return [
<Bookmark
key={i}
index={i}
bookmarks={bookmarks}
methods={methods}
searchActive={searchActive}
isExpanded={false}
isCurrentChannel={
bookmarksIndependentFromTabs &&
!channelMatchesActiveTab &&
bookmark.channelId === currentChannelId &&
(bookmark.guildId || "@me") === currentGuildId
}
/>
];
}
const folderMatches = searchActive ? matchesSearch(bookmark.name) : false;
const visibleFolderBookmarks = bookmark.bookmarks
.map((childBookmark, childIndex) => ({ childBookmark, childIndex }))
.filter(({ childBookmark }) => !searchActive || folderMatches || matchesSearch(childBookmark.name));
if (searchActive && !folderMatches && !visibleFolderBookmarks.length) return [];
const isExpanded = searchActive
? visibleFolderBookmarks.length > 0
: expandedFolders.has(i);
const folderItems = [
<Bookmark
key={i}
index={i}
bookmarks={bookmarks}
methods={methods}
isExpanded={expandedFolders.has(i)}
onToggleFolder={() => toggleFolder(i)}
isCurrentChannel={
bookmarksIndependentFromTabs &&
!channelMatchesActiveTab && (
!isBookmarkFolder(bookmark) &&
bookmark.channelId === currentChannelId &&
(bookmark.guildId || "@me") === currentGuildId
)
}
searchActive={searchActive}
isExpanded={isExpanded}
onToggleFolder={searchActive ? undefined : () => toggleFolder(i)}
isCurrentChannel={false}
/>
];
// if folder is expanded, render its bookmarks
if (isBookmarkFolder(bookmark) && expandedFolders.has(i)) {
bookmark.bookmarks.forEach((childBookmark, childIndex) => {
items.push(
if (isExpanded) {
visibleFolderBookmarks.forEach(({ childBookmark, childIndex }) => {
folderItems.push(
<FolderBookmarkItem
key={`${i}-${childIndex}`}
bookmark={childBookmark}
bookmarks={bookmarks}
folderIndex={i}
bookmarkIndex={childIndex}
methods={methods}
searchActive={searchActive}
isCurrentChannel={
bookmarksIndependentFromTabs &&
!channelMatchesActiveTab && (
childBookmark.channelId === currentChannelId &&
(childBookmark.guildId || "@me") === currentGuildId
)
!channelMatchesActiveTab &&
childBookmark.channelId === currentChannelId &&
(childBookmark.guildId || "@me") === currentGuildId
}
/>
);
});
}
return items;
return folderItems;
})
}
</HorizontalScroller>
<div className={classes(cl("bookmark-search-shell"), isSearchOpen && cl("bookmark-search-shell-open"))}>
<div className={cl("bookmark-search-field")}>
<TextInput
inputRef={searchInputRef}
inputClassName={cl("bookmark-search-input")}
style={{ width: "100%" }}
value={searchQuery}
onChange={setSearchQuery}
placeholder="Search bookmarks"
onBlur={() => {
if (!searchQuery.trim()) setIsSearchOpen(false);
}}
onKeyDown={e => {
if (e.key !== "Escape") return;
if (searchQuery.trim()) {
setSearchQuery("");
} else {
setIsSearchOpen(false);
}
}}
/>
</div>
<Tooltip text="Search bookmarks" position="left">
{p => <button
className={classes(cl("button"), cl("bookmark-search-button"))}
{...p}
onClick={() => {
if (isSearchOpen && !searchQuery.trim()) {
setIsSearchOpen(false);
return;
}
setIsSearchOpen(true);
}}
>
<svg width={20} height={20} viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx="11" cy="11" r="6.5" stroke="currentColor" strokeWidth="2" />
<path d="m16 16 4 4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
</button>}
</Tooltip>
</div>
<Tooltip text={isCurrentChannelBookmarked ? "Remove from Bookmarks" : "Add to Bookmarks"} position="left" >
{p => <button className={cl("button")} {...p} onClick={() => {
if (isCurrentChannelBookmarked) {

View file

@ -5,7 +5,7 @@
*/
import { BaseText } from "@components/BaseText";
import { ChannelTabsProps, closeTab, isTabSelected, moveDraggedTabs, moveToTab, openedTabs, settings } from "@equicordplugins/channelTabs/util";
import { ChannelTabsProps, closeTab, ensureUnreadFallbackCountsLoaded, getNotificationDotState, getUnreadFallbackCounts, isTabSelected, moveDraggedTabs, moveToTab, openedTabs, settings, updateUnreadFallbackCounts } from "@equicordplugins/channelTabs/util";
import { ActivityIcon, CircleQuestionIcon, DiscoveryIcon, EnvelopeIcon, FriendsIcon, ICYMIIcon, NitroIcon, QuestIcon, ShopIcon } from "@equicordplugins/channelTabs/util/icons";
import { activeQuestIntervals } from "@equicordplugins/questify"; // sorry murphy!
import { classNameFactory } from "@utils/css";
@ -13,7 +13,7 @@ import { getGuildAcronym, getIntlMessage, getUniqueUsername } from "@utils/disco
import { classes } from "@utils/misc";
import { Channel, Guild, User } from "@vencord/discord-types";
import { findComponentByCodeLazy, findCssClassesLazy } from "@webpack";
import { Avatar, ChannelStore, ContextMenuApi, GuildStore, PresenceStore, ReadStateStore, ThemeStore, TypingStore, useDrag, useDrop, useEffect, useRef, UserStore, useState, useStateFromStores } from "@webpack/common";
import { ActiveJoinedThreadsStore, Avatar, ChannelStore, ContextMenuApi, GuildStore, PresenceStore, ReadStateStore, TypingStore, useDrag, useDrop, useEffect, useRef, UserStore, useState, useStateFromStores } from "@webpack/common";
import { JSX } from "react";
import { TabContextMenu } from "./ContextMenus";
@ -75,27 +75,68 @@ function TypingIndicator({ isTyping }: { isTyping: boolean; }) {
: null;
}
function getChannelUnreadState(channelId: string) {
const channel = ChannelStore.getChannel(channelId);
const newForumPostCount = channel?.guild_id && channel.isForumLikeChannel?.()
? ActiveJoinedThreadsStore.getNewThreadCount(channel.guild_id, channel.id)
: 0;
const unreadCount = ReadStateStore.getUnreadCount(channelId) || newForumPostCount;
return {
channelId,
hasUnread: ReadStateStore.hasUnread(channelId) || newForumPostCount > 0,
mentionCount: ReadStateStore.getMentionCount(channelId),
unreadCount
};
}
export const NotificationDot = ({ channelIds }: { channelIds: string[]; }) => {
const [unreadCount, mentionCount] = useStateFromStores(
[ReadStateStore],
() => [
channelIds.reduce((count, channelId) => count + ReadStateStore.getUnreadCount(channelId), 0),
channelIds.reduce((count, channelId) => count + ReadStateStore.getMentionCount(channelId), 0),
]
const userId = UserStore.getCurrentUser()?.id;
const { persistUnreadCountFallback } = settings.use(["persistUnreadCountFallback"]);
const [, forceUpdate] = useState(0);
const channelStateKey = channelIds.join(",");
const channelStates = useStateFromStores(
[ActiveJoinedThreadsStore, ReadStateStore],
() => channelIds.map(getChannelUnreadState)
);
const stateSignature = channelStates.map(state => `${state.channelId}:${Number(state.hasUnread)}:${state.mentionCount}:${state.unreadCount}`).join("|");
const { badgeText, hasMention, shouldShow } = getNotificationDotState(
channelStates,
userId ? getUnreadFallbackCounts(userId) : {},
persistUnreadCountFallback
);
return unreadCount > 0 ?
useEffect(() => {
if (!userId || !persistUnreadCountFallback) return;
let didCancel = false;
ensureUnreadFallbackCountsLoaded(userId).then(() => {
if (didCancel) return;
forceUpdate(prev => prev + 1);
});
return () => {
didCancel = true;
};
}, [persistUnreadCountFallback, userId]);
useEffect(() => {
if (!userId || !persistUnreadCountFallback) return;
updateUnreadFallbackCounts(userId, channelStates);
}, [channelStateKey, persistUnreadCountFallback, stateSignature, userId]);
return shouldShow ?
<div
data-has-mention={!!mentionCount}
data-has-mention={hasMention}
className={classes(cl("notification-badge"), dotStyles.numberBadge, dotStyles.baseShapeRound)}
style={{
width: "16px"
}}
ref={node => node?.style.setProperty("background-color",
mentionCount ? "var(--red-400)" : "var(--brand-500)", "important"
hasMention ? "var(--red-400)" : "var(--brand-500)", "important"
)}
>
{mentionCount || unreadCount}
{badgeText}
</div> : null;
};
@ -428,11 +469,8 @@ export default function ChannelTab(props: ChannelTabsProps & { index: number; })
// check if quests running (questify momentLet)
const hasActiveQuests = activeQuestIntervals.size > 0;
console.log(ThemeStore.theme);
return <div
className={cl("tab",
ThemeStore.theme === "light" ? "light-theme" : "dark-theme",
{
className={cl("tab", {
"tab-compact": compact,
"tab-selected": isTabSelected(id),
"tab-entering": isEntering,

View file

@ -7,12 +7,39 @@
import { BaseText } from "@components/BaseText";
import { Heading } from "@components/Heading";
import { Paragraph } from "@components/Paragraph";
import { bookmarkFolderColors, bookmarkPlaceholderName, closeOtherTabs, closeTab, closeTabsToTheLeft, closeTabsToTheRight, createTab, hasClosedTabs, isBookmarkFolder, openedTabs, reopenClosedTab, settings, toggleCompactTab } from "@equicordplugins/channelTabs/util";
import { bookmarkFolderColors, bookmarkPlaceholderName, closeOtherTabs, closeTab, closeTabsToTheLeft, closeTabsToTheRight, createTab, getDiscordFolderIcon, getDiscordFolderIconNames, hasClosedTabs, isBookmarkFolder, openedTabs, reopenClosedTab, settings, toggleCompactTab } from "@equicordplugins/channelTabs/util";
import { Bookmark, BookmarkFolder, Bookmarks, ChannelTabsProps, UseBookmarkMethods } from "@equicordplugins/channelTabs/util/types";
import { getIntlMessage } from "@utils/discord";
import { Margins } from "@utils/margins";
import { closeModal, ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, openModal } from "@utils/modal";
import { Button, ChannelStore, FluxDispatcher, Menu, ReadStateStore, ReadStateUtils, Select, TextInput, useState } from "@webpack/common";
import { Button, ChannelStore, ColorPicker, FluxDispatcher, Menu, ReadStateStore, ReadStateUtils, Select, TextInput, useMemo, useState } from "@webpack/common";
const legacyFolderColors: Record<string, string> = {
"var(--channeltabs-red)": bookmarkFolderColors.Red,
"var(--channeltabs-blue)": bookmarkFolderColors.Blue,
"var(--channeltabs-yellow)": bookmarkFolderColors.Yellow,
"var(--channeltabs-green)": bookmarkFolderColors.Green,
"var(--channeltabs-black)": bookmarkFolderColors.Black,
"var(--channeltabs-white)": bookmarkFolderColors.White,
"var(--channeltabs-orange)": bookmarkFolderColors.Orange,
"var(--channeltabs-pink)": bookmarkFolderColors.Pink
};
const normalizeFolderColor = (color?: string) => {
if (!color) return bookmarkFolderColors.Black;
return legacyFolderColors[color] ?? color;
};
const colorToInt = (color?: string) => {
const normalized = normalizeFolderColor(color);
if (!/^#([0-9a-f]{6})$/i.test(normalized)) return parseInt(bookmarkFolderColors.Black.slice(1), 16);
return parseInt(normalized.slice(1), 16);
};
const intToColor = (value: number | null) =>
value == null
? bookmarkFolderColors.Black
: `#${value.toString(16).padStart(6, "0")}`;
export function BasicContextMenu() {
const { showBookmarkBar } = settings.use(["showBookmarkBar"]);
@ -37,14 +64,213 @@ export function BasicContextMenu() {
);
}
function FolderGlyph({ color, iconName }: { color: string; iconName?: string; }) {
const IconComponent = getDiscordFolderIcon(iconName);
const resolvedColor = normalizeFolderColor(color);
const fill = iconName === "CircleShieldIcon" ? "var(--background-base-low)" : resolvedColor;
const customIconSize = 16;
return (
<div style={{
alignItems: "center",
color: resolvedColor,
display: "flex",
justifyContent: "center",
lineHeight: 0
}}>
{IconComponent
? <IconComponent
height={customIconSize}
width={customIconSize}
color={resolvedColor}
fill={fill}
style={{ transform: "scale(0.6666667)", transformOrigin: "center" }}
/>
: <svg height={16} width={16} viewBox="0 0 24 24">
<path
fill="currentColor"
d="M20 7H12L10.553 5.106C10.214 4.428 9.521 4 8.764 4H3C2.447 4 2 4.447 2 5V19C2 20.104 2.895 21 4 21H20C21.104 21 22 20.104 22 19V9C22 7.896 21.104 7 20 7Z"
/>
</svg>}
</div>
);
}
function FolderChipPreview({ name, color, iconName }: { name: string; color: string; iconName?: string; }) {
return (
<div style={{
alignItems: "center",
border: "1px solid var(--border-subtle)",
borderRadius: 8,
display: "inline-flex",
gap: 8,
marginTop: 12,
maxWidth: "100%",
padding: "6px 10px"
}}>
<FolderGlyph color={color} iconName={iconName} />
<BaseText size="sm">{name.trim() || "Folder"}</BaseText>
</div>
);
}
function FolderIconPickerModal({ modalProps, modalKey, name, color, iconName, onSelect, onColorChange }: {
modalProps: ModalProps,
modalKey: string,
name: string,
color: string,
iconName?: string,
onSelect: (iconName: string) => void,
onColorChange: (color: string) => void;
}) {
const [search, setSearch] = useState("");
const [localColor, setLocalColor] = useState(normalizeFolderColor(color));
const iconNames = useMemo(() => getDiscordFolderIconNames(), []);
const filteredIconNames = useMemo(() => {
const normalized = search.trim().toLowerCase();
if (!normalized) return iconNames;
return iconNames.filter(iconName => iconName.toLowerCase().includes(normalized));
}, [iconNames, search]);
return (
<ModalRoot {...modalProps}>
<ModalHeader>
<BaseText size="lg" weight="semibold">Choose Folder Icon</BaseText>
</ModalHeader>
<ModalContent>
<Heading className={Margins.top16}>Preview</Heading>
<FolderChipPreview name={name} color={localColor} iconName={iconName} />
<Heading className={Margins.top16}>Icon Color</Heading>
<div className={Margins.top8}>
<ColorPicker
color={colorToInt(localColor)}
onChange={value => {
const nextColor = intToColor(value);
setLocalColor(nextColor);
onColorChange(nextColor);
}}
showEyeDropper={false}
/>
</div>
<Heading className={Margins.top16}>Search</Heading>
<TextInput
value={search}
placeholder={`Search ${iconNames.length} icons...`}
onChange={setSearch}
/>
<div style={{
display: "grid",
gap: 8,
gridTemplateColumns: "repeat(auto-fill, minmax(52px, 1fr))",
marginTop: 16,
maxHeight: 320,
overflowY: "auto"
}}>
{filteredIconNames.map(name => (
<button
key={name}
onClick={() => {
onSelect(name);
closeModal(modalKey);
}}
style={{
alignItems: "center",
background: name === iconName ? "var(--background-modifier-hover)" : "var(--background-secondary)",
border: "1px solid var(--border-subtle)",
borderRadius: 8,
cursor: "pointer",
display: "flex",
height: 52,
justifyContent: "center",
padding: 0
}}
title={name}
type="button"
>
<FolderGlyph color={localColor} iconName={name} />
</button>
))}
</div>
</ModalContent>
<ModalFooter>
<Button
color={Button.Colors.TRANSPARENT}
look={Button.Looks.FILLED}
onClick={() => closeModal(modalKey)}
>Close</Button>
</ModalFooter>
</ModalRoot>
);
}
function FolderAppearanceFields({
name,
setName,
color,
setColor,
iconName,
setIconName,
placeholder = "Folder"
}: {
name: string,
setName: (value: string) => void,
color: string,
setColor: (value: string) => void,
iconName?: string,
setIconName: (value?: string) => void,
placeholder?: string;
}) {
return <>
<Heading className={Margins.top16}>Folder Name</Heading>
<TextInput
value={name === placeholder ? undefined : name}
placeholder={placeholder}
onChange={setName}
/>
<Heading className={Margins.top16}>Folder Color</Heading>
<div className={Margins.top8}>
<ColorPicker
color={colorToInt(color)}
onChange={value => setColor(intToColor(value))}
showEyeDropper={false}
/>
</div>
<Heading className={Margins.top16}>Folder Icon</Heading>
<FolderChipPreview name={name} color={color} iconName={iconName} />
<Button
className={Margins.top8}
onClick={() => {
const key = openModal(modalProps => (
<FolderIconPickerModal
modalProps={modalProps}
modalKey={key}
name={name}
color={color}
iconName={iconName}
onSelect={setIconName}
onColorChange={setColor}
/>
));
}}
>Choose Icon</Button>
{iconName && <Button
className={Margins.top8}
color={Button.Colors.TRANSPARENT}
look={Button.Looks.FILLED}
onClick={() => setIconName(undefined)}
>Use Default Icon</Button>}
</>;
}
export function EditModal({ modalProps, modalKey, bookmark, onSave }: {
modalProps: ModalProps,
modalKey: string,
bookmark: Bookmark | BookmarkFolder,
onSave: (name: string, color: string) => void;
onSave: (name: string, color: string, iconName?: string) => void;
}) {
const [name, setName] = useState(bookmark.name);
const [color, setColor] = useState(isBookmarkFolder(bookmark) ? bookmark.iconColor : undefined);
const [color, setColor] = useState(isBookmarkFolder(bookmark) ? normalizeFolderColor(bookmark.iconColor) : bookmarkFolderColors.Black);
const [iconName, setIconName] = useState(isBookmarkFolder(bookmark) ? bookmark.iconName : undefined);
const placeholder = bookmarkPlaceholderName(bookmark);
return (
@ -53,31 +279,28 @@ export function EditModal({ modalProps, modalKey, bookmark, onSave }: {
<BaseText size="lg" weight="semibold">Edit Bookmark</BaseText>
</ModalHeader>
<ModalContent>
<Heading className={Margins.top16}>Bookmark Name</Heading>
<TextInput
value={name === placeholder ? undefined : name}
placeholder={placeholder}
onChange={v => setName(v)}
/>
{isBookmarkFolder(bookmark) && <>
<Heading className={Margins.top16}>Folder Color</Heading>
<Select
options={
Object.entries(bookmarkFolderColors).map(([name, value]) => ({
label: name,
value,
default: bookmark.iconColor === value
}))
}
isSelected={v => color === v}
select={setColor}
serialize={String}
{isBookmarkFolder(bookmark)
? <FolderAppearanceFields
name={name}
setName={setName}
color={color}
setColor={setColor}
iconName={iconName}
setIconName={setIconName}
placeholder={placeholder}
/>
</>}
: <>
<Heading className={Margins.top16}>Bookmark Name</Heading>
<TextInput
value={name === placeholder ? undefined : name}
placeholder={placeholder}
onChange={setName}
/>
</>}
</ModalContent>
<ModalFooter>
<Button
onClick={() => onSave(name || placeholder, color!)}
onClick={() => onSave(name || placeholder, color, iconName)}
>Save</Button>
<Button
color={Button.Colors.TRANSPARENT}
@ -93,9 +316,12 @@ function AddToFolderModal({ modalProps, modalKey, bookmarks, onSave }: {
modalProps: any,
modalKey: string,
bookmarks: Bookmarks,
onSave: (folderIndex: number) => void;
onSave: (folderIndex: number, folderName: string, folderColor: string, iconName?: string) => void;
}) {
const [folderIndex, setIndex] = useState(-1);
const [folderName, setFolderName] = useState("");
const [folderColor, setFolderColor] = useState<string>(bookmarkFolderColors.Black);
const [iconName, setIconName] = useState<string | undefined>();
return (
<ModalRoot {...modalProps}>
@ -120,10 +346,18 @@ function AddToFolderModal({ modalProps, modalKey, bookmarks, onSave }: {
select={setIndex}
serialize={String}
/>
{folderIndex === -1 && <FolderAppearanceFields
name={folderName}
setName={setFolderName}
color={folderColor}
setColor={setFolderColor}
iconName={iconName}
setIconName={setIconName}
/>}
</ModalContent>
<ModalFooter>
<Button
onClick={() => onSave(folderIndex)}
onClick={() => onSave(folderIndex, folderName, folderColor, iconName)}
>Save</Button>
<Button
color={Button.Colors.TRANSPARENT}
@ -208,9 +442,11 @@ export function BookmarkContextMenu({ bookmarks, index, methods }: { bookmarks:
modalProps={modalProps}
modalKey={key}
bookmark={bookmark}
onSave={(name, color) => {
methods.editBookmark(index, { name });
if (color) methods.editBookmark(index, { iconColor: color });
onSave={(name, color, iconName) => {
methods.editBookmark(index, {
name,
...(isFolder && { iconColor: normalizeFolderColor(color), iconName })
});
closeModal(key);
}
}
@ -246,9 +482,9 @@ export function BookmarkContextMenu({ bookmarks, index, methods }: { bookmarks:
modalProps={modalProps}
modalKey={key}
bookmarks={bookmarks}
onSave={index => {
onSave={(index, folderName, folderColor, iconName) => {
if (index === -1) {
const folderIndex = methods.addFolder();
const folderIndex = methods.addFolder(folderName, normalizeFolderColor(folderColor), iconName);
methods.addBookmark(bookmark as Bookmark, folderIndex);
}
else methods.addBookmark(bookmark as Bookmark, index);

View file

@ -1,3 +1,14 @@
:root {
--channeltabs-red: #f23f42;
--channeltabs-blue: #0052b6;
--channeltabs-yellow: #f0b132;
--channeltabs-green: #24934f;
--channeltabs-black: #000;
--channeltabs-white: #fff;
--channeltabs-orange: #e67e22;
--channeltabs-pink: #ff73fa;
}
.vc-channeltabs-icon {
height: 1.5rem;
width: 1.5rem;
@ -45,16 +56,11 @@
padding: 0 4px;
}
.vc-channeltabs-container {
--channeltabs-red: #f23f42;
--channeltabs-blue: #0052b6;
--channeltabs-yellow: #f0b132;
--channeltabs-green: #24934f;
--channeltabs-black: #000;
--channeltabs-white: #fff;
--channeltabs-orange: #e67e22;
--channeltabs-pink: #ff73fa;
.vc-channeltabs-bookmark-container {
position: relative;
}
.vc-channeltabs-container {
padding: 4px;
display: flex;
flex-direction: column-reverse;
@ -117,8 +123,7 @@
}
.vc-channeltabs-tab-scroller-following {
width: fit-content;
flex: unset;
max-width: 100%;
}
.vc-channeltabs-tab-scroller::-webkit-scrollbar {
@ -159,12 +164,19 @@
padding: 4px 8px;
border-radius: 4px;
align-items: center;
position: relative;
transition: transform 0.14s ease, border-color 0.14s ease, box-shadow 0.14s ease, background-color 0.14s ease;
}
.vc-channeltabs-bookmark.vc-channeltabs-wider {
max-width: calc(12rem * var(--tab-width-scale, 1));
}
.vc-channeltabs-bookmark.vc-channeltabs-bookmark-folder {
margin-left: 4px;
margin-right: 4px;
}
.vc-channeltabs-bookmark .vc-channeltabs-name-text {
margin-left: 0.25rem;
margin-right: 0.25rem;
@ -361,6 +373,61 @@
color: var(--text-default);
}
.vc-channeltabs-bookmark-search-shell {
position: relative;
width: 28px;
height: 28px;
margin-right: 6px;
flex-shrink: 0;
}
.vc-channeltabs-bookmark-search-shell-open {
z-index: 3;
}
.vc-channeltabs-bookmark-search-field {
position: absolute;
right: 0;
top: 50%;
width: 0;
opacity: 0;
pointer-events: none;
overflow: hidden;
transform: translateY(-50%) scaleX(0.92);
transform-origin: right center;
transition: width 0.18s ease, opacity 0.15s ease, transform 0.18s ease;
}
.vc-channeltabs-bookmark-search-shell-open .vc-channeltabs-bookmark-search-field {
width: 160px;
opacity: 1;
pointer-events: auto;
transform: translateY(-50%) scaleX(1);
}
.vc-channeltabs-bookmark-search-input {
padding-right: 34px !important;
}
.vc-channeltabs-bookmark-search-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 8px;
position: absolute;
right: 0;
top: 0;
flex-shrink: 0;
color: var(--text-muted);
transition: color 0.15s ease;
}
.vc-channeltabs-bookmark-search-button:hover {
color: var(--interactive-icon-hover);
}
.vc-channeltabs-new-button {
padding: 4px;
margin: 0 4px;
@ -1004,19 +1071,56 @@
/* Drag and drop visual feedback */
.vc-channeltabs-bookmarks-drop-target {
background-color: color-mix(in srgb, var(--brand-500) 20%, transparent);
border: 2px dashed var(--brand-500);
border-radius: 4px;
background-color: color-mix(in srgb, var(--brand-500) 15%, transparent);
border: 1px dashed color-mix(in srgb, var(--brand-500) 65%, transparent);
border-radius: 6px;
}
.vc-channeltabs-bookmark.vc-channeltabs-bookmark-dragging {
opacity: 0.55;
transform: scale(0.98);
}
.vc-channeltabs-bookmark.vc-channeltabs-bookmark-drop-target {
border-color: color-mix(in srgb, var(--brand-500) 70%, var(--border-subtle));
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--brand-500) 35%, transparent);
}
.vc-channeltabs-bookmark.vc-channeltabs-bookmark-drop-before::before,
.vc-channeltabs-bookmark.vc-channeltabs-bookmark-drop-after::after {
content: "";
position: absolute;
top: 3px;
bottom: 3px;
width: 2px;
border-radius: 999px;
background-color: var(--brand-500);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--brand-500) 30%, transparent);
}
.vc-channeltabs-bookmark.vc-channeltabs-bookmark-drop-before::before {
left: -3px;
}
.vc-channeltabs-bookmark.vc-channeltabs-bookmark-drop-after::after {
right: -3px;
}
.vc-channeltabs-bookmark.vc-channeltabs-bookmark-drop-before {
transform: translateX(5px);
}
.vc-channeltabs-bookmark.vc-channeltabs-bookmark-drop-after {
transform: translateX(-5px);
}
.vc-channeltabs-bookmark.vc-channeltabs-folder-drop-target {
background-color: color-mix(in srgb, var(--brand-500) 30%, transparent);
border: 2px solid var(--brand-500);
transform: scale(1.05);
box-shadow: 0 0 8px var(--brand-500);
background-color: color-mix(in srgb, var(--brand-500) 22%, transparent);
border: 1px solid color-mix(in srgb, var(--brand-500) 80%, var(--border-subtle));
transform: scale(1.03);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--brand-500) 20%, transparent);
}
/* Folder expansion */
.vc-channeltabs-bookmark.vc-channeltabs-folder-expanded {
background-color: var(--background-mod-strong);
}
@ -1168,7 +1272,7 @@
/* Tab Number Badge Styles */
.vc-channeltabs-tab-number-badge {
background-color: rgb(0 0 0 / 25%);
color: var(--text-normal);
color: var(--text-default);
border-radius: 50%;
width: 20px;
height: 20px;

View file

@ -85,11 +85,12 @@ export function useBookmarks(userId: string): UseBookmark {
...bookmarks
});
},
addFolder() {
addFolder(name, iconColor, iconName) {
if (!bookmarks) return;
const length = bookmarks[userId].push({
name: "Folder",
iconColor: bookmarkFolderColors.Black,
name: name?.trim() || "Folder",
iconColor: iconColor ?? bookmarkFolderColors.Black,
iconName,
bookmarks: []
});
@ -109,12 +110,22 @@ export function useBookmarks(userId: string): UseBookmark {
},
deleteBookmark(index, folderIndex) {
if (!bookmarks) return;
if (index < 0 || index > (bookmarks[userId].length - 1))
return logger.error("Attempted to delete bookmark at index " + index, bookmarks);
if (typeof folderIndex === "number")
(bookmarks[userId][folderIndex] as BookmarkFolder).bookmarks.splice(index, 1);
else bookmarks[userId].splice(index, 1);
if (typeof folderIndex === "number") {
const folder = bookmarks[userId][folderIndex];
if (!isBookmarkFolder(folder))
return logger.error("Attempted to delete bookmark from non-folder " + folderIndex, bookmarks);
if (index < 0 || index > (folder.bookmarks.length - 1))
return logger.error("Attempted to delete bookmark at index " + index, bookmarks);
folder.bookmarks.splice(index, 1);
} else {
if (index < 0 || index > (bookmarks[userId].length - 1))
return logger.error("Attempted to delete bookmark at index " + index, bookmarks);
bookmarks[userId].splice(index, 1);
}
setBookmarks({
...bookmarks

View file

@ -109,14 +109,14 @@ function AnimationSettings(): JSX.Element {
export const logger = new Logger("ChannelTabs");
export const bookmarkFolderColors = {
Red: "var(--channeltabs-red)",
Blue: "var(--channeltabs-blue)",
Yellow: "var(--channeltabs-yellow)",
Green: "var(--channeltabs-green)",
Black: "var(--channeltabs-black)",
White: "var(--channeltabs-white)",
Orange: "var(--channeltabs-orange)",
Pink: "var(--channeltabs-pink)"
Red: "#f23f42",
Blue: "#0052b6",
Yellow: "#f0b132",
Green: "#24934f",
Black: "#000",
White: "#fff",
Orange: "#e67e22",
Pink: "#ff73fa"
} as const;
export const settings = definePluginSettings({
@ -160,6 +160,12 @@ export const settings = definePluginSettings({
type: OptionType.BOOLEAN,
default: true
},
persistUnreadCountFallback: {
description: "Persist unread badge fallbacks across reloads for tabs and bookmarks",
type: OptionType.BOOLEAN,
default: true,
restartNeeded: false
},
widerTabsAndBookmarks: {
description: "Extend the length of tabs and bookmarks for larger monitors",
type: OptionType.BOOLEAN,

View file

@ -0,0 +1,29 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { Icon } from "@vencord/discord-types";
import { findByPropsLazy } from "@webpack";
const iconsModule = findByPropsLazy("AngleBracketsIcon", "StaffBadgeIcon") as Record<string, Icon>;
let iconNames: string[] | undefined;
export function getDiscordFolderIcon(name?: string): Icon | undefined {
if (!name) return;
const icon = iconsModule[name];
return typeof icon === "function" && name.endsWith("Icon") ? icon : undefined;
}
export function getDiscordFolderIconNames(): string[] {
if (iconNames) return iconNames;
iconNames = Object.keys(iconsModule)
.filter(name => name.endsWith("Icon") && typeof iconsModule[name] === "function")
.sort((a, b) => a.localeCompare(b));
return iconNames;
}

View file

@ -6,6 +6,9 @@
export * from "./bookmarks";
export * from "./constants";
export * from "./folderIcons";
export * from "./icons";
export * from "./tabs";
export * from "./types";
export * from "./unread";
export * from "./unreadState";

View file

@ -29,6 +29,7 @@ export interface BookmarkFolder {
bookmarks: Bookmark[];
name: string;
iconColor: string;
iconName?: string;
}
export interface BookmarkProps {
bookmarks: Bookmarks,
@ -38,7 +39,7 @@ export interface BookmarkProps {
export type Bookmarks = (Bookmark | BookmarkFolder)[];
export type UseBookmarkMethods = {
addBookmark: (bookmark: Omit<Bookmark, "name"> & { name?: string; }, folderIndex?: number) => void;
addFolder: () => number;
addFolder: (name?: string, iconColor?: string, iconName?: string) => number;
deleteBookmark: (index: number, folderIndex?: number) => void;
editBookmark: (index: number, bookmark: Partial<Bookmark | BookmarkFolder>, modalKey?) => void;
moveDraggedBookmarks: (index1: number, index2: number) => void;

View file

@ -0,0 +1,57 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { DataStore } from "@api/index";
import { ChannelUnreadState, reconcileUnreadFallbackCache } from "@equicordplugins/channelTabs/util/unreadState";
export interface PersistedUnreadFallbacks {
[userId: string]: Record<string, number>;
}
const DATASTORE_KEY = "ChannelTabs_unreadFallbacks_v1";
const unreadFallbacks: PersistedUnreadFallbacks = {};
const unreadFallbackLoads = new Map<string, Promise<Record<string, number>>>();
const unreadFallbackSaves = new Map<string, Promise<void>>();
export function getUnreadFallbackCounts(userId: string) {
return unreadFallbacks[userId] ?? {};
}
export async function ensureUnreadFallbackCountsLoaded(userId: string) {
if (unreadFallbacks[userId]) return unreadFallbacks[userId];
if (unreadFallbackLoads.has(userId)) return unreadFallbackLoads.get(userId)!;
const loadPromise = DataStore.get<PersistedUnreadFallbacks>(DATASTORE_KEY)
.then(fallbacks => {
unreadFallbacks[userId] = {
...(fallbacks?.[userId] ?? {}),
...(unreadFallbacks[userId] ?? {})
};
unreadFallbackLoads.delete(userId);
return unreadFallbacks[userId];
});
unreadFallbackLoads.set(userId, loadPromise);
return loadPromise;
}
export function updateUnreadFallbackCounts(userId: string, channelStates: ChannelUnreadState[]) {
const currentFallbacks = unreadFallbacks[userId] ?? {};
const nextFallbacks = reconcileUnreadFallbackCache(currentFallbacks, channelStates);
if (JSON.stringify(nextFallbacks) === JSON.stringify(currentFallbacks)) return;
unreadFallbacks[userId] = nextFallbacks;
const pendingSave = unreadFallbackSaves.get(userId) ?? Promise.resolve();
const nextSave = pendingSave
.catch(() => void 0)
.then(() => DataStore.update<PersistedUnreadFallbacks>(DATASTORE_KEY, old => ({
...(old ?? {}),
[userId]: unreadFallbacks[userId]
})));
unreadFallbackSaves.set(userId, nextSave);
}

View file

@ -0,0 +1,78 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
export interface ChannelUnreadState {
channelId: string;
hasUnread: boolean;
mentionCount: number;
unreadCount: number;
}
export interface NotificationDotState {
badgeText: string | null;
hasMention: boolean;
shouldShow: boolean;
}
export function getNotificationDotState(
channelStates: ChannelUnreadState[],
cachedUnreadCounts: Record<string, number>,
shouldUseFallback: boolean
): NotificationDotState {
const mentionCount = channelStates.reduce((count, state) => count + state.mentionCount, 0);
if (mentionCount > 0) {
return {
badgeText: String(mentionCount),
hasMention: true,
shouldShow: true
};
}
let unreadCount = 0;
let fallbackCount = 0;
for (const state of channelStates) {
unreadCount += state.unreadCount;
if (state.unreadCount > 0 || !shouldUseFallback || !state.hasUnread) continue;
fallbackCount += Math.max(cachedUnreadCounts[state.channelId] ?? 0, 1);
}
const totalCount = unreadCount + fallbackCount;
if (totalCount === 0) {
return {
badgeText: null,
hasMention: false,
shouldShow: false
};
}
return {
badgeText: fallbackCount > 0 ? `${totalCount}+` : String(totalCount),
hasMention: false,
shouldShow: true
};
}
export function reconcileUnreadFallbackCache(
cachedUnreadCounts: Record<string, number>,
channelStates: ChannelUnreadState[]
): Record<string, number> {
const nextCachedUnreadCounts = { ...cachedUnreadCounts };
for (const state of channelStates) {
if (state.unreadCount > 0) {
nextCachedUnreadCounts[state.channelId] = state.unreadCount;
continue;
}
if (!state.hasUnread) {
delete nextCachedUnreadCounts[state.channelId];
}
}
return nextCachedUnreadCounts;
}

View file

@ -1,64 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import "./style.css";
import { definePluginSettings } from "@api/Settings";
import { Devs, EquicordDevs } from "@utils/constants";
import definePlugin, { OptionType } from "@utils/types";
import { UserStore } from "@webpack/common";
const settings = definePluginSettings({
colorEffects: {
type: OptionType.BOOLEAN,
description: "Turn on or off color effects for getting close to the character limit",
default: true,
},
});
export default definePlugin({
name: "CharacterCounter",
description: "Adds a character counter to the chat input",
authors: [EquicordDevs.creations, EquicordDevs.Panniku, Devs.thororen],
settings,
patches: [
{
find: ".CREATE_FORUM_POST||",
replacement: {
match: /(textValue:.{0,50}channelId:\i\.id\}\)),\i/,
replace: "$1,$self.getCharCounter(arguments[0].textValue)"
}
},
{
find: "#{intl::PREMIUM_MESSAGE_LENGTH_UPSELL_TOOLTIP}",
replacement: {
match: /return \i\?\i\(\):\i\(\)/,
replace: "return null"
}
}
],
getCharCounter(text: string) {
const premiumType = (UserStore.getCurrentUser().premiumType ?? 0);
const charMax = premiumType === 2 ? 4000 : 2000;
const { length } = text;
let color = "var(--primary-330)";
if (settings.store.colorEffects) {
const percentage = (length / charMax) * 100;
if (percentage < 50) color = "var(--text-muted)";
else if (percentage < 75) color = "var(--yellow-330)";
else if (percentage < 90) color = "var(--orange-330)";
else color = "var(--red-360)";
}
return (
<div className="vc-char-counter" style={{ color }}>
<span className="vc-char-count">{length}</span>/
<span className="vc-char-max">{charMax}</span>
</div>
);
}
});

View file

@ -168,20 +168,19 @@ export default definePlugin({
},
// member list
{
find: "._areActivitiesExperimentallyHidden=(",
replacement: {
match: /(?<=user:(\i),guildId:\i,channel:(\i).*?)BOOST_GEM_ICON.{0,10}\);/,
replace: "$&if($self.shouldHideUser($1.id, $2.id)) return null; "
}
},
// stop the role header from displaying if all users with that role are hidden (wip sorta)
{
find: "._areActivitiesExperimentallyHidden=(",
replacement: {
match: /\i.memo\(function\(\i\){/,
replace: "$&if($self.isRoleAllBlockedMembers(arguments[0].id, arguments[0].guildId)) return null;"
},
predicate: () => settings.store.hideEmptyRoles
find: "this.updateMaxContentFeedRowSeen()",
replacement: [
{
match: /(?<=user:(\i),guildId:\i,channel:(\i).*?)BOOST_GEM_ICON.{0,10}\);/,
replace: "$&if($self.shouldHideUser($1.id, $2.id)) return null; "
},
// stop the role header from displaying if all users with that role are hidden (wip sorta)
{
match: /\i.memo\(function\(\i\){/,
replace: "$&if($self.isRoleAllBlockedMembers(arguments[0].id, arguments[0].guildId)) return null;",
predicate: () => settings.store.hideEmptyRoles
},
]
},
// "1 blocked message"
{

View file

@ -4,23 +4,14 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import "./style.css";
import { CogWheel } from "@components/Icons";
import { classNameFactory } from "@utils/css";
import { ModalRoot, ModalSize, openModal } from "@utils/modal";
import { React, TextInput, useCallback, useEffect, useMemo, useRef, useState } from "@webpack/common";
import { classes } from "@utils/misc";
import { type ModalProps,ModalRoot, ModalSize, openModal } from "@utils/modal";
import { TextInput, useCallback, useEffect, useMemo, useRef, useState } from "@webpack/common";
import { settings } from "./index";
import {
type CommandEntry,
getCategoryGroupLabel,
getCategoryPath,
getCategoryWeight,
getCommandSearchText,
getRecentRank,
getRegistryVersion,
listCommands,
subscribeRegistry
type CommandEntry, getCategoryGroupLabel, getCategoryWeight, getCommandSearchText, getRecentRank, getRegistryVersion, listCommands, subscribeRegistry
} from "./registry";
type PickerItem =
@ -43,10 +34,16 @@ const getNextSelectableIndex = (start: number, direction: 1 | -1, items: PickerI
return getFirstSelectableIndex(items);
};
const cl = classNameFactory("vc-command-palette-");
const cn = classNameFactory();
interface CommandPickerProps {
commands?: CommandEntry[];
allowMultiple?: boolean;
initialQuery?: string;
initialSelectedIds?: string[];
includeHiddenInSearch?: boolean;
emptyStateText?: string;
filter?(command: CommandEntry): boolean;
onSelect?(command: CommandEntry): void;
onComplete?(commands: CommandEntry[], commandIds: string[]): void;
@ -59,31 +56,28 @@ export function openCommandPicker(props: CommandPickerProps) {
));
}
function CommandPickerModal({ modalProps, allowMultiple = false, initialQuery = "", initialSelectedIds = [], filter: filterPredicate, onSelect, onComplete, onClose }: CommandPickerProps & { modalProps: any; }) {
const cl = classNameFactory("vc-command-palette-");
function CommandPickerModal({ modalProps, commands: providedCommands, allowMultiple = false, initialQuery = "", initialSelectedIds = [], includeHiddenInSearch = false, emptyStateText = "No commands found", filter: filterPredicate, onSelect, onComplete, onClose }: CommandPickerProps & { modalProps: ModalProps; }) {
const [query, setQuery] = useState(initialQuery);
const [registryVersion, setRegistryVersion] = useState(() => getRegistryVersion());
const [selectedIndex, setSelectedIndex] = useState(-1);
const [visible, setVisible] = useState(false);
const showTags = settings.store.showTags ?? true;
const { visualStyle = "classic" } = settings.use(["visualStyle"]);
const styleClass = visualStyle === "polished" ? "style-polished" : "style-classic";
const normalizedInitialSelected = useMemo(() => Array.from(new Set(initialSelectedIds)), [initialSelectedIds]);
const [pickedIds, setPickedIds] = useState<string[]>(() => normalizedInitialSelected);
const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
const pickedIdsRef = useRef<string[]>([]);
useEffect(() => subscribeRegistry(setRegistryVersion), []);
useEffect(() => {
const frame = requestAnimationFrame(() => setVisible(true));
return () => cancelAnimationFrame(frame);
}, []);
if (providedCommands) return;
return subscribeRegistry(setRegistryVersion);
}, [providedCommands]);
useEffect(() => {
pickedIdsRef.current = pickedIds;
}, [pickedIds]);
const commands = useMemo(() => listCommands(), [registryVersion]);
const commands = useMemo(() => {
if (providedCommands) return providedCommands;
return listCommands();
}, [providedCommands, registryVersion]);
const commandMap = useMemo(() => new Map(commands.map(entry => [entry.id, entry])), [commands]);
const availableCommands = useMemo(() => {
if (!filterPredicate) return commands;
@ -138,9 +132,7 @@ function CommandPickerModal({ modalProps, allowMultiple = false, initialQuery =
score += getCategoryWeight(entry.categoryId);
const recentRank = getRecentRank(entry.id);
if (recentRank >= 0) {
score += Math.max(0, 30 - recentRank * 4);
}
if (recentRank >= 0) score += Math.max(0, 30 - recentRank * 4);
return score;
};
@ -149,7 +141,7 @@ function CommandPickerModal({ modalProps, allowMultiple = false, initialQuery =
let orderCounter = 0;
for (const entry of availableCommands) {
if (entry.hiddenInSearch) continue;
if (!includeHiddenInSearch && entry.hiddenInSearch) continue;
const searchText = getCommandSearchText(entry.id);
if (!searchText.includes(lowerQuery)) continue;
@ -181,7 +173,7 @@ function CommandPickerModal({ modalProps, allowMultiple = false, initialQuery =
grouped.set(label, []);
groupOrder.push(label);
}
grouped.get(label)!.push(entry);
grouped.get(label)?.push(entry);
}
const built: PickerItem[] = [];
@ -195,7 +187,7 @@ function CommandPickerModal({ modalProps, allowMultiple = false, initialQuery =
}
return { items: built, hasCommands: entries.length > 0 };
}, [availableCommands, lowerQuery]);
}, [availableCommands, includeHiddenInSearch, lowerQuery]);
useEffect(() => {
if (!hasCommands) {
@ -307,12 +299,12 @@ function CommandPickerModal({ modalProps, allowMultiple = false, initialQuery =
}
if (
allowMultiple &&
event.key === " " &&
!event.altKey &&
!event.ctrlKey &&
!event.metaKey &&
!isEditableTarget(event.target)
allowMultiple
&& event.key === " "
&& !event.altKey
&& !event.ctrlKey
&& !event.metaKey
&& !isEditableTarget(event.target)
) {
const selectedItem = selectedIndex >= 0 ? items[selectedIndex] : undefined;
if (isCommandItem(selectedItem)) {
@ -328,84 +320,53 @@ function CommandPickerModal({ modalProps, allowMultiple = false, initialQuery =
}
};
const headerText = allowMultiple ? "Add Commands" : "Choose Command";
return (
<ModalRoot
{...modalProps}
size={ModalSize.SMALL}
className={cl("root", styleClass, visible && "root-visible")}
>
<div className={cl("container")} onKeyDown={handleKeyDown}>
<div className={cl("header")}>{headerText}</div>
<div
className={cl("search")}
onMouseDown={event => {
event.preventDefault();
event.currentTarget.querySelector("input")?.focus({ preventScroll: true });
}}
>
<span className={cl("prompt")}>›</span>
<ModalRoot {...modalProps} size={ModalSize.SMALL} className={classes(cn("vc-command-palette"), cl("picker"))}>
<div className={classes(cl("shell"), cl("picker-shell"))} onKeyDown={handleKeyDown}>
<div className={cl("input")}>
<TextInput
autoFocus
value={query}
onChange={setQuery}
placeholder="Search commands"
className={cl("main-input")}
/>
</div>
<div className={cl("list")}>
{!hasCommands && (
<div className={cl("empty")}>No commands found</div>
)}
{!hasCommands && <div className={cl("empty")}>{emptyStateText}</div>}
{items.map((item, index) => {
if (item.type === "section") {
itemRefs.current[index] = null;
return (
<div key={`section-${index}`} className={cl("section")}>
{item.label}
</div>
);
return <div key={`section-${index}`} className={cl("section-label")}>{item.label}</div>;
}
const { command } = item;
const path = getCategoryPath(command.categoryId);
const categoryLabel = path
.filter(cat => cat.label && cat.id !== "quick-actions")
.map(cat => cat.label)
.join(" / ");
const Icon = command.icon ?? CogWheel;
const isSelected = index === selectedIndex;
const isPicked = allowMultiple && pickedSet.has(command.id);
return (
<button
key={command.id}
type="button"
className={cl("item", {
selected: index === selectedIndex,
chosen: allowMultiple && pickedSet.has(command.id),
danger: command.danger
})}
className={classes(cl("row"), isSelected && cl("row-selected"))}
onClick={() => handleSelect(command)}
onMouseEnter={() => setSelectedIndex(index)}
ref={element => {
itemRefs.current[index] = element;
}}
>
<div className={cl("item-content")}>
<div className={cl("text")}>
<div className={cl("label")}>{command.label}</div>
{command.description && (
<div className={cl("description")}>{command.description}</div>
)}
{categoryLabel && (
<div className={cl("meta")}>{categoryLabel}</div>
)}
{showTags && command.tags && command.tags.length > 0 && (
<div className={cl("tags")}>
{command.tags.map(tag => (
<span key={tag} className={cl("tag-chip")}>{tag}</span>
))}
</div>
)}
<div className={cl("row-icon")}>
<Icon size="18" />
</div>
<div className={cl("row-content")}>
<div className={cl("row-title")}>
{allowMultiple && (isPicked ? "[x] " : "[ ] ")}
{command.label}
</div>
{command.description && (
<div className={cl("row-subtitle")}>{command.description}</div>
)}
</div>
</button>
);

View file

@ -0,0 +1,158 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
export interface ActionRoutingIntent {
type: string;
actionKey?: string;
}
export interface ActionRoutingAction {
id: string;
shortcut: string;
intent: ActionRoutingIntent;
}
export type ActionMatchSource = "intent-action-key" | "shortcut" | "id";
export interface ResolvedActionByKey<TAction extends ActionRoutingAction> {
action: TAction;
matchSource: ActionMatchSource;
normalizedActionKey: string;
}
const actionKeyNormalizationOrder = new Map([
["meta", 0],
["alt", 1],
["ctrl", 2],
["shift", 3],
["enter", 4],
["left", 5],
["right", 6],
["up", 7],
["down", 8],
["esc", 9],
["tab", 10],
["space", 11]
]);
const shortcutSymbolTokenMap = new Map<string, string>([
["⌘", "meta"],
["⌥", "alt"],
["⌃", "ctrl"],
["⇧", "shift"],
["↵", "enter"],
["←", "left"],
["→", "right"],
["↑", "up"],
["↓", "down"]
]);
function normalizeShortcutToken(token: string): string {
const normalized = token.trim().toLowerCase();
if (!normalized) return "";
if (normalized === "cmd" || normalized === "command" || normalized === "meta" || normalized === "⌘") return "meta";
if (normalized === "alt" || normalized === "option" || normalized === "⌥") return "alt";
if (normalized === "ctrl" || normalized === "control" || normalized === "^" || normalized === "⌃") return "ctrl";
if (normalized === "shift" || normalized === "⇧") return "shift";
if (normalized === "enter" || normalized === "return" || normalized === "↵") return "enter";
if (normalized === "left" || normalized === "←") return "left";
if (normalized === "right" || normalized === "→") return "right";
if (normalized === "up" || normalized === "↑") return "up";
if (normalized === "down" || normalized === "↓") return "down";
if (normalized === "escape" || normalized === "esc") return "esc";
if (normalized === "tab" || normalized === "⇥") return "tab";
if (normalized === "space" || normalized === "spacebar") return "space";
return normalized;
}
function tokenizeActionKey(value: string): string[] {
let expanded = "";
for (const char of value) {
const token = shortcutSymbolTokenMap.get(char);
expanded += token ? `+${token}+` : char;
}
return expanded
.split(/[+\s]+/)
.map(normalizeShortcutToken)
.filter(Boolean);
}
export function normalizeActionKey(value: string): string {
const tokenSet = new Set(tokenizeActionKey(value));
return Array.from(tokenSet).sort((left, right) => {
const leftOrder = actionKeyNormalizationOrder.get(left) ?? Number.MAX_SAFE_INTEGER;
const rightOrder = actionKeyNormalizationOrder.get(right) ?? Number.MAX_SAFE_INTEGER;
if (leftOrder !== rightOrder) return leftOrder - rightOrder;
return left.localeCompare(right);
}).join("+");
}
export function resolveActionByActionKey<TAction extends ActionRoutingAction>(
actions: readonly TAction[] | undefined,
actionKey: string
): ResolvedActionByKey<TAction> | null {
if (!actions || actions.length === 0) return null;
const normalizedActionKey = normalizeActionKey(actionKey);
if (!normalizedActionKey) return null;
for (const action of actions) {
if (action.intent.type !== "execute-secondary" || !action.intent.actionKey) continue;
const normalizedIntentActionKey = normalizeActionKey(action.intent.actionKey);
if (normalizedIntentActionKey && normalizedIntentActionKey === normalizedActionKey) {
return {
action,
matchSource: "intent-action-key",
normalizedActionKey
};
}
}
for (const action of actions) {
const normalizedShortcut = normalizeActionKey(action.shortcut);
if (normalizedShortcut && normalizedShortcut === normalizedActionKey) {
return {
action,
matchSource: "shortcut",
normalizedActionKey
};
}
}
for (const action of actions) {
const normalizedId = normalizeActionKey(action.id);
if (normalizedId && normalizedId === normalizedActionKey) {
return {
action,
matchSource: "id",
normalizedActionKey
};
}
}
return null;
}
export function resolveActionIntentByActionKey<TAction extends ActionRoutingAction>(
actions: readonly TAction[] | undefined,
actionKey: string
): ActionRoutingIntent | null {
if (actionKey === "primary") {
return { type: "execute-primary" };
}
const resolved = resolveActionByActionKey(actions, actionKey);
if (!resolved) return null;
if (resolved.action.intent.type === "execute-primary" || resolved.action.intent.type === "execute-secondary") {
const resolvedActionKey = resolved.matchSource === "intent-action-key" && resolved.action.intent.actionKey
? normalizeActionKey(resolved.action.intent.actionKey)
: resolved.normalizedActionKey;
return { type: "execute-secondary", actionKey: resolvedActionKey };
}
return resolved.action.intent;
}

View file

@ -0,0 +1,993 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { isPluginEnabled, plugins } from "@api/PluginManager";
import { NotesIcon } from "@components/Icons";
import { toggleEnabled } from "@equicordplugins/equicordHelper/utils";
import { noteHandler } from "@equicordplugins/holyNotes/NoteHandler";
import type { Note } from "@equicordplugins/holyNotes/types";
import type { ScheduledMessage } from "@equicordplugins/scheduledMessages/types";
import { addScheduledMessage, getChannelDisplayInfo, getScheduledMessages, removeScheduledMessage, sendScheduledMessageNow, updateScheduledMessageTime } from "@equicordplugins/scheduledMessages/utils";
import { sleep } from "@utils/misc";
import { ChannelActionCreators, ChannelStore, NavigationRouter, SelectedChannelStore, Toasts, UserStore } from "@webpack/common";
import { parseQuery } from "../query/parser";
import { pluginToggleVerb, resolveAllChannels, resolveChannels, resolveGuilds, resolvePlugins, resolveRecentDmUsers, resolveSettingsCommandIds, resolveUsers } from "../query/resolvers";
import type { QueryActionCandidate, QueryResolution } from "../query/types";
import { DISCORD_INTERNAL_HOSTS, executeCommand, getCommandById } from "../registry";
import { makeIconFromUrl } from "../ui/iconFromUrl";
import { sendMessageToChannel, sendMessageToUser } from "./sendMessageAction";
interface ResolvedHolyNote {
notebook: string;
note: Note;
}
interface StagedRescheduledMessage {
id: string;
label: string;
}
let stagedMoveNote: ResolvedHolyNote | null = null;
let stagedRescheduleMessage: StagedRescheduledMessage | null = null;
let pendingCancelScheduledMessageId: string | null = null;
async function ensureHolyNotesEnabled() {
const plugin = plugins.HolyNotes;
if (!plugin) {
showToast("HolyNotes plugin is unavailable.", Toasts.Type.FAILURE);
return false;
}
if (isPluginEnabled(plugin.name)) return true;
const success = await toggleEnabled(plugin.name);
if (!success || !isPluginEnabled(plugin.name)) {
showToast("Failed to enable HolyNotes.", Toasts.Type.FAILURE);
return false;
}
return true;
}
async function ensureScheduledMessagesEnabled() {
const plugin = plugins.ScheduledMessages;
if (!plugin) {
showToast("ScheduledMessages plugin is unavailable.", Toasts.Type.FAILURE);
return false;
}
if (isPluginEnabled(plugin.name)) return true;
const success = await toggleEnabled(plugin.name);
if (!success || !isPluginEnabled(plugin.name)) {
showToast("Failed to enable ScheduledMessages.", Toasts.Type.FAILURE);
return false;
}
return true;
}
function showToast(message: string, type: (typeof Toasts.Type)[keyof typeof Toasts.Type]) {
Toasts.show({
message,
type,
id: Toasts.genId(),
options: { position: Toasts.Position.BOTTOM }
});
}
function openExternalUrl(url: string) {
const external = (window as Window & { DiscordNative?: { app?: { openExternalURL?(url: string): void; }; }; }).DiscordNative?.app?.openExternalURL;
if (typeof external === "function") {
external(url);
return;
}
window.open(url, "_blank", "noopener,noreferrer");
}
function toDiscordRoute(url: URL): string | null {
const host = url.hostname.toLowerCase();
if (!DISCORD_INTERNAL_HOSTS.has(host)) return null;
const path = `${url.pathname}${url.search}${url.hash}`;
return path.startsWith("/") ? path : `/${path}`;
}
function normalizeUrl(input: string): URL | null {
try {
return new URL(input);
} catch {
try {
return new URL(`https://${input}`);
} catch {
return null;
}
}
}
function isSafeUrlProtocol(url: URL): boolean {
return url.protocol === "http:" || url.protocol === "https:";
}
function isDirectMessageChannelForUser(channelId: string, userId: string): boolean {
const channel = ChannelStore.getChannel(channelId);
if (!channel) return false;
const isDm = typeof channel.isDM === "function" ? channel.isDM() : channel.type === 1;
if (!isDm) return false;
const recipientId = channel.recipients?.[0];
return recipientId === userId;
}
function findDirectMessageChannelForUser(userId: string): string | null {
const sortedPrivateChannels = (ChannelStore as {
getSortedPrivateChannels?(): Array<string | { id?: string; channelId?: string; }>;
}).getSortedPrivateChannels?.() ?? [];
for (const raw of sortedPrivateChannels) {
const channelId = typeof raw === "string" ? raw : raw?.id ?? raw?.channelId ?? null;
if (!channelId) continue;
if (isDirectMessageChannelForUser(channelId, userId)) return channelId;
}
return null;
}
async function openDmByUserId(userId: string): Promise<string | null> {
const currentUserId = UserStore.getCurrentUser?.()?.id;
if (currentUserId && userId === currentUserId) return null;
try {
await Promise.resolve(ChannelActionCreators.openPrivateChannel?.({
recipientIds: [userId],
location: "CommandPalette",
navigateToChannel: false
}));
} catch {
return null;
}
const started = Date.now();
while (Date.now() - started <= 2500) {
const channelId = ChannelStore.getDMFromUserId?.(userId) ?? null;
if (channelId && isDirectMessageChannelForUser(channelId, userId)) return channelId;
const fallback = findDirectMessageChannelForUser(userId);
if (fallback) return fallback;
await sleep(80);
}
return null;
}
function buildSendCandidates(target: string, content: string, useFilePicker: boolean, silent: boolean): QueryActionCandidate[] {
const matches = resolveUsers(target);
if (matches.length === 0) {
return [{
id: "query-send-invalid",
label: "Send message",
description: "No matching user found.",
badge: "Query",
icon: NotesIcon,
run: () => showToast("No matching user found.", Toasts.Type.FAILURE)
}];
}
const candidates = matches.slice(0, 5).map(match => ({
id: `query-send-${match.user.id}`,
label: `Send message to ${match.display}`,
description: "Direct message",
inputPreview: content,
badge: "Query",
icon: makeIconFromUrl(match.iconUrl) ?? NotesIcon,
run: async () => {
await sendMessageToUser({
userId: match.user.id,
content,
useFilePicker,
silent
});
showToast(`Message sent to ${match.display}.`, Toasts.Type.SUCCESS);
}
} satisfies QueryActionCandidate));
return candidates;
}
function buildSendChannelCandidates(target: string, content: string, useFilePicker: boolean, silent: boolean): QueryActionCandidate[] {
const matches = resolveChannels(target).slice(0, 5);
if (matches.length === 0) {
return [{
id: "query-send-channel-invalid",
label: "Send to channel",
description: "No matching channel found.",
badge: "Query",
icon: NotesIcon,
run: () => showToast("No matching channel found.", Toasts.Type.FAILURE)
}];
}
return matches.map(match => ({
id: `query-send-channel-${match.id}`,
label: `Send to ${match.display}`,
description: "Channel message",
inputPreview: content,
badge: "Query",
icon: makeIconFromUrl(match.iconUrl) ?? NotesIcon,
run: async () => {
await sendMessageToChannel({
channelId: match.id,
content,
useFilePicker,
silent
});
showToast(`Message sent to ${match.display}.`, Toasts.Type.SUCCESS);
}
}));
}
function buildOpenDmCandidates(target: string): QueryActionCandidate[] {
const trimmedTarget = target.trim();
const currentUserId = UserStore.getCurrentUser?.()?.id;
const matches = (trimmedTarget ? resolveUsers(trimmedTarget) : resolveRecentDmUsers(40))
.filter(match => !currentUserId || match.user.id !== currentUserId);
if (matches.length === 0) {
return [{
id: "query-open-dm-invalid",
label: "Open DM",
description: trimmedTarget ? "No matching user found." : "No recent DMs found.",
badge: "Query",
run: () => showToast(trimmedTarget ? "No matching user found." : "No recent DMs found.", Toasts.Type.FAILURE)
}];
}
return matches.slice(0, 24).map(match => ({
id: `query-open-dm-${match.user.id}`,
label: match.display,
description: "Direct message",
badge: "Query",
icon: makeIconFromUrl(match.iconUrl) ?? NotesIcon,
iconUrl: match.iconUrl,
suggestionKind: "user",
run: async () => {
const dmId = await openDmByUserId(match.user.id);
if (!dmId) {
showToast("Unable to open DM.", Toasts.Type.FAILURE);
return;
}
NavigationRouter.transitionTo(`/channels/@me/${dmId}`);
}
}));
}
function buildGoToCandidates(target: string): QueryActionCandidate[] {
const trimmedTarget = target.trim();
const guilds = resolveGuilds(trimmedTarget, { includeAllWhenEmpty: true }).slice(0, 24);
const candidates: QueryActionCandidate[] = [];
for (const guild of guilds) {
candidates.push({
id: `query-go-guild-${guild.id}`,
label: guild.display,
badge: "Query",
icon: makeIconFromUrl(guild.iconUrl),
iconUrl: guild.iconUrl,
suggestionKind: "guild",
run: () => NavigationRouter.transitionToGuild(guild.id)
});
}
if (candidates.length > 0) return candidates;
return [{
id: "query-go-invalid",
label: "Navigate to",
description: "No server matches that target.",
badge: "Query",
run: () => showToast("No server matches that target.", Toasts.Type.FAILURE)
}];
}
function buildOpenSettingsCandidates(target: string): QueryActionCandidate[] {
const commandIds = resolveSettingsCommandIds(target).slice(0, 4);
if (commandIds.length === 0) {
return [{
id: "query-settings-invalid",
label: "Open settings",
description: "No matching settings section.",
badge: "Query",
run: () => showToast("No matching settings section.", Toasts.Type.FAILURE)
}];
}
return commandIds.map(commandId => {
const entry = getCommandById(commandId);
if (!entry) {
return {
id: `query-settings-missing-${commandId}`,
label: "Open settings",
description: "Settings command is unavailable.",
badge: "Query",
run: () => showToast("Settings command is unavailable.", Toasts.Type.FAILURE)
} satisfies QueryActionCandidate;
}
return {
id: `query-settings-${commandId}`,
label: entry.label,
description: entry.description,
badge: "Query",
run: async () => {
await executeCommand(entry);
}
} satisfies QueryActionCandidate;
});
}
function buildTogglePluginCandidates(target: string): QueryActionCandidate[] {
const matches = resolvePlugins(target).slice(0, 5);
if (matches.length === 0) {
return [{
id: "query-toggle-plugin-invalid",
label: "Toggle plugin",
description: "No matching plugin.",
badge: "Query",
run: () => showToast("No matching plugin.", Toasts.Type.FAILURE)
}];
}
return matches.map(plugin => ({
id: `query-toggle-plugin-${plugin.name}`,
label: `${pluginToggleVerb(plugin)} ${plugin.name}`,
description: plugin.description,
badge: "Query",
run: async () => {
const before = isPluginEnabled(plugin.name);
const success = await toggleEnabled(plugin.name);
const after = isPluginEnabled(plugin.name);
if (!success) {
showToast(`Failed to toggle ${plugin.name}.`, Toasts.Type.FAILURE);
return;
}
if (before !== after) {
showToast(`${after ? "Enabled" : "Disabled"} ${plugin.name}.`, Toasts.Type.SUCCESS);
return;
}
showToast(`No change for ${plugin.name}.`, Toasts.Type.MESSAGE);
}
}));
}
function buildOpenUrlCandidates(target: string): QueryActionCandidate[] {
const parsed = normalizeUrl(target);
if (!parsed || !isSafeUrlProtocol(parsed)) {
return [{
id: "query-open-url-invalid",
label: "Open URL",
description: "Invalid URL.",
badge: "Query",
run: () => showToast("Invalid URL.", Toasts.Type.FAILURE)
}];
}
const href = parsed.toString();
return [{
id: `query-open-url-${href}`,
label: `Open ${href}`,
badge: "Query",
run: () => {
const route = toDiscordRoute(parsed);
if (route) {
NavigationRouter.transitionTo(route);
return;
}
openExternalUrl(href);
}
}];
}
function normalizeText(value: string): string {
return value.trim().toLowerCase();
}
function formatScheduledTime(timestamp: number): string {
return new Date(timestamp).toLocaleString();
}
function toScheduledSnippet(content: string): string {
const normalized = content.replace(/\s+/g, " ").trim();
if (!normalized) return "(No text)";
if (normalized.length <= 56) return normalized;
return `${normalized.slice(0, 53)}...`;
}
function toScheduledMessageLabel(message: ScheduledMessage): string {
const channel = getChannelDisplayInfo(message.channelId);
const snippet = toScheduledSnippet(message.content);
return `${channel.name} · ${snippet} · ${formatScheduledTime(message.scheduledTime)}`;
}
function parseClockToken(value: string, base: Date): number | null {
const match = value.trim().match(/^(\d{1,2})(?::(\d{2}))?\s*(am|pm)?$/i);
if (!match) return null;
const rawHour = Number.parseInt(match[1], 10);
const minute = Number.parseInt(match[2] ?? "0", 10);
const meridiem = match[3]?.toLowerCase();
if (Number.isNaN(rawHour) || Number.isNaN(minute) || minute < 0 || minute > 59) return null;
let hour = rawHour;
if (meridiem) {
if (hour < 1 || hour > 12) return null;
if (meridiem === "pm" && hour < 12) hour += 12;
if (meridiem === "am" && hour === 12) hour = 0;
} else if (hour < 0 || hour > 23) {
return null;
}
const candidate = new Date(base);
candidate.setHours(hour, minute, 0, 0);
return candidate.getTime();
}
function parseScheduledTimeInput(input: string): number | null {
const normalized = input.trim();
if (!normalized) return null;
const lower = normalized.toLowerCase();
const now = Date.now();
const relative = lower.match(/^in\s+(\d+)\s*(m|min|mins|minute|minutes|h|hr|hrs|hour|hours|d|day|days)$/);
if (relative) {
const amount = Number.parseInt(relative[1], 10);
if (amount < 1) return null;
const unit = relative[2];
const multiplier = unit.startsWith("m")
? 60_000
: unit.startsWith("h")
? 3_600_000
: 86_400_000;
return now + amount * multiplier;
}
const tomorrow = lower.match(/^tomorrow(?:\s+at)?\s+(.+)$/);
if (tomorrow?.[1]) {
const base = new Date();
base.setDate(base.getDate() + 1);
return parseClockToken(tomorrow[1], base);
}
const today = lower.match(/^today(?:\s+at)?\s+(.+)$/);
if (today?.[1]) {
const timestamp = parseClockToken(today[1], new Date());
return timestamp && timestamp > now ? timestamp : null;
}
const clockOnly = parseClockToken(normalized, new Date());
if (clockOnly) {
if (clockOnly > now) return clockOnly;
return clockOnly + 86_400_000;
}
const numericDate = normalized.match(/^(\d{4}-\d{2}-\d{2})(?:[ t](\d{1,2}:\d{2}(?:\s*(?:am|pm))?))?$/i);
if (numericDate) {
if (numericDate[2]) {
const [year, month, day] = numericDate[1].split("-").map(part => Number.parseInt(part, 10));
const base = new Date(year, month - 1, day);
return parseClockToken(numericDate[2], base);
}
const dateOnly = new Date(`${numericDate[1]}T09:00:00`);
const timestamp = dateOnly.getTime();
if (Number.isNaN(timestamp) || timestamp <= now) return null;
return timestamp;
}
const parsed = new Date(normalized).getTime();
if (!Number.isNaN(parsed) && parsed > now) return parsed;
return null;
}
function parseQuickScheduleTarget(target: string): { message: string; timeInput: string; channelTarget: string | null; } | null {
const normalized = target.trim();
if (!normalized) return null;
const split = normalized.match(/^(.*)\s(?:at|in)\s(.+)$/i);
if (!split?.[1] || !split[2]) return null;
let message = split[1].trim();
let timeInput = split[2].trim();
let channelTarget: string | null = null;
const withDestination = timeInput.match(/^(.*)\s+to\s+(.+)$/i);
if (withDestination?.[1] && withDestination[2]) {
timeInput = withDestination[1].trim();
channelTarget = withDestination[2].trim();
}
const prefixedDestination = message.match(/^to\s+(.+?)\s+(.+)$/i);
if (prefixedDestination?.[1] && prefixedDestination[2] && !channelTarget) {
channelTarget = prefixedDestination[1].trim();
message = prefixedDestination[2].trim();
}
if (!message || !timeInput) return null;
return { message, timeInput, channelTarget };
}
function resolveChannelChoice(target: string | null): { id: string; display: string; } | null {
const normalized = target?.trim() ?? "";
if (normalized) {
const matches = resolveAllChannels(normalized);
if (matches.length > 0) return { id: matches[0].id, display: matches[0].display };
return null;
}
const currentChannelId = SelectedChannelStore.getChannelId();
if (!currentChannelId) return null;
const channel = ChannelStore.getChannel(currentChannelId);
if (!channel) return null;
const display = channel.name ? `#${channel.name}` : getChannelDisplayInfo(currentChannelId).name;
return { id: currentChannelId, display };
}
function filterScheduledMessages(target: string): ScheduledMessage[] {
const query = normalizeText(target);
const messages = getScheduledMessages();
if (!query) return messages;
return messages.filter(message => {
const channel = getChannelDisplayInfo(message.channelId).name.toLowerCase();
const content = message.content.toLowerCase();
const time = formatScheduledTime(message.scheduledTime).toLowerCase();
return channel.includes(query) || content.includes(query) || time.includes(query);
});
}
function getNotebookNames(): string[] {
return Object.keys(noteHandler.getAllNotes());
}
function filterNotebookNames(target: string, includeMain: boolean): string[] {
const normalizedTarget = normalizeText(target);
return getNotebookNames()
.filter(notebook => includeMain || notebook !== "Main")
.filter(notebook => !normalizedTarget || notebook.toLowerCase().includes(normalizedTarget))
.sort((left, right) => left.localeCompare(right));
}
function getResolvedNotes(target: string): ResolvedHolyNote[] {
const normalizedTarget = normalizeText(target);
const notebooks = noteHandler.getAllNotes();
const resolved: ResolvedHolyNote[] = [];
for (const [notebook, notes] of Object.entries(notebooks)) {
for (const note of Object.values(notes)) {
const content = note.content?.trim() ?? "";
if (normalizedTarget && !content.toLowerCase().includes(normalizedTarget)) continue;
resolved.push({ notebook, note });
}
}
return resolved;
}
function toNoteSnippet(note: Note): string {
const content = note.content?.replace(/\s+/g, " ").trim();
if (!content) return "(No text)";
if (content.length <= 56) return content;
return `${content.slice(0, 53)}...`;
}
function buildCreateNotebookCandidates(target: string): QueryActionCandidate[] {
const notebookName = target.trim();
if (!notebookName) {
return [{
id: "query-create-notebook-invalid",
label: "Create notebook",
description: "Notebook name is required.",
badge: "Query",
icon: NotesIcon,
run: () => {
showToast("Notebook name is required.", Toasts.Type.FAILURE);
return false;
}
}];
}
return [{
id: `query-create-notebook-${notebookName.toLowerCase()}`,
label: `Create notebook ${notebookName}`,
badge: "Query",
icon: NotesIcon,
run: async () => {
if (!await ensureHolyNotesEnabled()) return false;
if (noteHandler.getNotes(notebookName)) {
showToast(`Notebook ${notebookName} already exists`, Toasts.Type.FAILURE);
return false;
}
noteHandler.newNoteBook(notebookName);
}
}];
}
function buildDeleteNotebookCandidates(target: string): QueryActionCandidate[] {
const notebooks = filterNotebookNames(target, false);
if (notebooks.length === 0) {
return [{
id: "query-delete-notebook-none",
label: "Delete notebook",
description: "No deletable notebook found.",
badge: "Query",
icon: NotesIcon,
run: () => {
showToast("No deletable notebook found.", Toasts.Type.MESSAGE);
return false;
}
}];
}
return notebooks.slice(0, 8).map(notebook => ({
id: `query-delete-notebook-${notebook.toLowerCase()}`,
label: notebook,
description: "Notebook",
badge: "Query",
icon: NotesIcon,
run: async () => {
if (!await ensureHolyNotesEnabled()) return false;
noteHandler.deleteNotebook(notebook);
}
}));
}
function buildJumpNoteCandidates(target: string): QueryActionCandidate[] {
const notes = getResolvedNotes(target).slice(0, 10);
if (notes.length === 0) {
return [{
id: "query-jump-note-none",
label: "Jump to note",
description: "No matching note found.",
badge: "Query",
icon: NotesIcon,
run: () => {
showToast("No matching note found.", Toasts.Type.FAILURE);
return false;
}
}];
}
return notes.map(({ notebook, note }) => ({
id: `query-jump-note-${notebook}-${note.id}`,
label: `[${notebook}] ${toNoteSnippet(note)}`,
description: "Open original message.",
badge: "Query",
icon: NotesIcon,
run: async () => {
if (!await ensureHolyNotesEnabled()) return false;
NavigationRouter.transitionTo(`/channels/${note.guild_id || "@me"}/${note.channel_id}/${note.id}`);
}
}));
}
function buildMoveNoteCandidates(target: string): QueryActionCandidate[] {
if (stagedMoveNote) {
const destinationNotebooks = filterNotebookNames(target, true)
.filter(notebook => notebook !== stagedMoveNote?.notebook);
if (destinationNotebooks.length === 0) {
return [{
id: "query-move-note-no-destination",
label: "Move note",
description: "No destination notebook found.",
badge: "Query",
icon: NotesIcon,
run: () => {
showToast("No destination notebook found.", Toasts.Type.FAILURE);
return false;
}
}];
}
return destinationNotebooks.slice(0, 8).map(destinationNotebook => ({
id: `query-move-note-destination-${destinationNotebook.toLowerCase()}`,
label: `[${stagedMoveNote!.notebook}] ${toNoteSnippet(stagedMoveNote!.note)} -> Move to ${destinationNotebook}`,
description: "Move note",
badge: "Query",
icon: NotesIcon,
run: async () => {
if (!stagedMoveNote) return;
if (!await ensureHolyNotesEnabled()) return false;
if (!stagedMoveNote) return;
noteHandler.moveNote(stagedMoveNote.note, stagedMoveNote.notebook, destinationNotebook);
stagedMoveNote = null;
}
}));
}
const notes = getResolvedNotes(target).slice(0, 10);
if (notes.length === 0) {
return [{
id: "query-move-note-none",
label: "Move note",
description: "No matching note found.",
badge: "Query",
icon: NotesIcon,
run: () => {
showToast("No matching note found.", Toasts.Type.FAILURE);
return false;
}
}];
}
return notes.map(({ notebook, note }) => ({
id: `query-move-note-select-${notebook}-${note.id}`,
label: `[${notebook}] ${toNoteSnippet(note)}`,
description: "Select destination notebook.",
badge: "Query",
icon: NotesIcon,
run: () => {
return ensureHolyNotesEnabled().then(enabled => {
if (!enabled) return false;
stagedMoveNote = { notebook, note };
showToast("Note selected. Choose destination notebook.", Toasts.Type.MESSAGE);
return false;
});
}
}));
}
function buildQuickScheduleMessageCandidates(target: string): QueryActionCandidate[] {
const parsed = parseQuickScheduleTarget(target);
if (!parsed) return [];
const channelChoice = resolveChannelChoice(parsed.channelTarget);
if (!channelChoice) return [];
const scheduledTime = parseScheduledTimeInput(parsed.timeInput);
if (!scheduledTime) return [];
const label = `${channelChoice.display} · ${toScheduledSnippet(parsed.message)} · ${formatScheduledTime(scheduledTime)}`;
return [{
id: `query-schedule-message-${channelChoice.id}-${scheduledTime}`,
label,
description: "Schedule message",
badge: "Query",
run: async () => {
if (!await ensureScheduledMessagesEnabled()) return false;
const result = await addScheduledMessage(channelChoice.id, parsed.message, scheduledTime);
if (!result.success) {
showToast(result.error ?? "Failed to schedule message.", Toasts.Type.FAILURE);
return false;
}
showToast("Message scheduled.", Toasts.Type.SUCCESS);
}
}];
}
function buildRescheduleMessageCandidates(target: string): QueryActionCandidate[] {
if (!stagedRescheduleMessage) {
const messages = filterScheduledMessages(target).slice(0, 12);
if (messages.length === 0) {
return [{
id: "query-reschedule-none",
label: "Reschedule message",
description: "No scheduled messages found.",
badge: "Query",
run: () => {
showToast("No scheduled messages found.", Toasts.Type.MESSAGE);
return false;
}
}];
}
return messages.map(message => {
const label = toScheduledMessageLabel(message);
return {
id: `query-reschedule-select-${message.id}`,
label,
description: "Select message to reschedule.",
badge: "Query",
run: () => {
stagedRescheduleMessage = { id: message.id, label };
showToast("Message selected. Type the new schedule time.", Toasts.Type.MESSAGE);
return false;
}
};
});
}
const scheduledTime = parseScheduledTimeInput(target);
if (!scheduledTime) {
return [{
id: "query-reschedule-time-invalid",
label: stagedRescheduleMessage.label,
description: "Use in 10m, tomorrow 5pm, or 2026-02-14 18:00.",
badge: "Query",
run: () => {
showToast("Type a valid future time.", Toasts.Type.MESSAGE);
return false;
}
}];
}
return [{
id: `query-reschedule-apply-${stagedRescheduleMessage.id}-${scheduledTime}`,
label: `${stagedRescheduleMessage.label} -> ${formatScheduledTime(scheduledTime)}`,
description: "Apply new schedule time.",
badge: "Query",
run: async () => {
if (!await ensureScheduledMessagesEnabled()) return false;
const result = await updateScheduledMessageTime(stagedRescheduleMessage!.id, scheduledTime);
if (!result.success) {
showToast(result.error ?? "Failed to reschedule message.", Toasts.Type.FAILURE);
return false;
}
stagedRescheduleMessage = null;
showToast("Message rescheduled.", Toasts.Type.SUCCESS);
}
}];
}
function buildSendScheduledNowCandidates(target: string): QueryActionCandidate[] {
const messages = filterScheduledMessages(target).slice(0, 12);
if (messages.length === 0) {
return [{
id: "query-send-scheduled-none",
label: "Send scheduled message now",
description: "No scheduled messages found.",
badge: "Query",
run: () => {
showToast("No scheduled messages found.", Toasts.Type.MESSAGE);
return false;
}
}];
}
return messages.map(message => ({
id: `query-send-scheduled-${message.id}`,
label: toScheduledMessageLabel(message),
description: "Send now.",
badge: "Query",
run: async () => {
if (!await ensureScheduledMessagesEnabled()) return false;
const result = await sendScheduledMessageNow(message.id);
if (!result.success) {
showToast(result.error ?? "Failed to send scheduled message.", Toasts.Type.FAILURE);
return false;
}
showToast("Sent scheduled message.", Toasts.Type.SUCCESS);
}
}));
}
function buildCancelScheduledMessageCandidates(target: string): QueryActionCandidate[] {
if (pendingCancelScheduledMessageId) {
const message = getScheduledMessages().find(entry => entry.id === pendingCancelScheduledMessageId);
if (!message) {
pendingCancelScheduledMessageId = null;
} else {
return [{
id: `query-cancel-scheduled-confirm-${message.id}`,
label: `Confirm cancel · ${toScheduledMessageLabel(message)}`,
description: "Confirm removal.",
badge: "Query",
run: async () => {
if (!await ensureScheduledMessagesEnabled()) return false;
await removeScheduledMessage(message.id);
pendingCancelScheduledMessageId = null;
showToast("Scheduled message canceled.", Toasts.Type.SUCCESS);
}
}];
}
}
const messages = filterScheduledMessages(target).slice(0, 12);
if (messages.length === 0) {
return [{
id: "query-cancel-scheduled-none",
label: "Cancel scheduled message",
description: "No scheduled messages found.",
badge: "Query",
run: () => {
showToast("No scheduled messages found.", Toasts.Type.MESSAGE);
return false;
}
}];
}
return messages.map(message => ({
id: `query-cancel-scheduled-pick-${message.id}`,
label: toScheduledMessageLabel(message),
description: "Select to confirm cancel.",
badge: "Query",
run: () => {
pendingCancelScheduledMessageId = message.id;
showToast("Select again to confirm cancel.", Toasts.Type.MESSAGE);
return false;
}
}));
}
export function buildQueryResolution(query: string): QueryResolution {
const parsed = parseQuery(query);
if (!parsed) {
stagedMoveNote = null;
stagedRescheduleMessage = null;
pendingCancelScheduledMessageId = null;
return { type: "none" };
}
if (parsed.intent !== "move_note") {
stagedMoveNote = null;
}
if (parsed.intent !== "reschedule_message") {
stagedRescheduleMessage = null;
}
if (parsed.intent !== "cancel_scheduled_message") {
pendingCancelScheduledMessageId = null;
}
switch (parsed.intent) {
case "send_message": {
if (!parsed.content) {
return { type: "invalid", reason: "Message content is required." };
}
return {
type: "candidates",
candidates: buildSendCandidates(parsed.target, parsed.content, Boolean(parsed.useFilePicker), Boolean(parsed.silent))
};
}
case "send_channel": {
if (!parsed.content) {
return { type: "invalid", reason: "Message content is required." };
}
return {
type: "candidates",
candidates: buildSendChannelCandidates(parsed.target, parsed.content, Boolean(parsed.useFilePicker), Boolean(parsed.silent))
};
}
case "open_dm":
return { type: "candidates", candidates: buildOpenDmCandidates(parsed.target) };
case "go_to":
return { type: "candidates", candidates: buildGoToCandidates(parsed.target) };
case "open_settings":
return { type: "candidates", candidates: buildOpenSettingsCandidates(parsed.target) };
case "toggle_plugin":
return { type: "candidates", candidates: buildTogglePluginCandidates(parsed.target) };
case "open_url":
return { type: "candidates", candidates: buildOpenUrlCandidates(parsed.target) };
case "create_notebook":
return { type: "candidates", candidates: buildCreateNotebookCandidates(parsed.target) };
case "delete_notebook":
return { type: "candidates", candidates: buildDeleteNotebookCandidates(parsed.target) };
case "move_note":
return { type: "candidates", candidates: buildMoveNoteCandidates(parsed.target) };
case "jump_note":
return { type: "candidates", candidates: buildJumpNoteCandidates(parsed.target) };
case "schedule_message":
return { type: "candidates", candidates: buildQuickScheduleMessageCandidates(parsed.target) };
case "reschedule_message":
return { type: "candidates", candidates: buildRescheduleMessageCandidates(parsed.target) };
case "send_scheduled_now":
return { type: "candidates", candidates: buildSendScheduledNowCandidates(parsed.target) };
case "cancel_scheduled_message":
return { type: "candidates", candidates: buildCancelScheduledMessageCandidates(parsed.target) };
default:
return { type: "none" };
}
}

View file

@ -0,0 +1,126 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { openPrivateChannel, sendMessage } from "@utils/discord";
import { Logger } from "@utils/Logger";
import { sleep } from "@utils/misc";
import { chooseFile } from "@utils/web";
import { CloudUpload } from "@vencord/discord-types";
import { CloudUploadPlatform } from "@vencord/discord-types/enums";
import { findLazy } from "@webpack";
import { ChannelStore, Constants, RestAPI, SnowflakeUtils } from "@webpack/common";
const logger = new Logger("CommandPaletteSendMessage");
const CloudUploader = findLazy(m => m.prototype?.trackUploadFinished) as typeof CloudUpload;
async function waitForDmChannel(userId: string, timeoutMs = 2500): Promise<string | null> {
const existing = ChannelStore.getDMFromUserId?.(userId) ?? null;
if (existing) return existing;
const started = Date.now();
while (Date.now() - started < timeoutMs) {
const next = ChannelStore.getDMFromUserId?.(userId) ?? null;
if (next) return next;
await sleep(80);
}
return null;
}
async function pickFilesFromDisk(): Promise<File[]> {
if (typeof document === "undefined") return [];
const file = await chooseFile("*/*");
return file ? [file] : [];
}
async function uploadAttachment(channelId: string, file: File, index: number): Promise<{ id: string; filename: string; uploaded_filename: string; } | null> {
return new Promise(resolve => {
const upload = new CloudUploader({ file, platform: CloudUploadPlatform.WEB }, channelId);
upload.on("complete", () => {
resolve({
id: String(index),
filename: upload.filename,
uploaded_filename: upload.uploadedFilename
});
});
upload.on("error", () => {
resolve(null);
});
upload.upload();
});
}
async function postMessageWithAttachments(channelId: string, content: string, files: File[]) {
const uploaded = await Promise.all(files.map((file, index) => uploadAttachment(channelId, file, index)));
const attachments = uploaded.filter((entry): entry is { id: string; filename: string; uploaded_filename: string; } => Boolean(entry));
if (attachments.length !== files.length) {
throw new Error("One or more attachments failed to upload.");
}
await RestAPI.post({
url: Constants.Endpoints.MESSAGES(channelId),
body: {
content,
nonce: SnowflakeUtils.fromTimestamp(Date.now()),
channel_id: channelId,
sticker_ids: [],
type: 0,
attachments
}
});
}
async function sendToChannel(channelId: string, content: string, useFilePicker: boolean | undefined, silent: boolean | undefined) {
const bodyContent = silent ? `@silent ${content}` : content;
if (!useFilePicker) {
await sendMessage(channelId, { content: bodyContent });
return;
}
const files = await pickFilesFromDisk();
if (files.length === 0) {
throw new Error("No files selected.");
}
try {
await postMessageWithAttachments(channelId, bodyContent, files);
} catch (error) {
logger.error("Failed sending message with attachments", error);
throw error;
}
}
export async function sendMessageToUser(options: { userId: string; content: string; useFilePicker?: boolean; silent?: boolean; }) {
const { userId, content, useFilePicker, silent } = options;
if (!userId) throw new Error("User target is required.");
if (!content.trim()) throw new Error("Message content is required.");
openPrivateChannel(userId);
const channelId = await waitForDmChannel(userId, 3000);
if (!channelId) {
throw new Error("Unable to open a DM with that user.");
}
await sendToChannel(channelId, content, useFilePicker, silent);
}
export async function sendMessageToChannel(options: { channelId: string; content: string; useFilePicker?: boolean; silent?: boolean; }) {
const { channelId, content, useFilePicker, silent } = options;
if (!channelId) throw new Error("Channel target is required.");
if (!content.trim()) throw new Error("Message content is required.");
await sendToChannel(channelId, content, useFilePicker, silent);
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,268 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { evaluateAdvancedMath } from "./advancedMath";
import {
formatDateMedium,
formatDurationMinutes,
formatNumber,
formatRawNumber,
formatTime12,
formatTime24,
formatTimespanMinutes,
numberToWords
} from "./formatters";
import { convertTimeBetweenTimezones, getNowInTimezone, getTimezoneCountryName, getTimezoneDate } from "./timezones";
import type { CalculatorIntent, CalculatorResult } from "./types";
type UnitKind = "length" | "mass" | "temperature" | "duration";
type UnitDefinition = {
kind: UnitKind;
toBase: (value: number) => number;
fromBase: (value: number) => number;
label: string;
};
const UNITS: Record<string, UnitDefinition> = {
mm: { kind: "length", toBase: v => v / 1000, fromBase: v => v * 1000, label: "mm" },
cm: { kind: "length", toBase: v => v / 100, fromBase: v => v * 100, label: "cm" },
m: { kind: "length", toBase: v => v, fromBase: v => v, label: "m" },
km: { kind: "length", toBase: v => v * 1000, fromBase: v => v / 1000, label: "km" },
in: { kind: "length", toBase: v => v * 0.0254, fromBase: v => v / 0.0254, label: "in" },
inch: { kind: "length", toBase: v => v * 0.0254, fromBase: v => v / 0.0254, label: "in" },
inches: { kind: "length", toBase: v => v * 0.0254, fromBase: v => v / 0.0254, label: "in" },
ft: { kind: "length", toBase: v => v * 0.3048, fromBase: v => v / 0.3048, label: "ft" },
feet: { kind: "length", toBase: v => v * 0.3048, fromBase: v => v / 0.3048, label: "ft" },
yd: { kind: "length", toBase: v => v * 0.9144, fromBase: v => v / 0.9144, label: "yd" },
mi: { kind: "length", toBase: v => v * 1609.344, fromBase: v => v / 1609.344, label: "mi" },
g: { kind: "mass", toBase: v => v, fromBase: v => v, label: "g" },
kg: { kind: "mass", toBase: v => v * 1000, fromBase: v => v / 1000, label: "kg" },
lb: { kind: "mass", toBase: v => v * 453.59237, fromBase: v => v / 453.59237, label: "lb" },
lbs: { kind: "mass", toBase: v => v * 453.59237, fromBase: v => v / 453.59237, label: "lb" },
oz: { kind: "mass", toBase: v => v * 28.349523125, fromBase: v => v / 28.349523125, label: "oz" },
c: { kind: "temperature", toBase: v => v, fromBase: v => v, label: "°C" },
"°c": { kind: "temperature", toBase: v => v, fromBase: v => v, label: "°C" },
f: { kind: "temperature", toBase: v => (v - 32) * (5 / 9), fromBase: v => v * (9 / 5) + 32, label: "°F" },
"°f": { kind: "temperature", toBase: v => (v - 32) * (5 / 9), fromBase: v => v * (9 / 5) + 32, label: "°F" },
k: { kind: "temperature", toBase: v => v - 273.15, fromBase: v => v + 273.15, label: "K" },
s: { kind: "duration", toBase: v => v, fromBase: v => v, label: "s" },
sec: { kind: "duration", toBase: v => v, fromBase: v => v, label: "s" },
second: { kind: "duration", toBase: v => v, fromBase: v => v, label: "s" },
seconds: { kind: "duration", toBase: v => v, fromBase: v => v, label: "s" },
min: { kind: "duration", toBase: v => v * 60, fromBase: v => v / 60, label: "min" },
mins: { kind: "duration", toBase: v => v * 60, fromBase: v => v / 60, label: "min" },
minute: { kind: "duration", toBase: v => v * 60, fromBase: v => v / 60, label: "min" },
minutes: { kind: "duration", toBase: v => v * 60, fromBase: v => v / 60, label: "min" },
h: { kind: "duration", toBase: v => v * 3600, fromBase: v => v / 3600, label: "h" },
hr: { kind: "duration", toBase: v => v * 3600, fromBase: v => v / 3600, label: "h" },
hrs: { kind: "duration", toBase: v => v * 3600, fromBase: v => v / 3600, label: "h" },
hour: { kind: "duration", toBase: v => v * 3600, fromBase: v => v / 3600, label: "h" },
hours: { kind: "duration", toBase: v => v * 3600, fromBase: v => v / 3600, label: "h" },
d: { kind: "duration", toBase: v => v * 86400, fromBase: v => v / 86400, label: "d" },
day: { kind: "duration", toBase: v => v * 86400, fromBase: v => v / 86400, label: "d" },
days: { kind: "duration", toBase: v => v * 86400, fromBase: v => v / 86400, label: "d" }
};
function positiveModulo(value: number, divisor: number): number {
return ((value % divisor) + divisor) % divisor;
}
function evaluateTimeDifference(leftHour: number, leftMinute: number, rightHour: number, rightMinute: number): number {
const left = leftHour * 60 + leftMinute;
const right = rightHour * 60 + rightMinute;
const diff = positiveModulo(left - right, 1440);
if (diff > 720) return 1440 - diff;
return diff;
}
function evaluateWeekdayInWeeks(weekday: number, weeks: number): Date {
const now = new Date();
const start = new Date(now);
const currentWeekday = start.getDay();
const daysFromMonday = positiveModulo(currentWeekday - 1, 7);
start.setDate(start.getDate() - daysFromMonday + weeks * 7);
const targetOffset = positiveModulo(weekday - 1, 7);
start.setDate(start.getDate() + targetOffset);
start.setHours(0, 0, 0, 0);
return start;
}
function evaluateDaysUntil(targetDate: Date): number {
const now = new Date();
const current = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
const target = new Date(targetDate.getFullYear(), targetDate.getMonth(), targetDate.getDate()).getTime();
return Math.ceil((target - current) / 86400000);
}
function evaluateDaysSince(targetDate: Date): number {
const now = new Date();
const current = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
const target = new Date(targetDate.getFullYear(), targetDate.getMonth(), targetDate.getDate()).getTime();
return Math.floor((current - target) / 86400000);
}
function evaluateUnitConversion(value: number, fromUnit: string, toUnit: string): { value: number; label: string; } | null {
const from = UNITS[fromUnit];
const to = UNITS[toUnit];
if (!from || !to) return null;
if (from.kind !== to.kind) return null;
const base = from.toBase(value);
const converted = to.fromBase(base);
if (!Number.isFinite(converted)) return null;
return {
value: converted,
label: to.label
};
}
function durationToMinutes(value: number, unit: string): number | null {
const from = UNITS[unit];
if (!from || from.kind !== "duration") return null;
const seconds = from.toBase(value);
return seconds / 60;
}
export function evaluateCalculatorIntent(intent: CalculatorIntent): CalculatorResult | null {
if (intent.kind === "unsupported_rate") return null;
if (intent.kind === "advanced_math") {
return evaluateAdvancedMath(intent.displayInput, intent.normalizedInput);
}
if (intent.kind === "math") {
return evaluateAdvancedMath(intent.displayInput, intent.expression);
}
if (intent.kind === "time_convert") {
if (intent.hasMeridiem) {
return {
kind: "time",
displayInput: intent.displayInput,
displayAnswer: formatTime24(intent.hour, intent.minute),
rawAnswer: formatTime24(intent.hour, intent.minute),
secondaryText: formatTime12(intent.hour, intent.minute).toLowerCase()
};
}
const converted = formatTime12(intent.hour, intent.minute);
return {
kind: "time",
displayInput: intent.displayInput,
displayAnswer: converted,
rawAnswer: converted,
secondaryText: formatTime24(intent.hour, intent.minute)
};
}
if (intent.kind === "time_diff") {
const diffMinutes = evaluateTimeDifference(intent.leftHour, intent.leftMinute, intent.rightHour, intent.rightMinute);
const duration = formatDurationMinutes(diffMinutes);
return {
kind: "duration",
displayInput: intent.displayInput,
displayAnswer: duration.display,
rawAnswer: duration.raw,
secondaryText: formatTime12(intent.rightHour, intent.rightMinute).toLowerCase()
};
}
if (intent.kind === "timezone_convert") {
const converted = convertTimeBetweenTimezones(intent.hour, intent.minute, intent.fromTimezone, intent.toTimezone);
if (!converted) return null;
const dayShift = converted.dayShift === 0
? ""
: converted.dayShift > 0
? ` (+${converted.dayShift}d)`
: ` (${converted.dayShift}d)`;
return {
kind: "time",
displayInput: intent.displayInput,
displayAnswer: `${formatTime24(converted.hour, converted.minute)}${dayShift}`,
rawAnswer: formatTime24(converted.hour, converted.minute),
secondaryText: formatTime12(converted.hour, converted.minute).toLowerCase()
};
}
if (intent.kind === "timezone_now") {
const now = getNowInTimezone(intent.timezone);
const country = getTimezoneCountryName(intent.timezone);
const date = getTimezoneDate(intent.timezone);
return {
kind: "time",
displayInput: intent.displayInput,
displayAnswer: formatTime24(now.hour, now.minute),
rawAnswer: formatTime24(now.hour, now.minute),
secondaryText: country ?? intent.timezone,
tertiaryText: formatDateMedium(date)
};
}
if (intent.kind === "days_until") {
const days = evaluateDaysUntil(intent.targetDate);
return {
kind: "date",
displayInput: intent.displayInput,
displayAnswer: `${days} ${Math.abs(days) === 1 ? "day" : "days"}`,
rawAnswer: String(days),
secondaryText: formatDateMedium(intent.targetDate)
};
}
if (intent.kind === "days_since") {
const days = evaluateDaysSince(intent.targetDate);
return {
kind: "date",
displayInput: intent.displayInput,
displayAnswer: `${days} ${Math.abs(days) === 1 ? "day" : "days"}`,
rawAnswer: String(days),
secondaryText: formatDateMedium(intent.targetDate)
};
}
if (intent.kind === "weekday_in_weeks") {
const date = evaluateWeekdayInWeeks(intent.weekday, intent.weeks);
const dateText = formatDateMedium(date);
return {
kind: "date",
displayInput: intent.displayInput,
displayAnswer: dateText,
rawAnswer: date.toISOString(),
secondaryText: `${Math.max(intent.weeks, 0)} weeks ahead`
};
}
if (intent.kind === "unit_convert") {
const conversion = evaluateUnitConversion(intent.value, intent.fromUnit, intent.toUnit);
if (!conversion) return null;
const formatted = formatNumber(conversion.value);
return {
kind: "unit",
displayInput: intent.displayInput,
displayAnswer: `${formatted} ${conversion.label}`,
rawAnswer: `${formatRawNumber(conversion.value)} ${conversion.label}`,
secondaryText: numberToWords(Math.trunc(conversion.value)) || undefined
};
}
if (intent.kind === "duration_timespan") {
const minutes = durationToMinutes(intent.value, intent.unit);
if (minutes == null) return null;
return {
kind: "duration",
displayInput: intent.displayInput,
displayAnswer: formatTimespanMinutes(minutes),
rawAnswer: String(minutes),
secondaryText: `${formatNumber(minutes)} minutes`
};
}
return null;
}

View file

@ -0,0 +1,133 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
const ONES = ["", "One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine"];
const TEENS = ["Ten", "Eleven", "Twelve", "Thirteen", "Fourteen", "Fifteen", "Sixteen", "Seventeen", "Eighteen", "Nineteen"];
const TENS = ["", "", "Twenty", "Thirty", "Forty", "Fifty", "Sixty", "Seventy", "Eighty", "Ninety"];
const SCALES = ["", "Thousand", "Million", "Billion", "Trillion"];
function underThousandToWords(value: number): string {
const hundreds = Math.floor(value / 100);
const tensAndOnes = value % 100;
const parts: string[] = [];
if (hundreds > 0) {
parts.push(`${ONES[hundreds]} Hundred`);
}
if (tensAndOnes >= 10 && tensAndOnes <= 19) {
parts.push(TEENS[tensAndOnes - 10]);
} else {
const tens = Math.floor(tensAndOnes / 10);
const ones = tensAndOnes % 10;
if (tens > 0) parts.push(TENS[tens]);
if (ones > 0) parts.push(ONES[ones]);
}
return parts.join(" ").trim();
}
export function numberToWords(value: number): string {
if (!Number.isFinite(value)) return "";
if (value === 0) return "Zero";
const negative = value < 0;
let remaining = Math.trunc(Math.abs(value));
const chunks: string[] = [];
let scaleIndex = 0;
while (remaining > 0 && scaleIndex < SCALES.length) {
const chunk = remaining % 1000;
if (chunk > 0) {
const words = underThousandToWords(chunk);
const scale = SCALES[scaleIndex];
chunks.unshift(scale ? `${words} ${scale}` : words);
}
remaining = Math.floor(remaining / 1000);
scaleIndex += 1;
}
const result = chunks.join(" ").trim();
if (!result) return "";
return negative ? `Negative ${result}` : result;
}
export function formatNumber(value: number): string {
if (Number.isNaN(value)) return "NaN";
if (value === Number.POSITIVE_INFINITY) return "Infinity";
if (value === Number.NEGATIVE_INFINITY) return "-Infinity";
return new Intl.NumberFormat("en-US", {
maximumFractionDigits: 12
}).format(value);
}
export function formatRawNumber(value: number): string {
if (Number.isNaN(value)) return "NaN";
if (value === Number.POSITIVE_INFINITY) return "Infinity";
if (value === Number.NEGATIVE_INFINITY) return "-Infinity";
const normalized = Number(value.toFixed(12));
return String(normalized);
}
export function formatTime24(hour: number, minute: number): string {
return `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
}
export function formatTime12(hour: number, minute: number): string {
const period = hour >= 12 ? "PM" : "AM";
const converted = hour % 12 || 12;
return `${converted}:${String(minute).padStart(2, "0")} ${period}`;
}
export function formatDurationMinutes(minutes: number): { display: string; raw: string; } {
const abs = Math.abs(minutes);
if (abs < 60) {
const rounded = Number(abs.toFixed(2));
return {
display: `${rounded} ${rounded === 1 ? "minute" : "minutes"}`,
raw: String(rounded)
};
}
const hours = abs / 60;
if (Number.isInteger(hours)) {
return {
display: `${hours} ${hours === 1 ? "hour" : "hours"}`,
raw: String(hours)
};
}
const rounded = Number(hours.toFixed(2));
return {
display: `${rounded} hours`,
raw: String(rounded)
};
}
export function formatDateMedium(date: Date): string {
return new Intl.DateTimeFormat("en-US", {
weekday: "short",
month: "short",
day: "numeric",
year: "numeric"
}).format(date);
}
export function formatTimespanMinutes(totalMinutes: number): string {
const sign = totalMinutes < 0 ? "-" : "";
let remaining = Math.abs(Math.round(totalMinutes));
const days = Math.floor(remaining / 1440);
remaining %= 1440;
const hours = Math.floor(remaining / 60);
const minutes = remaining % 60;
const parts: string[] = [];
if (days > 0) parts.push(`${days}d`);
if (hours > 0) parts.push(`${hours}h`);
if (minutes > 0 || parts.length === 0) parts.push(`${minutes}m`);
return `${sign}${parts.join(" ")}`;
}

View file

@ -0,0 +1,22 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { evaluateCalculatorIntent } from "./evaluator";
import { parseCalculatorQuery } from "./parser";
import type { CalculatorResult } from "./types";
export type {
CalculatorGraphSeries,
CalculatorResult,
CalculatorResultKind,
CalculatorViewMode
} from "./types";
export function resolveCalculatorQuery(query: string): CalculatorResult | null {
const intent = parseCalculatorQuery(query);
if (!intent) return null;
return evaluateCalculatorIntent(intent);
}

View file

@ -0,0 +1,374 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { parseAdvancedMathQuery } from "./advancedMath";
import { resolveTimezone } from "./timezones";
import type { CalculatorIntent } from "./types";
const CURRENCY_RATE_PATTERN = /\b(?:usd|eur|gbp|cad|aud|jpy|cny|inr|btc|eth|usdt|sol)\b/i;
const WEEKDAY_TO_INDEX: Record<string, number> = {
sunday: 0,
monday: 1,
tuesday: 2,
wednesday: 3,
thursday: 4,
friday: 5,
saturday: 6
};
const IN_WORDS = ["in", "en", "dans", "em", "fra", "через"];
const TO_WORDS = ["to", "a", "au", "en", "para", "til"];
const TIME_WORDS = ["time", "hora", "heure", "uhr", "ora", "время"];
const DAYS_WORDS = ["days", "dias", "jours", "tage", "giorni", "дней"];
const UNTIL_WORDS = ["until", "hasta", "jusqua", "bis", "fino", "до"];
const SINCE_WORDS = ["since", "desde", "depuis", "seit", "da", "с"];
const WEEK_WORDS = ["week", "weeks", "semana", "semanas", "semaine", "semaines", "woche", "wochen", "settimana", "settimane", "неделя", "недели", "недель"];
function normalizeQuery(input: string): string {
return input.trim().replace(/\s+/g, " ");
}
function normalizeLocaleToken(input: string): string {
return input
.trim()
.toLowerCase()
.normalize("NFD")
.replace(/\p{Diacritic}/gu, "")
.replace(/[.'’]/g, "")
.replace(/\s+/g, " ");
}
function escapeRegexToken(input: string): string {
return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function buildPattern(words: string[]): string {
return words
.map(normalizeLocaleToken)
.filter(Boolean)
.sort((a, b) => b.length - a.length)
.map(escapeRegexToken)
.join("|");
}
function buildWeekdayLookup() {
const map: Record<string, number> = { ...WEEKDAY_TO_INDEX };
const locales = [
"en-US",
(typeof navigator !== "undefined" && navigator.language) ? navigator.language : "en-US"
];
for (const locale of locales) {
for (let weekday = 0; weekday < 7; weekday++) {
const date = new Date(Date.UTC(2024, 0, 7 + weekday));
const longName = normalizeLocaleToken(new Intl.DateTimeFormat(locale, { weekday: "long" }).format(date));
const shortName = normalizeLocaleToken(new Intl.DateTimeFormat(locale, { weekday: "short" }).format(date));
if (longName) map[longName] = weekday;
if (shortName) map[shortName] = weekday;
}
}
return map;
}
const WEEKDAY_LOOKUP = buildWeekdayLookup();
const WEEKDAY_PATTERN = buildPattern(Object.keys(WEEKDAY_LOOKUP));
const IN_PATTERN = buildPattern(IN_WORDS);
const TIME_PATTERN = buildPattern(TIME_WORDS);
const DAYS_PATTERN = buildPattern(DAYS_WORDS);
const UNTIL_PATTERN = buildPattern(UNTIL_WORDS);
const SINCE_PATTERN = buildPattern(SINCE_WORDS);
const WEEK_PATTERN = buildPattern(WEEK_WORDS);
const TO_OR_IN_PATTERN = buildPattern([...TO_WORDS, ...IN_WORDS]);
function parseNumber(input: string): number | null {
const normalized = input.trim().toLowerCase().replace(/,/g, "");
const match = normalized.match(/^(-?\d+(?:\.\d+)?)([kmb])?$/);
if (!match) return null;
const base = Number(match[1]);
if (!Number.isFinite(base)) return null;
const suffix = match[2];
if (!suffix) return base;
if (suffix === "k") return base * 1000;
if (suffix === "m") return base * 1000000;
return base * 1000000000;
}
function parseTimeToken(input: string): { hour: number; minute: number; hasMeridiem: boolean; meridiem?: "am" | "pm"; } | null {
const normalized = input.trim().toLowerCase();
const withMeridiem = normalized.match(/^(\d{1,2})(?::(\d{2}))?\s*([ap]m)$/);
if (withMeridiem) {
let hour = Number(withMeridiem[1]);
const minute = Number(withMeridiem[2] ?? "0");
const meridiem = withMeridiem[3] as "am" | "pm";
if (!Number.isFinite(hour) || !Number.isFinite(minute) || minute < 0 || minute > 59 || hour < 1 || hour > 12) return null;
if (meridiem === "am") {
if (hour === 12) hour = 0;
} else if (hour !== 12) {
hour += 12;
}
return {
hour,
minute,
hasMeridiem: true,
meridiem
};
}
const military = normalized.match(/^(\d{1,2})(?::(\d{2}))$/);
if (military) {
const hour = Number(military[1]);
const minute = Number(military[2]);
if (!Number.isFinite(hour) || !Number.isFinite(minute) || hour < 0 || hour > 23 || minute < 0 || minute > 59) return null;
return {
hour,
minute,
hasMeridiem: false
};
}
return null;
}
type DateDirection = "until" | "since";
const MONTH_INDEX: Record<string, number> = {
january: 0,
jan: 0,
february: 1,
feb: 1,
march: 2,
mar: 2,
april: 3,
apr: 3,
may: 4,
june: 5,
jun: 5,
july: 6,
jul: 6,
august: 7,
aug: 7,
september: 8,
sept: 8,
sep: 8,
october: 9,
oct: 9,
november: 10,
nov: 10,
december: 11,
dec: 11
};
function parseDateLoose(input: string, direction: DateDirection): Date | null {
const trimmed = input
.trim()
.replace(/\b(\d{1,2})(st|nd|rd|th)\b/gi, "$1")
.replace(/,/g, "");
if (!trimmed) return null;
const yearOnly = trimmed.match(/^(\d{4})$/);
if (yearOnly) {
const year = Number(yearOnly[1]);
return new Date(year, 0, 1);
}
const dayMonth = trimmed.match(/^(\d{1,2})\s+([a-zA-Z]+)(?:\s+(\d{4}))?$/);
const monthDay = trimmed.match(/^([a-zA-Z]+)\s+(\d{1,2})(?:\s+(\d{4}))?$/);
const structured = dayMonth
? { day: Number(dayMonth[1]), monthToken: dayMonth[2], yearToken: dayMonth[3] }
: monthDay
? { day: Number(monthDay[2]), monthToken: monthDay[1], yearToken: monthDay[3] }
: null;
if (structured) {
const month = MONTH_INDEX[structured.monthToken.toLowerCase()];
if (month == null || structured.day < 1 || structured.day > 31) return null;
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const year = structured.yearToken ? Number(structured.yearToken) : now.getFullYear();
let candidate = new Date(year, month, structured.day);
if (!structured.yearToken) {
if (direction === "until" && candidate < today) {
candidate = new Date(year + 1, month, structured.day);
} else if (direction === "since" && candidate > today) {
candidate = new Date(year - 1, month, structured.day);
}
}
if (Number.isNaN(candidate.getTime())) return null;
return candidate;
}
return null;
}
function parseUnitConversion(query: string): CalculatorIntent | null {
const match = query.match(/^(-?\d+(?:\.\d+)?(?:[kmb])?)\s*([a-zA-Z°]+)\s+(?:in|to)\s+([a-zA-Z]+)$/i);
if (!match) return null;
const value = parseNumber(match[1]);
if (value == null) return null;
const fromUnit = match[2].trim().toLowerCase();
const toUnit = match[3].trim().toLowerCase();
if (toUnit === "timespan") {
return {
kind: "duration_timespan",
value,
unit: fromUnit,
displayInput: query
};
}
return {
kind: "unit_convert",
value,
fromUnit,
toUnit,
displayInput: query
};
}
function parseTimeTimezoneConversion(query: string): CalculatorIntent | null {
const match = query.match(new RegExp(`^(.+?)\\s+([a-zA-Z/_ ]+)\\s+(?:${TO_OR_IN_PATTERN})\\s+([a-zA-Z/_ ]+)$`, "i"));
if (!match) return null;
const time = parseTimeToken(match[1]);
if (!time) return null;
const fromTimezone = resolveTimezone(match[2]);
const toTimezone = resolveTimezone(match[3]);
if (!fromTimezone || !toTimezone) return null;
return {
kind: "timezone_convert",
hour: time.hour,
minute: time.minute,
fromTimezone,
toTimezone,
displayInput: query
};
}
function parseTimeDiff(query: string): CalculatorIntent | null {
const match = query.match(/^(.+?)\s*-\s*(.+)$/);
if (!match) return null;
const left = parseTimeToken(match[1]);
const right = parseTimeToken(match[2]);
if (!left || !right) return null;
return {
kind: "time_diff",
leftHour: left.hour,
leftMinute: left.minute,
rightHour: right.hour,
rightMinute: right.minute,
displayInput: query
};
}
function parseSingleTime(query: string): CalculatorIntent | null {
const time = parseTimeToken(query);
if (!time) return null;
return {
kind: "time_convert",
hour: time.hour,
minute: time.minute,
hasMeridiem: time.hasMeridiem,
meridiem: time.meridiem,
displayInput: query
};
}
function parseTimezoneNow(query: string): CalculatorIntent | null {
const normalized = normalizeLocaleToken(query);
const match = normalized.match(new RegExp(`^(?:${TIME_PATTERN})\\s+(?:${IN_PATTERN})\\s+(.+)$`, "i"));
if (!match) return null;
const timezone = resolveTimezone(match[1]);
if (!timezone) return null;
return {
kind: "timezone_now",
timezone,
displayInput: query
};
}
function parseDaysUntil(query: string): CalculatorIntent | null {
const normalized = normalizeLocaleToken(query);
const match = normalized.match(new RegExp(`^(?:${DAYS_PATTERN})\\s+(?:${UNTIL_PATTERN})\\s+(.+)$`, "i"));
if (!match) return null;
const targetDate = parseDateLoose(match[1], "until");
if (!targetDate) return null;
return {
kind: "days_until",
targetDate,
displayInput: query
};
}
function parseDaysSince(query: string): CalculatorIntent | null {
const normalized = normalizeLocaleToken(query);
const match = normalized.match(new RegExp(`^(?:${DAYS_PATTERN})\\s+(?:${SINCE_PATTERN})\\s+(.+)$`, "i"));
if (!match) return null;
const targetDate = parseDateLoose(match[1], "since");
if (!targetDate) return null;
return {
kind: "days_since",
targetDate,
displayInput: query
};
}
function parseWeekdayInWeeks(query: string): CalculatorIntent | null {
const normalized = normalizeLocaleToken(query);
const match = normalized.match(new RegExp(`^(${WEEKDAY_PATTERN})\\s+(?:${IN_PATTERN})\\s+(\\d+)\\s+(?:${WEEK_PATTERN})$`, "i"));
if (!match) return null;
const weekday = WEEKDAY_LOOKUP[normalizeLocaleToken(match[1])];
const weeks = Number(match[2]);
if (!Number.isFinite(weeks)) return null;
return {
kind: "weekday_in_weeks",
weekday,
weeks,
displayInput: query
};
}
export function parseCalculatorQuery(rawQuery: string): CalculatorIntent | null {
const query = normalizeQuery(rawQuery);
if (!query) return null;
const lowered = query.toLowerCase();
if (CURRENCY_RATE_PATTERN.test(lowered) && /\b(?:to|in)\b/i.test(lowered)) {
return { kind: "unsupported_rate" };
}
return parseTimeTimezoneConversion(query)
?? parseTimeDiff(query)
?? parseTimezoneNow(query)
?? parseWeekdayInWeeks(query)
?? parseDaysSince(query)
?? parseDaysUntil(query)
?? parseUnitConversion(query)
?? parseSingleTime(query)
?? parseAdvancedMathQuery(query);
}

View file

@ -0,0 +1,346 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import type { TimezoneAliasMap } from "./types";
const DEFAULT_TIMEZONE = "UTC";
const TIMEZONE_ALIASES: TimezoneAliasMap = {
utc: "UTC",
gmt: "UTC",
uk: "Europe/London",
london: "Europe/London",
ldn: "Europe/London",
bst: "Europe/London",
sf: "America/Los_Angeles",
"san francisco": "America/Los_Angeles",
la: "America/Los_Angeles",
pst: "America/Los_Angeles",
pdt: "America/Los_Angeles",
pt: "America/Los_Angeles",
est: "America/New_York",
edt: "America/New_York",
et: "America/New_York",
cst: "America/Chicago",
cdt: "America/Chicago",
ct: "America/Chicago",
mst: "America/Denver",
mdt: "America/Denver",
mt: "America/Denver",
tokyo: "Asia/Tokyo",
japan: "Asia/Tokyo",
cairo: "Africa/Cairo",
egypt: "Africa/Cairo",
seoul: "Asia/Seoul",
korea: "Asia/Seoul",
beijing: "Asia/Shanghai",
shanghai: "Asia/Shanghai",
china: "Asia/Shanghai",
hk: "Asia/Hong_Kong",
"hong kong": "Asia/Hong_Kong",
singapore: "Asia/Singapore",
"kuala lumpur": "Asia/Kuala_Lumpur",
malaysia: "Asia/Kuala_Lumpur",
jakarta: "Asia/Jakarta",
indonesia: "Asia/Jakarta",
bangkok: "Asia/Bangkok",
thailand: "Asia/Bangkok",
manila: "Asia/Manila",
philippines: "Asia/Manila",
taipei: "Asia/Taipei",
taiwan: "Asia/Taipei",
mumbai: "Asia/Kolkata",
delhi: "Asia/Kolkata",
kolkata: "Asia/Kolkata",
ist: "Asia/Kolkata",
india: "Asia/Kolkata",
dubai: "Asia/Dubai",
uae: "Asia/Dubai",
riyadh: "Asia/Riyadh",
saudi: "Asia/Riyadh",
doha: "Asia/Qatar",
qatar: "Asia/Qatar",
moscow: "Europe/Moscow",
russia: "Europe/Moscow",
istanbul: "Europe/Istanbul",
turkey: "Europe/Istanbul",
paris: "Europe/Paris",
france: "Europe/Paris",
berlin: "Europe/Berlin",
germany: "Europe/Berlin",
rome: "Europe/Rome",
italy: "Europe/Rome",
madrid: "Europe/Madrid",
spain: "Europe/Madrid",
amsterdam: "Europe/Amsterdam",
netherlands: "Europe/Amsterdam",
stockholm: "Europe/Stockholm",
sweden: "Europe/Stockholm",
oslo: "Europe/Oslo",
norway: "Europe/Oslo",
helsinki: "Europe/Helsinki",
finland: "Europe/Helsinki",
athens: "Europe/Athens",
greece: "Europe/Athens",
lisbon: "Europe/Lisbon",
portugal: "Europe/Lisbon",
dublin: "Europe/Dublin",
ireland: "Europe/Dublin",
warsaw: "Europe/Warsaw",
poland: "Europe/Warsaw",
zurich: "Europe/Zurich",
switzerland: "Europe/Zurich",
prague: "Europe/Prague",
czechia: "Europe/Prague",
vienna: "Europe/Vienna",
austria: "Europe/Vienna",
nyc: "America/New_York",
"new york": "America/New_York",
boston: "America/New_York",
miami: "America/New_York",
atlanta: "America/New_York",
toronto: "America/Toronto",
ottawa: "America/Toronto",
montreal: "America/Toronto",
canada: "America/Toronto",
chicago: "America/Chicago",
dallas: "America/Chicago",
houston: "America/Chicago",
austin: "America/Chicago",
denver: "America/Denver",
phoenix: "America/Phoenix",
vancouver: "America/Vancouver",
seattle: "America/Los_Angeles",
"los angeles": "America/Los_Angeles",
brazil: "America/Sao_Paulo",
"sao paulo": "America/Sao_Paulo",
rio: "America/Sao_Paulo",
"rio de janeiro": "America/Sao_Paulo",
mexico: "America/Mexico_City",
"mexico city": "America/Mexico_City",
bogota: "America/Bogota",
colombia: "America/Bogota",
buenos: "America/Argentina/Buenos_Aires",
"buenos aires": "America/Argentina/Buenos_Aires",
argentina: "America/Argentina/Buenos_Aires",
santiago: "America/Santiago",
chile: "America/Santiago",
lima: "America/Lima",
peru: "America/Lima",
auckland: "Pacific/Auckland",
wellington: "Pacific/Auckland",
nz: "Pacific/Auckland",
"new zealand": "Pacific/Auckland",
sydney: "Australia/Sydney"
,
melbourne: "Australia/Melbourne",
brisbane: "Australia/Brisbane",
perth: "Australia/Perth",
australia: "Australia/Sydney",
johannesburg: "Africa/Johannesburg",
southafrica: "Africa/Johannesburg",
"south africa": "Africa/Johannesburg",
lagos: "Africa/Lagos",
nigeria: "Africa/Lagos",
nairobi: "Africa/Nairobi",
kenya: "Africa/Nairobi",
casablanca: "Africa/Casablanca",
morocco: "Africa/Casablanca"
};
let supportedTimezonesCache: Set<string> | null = null;
function getSupportedTimezones(): Set<string> {
if (supportedTimezonesCache) return supportedTimezonesCache;
try {
const values = Intl.supportedValuesOf("timeZone");
supportedTimezonesCache = new Set(values);
return supportedTimezonesCache;
} catch {
supportedTimezonesCache = new Set<string>([
"UTC",
"Europe/London",
"America/New_York",
"America/Chicago",
"America/Denver",
"America/Los_Angeles",
"Asia/Tokyo",
"Europe/Berlin",
"Europe/Paris",
"Australia/Sydney"
]);
return supportedTimezonesCache;
}
}
function normalizeTimezoneToken(input: string): string {
return input.trim().toLowerCase().replace(/\s+/g, " ");
}
function titleCaseTimezoneSegment(segment: string): string {
return segment
.toLowerCase()
.split("_")
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
.join("_");
}
function normalizeIanaCandidate(input: string): string {
const compact = input.trim().replace(/\s+/g, "_").replace(/\\/g, "/");
if (!compact.includes("/")) return compact.toUpperCase();
return compact
.split("/")
.map(titleCaseTimezoneSegment)
.join("/");
}
export function resolveTimezone(input: string): string | null {
const token = normalizeTimezoneToken(input);
if (!token) return null;
const alias = TIMEZONE_ALIASES[token];
if (alias) return alias;
const candidate = normalizeIanaCandidate(input);
if (getSupportedTimezones().has(candidate)) return candidate;
const fallback = Array.from(getSupportedTimezones()).find(zone => zone.toLowerCase().endsWith(`/${token.replace(/\s+/g, "_")}`));
return fallback ?? null;
}
function getTimezoneDateParts(date: Date, timeZone: string): { year: number; month: number; day: number; hour: number; minute: number; second: number; } {
const formatter = new Intl.DateTimeFormat("en-US", {
timeZone,
hour12: false,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit"
});
const parts = formatter.formatToParts(date);
const get = (type: string) => Number(parts.find(part => part.type === type)?.value ?? 0);
return {
year: get("year"),
month: get("month"),
day: get("day"),
hour: get("hour"),
minute: get("minute"),
second: get("second")
};
}
function timezoneOffsetMinutes(timeZone: string, instant: Date): number {
const parts = getTimezoneDateParts(instant, timeZone);
const utcMs = Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second);
return Math.round((utcMs - instant.getTime()) / 60000);
}
function wallClockToUtcMs(year: number, month: number, day: number, hour: number, minute: number, sourceTimezone: string): number {
const base = Date.UTC(year, month - 1, day, hour, minute, 0);
const baseDate = new Date(base);
const offset = timezoneOffsetMinutes(sourceTimezone, baseDate);
const guess = base - offset * 60000;
const refinedOffset = timezoneOffsetMinutes(sourceTimezone, new Date(guess));
return base - refinedOffset * 60000;
}
export function convertTimeBetweenTimezones(hour: number, minute: number, sourceTimezone: string, targetTimezone: string): { hour: number; minute: number; dayShift: number; } | null {
const now = new Date();
const sourceDate = getTimezoneDateParts(now, sourceTimezone);
const utcMs = wallClockToUtcMs(sourceDate.year, sourceDate.month, sourceDate.day, hour, minute, sourceTimezone);
const targetParts = getTimezoneDateParts(new Date(utcMs), targetTimezone);
const sourceDayStamp = Date.UTC(sourceDate.year, sourceDate.month - 1, sourceDate.day, 0, 0, 0);
const targetDayStamp = Date.UTC(targetParts.year, targetParts.month - 1, targetParts.day, 0, 0, 0);
const dayShift = Math.round((targetDayStamp - sourceDayStamp) / 86400000);
return {
hour: targetParts.hour,
minute: targetParts.minute,
dayShift
};
}
export function getNowInTimezone(timezone: string): { hour: number; minute: number; } {
const parts = getTimezoneDateParts(new Date(), timezone);
return { hour: parts.hour, minute: parts.minute };
}
export function getTimezoneDate(timezone: string): Date {
const parts = getTimezoneDateParts(new Date(), timezone);
return new Date(parts.year, parts.month - 1, parts.day);
}
export function normalizeTimezoneOrDefault(input: string): string {
return resolveTimezone(input) ?? DEFAULT_TIMEZONE;
}
const TIMEZONE_COUNTRY: Record<string, string> = {
UTC: "UTC",
"Europe/London": "United Kingdom",
"America/New_York": "United States",
"America/Chicago": "United States",
"America/Denver": "United States",
"America/Los_Angeles": "United States",
"Asia/Tokyo": "Japan",
"Asia/Seoul": "South Korea",
"Asia/Shanghai": "China",
"Asia/Hong_Kong": "Hong Kong",
"Asia/Singapore": "Singapore",
"Asia/Kuala_Lumpur": "Malaysia",
"Asia/Jakarta": "Indonesia",
"Asia/Bangkok": "Thailand",
"Asia/Manila": "Philippines",
"Asia/Taipei": "Taiwan",
"Asia/Kolkata": "India",
"Asia/Dubai": "United Arab Emirates",
"Asia/Riyadh": "Saudi Arabia",
"Asia/Qatar": "Qatar",
"Europe/Moscow": "Russia",
"Europe/Istanbul": "Turkey",
"Europe/Berlin": "Germany",
"Europe/Paris": "France",
"Europe/Rome": "Italy",
"Europe/Madrid": "Spain",
"Europe/Amsterdam": "Netherlands",
"Europe/Stockholm": "Sweden",
"Europe/Oslo": "Norway",
"Europe/Helsinki": "Finland",
"Europe/Athens": "Greece",
"Europe/Lisbon": "Portugal",
"Europe/Dublin": "Ireland",
"Europe/Warsaw": "Poland",
"Europe/Zurich": "Switzerland",
"Europe/Prague": "Czechia",
"Europe/Vienna": "Austria",
"America/Toronto": "Canada",
"America/Vancouver": "Canada",
"America/Sao_Paulo": "Brazil",
"America/Mexico_City": "Mexico",
"America/Bogota": "Colombia",
"America/Argentina/Buenos_Aires": "Argentina",
"America/Santiago": "Chile",
"America/Lima": "Peru",
"Australia/Sydney": "Australia",
"Australia/Melbourne": "Australia",
"Australia/Brisbane": "Australia",
"Australia/Perth": "Australia",
"Pacific/Auckland": "New Zealand",
"Africa/Johannesburg": "South Africa",
"Africa/Lagos": "Nigeria",
"Africa/Nairobi": "Kenya",
"Africa/Casablanca": "Morocco",
"Africa/Cairo": "Egypt"
};
export function getTimezoneCountryName(timezone: string): string | null {
return TIMEZONE_COUNTRY[timezone] ?? null;
}

View file

@ -0,0 +1,119 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
export type CalculatorResultKind =
| "number"
| "time"
| "duration"
| "date"
| "unit";
export type CalculatorViewMode = "result" | "graph";
export interface CalculatorGraphPoint {
x: number;
y: number | null;
}
export interface CalculatorGraphSeries {
id: string;
label: string;
color: string;
points: CalculatorGraphPoint[];
}
export interface CalculatorGraphData {
defaultViewMode: CalculatorViewMode;
domain: [number, number];
range: [number, number];
series: CalculatorGraphSeries[];
}
export interface CalculatorResult {
kind: CalculatorResultKind;
displayInput: string;
displayAnswer: string;
rawAnswer: string;
normalizedInput?: string;
graph?: CalculatorGraphData;
secondaryText?: string;
tertiaryText?: string;
}
export type CalculatorIntent =
| {
kind: "advanced_math";
displayInput: string;
normalizedInput: string;
}
| {
kind: "math";
expression: string;
displayInput: string;
}
| {
kind: "time_convert";
hour: number;
minute: number;
hasMeridiem: boolean;
meridiem?: "am" | "pm";
displayInput: string;
}
| {
kind: "time_diff";
leftHour: number;
leftMinute: number;
rightHour: number;
rightMinute: number;
displayInput: string;
}
| {
kind: "timezone_convert";
hour: number;
minute: number;
fromTimezone: string;
toTimezone: string;
displayInput: string;
}
| {
kind: "timezone_now";
timezone: string;
displayInput: string;
}
| {
kind: "days_until";
targetDate: Date;
displayInput: string;
}
| {
kind: "days_since";
targetDate: Date;
displayInput: string;
}
| {
kind: "weekday_in_weeks";
weekday: number;
weeks: number;
displayInput: string;
}
| {
kind: "unit_convert";
value: number;
fromUnit: string;
toUnit: string;
displayInput: string;
}
| {
kind: "duration_timespan";
value: number;
unit: string;
displayInput: string;
}
| {
kind: "unsupported_rate";
};
export type TimezoneAliasMap = Record<string, string>;

View file

@ -0,0 +1,72 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { CogWheel, CopyIcon, LinkIcon, MainSettingsIcon, NotesIcon, PluginIcon, RestartIcon } from "@components/Icons";
import type { ComponentType } from "react";
type IconComponent = ComponentType<{ className?: string; size?: string; height?: number; width?: number; }>;
export type CustomCommandIconId =
| "auto"
| "alias"
| "quicklink"
| "sequence"
| "settings"
| "notes"
| "plugin"
| "gear";
interface CustomCommandIconMeta {
id: CustomCommandIconId;
label: string;
icon: IconComponent;
}
const CUSTOM_COMMAND_ICON_META: CustomCommandIconMeta[] = [
{ id: "auto", label: "Auto", icon: CogWheel },
{ id: "alias", label: "Alias", icon: CopyIcon },
{ id: "quicklink", label: "Quicklink", icon: LinkIcon },
{ id: "sequence", label: "Sequence", icon: RestartIcon },
{ id: "settings", label: "Settings", icon: MainSettingsIcon },
{ id: "notes", label: "Notes", icon: NotesIcon },
{ id: "plugin", label: "Plugin", icon: PluginIcon },
{ id: "gear", label: "Gear", icon: CogWheel }
];
const CUSTOM_COMMAND_ICON_MAP = new Map<CustomCommandIconId, IconComponent>(
CUSTOM_COMMAND_ICON_META.map(item => [item.id, item.icon])
);
const CUSTOM_COMMAND_ICON_ID_SET = new Set<CustomCommandIconId>(
CUSTOM_COMMAND_ICON_META.map(item => item.id)
);
export function isCustomCommandIconId(value: unknown): value is CustomCommandIconId {
return typeof value === "string" && CUSTOM_COMMAND_ICON_ID_SET.has(value as CustomCommandIconId);
}
export function getCustomCommandIconMetaList() {
return CUSTOM_COMMAND_ICON_META;
}
export function resolveCustomCommandDefaultIconId(actionType: "command" | "settings" | "url" | "macro"): Exclude<CustomCommandIconId, "auto"> {
switch (actionType) {
case "command":
return "alias";
case "url":
return "quicklink";
case "macro":
return "sequence";
case "settings":
return "settings";
default:
return "gear";
}
}
export function getCustomCommandIconById(iconId: CustomCommandIconId): IconComponent {
return CUSTOM_COMMAND_ICON_MAP.get(iconId) ?? CogWheel;
}

View file

@ -0,0 +1,7 @@
# Command Action Authoring
Use typed `actions(ctx)` on each command entry.
For execute-style secondary actions, use `createExecuteSecondaryAction(...)` from `actionHelpers.ts` so shortcut labels and action-key intents stay consistent.
New command actions should be added in the command definition file and not in `CommandPaletteModal.tsx`.

View file

@ -0,0 +1,77 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { IS_MAC } from "@utils/constants";
import type { ComponentType } from "react";
import type { CommandActionDefinition } from "../../registry";
interface CreateExecuteSecondaryActionInput {
id: string;
label: string;
chord: string;
handler: () => void | Promise<void>;
icon?: ComponentType<{ className?: string; size?: string; }>;
}
function normalizePlatformChord(chord: string): string {
if (IS_MAC) return chord;
return chord
.split("+")
.map(token => {
const normalized = token.trim().toLowerCase();
if (normalized === "meta" || normalized === "cmd" || normalized === "command" || normalized === "⌘") {
return "ctrl";
}
return token;
})
.join("+");
}
function formatActionChordToken(token: string): string {
const normalized = token.trim().toLowerCase();
if (!normalized) return "";
if (normalized === "meta" || normalized === "cmd" || normalized === "command" || normalized === "⌘") return "Cmd";
if (normalized === "alt" || normalized === "option" || normalized === "⌥") return "Alt";
if (normalized === "ctrl" || normalized === "control" || normalized === "⌃" || normalized === "^") return "CTRL";
if (normalized === "shift" || normalized === "⇧") return "SHIFT";
if (normalized === "enter" || normalized === "return" || normalized === "↵") return "ENTER";
if (normalized === "left" || normalized === "←") return "LEFT";
if (normalized === "right" || normalized === "→") return "RIGHT";
if (normalized === "up" || normalized === "↑") return "UP";
if (normalized === "down" || normalized === "↓") return "DOWN";
if (normalized === "escape" || normalized === "esc") return "ESC";
if (normalized === "tab") return "TAB";
if (normalized === "space") return "SPACE";
return normalized.length === 1 ? normalized.toUpperCase() : normalized.toUpperCase();
}
export function formatActionChordLabel(chord: string): string {
return normalizePlatformChord(chord)
.split("+")
.map(formatActionChordToken)
.filter(Boolean)
.join("+");
}
export function createExecuteSecondaryAction({
id,
label,
chord,
handler,
icon
}: CreateExecuteSecondaryActionInput): CommandActionDefinition {
return {
id,
label,
shortcut: formatActionChordLabel(chord),
icon,
intent: { type: "execute-secondary", actionKey: normalizePlatformChord(chord) },
handler
};
}

View file

@ -0,0 +1,161 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { isPluginEnabled, plugins } from "@api/PluginManager";
import { toggleEnabled } from "@equicordplugins/equicordHelper/utils";
import { noteHandler } from "@equicordplugins/holyNotes/NoteHandler";
import { Message } from "@vencord/discord-types";
import { MessageStore, SelectedChannelStore, Toasts } from "@webpack/common";
import { DEFAULT_CATEGORY_ID } from "../../metadata/categories";
import { TAG_NAVIGATION, TAG_PLUGINS, TAG_UTILITY } from "../../metadata/tags";
import type { CommandEntry } from "../../registry";
import type { HolyNotesPlugin } from "../types";
function showToast(message: string, type: (typeof Toasts.Type)[keyof typeof Toasts.Type]) {
Toasts.show({ message, type, id: Toasts.genId(), options: { position: Toasts.Position.BOTTOM } });
}
function getHolyNotesPlugin(): HolyNotesPlugin | null {
const plugin = plugins.HolyNotes as HolyNotesPlugin | undefined;
return plugin ?? null;
}
async function ensureHolyNotesPluginEnabled() {
const plugin = getHolyNotesPlugin();
if (!plugin) {
showToast("HolyNotes plugin is unavailable.", Toasts.Type.FAILURE);
return false;
}
if (isPluginEnabled(plugin.name)) return true;
const success = await toggleEnabled(plugin.name);
if (!success || !isPluginEnabled(plugin.name)) {
showToast("Failed to enable HolyNotes.", Toasts.Type.FAILURE);
return false;
}
showToast("Enabled HolyNotes.", Toasts.Type.SUCCESS);
return true;
}
async function runHolyNotesOpen() {
if (!await ensureHolyNotesPluginEnabled()) return;
const plugin = getHolyNotesPlugin();
const openNotes = plugin?.toolboxActions?.["Open Notes"];
if (typeof openNotes !== "function") {
showToast("HolyNotes action is unavailable.", Toasts.Type.FAILURE);
return;
}
await Promise.resolve(openNotes());
}
function getLastNonSystemMessageInChannel(channelId: string): Message | null {
const messageCollection = MessageStore.getMessages?.(channelId) as { _array?: Message[]; } | undefined;
const messages = messageCollection?._array;
if (!messages?.length) return null;
for (let index = messages.length - 1; index >= 0; index--) {
const message = messages[index] as Message & { deleted?: boolean; type?: number; };
if (!message || message.deleted) continue;
if (typeof message.type === "number" && message.type !== 0) continue;
return message;
}
return null;
}
async function runHolyNotesQuickSaveLastMessage() {
if (!await ensureHolyNotesPluginEnabled()) return;
const channelId = SelectedChannelStore.getChannelId();
if (!channelId) {
showToast("No active channel available.", Toasts.Type.FAILURE);
return;
}
const message = getLastNonSystemMessageInChannel(channelId);
if (!message) {
showToast("No message available to save.", Toasts.Type.MESSAGE);
return;
}
noteHandler.addNote(message, "Main");
}
export function createHolyNotesExtensionCommands(): CommandEntry[] {
return [
{
id: "extension-holy-notes-open",
label: "Open Notes",
description: "Open the HolyNotes notebook modal.",
keywords: ["holy", "notes", "notebook", "bookmark", "open", "plugin", "extension"],
categoryId: DEFAULT_CATEGORY_ID,
tags: [TAG_PLUGINS, TAG_UTILITY, TAG_NAVIGATION],
handler: runHolyNotesOpen
},
{
id: "extension-holy-notes-create-notebook-query",
label: "Create Notebook",
description: "Create a HolyNotes notebook.",
keywords: ["holy", "notes", "notebook", "create", "new", "plugin", "extension"],
categoryId: DEFAULT_CATEGORY_ID,
tags: [TAG_PLUGINS, TAG_UTILITY],
closeAfterExecute: true,
queryTemplate: "create notebook ",
queryPlaceholder: "Notebook name",
handler: () => undefined
},
{
id: "extension-holy-notes-delete-notebook-query",
label: "Delete Notebook",
description: "Delete a HolyNotes notebook.",
keywords: ["holy", "notes", "notebook", "delete", "remove", "plugin", "extension"],
categoryId: DEFAULT_CATEGORY_ID,
tags: [TAG_PLUGINS, TAG_UTILITY],
closeAfterExecute: true,
queryTemplate: "delete notebook ",
queryPlaceholder: "Notebook name",
handler: () => undefined
},
{
id: "extension-holy-notes-move-note-query",
label: "Move Note",
description: "Move a HolyNotes note to another notebook.",
keywords: ["holy", "notes", "note", "move", "notebook", "plugin", "extension"],
categoryId: DEFAULT_CATEGORY_ID,
tags: [TAG_PLUGINS, TAG_UTILITY],
closeAfterExecute: true,
queryTemplate: "move note ",
queryPlaceholder: "Find note by content, then destination",
handler: () => undefined
},
{
id: "extension-holy-notes-jump-note-query",
label: "Jump To Note",
description: "Jump to the original message for a HolyNotes note.",
keywords: ["holy", "notes", "jump", "note", "message", "plugin", "extension"],
categoryId: DEFAULT_CATEGORY_ID,
tags: [TAG_PLUGINS, TAG_NAVIGATION],
closeAfterExecute: true,
queryTemplate: "jump to note ",
queryPlaceholder: "Search note content",
handler: () => undefined
},
{
id: "extension-holy-notes-quick-save-last-message",
label: "Quick Save Last Message",
description: "Save the latest message in this channel to Main.",
keywords: ["holy", "notes", "save", "last", "message", "bookmark", "main", "plugin", "extension"],
categoryId: DEFAULT_CATEGORY_ID,
tags: [TAG_PLUGINS, TAG_UTILITY],
handler: runHolyNotesQuickSaveLastMessage
}
];
}

View file

@ -0,0 +1,210 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { isPluginEnabled, plugins } from "@api/PluginManager";
import { openPluginModal } from "@components/settings/tabs";
import { toggleEnabled } from "@equicordplugins/equicordHelper/utils";
import { ChannelActions, ChannelRouter, ChannelStore, MediaEngineStore, PermissionsBits, PermissionStore, SelectedChannelStore, Toasts, UserStore, VoiceActions, VoiceStateStore } from "@webpack/common";
import { DEFAULT_CATEGORY_ID } from "../../metadata/categories";
import { TAG_PLUGINS, TAG_UTILITY } from "../../metadata/tags";
import type { CommandEntry } from "../../registry";
import { DEFAULT_EXTENSION_KEYBINDS, RANDOM_VOICE_EXTENSION_ID } from "../catalog";
import type { ExtensionKeybindMap, RandomVoiceOperation, RandomVoicePluginWithSettings, RandomVoiceSettingsStore, RandomVoiceStateLike } from "../types";
import { createExecuteSecondaryAction } from "./actionHelpers";
function showToast(message: string, type: (typeof Toasts.Type)[keyof typeof Toasts.Type]) {
Toasts.show({ message, type, id: Toasts.genId(), options: { position: Toasts.Position.BOTTOM } });
}
function getRandomVoicePlugin(): RandomVoicePluginWithSettings | null {
const plugin = plugins.RandomVoice as RandomVoicePluginWithSettings | undefined;
return plugin ?? null;
}
function getRandomVoiceSettingsStore(): RandomVoiceSettingsStore | null {
const plugin = getRandomVoicePlugin();
if (!plugin) return null;
return plugin.settings?.store ?? null;
}
async function ensureRandomVoicePluginEnabled() {
const plugin = getRandomVoicePlugin();
if (!plugin) {
showToast("RandomVoice plugin is unavailable.", Toasts.Type.FAILURE);
return false;
}
if (isPluginEnabled(plugin.name)) return true;
const success = await toggleEnabled(plugin.name);
if (!success || !isPluginEnabled(plugin.name)) {
showToast("Failed to enable RandomVoice.", Toasts.Type.FAILURE);
return false;
}
showToast("Enabled RandomVoice.", Toasts.Type.SUCCESS);
return true;
}
function hasActiveRandomVoiceStateFilters(store: RandomVoiceSettingsStore): boolean {
return Boolean(store.mute || store.deafen || store.video || store.stream);
}
function randomVoiceStateMatchesFilters(state: RandomVoiceStateLike, store: RandomVoiceSettingsStore): boolean {
if (store.mute && !state.selfMute) return false;
if (store.deafen && !state.selfDeaf) return false;
if (store.video && !state.selfVideo) return false;
if (store.stream && !state.selfStream) return false;
return true;
}
function randomVoiceOperationMatches(operation: RandomVoiceOperation, left: number, right: number): boolean {
if (operation === "==") return left === right;
if (operation === ">") return left > right;
return left < right;
}
function pickRandomVoiceChannelId(store: RandomVoiceSettingsStore): string | null {
const candidates = new Set<string>();
const currentUserId = UserStore.getCurrentUser()?.id;
const serverFilters = store.Servers.split("/").map(value => value.trim()).filter(Boolean);
const hasStateFilters = hasActiveRandomVoiceStateFilters(store);
const users = Object.values(UserStore.getUsers() ?? {}) as Array<{ id?: string; }>;
for (const user of users) {
const userId = user.id;
if (!userId) continue;
const state = VoiceStateStore.getVoiceStateForUser(userId) as RandomVoiceStateLike | undefined;
const channelId = state?.channelId;
if (!channelId || candidates.has(channelId)) continue;
const channel = ChannelStore.getChannel(channelId);
if (!channel) continue;
const guildId = typeof channel.getGuildId === "function"
? channel.getGuildId()
: channel.guild_id;
if (!guildId) continue;
if (serverFilters.length > 0 && !serverFilters.includes(guildId)) continue;
if (store.avoidStages && typeof channel.isGuildStageVoice === "function" && channel.isGuildStageVoice()) continue;
const voiceStates = VoiceStateStore.getVoiceStatesForChannel(channelId) as Record<string, RandomVoiceStateLike> | undefined;
const usersInChannel = voiceStates ? Object.keys(voiceStates).length : 0;
const channelLimit = channel.userLimit === 0 ? 99 : channel.userLimit;
const spacesLeft = channelLimit - usersInChannel;
if (!randomVoiceOperationMatches(store.spacesLeftOperation, spacesLeft, store.spacesLeft)) continue;
if (!randomVoiceOperationMatches(store.UserAmountOperation, usersInChannel, store.UserAmount)) continue;
if (!randomVoiceOperationMatches(store.vcLimitOperation, channelLimit, store.vcLimit)) continue;
if (channel.userLimit > 0 && usersInChannel >= channel.userLimit) continue;
if (currentUserId && voiceStates && Object.prototype.hasOwnProperty.call(voiceStates, currentUserId)) continue;
if (!PermissionStore.can(PermissionsBits.CONNECT, channel)) continue;
if (store.avoidAfk && !PermissionStore.can(PermissionsBits.SPEAK, channel)) continue;
if (hasStateFilters && voiceStates) {
const states = Object.values(voiceStates);
const hasMatches = states.some(voiceState => randomVoiceStateMatchesFilters(voiceState, store));
if (store.includeStates && !hasMatches) continue;
if (store.avoidStates && hasMatches) continue;
}
candidates.add(channelId);
}
if (candidates.size === 0) return null;
const available = Array.from(candidates);
const randomIndex = Math.floor(Math.random() * available.length);
return available[randomIndex] ?? null;
}
async function runRandomVoiceJoin() {
if (!await ensureRandomVoicePluginEnabled()) return;
const store = getRandomVoiceSettingsStore();
if (!store) {
showToast("RandomVoice settings are unavailable.", Toasts.Type.FAILURE);
return;
}
const channelId = pickRandomVoiceChannelId(store);
if (!channelId) {
showToast("Failed to find a voice channel.", Toasts.Type.MESSAGE);
return;
}
const channel = ChannelStore.getChannel(channelId);
if (!channel) {
showToast("Voice channel is unavailable.", Toasts.Type.FAILURE);
return;
}
ChannelActions.selectVoiceChannel(channelId);
if (store.autoNavigate) {
ChannelRouter.transitionToChannel(channelId);
}
if (store.selfMute && !MediaEngineStore.isSelfMute() && SelectedChannelStore.getVoiceChannelId()) {
VoiceActions.toggleSelfMute();
}
if (store.selfDeafen && !MediaEngineStore.isSelfDeaf() && SelectedChannelStore.getVoiceChannelId()) {
VoiceActions.toggleSelfDeaf();
}
}
async function runRandomVoiceToggleAutoNavigate() {
if (!await ensureRandomVoicePluginEnabled()) return;
const store = getRandomVoiceSettingsStore();
if (!store) {
showToast("RandomVoice settings are unavailable.", Toasts.Type.FAILURE);
return;
}
store.autoNavigate = !store.autoNavigate;
showToast(`RandomVoice auto navigate ${store.autoNavigate ? "enabled" : "disabled"}.`, Toasts.Type.SUCCESS);
}
async function runRandomVoiceOpenSettings() {
if (!await ensureRandomVoicePluginEnabled()) return;
const plugin = getRandomVoicePlugin();
if (!plugin) {
showToast("RandomVoice plugin is unavailable.", Toasts.Type.FAILURE);
return;
}
openPluginModal(plugin);
}
export function createRandomVoiceExtensionCommand(extensionKeybinds: Map<string, ExtensionKeybindMap>): CommandEntry {
const keybinds = extensionKeybinds.get(RANDOM_VOICE_EXTENSION_ID) ?? DEFAULT_EXTENSION_KEYBINDS[RANDOM_VOICE_EXTENSION_ID];
return {
id: "extension-random-voice-join",
label: "Join Random Voice",
description: "Joins a random voice channel using RandomVoice filters.",
keywords: ["randomvoice", "random voice", "join", "voice", "channel", "plugin", "extension", "auto navigate", "settings"],
categoryId: DEFAULT_CATEGORY_ID,
tags: [TAG_PLUGINS, TAG_UTILITY],
handler: runRandomVoiceJoin,
actions: () => [
createExecuteSecondaryAction({
id: "toggle-auto-navigate",
label: "Toggle auto navigate",
chord: keybinds.secondaryActionChord,
handler: runRandomVoiceToggleAutoNavigate
}),
createExecuteSecondaryAction({
id: "open-settings",
label: "Open settings",
chord: keybinds.tertiaryActionChord,
handler: runRandomVoiceOpenSettings
})
]
};
}

View file

@ -0,0 +1,120 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { isPluginEnabled, plugins } from "@api/PluginManager";
import { toggleEnabled } from "@equicordplugins/equicordHelper/utils";
import { openViewScheduledModal } from "@equicordplugins/scheduledMessages/components/ViewScheduledModal";
import { Toasts } from "@webpack/common";
import { DEFAULT_CATEGORY_ID } from "../../metadata/categories";
import { TAG_NAVIGATION, TAG_PLUGINS, TAG_UTILITY } from "../../metadata/tags";
import type { CommandEntry } from "../../registry";
import { createCommandPageCommand } from "../../ui/pages/createCommandPageCommand";
import type { ScheduledMessagesPlugin } from "../types";
function showToast(message: string, type: (typeof Toasts.Type)[keyof typeof Toasts.Type]) {
Toasts.show({ message, type, id: Toasts.genId(), options: { position: Toasts.Position.BOTTOM } });
}
function getScheduledMessagesPlugin(): ScheduledMessagesPlugin | null {
const plugin = plugins.ScheduledMessages as ScheduledMessagesPlugin | undefined;
return plugin ?? null;
}
async function ensureScheduledMessagesPluginEnabled() {
const plugin = getScheduledMessagesPlugin();
if (!plugin) {
showToast("ScheduledMessages plugin is unavailable.", Toasts.Type.FAILURE);
return false;
}
if (isPluginEnabled(plugin.name)) return true;
const success = await toggleEnabled(plugin.name);
if (!success || !isPluginEnabled(plugin.name)) {
showToast("Failed to enable ScheduledMessages.", Toasts.Type.FAILURE);
return false;
}
showToast("Enabled ScheduledMessages.", Toasts.Type.SUCCESS);
return true;
}
async function runOpenScheduledMessages() {
if (!await ensureScheduledMessagesPluginEnabled()) return;
openViewScheduledModal();
}
export function createScheduledMessagesExtensionCommands(): CommandEntry[] {
return [
{
id: "extension-scheduled-messages-open",
label: "Open Scheduled Messages",
description: "Open the ScheduledMessages modal.",
keywords: ["scheduled", "messages", "schedule", "queue", "open", "plugin", "extension"],
categoryId: DEFAULT_CATEGORY_ID,
tags: [TAG_PLUGINS, TAG_UTILITY, TAG_NAVIGATION],
handler: runOpenScheduledMessages
},
createCommandPageCommand({
id: "extension-scheduled-messages-create",
label: "Create Scheduled Message",
description: "Create a scheduled message from separate fields.",
keywords: ["create", "scheduled", "message", "channel", "time", "plugin", "extension"],
categoryId: DEFAULT_CATEGORY_ID,
tags: [TAG_PLUGINS, TAG_UTILITY],
page: { id: "scheduled-create" }
}),
{
id: "extension-scheduled-messages-quick-schedule-query",
label: "Quick Schedule",
description: "Schedule a message from natural language input.",
keywords: ["schedule", "message", "quick", "delay", "time", "channel", "plugin", "extension"],
categoryId: DEFAULT_CATEGORY_ID,
tags: [TAG_PLUGINS, TAG_UTILITY],
closeAfterExecute: true,
queryTemplate: "schedule ",
queryPlaceholder: "Message at time, optional channel",
handler: () => undefined
},
{
id: "extension-scheduled-messages-reschedule-query",
label: "Reschedule Message",
description: "Reschedule an existing queued message.",
keywords: ["reschedule", "scheduled", "message", "queue", "time", "plugin", "extension"],
categoryId: DEFAULT_CATEGORY_ID,
tags: [TAG_PLUGINS, TAG_UTILITY],
closeAfterExecute: true,
queryTemplate: "reschedule ",
queryPlaceholder: "Select scheduled message, then new time",
handler: () => undefined
},
{
id: "extension-scheduled-messages-send-now-query",
label: "Send Scheduled Message Now",
description: "Send a queued scheduled message immediately.",
keywords: ["send", "now", "scheduled", "message", "queue", "plugin", "extension"],
categoryId: DEFAULT_CATEGORY_ID,
tags: [TAG_PLUGINS, TAG_UTILITY],
closeAfterExecute: true,
queryTemplate: "send now ",
queryPlaceholder: "Select scheduled message",
handler: () => undefined
},
{
id: "extension-scheduled-messages-cancel-query",
label: "Cancel Scheduled Message",
description: "Cancel a queued scheduled message.",
keywords: ["cancel", "delete", "remove", "scheduled", "message", "queue", "plugin", "extension"],
categoryId: DEFAULT_CATEGORY_ID,
tags: [TAG_PLUGINS, TAG_UTILITY],
closeAfterExecute: true,
queryTemplate: "cancel scheduled ",
queryPlaceholder: "Select scheduled message",
handler: () => undefined
}
];
}

View file

@ -0,0 +1,128 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { isPluginEnabled, plugins } from "@api/PluginManager";
import { openPluginModal } from "@components/settings/tabs";
import { toggleEnabled } from "@equicordplugins/equicordHelper/utils";
import { Toasts } from "@webpack/common";
import { DEFAULT_CATEGORY_ID } from "../../metadata/categories";
import { TAG_PLUGINS, TAG_UTILITY } from "../../metadata/tags";
import type { CommandEntry } from "../../registry";
import { DEFAULT_EXTENSION_KEYBINDS, SILENT_MESSAGE_TOGGLE_EXTENSION_ID } from "../catalog";
import type { ExtensionKeybindMap, SilentMessageTogglePluginWithSettings, SilentMessageToggleSettingsStore } from "../types";
import { createExecuteSecondaryAction } from "./actionHelpers";
function showToast(message: string, type: (typeof Toasts.Type)[keyof typeof Toasts.Type]) {
Toasts.show({ message, type, id: Toasts.genId(), options: { position: Toasts.Position.BOTTOM } });
}
function getSilentMessageTogglePlugin(): SilentMessageTogglePluginWithSettings | null {
const plugin = plugins.SilentMessageToggle as SilentMessageTogglePluginWithSettings | undefined;
return plugin ?? null;
}
function getSilentMessageToggleSettingsStore(): SilentMessageToggleSettingsStore | null {
const plugin = getSilentMessageTogglePlugin();
if (!plugin) return null;
return plugin.settings?.store ?? null;
}
async function ensureSilentMessageTogglePluginEnabled() {
const plugin = getSilentMessageTogglePlugin();
if (!plugin) {
showToast("SilentMessageToggle plugin is unavailable.", Toasts.Type.FAILURE);
return false;
}
if (isPluginEnabled(plugin.name)) return true;
const success = await toggleEnabled(plugin.name);
if (!success || !isPluginEnabled(plugin.name)) {
showToast("Failed to enable SilentMessageToggle.", Toasts.Type.FAILURE);
return false;
}
showToast("Enabled SilentMessageToggle.", Toasts.Type.SUCCESS);
return true;
}
async function runSilentMessageTogglePlugin() {
const plugin = getSilentMessageTogglePlugin();
if (!plugin) {
showToast("SilentMessageToggle plugin is unavailable.", Toasts.Type.FAILURE);
return;
}
const wasEnabled = isPluginEnabled(plugin.name);
const success = await toggleEnabled(plugin.name);
const isEnabledNow = isPluginEnabled(plugin.name);
if (!success) {
showToast("Failed to toggle SilentMessageToggle.", Toasts.Type.FAILURE);
return;
}
if (wasEnabled !== isEnabledNow) {
showToast(`SilentMessageToggle ${isEnabledNow ? "enabled" : "disabled"}.`, Toasts.Type.SUCCESS);
return;
}
showToast("SilentMessageToggle did not change.", Toasts.Type.MESSAGE);
}
async function runSilentMessageToggleAutoDisable() {
if (!await ensureSilentMessageTogglePluginEnabled()) return;
const store = getSilentMessageToggleSettingsStore();
if (!store) {
showToast("SilentMessageToggle settings are unavailable.", Toasts.Type.FAILURE);
return;
}
store.autoDisable = !store.autoDisable;
showToast(`SilentMessageToggle auto disable ${store.autoDisable ? "enabled" : "disabled"}.`, Toasts.Type.SUCCESS);
}
async function runSilentMessageToggleOpenSettings() {
if (!await ensureSilentMessageTogglePluginEnabled()) return;
const plugin = getSilentMessageTogglePlugin();
if (!plugin) {
showToast("SilentMessageToggle plugin is unavailable.", Toasts.Type.FAILURE);
return;
}
openPluginModal(plugin);
}
export function createSilentMessageToggleExtensionCommand(extensionKeybinds: Map<string, ExtensionKeybindMap>): CommandEntry {
const keybinds = extensionKeybinds.get(SILENT_MESSAGE_TOGGLE_EXTENSION_ID) ?? DEFAULT_EXTENSION_KEYBINDS[SILENT_MESSAGE_TOGGLE_EXTENSION_ID];
return {
id: "extension-silent-message-toggle-plugin",
label: "Toggle SilentMessageToggle",
description: "Toggle the SilentMessageToggle plugin.",
keywords: ["silent", "message", "toggle", "plugin", "extension", "auto disable", "settings"],
categoryId: DEFAULT_CATEGORY_ID,
tags: [TAG_PLUGINS, TAG_UTILITY],
handler: runSilentMessageTogglePlugin,
actions: () => [
createExecuteSecondaryAction({
id: "toggle-auto-disable",
label: "Toggle auto disable",
chord: keybinds.secondaryActionChord,
handler: runSilentMessageToggleAutoDisable
}),
createExecuteSecondaryAction({
id: "open-settings",
label: "Open settings",
chord: keybinds.tertiaryActionChord,
handler: runSilentMessageToggleOpenSettings
})
]
};
}

View file

@ -0,0 +1,146 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { isPluginEnabled, plugins } from "@api/PluginManager";
import { openPluginModal } from "@components/settings/tabs";
import { toggleEnabled } from "@equicordplugins/equicordHelper/utils";
import { SelectedChannelStore, Toasts } from "@webpack/common";
import { DEFAULT_CATEGORY_ID } from "../../metadata/categories";
import { TAG_PLUGINS, TAG_UTILITY } from "../../metadata/tags";
import type { CommandEntry } from "../../registry";
import { DEFAULT_EXTENSION_KEYBINDS, SILENT_TYPING_EXTENSION_ID } from "../catalog";
import type { ExtensionKeybindMap, SilentTypingPluginWithSettings, SilentTypingSettingsStore } from "../types";
import { createExecuteSecondaryAction } from "./actionHelpers";
function showToast(message: string, type: (typeof Toasts.Type)[keyof typeof Toasts.Type]) {
Toasts.show({ message, type, id: Toasts.genId(), options: { position: Toasts.Position.BOTTOM } });
}
function getSilentTypingPlugin(): SilentTypingPluginWithSettings | null {
const plugin = plugins.SilentTyping as SilentTypingPluginWithSettings | undefined;
return plugin ?? null;
}
function getSilentTypingSettingsStore(): SilentTypingSettingsStore | null {
const plugin = getSilentTypingPlugin();
if (!plugin) return null;
return plugin.settings?.store ?? null;
}
function parseSilentTypingLocations(locations: string): string[] {
return locations.split(";").map(value => value.trim()).filter(Boolean);
}
function writeSilentTypingLocations(store: SilentTypingSettingsStore, nextLocations: string[]) {
store.enabledLocations = nextLocations.join(";");
}
function toggleSilentTypingLocation(locationId: string): boolean {
const store = getSilentTypingSettingsStore();
if (!store) return false;
const entries = parseSilentTypingLocations(store.enabledLocations);
const next = new Set(entries);
if (next.has(locationId)) {
next.delete(locationId);
} else {
next.add(locationId);
}
writeSilentTypingLocations(store, Array.from(next));
return true;
}
async function ensureSilentTypingPluginEnabled() {
const plugin = getSilentTypingPlugin();
if (!plugin) {
showToast("SilentTyping plugin is unavailable.", Toasts.Type.FAILURE);
return false;
}
if (isPluginEnabled(plugin.name)) return true;
const success = await toggleEnabled(plugin.name);
if (!success || !isPluginEnabled(plugin.name)) {
showToast("Failed to enable SilentTyping.", Toasts.Type.FAILURE);
return false;
}
showToast("Enabled SilentTyping.", Toasts.Type.SUCCESS);
return true;
}
async function runSilentTypingChannelToggle() {
if (!await ensureSilentTypingPluginEnabled()) return;
const channelId = SelectedChannelStore?.getChannelId?.();
if (!channelId) {
showToast("No channel selected.", Toasts.Type.FAILURE);
return;
}
const changed = toggleSilentTypingLocation(channelId);
if (!changed) {
showToast("SilentTyping settings are unavailable.", Toasts.Type.FAILURE);
return;
}
showToast("Toggled SilentTyping for this channel.", Toasts.Type.SUCCESS);
}
async function runSilentTypingGlobalToggle() {
if (!await ensureSilentTypingPluginEnabled()) return;
const store = getSilentTypingSettingsStore();
if (!store) {
showToast("SilentTyping settings are unavailable.", Toasts.Type.FAILURE);
return;
}
store.enabledGlobally = !store.enabledGlobally;
showToast(`SilentTyping ${store.enabledGlobally ? "enabled" : "disabled"} globally.`, Toasts.Type.SUCCESS);
}
async function runSilentTypingOpenSettings() {
if (!await ensureSilentTypingPluginEnabled()) return;
const plugin = getSilentTypingPlugin();
if (!plugin) {
showToast("SilentTyping plugin is unavailable.", Toasts.Type.FAILURE);
return;
}
openPluginModal(plugin);
}
export function createSilentTypingExtensionCommand(extensionKeybinds: Map<string, ExtensionKeybindMap>): CommandEntry {
const keybinds = extensionKeybinds.get(SILENT_TYPING_EXTENSION_ID) ?? DEFAULT_EXTENSION_KEYBINDS[SILENT_TYPING_EXTENSION_ID];
return {
id: "extension-silent-typing-toggle",
label: "Toggle SilentTyping",
description: "Toggles SilentTyping for the current channel.",
keywords: ["silenttyping", "silent typing", "typing", "plugin", "extension", "toggle", "channel", "global", "settings"],
categoryId: DEFAULT_CATEGORY_ID,
tags: [TAG_PLUGINS, TAG_UTILITY],
handler: runSilentTypingChannelToggle,
actions: () => [
createExecuteSecondaryAction({
id: "toggle-global",
label: "Toggle global",
chord: keybinds.secondaryActionChord,
handler: runSilentTypingGlobalToggle
}),
createExecuteSecondaryAction({
id: "open-settings",
label: "Open settings",
chord: keybinds.tertiaryActionChord,
handler: runSilentTypingOpenSettings
})
]
};
}

View file

@ -0,0 +1,59 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { isPluginEnabled, plugins } from "@api/PluginManager";
import { toggleEnabled } from "@equicordplugins/equicordHelper/utils";
import { SettingsRouter, Toasts } from "@webpack/common";
import { DEFAULT_CATEGORY_ID } from "../../metadata/categories";
import { TAG_NAVIGATION, TAG_PLUGINS, TAG_UTILITY } from "../../metadata/tags";
import type { CommandEntry } from "../../registry";
import type { ThemeLibraryPlugin } from "../types";
function showToast(message: string, type: (typeof Toasts.Type)[keyof typeof Toasts.Type]) {
Toasts.show({ message, type, id: Toasts.genId(), options: { position: Toasts.Position.BOTTOM } });
}
function getThemeLibraryPlugin(): ThemeLibraryPlugin | null {
const plugin = plugins.ThemeLibrary as ThemeLibraryPlugin | undefined;
return plugin ?? null;
}
async function ensureThemeLibraryPluginEnabled() {
const plugin = getThemeLibraryPlugin();
if (!plugin) {
showToast("ThemeLibrary plugin is unavailable.", Toasts.Type.FAILURE);
return false;
}
if (isPluginEnabled(plugin.name)) return true;
const success = await toggleEnabled(plugin.name);
if (!success || !isPluginEnabled(plugin.name)) {
showToast("Failed to enable ThemeLibrary.", Toasts.Type.FAILURE);
return false;
}
showToast("Enabled ThemeLibrary.", Toasts.Type.SUCCESS);
return true;
}
async function runOpenThemeLibrary() {
if (!await ensureThemeLibraryPluginEnabled()) return;
SettingsRouter.openUserSettings("equicord_theme_library_panel");
}
export function createThemeLibraryExtensionCommand(): CommandEntry {
return {
id: "extension-theme-library-open",
label: "Open Theme Library",
description: "Open the ThemeLibrary settings page.",
keywords: ["theme", "library", "themes", "settings", "plugin", "extension"],
categoryId: DEFAULT_CATEGORY_ID,
tags: [TAG_PLUGINS, TAG_UTILITY, TAG_NAVIGATION],
handler: runOpenThemeLibrary
};
}

View file

@ -0,0 +1,133 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { TAG_PLUGINS, TAG_UTILITY } from "../metadata/tags";
import type { ExtensionDefinition } from "../registry";
import type { ExtensionKeybindMap } from "./types";
export const INSTALLED_EXTENSIONS_KEY = "CommandPaletteInstalledExtensions";
export const EXTENSION_KEYBINDS_KEY = "CommandPaletteExtensionKeybinds";
export const EXTENSIONS_ROOT_CATEGORY_ID = "extensions-root";
export const EXTENSIONS_CATALOG_CATEGORY_ID = "extensions-catalog";
export const EXTENSIONS_DETAIL_PROVIDER_ID = "extensions-detail-provider";
export const EXTENSIONS_PACK_PROVIDER_ID = "extensions-pack-provider";
export const SILENT_TYPING_EXTENSION_ID = "silent-typing";
export const SILENT_TYPING_EXTENSION_DETAIL_CATEGORY_ID = "extensions-detail-silent-typing";
export const RANDOM_VOICE_EXTENSION_ID = "random-voice";
export const RANDOM_VOICE_EXTENSION_DETAIL_CATEGORY_ID = "extensions-detail-random-voice";
export const HOLY_NOTES_EXTENSION_ID = "holy-notes";
export const HOLY_NOTES_EXTENSION_DETAIL_CATEGORY_ID = "extensions-detail-holy-notes";
export const SILENT_MESSAGE_TOGGLE_EXTENSION_ID = "silent-message-toggle";
export const SILENT_MESSAGE_TOGGLE_EXTENSION_DETAIL_CATEGORY_ID = "extensions-detail-silent-message-toggle";
export const SCHEDULED_MESSAGES_EXTENSION_ID = "scheduled-messages";
export const SCHEDULED_MESSAGES_EXTENSION_DETAIL_CATEGORY_ID = "extensions-detail-scheduled-messages";
export const THEME_LIBRARY_EXTENSION_ID = "theme-library";
export const THEME_LIBRARY_EXTENSION_DETAIL_CATEGORY_ID = "extensions-detail-theme-library";
export const DEFAULT_EXTENSION_KEYBINDS: Record<string, ExtensionKeybindMap> = {
[SILENT_TYPING_EXTENSION_ID]: {
secondaryActionChord: "meta+enter",
tertiaryActionChord: "alt+enter"
},
[RANDOM_VOICE_EXTENSION_ID]: {
secondaryActionChord: "meta+enter",
tertiaryActionChord: "alt+enter"
},
[SILENT_MESSAGE_TOGGLE_EXTENSION_ID]: {
secondaryActionChord: "meta+enter",
tertiaryActionChord: "alt+enter"
}
};
export const EXTENSIONS_CATALOG: ExtensionDefinition[] = [
{
id: SILENT_TYPING_EXTENSION_ID,
label: "SilentTyping",
description: "Control the SilentTyping plugin from the command palette.",
detailCategoryId: SILENT_TYPING_EXTENSION_DETAIL_CATEGORY_ID,
commandId: "extension-silent-typing-toggle",
commandLabel: "Toggle SilentTyping",
commandDescription: "Run the extension command from this detail page.",
sourcePath: "src/plugins/silentTyping",
tags: [TAG_PLUGINS, TAG_UTILITY],
keywords: ["extension", "plugin", "silent", "typing", "toggle", "keyboard"]
},
{
id: RANDOM_VOICE_EXTENSION_ID,
label: "RandomVoice",
description: "Control the RandomVoice plugin from the command palette.",
detailCategoryId: RANDOM_VOICE_EXTENSION_DETAIL_CATEGORY_ID,
commandId: "extension-random-voice-join",
commandLabel: "Join Random Voice",
commandDescription: "Run the extension command from this detail page.",
sourcePath: "src/equicordplugins/randomVoice",
tags: [TAG_PLUGINS, TAG_UTILITY],
keywords: ["extension", "plugin", "random", "voice", "join", "channel", "vc"]
},
{
id: HOLY_NOTES_EXTENSION_ID,
label: "HolyNotes",
description: "Control HolyNotes from the command palette.",
detailCategoryId: HOLY_NOTES_EXTENSION_DETAIL_CATEGORY_ID,
commandId: "extension-holy-notes-open",
commandLabel: "Open Notes",
commandDescription: "Open the HolyNotes notebook modal.",
sourcePath: "src/equicordplugins/holyNotes",
tags: [TAG_PLUGINS, TAG_UTILITY],
keywords: ["extension", "plugin", "holy", "notes", "notebook", "bookmark"]
},
{
id: SILENT_MESSAGE_TOGGLE_EXTENSION_ID,
label: "SilentMessageToggle",
description: "Control SilentMessageToggle from the command palette.",
detailCategoryId: SILENT_MESSAGE_TOGGLE_EXTENSION_DETAIL_CATEGORY_ID,
commandId: "extension-silent-message-toggle-plugin",
commandLabel: "Toggle SilentMessageToggle",
commandDescription: "Toggle the SilentMessageToggle plugin.",
sourcePath: "src/plugins/silentMessageToggle",
tags: [TAG_PLUGINS, TAG_UTILITY],
keywords: ["extension", "plugin", "silent", "message", "toggle", "auto disable"]
},
{
id: SCHEDULED_MESSAGES_EXTENSION_ID,
label: "ScheduledMessages",
description: "Control ScheduledMessages from the command palette.",
detailCategoryId: SCHEDULED_MESSAGES_EXTENSION_DETAIL_CATEGORY_ID,
commandId: "extension-scheduled-messages-open",
commandLabel: "Open Scheduled Messages",
commandDescription: "Open the ScheduledMessages modal.",
sourcePath: "src/equicordplugins/scheduledMessages",
tags: [TAG_PLUGINS, TAG_UTILITY],
keywords: ["extension", "plugin", "schedule", "message", "queue", "remind", "delay"]
},
{
id: THEME_LIBRARY_EXTENSION_ID,
label: "ThemeLibrary",
description: "Control ThemeLibrary from the command palette.",
detailCategoryId: THEME_LIBRARY_EXTENSION_DETAIL_CATEGORY_ID,
commandId: "extension-theme-library-open",
commandLabel: "Open Theme Library",
commandDescription: "Open the ThemeLibrary settings page.",
sourcePath: "src/equicordplugins/themeLibrary",
tags: [TAG_PLUGINS, TAG_UTILITY],
keywords: ["extension", "plugin", "theme", "library", "themes", "settings"]
}
];
export const extensionDefinitionsById = new Map(EXTENSIONS_CATALOG.map(extension => [extension.id, extension]));
export const EQUICORD_REPOSITORY_BLOB_BASE_URL = "https://github.com/Equicord/Equicord/blob/main";
export function normalizeRepositoryPath(path: string): string {
return path.trim().replace(/^\/+/, "");
}
export function toRepositoryBlobUrl(path: string): string {
const normalizedPath = normalizeRepositoryPath(path);
return `${EQUICORD_REPOSITORY_BLOB_BASE_URL}/${normalizedPath}`;
}

View file

@ -0,0 +1,10 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
export * from "./catalog";
export * from "./providers";
export * from "./state";
export * from "./types";

View file

@ -0,0 +1,192 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { SettingsStore } from "@api/Settings";
import { Toasts } from "@webpack/common";
import { TAG_DEVELOPER, TAG_NAVIGATION, TAG_PLUGINS, TAG_UTILITY } from "../metadata/tags";
import type { CommandEntry, ExtensionDefinition } from "../registry";
import { createHolyNotesExtensionCommands } from "./actions/holyNotes";
import { createRandomVoiceExtensionCommand } from "./actions/randomVoice";
import { createScheduledMessagesExtensionCommands } from "./actions/scheduledMessages";
import { createSilentMessageToggleExtensionCommand } from "./actions/silentMessageToggle";
import { createSilentTypingExtensionCommand } from "./actions/silentTyping";
import { createThemeLibraryExtensionCommand } from "./actions/themeLibrary";
import {
EXTENSIONS_CATALOG_CATEGORY_ID,
EXTENSIONS_DETAIL_PROVIDER_ID,
EXTENSIONS_PACK_PROVIDER_ID,
HOLY_NOTES_EXTENSION_ID,
RANDOM_VOICE_EXTENSION_ID,
SCHEDULED_MESSAGES_EXTENSION_ID,
SILENT_MESSAGE_TOGGLE_EXTENSION_ID,
SILENT_TYPING_EXTENSION_ID,
THEME_LIBRARY_EXTENSION_ID,
toRepositoryBlobUrl
} from "./catalog";
import type { ExtensionsState } from "./state";
interface ContextCommandProvider {
id: string;
getCommands(): CommandEntry[];
subscribe?(refresh: () => void): () => void;
}
interface ExtensionsProviderDeps {
extensionsState: ExtensionsState;
registerContextProvider(provider: ContextCommandProvider): void;
showToast(message: string, type: (typeof Toasts.Type)[keyof typeof Toasts.Type]): void;
openExternalUrl(url: string): void;
executeCommand(entry: CommandEntry): Promise<void>;
getCommandById(commandId: string): CommandEntry | undefined;
}
function createInstalledExtensionCommands(extensionsState: ExtensionsState): CommandEntry[] {
const entries: CommandEntry[] = [];
if (extensionsState.installedExtensionIds.has(SILENT_TYPING_EXTENSION_ID)) {
entries.push(createSilentTypingExtensionCommand(extensionsState.extensionKeybinds));
}
if (extensionsState.installedExtensionIds.has(RANDOM_VOICE_EXTENSION_ID)) {
entries.push(createRandomVoiceExtensionCommand(extensionsState.extensionKeybinds));
}
if (extensionsState.installedExtensionIds.has(HOLY_NOTES_EXTENSION_ID)) {
entries.push(...createHolyNotesExtensionCommands());
}
if (extensionsState.installedExtensionIds.has(SILENT_MESSAGE_TOGGLE_EXTENSION_ID)) {
entries.push(createSilentMessageToggleExtensionCommand(extensionsState.extensionKeybinds));
}
if (extensionsState.installedExtensionIds.has(SCHEDULED_MESSAGES_EXTENSION_ID)) {
entries.push(...createScheduledMessagesExtensionCommands());
}
if (extensionsState.installedExtensionIds.has(THEME_LIBRARY_EXTENSION_ID)) {
entries.push(createThemeLibraryExtensionCommand());
}
return entries;
}
function createExtensionCatalogCommands(extensions: ExtensionDefinition[]): CommandEntry[] {
return extensions.map(extension => ({
id: `extension-catalog-${extension.id}`,
label: extension.label,
description: extension.description,
keywords: extension.keywords,
tags: extension.tags,
categoryId: EXTENSIONS_CATALOG_CATEGORY_ID,
hiddenInSearch: true,
drilldownCategoryId: extension.detailCategoryId,
handler: () => undefined
}));
}
function createExtensionDetailCommands(deps: ExtensionsProviderDeps): CommandEntry[] {
const { extensionsState } = deps;
const commands: CommandEntry[] = [];
for (const extension of extensionsState.listExtensions()) {
const installed = extensionsState.installedExtensionIds.has(extension.id);
commands.push({
id: `extension-detail-${extension.id}-install-toggle`,
label: installed ? "Uninstall Extension" : "Install Extension",
description: installed
? "Removes this plugin command pack from your palette."
: "Installs this plugin command pack into your palette.",
keywords: ["install", "uninstall", "extension", extension.label.toLowerCase()],
categoryId: extension.detailCategoryId,
hiddenInSearch: true,
tags: [TAG_PLUGINS, TAG_UTILITY],
handler: async () => {
const success = installed
? await extensionsState.uninstall(extension.id)
: await extensionsState.install(extension.id);
if (!success) {
deps.showToast(`Failed to ${installed ? "uninstall" : "install"} ${extension.label}.`, Toasts.Type.FAILURE);
return;
}
deps.showToast(`${installed ? "Uninstalled" : "Installed"} ${extension.label} plugin commands.`, Toasts.Type.SUCCESS);
}
});
if (installed) {
commands.push({
id: `extension-detail-${extension.id}-command`,
label: extension.commandLabel,
description: extension.commandDescription,
keywords: ["command", extension.label.toLowerCase(), "plugin", "extension"],
categoryId: extension.detailCategoryId,
hiddenInSearch: true,
tags: [TAG_PLUGINS, TAG_UTILITY],
handler: async () => {
const command = deps.getCommandById(extension.commandId);
if (!command) {
deps.showToast(`${extension.label} command is unavailable.`, Toasts.Type.FAILURE);
return;
}
await deps.executeCommand(command);
}
});
}
if (extension.sourcePath) {
commands.push({
id: `extension-detail-${extension.id}-source`,
label: "View Source Code",
description: "Open extension source code.",
keywords: ["source", "code", "github", "extension"],
categoryId: extension.detailCategoryId,
hiddenInSearch: true,
tags: [TAG_NAVIGATION, TAG_DEVELOPER],
handler: () => deps.openExternalUrl(toRepositoryBlobUrl(extension.sourcePath!))
});
}
}
return commands;
}
export function registerExtensionProviders(deps: ExtensionsProviderDeps) {
deps.registerContextProvider({
id: EXTENSIONS_CATALOG_CATEGORY_ID,
getCommands: () => createExtensionCatalogCommands(deps.extensionsState.listExtensions()),
subscribe: refresh => {
void deps.extensionsState.ready.finally(refresh);
return () => undefined;
}
});
deps.registerContextProvider({
id: EXTENSIONS_DETAIL_PROVIDER_ID,
getCommands: () => createExtensionDetailCommands(deps),
subscribe: refresh => {
void deps.extensionsState.ready.finally(refresh);
return () => undefined;
}
});
deps.registerContextProvider({
id: EXTENSIONS_PACK_PROVIDER_ID,
getCommands: () => createInstalledExtensionCommands(deps.extensionsState),
subscribe: refresh => {
const handler = (_: unknown, path: string) => {
if (typeof path !== "string") return;
if (!path.startsWith("plugins.SilentTyping")) return;
refresh();
};
SettingsStore.addGlobalChangeListener(handler);
void deps.extensionsState.ready.finally(refresh);
return () => {
SettingsStore.removeGlobalChangeListener(handler);
};
}
});
}

View file

@ -0,0 +1,134 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { DataStore } from "@api/index";
import { DEFAULT_EXTENSION_KEYBINDS, EXTENSION_KEYBINDS_KEY, extensionDefinitionsById, EXTENSIONS_CATALOG, EXTENSIONS_DETAIL_PROVIDER_ID, EXTENSIONS_PACK_PROVIDER_ID, INSTALLED_EXTENSIONS_KEY } from "./catalog";
import type { ExtensionKeybindMap } from "./types";
export interface ExtensionsState {
ready: Promise<void>;
installedExtensionIds: Set<string>;
extensionKeybinds: Map<string, ExtensionKeybindMap>;
listExtensions(): typeof EXTENSIONS_CATALOG;
isInstalled(extensionId: string): boolean;
install(extensionId: string): Promise<boolean>;
uninstall(extensionId: string): Promise<boolean>;
}
function toExtensionKeybindRecord(extensionKeybinds: Map<string, ExtensionKeybindMap>) {
const record: Record<string, ExtensionKeybindMap> = {};
for (const [extensionId, keybinds] of extensionKeybinds) {
record[extensionId] = keybinds;
}
return record;
}
export function createExtensionsState(
refreshContextProvider: (id: string) => void,
bumpRegistryVersion: () => void
): ExtensionsState {
const installedExtensionIds = new Set<string>();
const extensionKeybinds = new Map<string, ExtensionKeybindMap>();
const persistInstalledExtensions = async () => {
try {
await DataStore.set(INSTALLED_EXTENSIONS_KEY, Array.from(installedExtensionIds));
} catch {
return;
}
};
const persistExtensionKeybinds = async () => {
try {
await DataStore.set(EXTENSION_KEYBINDS_KEY, toExtensionKeybindRecord(extensionKeybinds));
} catch {
return;
}
};
const ready = (async () => {
try {
const storedInstalled = await DataStore.get<string[]>(INSTALLED_EXTENSIONS_KEY);
if (Array.isArray(storedInstalled)) {
for (const extensionId of storedInstalled) {
if (!extensionDefinitionsById.has(extensionId)) continue;
installedExtensionIds.add(extensionId);
}
}
} catch {
return;
}
try {
const storedKeybinds = await DataStore.get<Record<string, ExtensionKeybindMap>>(EXTENSION_KEYBINDS_KEY);
for (const extension of EXTENSIONS_CATALOG) {
const stored = storedKeybinds?.[extension.id];
if (stored?.secondaryActionChord && stored.tertiaryActionChord) {
extensionKeybinds.set(extension.id, stored);
continue;
}
const fallback = DEFAULT_EXTENSION_KEYBINDS[extension.id];
if (fallback) {
extensionKeybinds.set(extension.id, fallback);
}
}
} catch {
return;
} finally {
refreshContextProvider(EXTENSIONS_PACK_PROVIDER_ID);
refreshContextProvider(EXTENSIONS_DETAIL_PROVIDER_ID);
bumpRegistryVersion();
}
})();
return {
ready,
installedExtensionIds,
extensionKeybinds,
listExtensions: () => EXTENSIONS_CATALOG,
isInstalled: extensionId => installedExtensionIds.has(extensionId),
install: async extensionId => {
await ready;
if (!extensionDefinitionsById.has(extensionId)) return false;
if (installedExtensionIds.has(extensionId)) return true;
installedExtensionIds.add(extensionId);
if (!extensionKeybinds.has(extensionId) && DEFAULT_EXTENSION_KEYBINDS[extensionId]) {
extensionKeybinds.set(extensionId, DEFAULT_EXTENSION_KEYBINDS[extensionId]);
}
await Promise.all([
persistInstalledExtensions(),
persistExtensionKeybinds()
]);
refreshContextProvider(EXTENSIONS_PACK_PROVIDER_ID);
refreshContextProvider(EXTENSIONS_DETAIL_PROVIDER_ID);
bumpRegistryVersion();
return true;
},
uninstall: async extensionId => {
await ready;
if (!extensionDefinitionsById.has(extensionId)) return false;
if (!installedExtensionIds.has(extensionId)) return true;
installedExtensionIds.delete(extensionId);
extensionKeybinds.delete(extensionId);
await Promise.all([
persistInstalledExtensions(),
persistExtensionKeybinds()
]);
refreshContextProvider(EXTENSIONS_PACK_PROVIDER_ID);
refreshContextProvider(EXTENSIONS_DETAIL_PROVIDER_ID);
bumpRegistryVersion();
return true;
}
};
}

View file

@ -0,0 +1,82 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import type { Plugin } from "@utils/types";
export interface ExtensionKeybindMap {
secondaryActionChord: string;
tertiaryActionChord: string;
}
export interface SilentTypingSettingsStore {
enabledGlobally: boolean;
defaultHidden: boolean;
enabledLocations: string;
disabledLocations: string;
}
export type SilentTypingPluginWithSettings = Plugin & {
settings?: {
store?: SilentTypingSettingsStore;
};
};
export type RandomVoiceOperation = ">" | "<" | "==";
export interface RandomVoiceSettingsStore {
UserAmountOperation: RandomVoiceOperation;
UserAmount: number;
spacesLeftOperation: RandomVoiceOperation;
spacesLeft: number;
vcLimitOperation: RandomVoiceOperation;
vcLimit: number;
Servers: string;
autoNavigate: boolean;
selfMute: boolean;
selfDeafen: boolean;
avoidStages: boolean;
avoidAfk: boolean;
video: boolean;
stream: boolean;
mute: boolean;
deafen: boolean;
includeStates: boolean;
avoidStates: boolean;
}
export interface RandomVoiceStateLike {
channelId?: string;
selfDeaf?: boolean;
selfMute?: boolean;
selfStream?: boolean;
selfVideo?: boolean;
}
export type RandomVoicePluginWithSettings = Plugin & {
settings?: {
store?: RandomVoiceSettingsStore;
};
};
export type HolyNotesPlugin = Plugin & {
toolboxActions?: Record<string, () => void | Promise<void>>;
};
export interface SilentMessageToggleSettingsStore {
autoDisable: boolean;
}
export type SilentMessageTogglePluginWithSettings = Plugin & {
settings?: {
store?: SilentMessageToggleSettingsStore;
};
};
export type ScheduledMessagesPlugin = Plugin & {
toolboxActions?: Record<string, () => void | Promise<void>>;
};
export type ThemeLibraryPlugin = Plugin;

View file

@ -8,6 +8,7 @@ import { definePluginSettings } from "@api/Settings";
import { BaseText } from "@components/BaseText";
import { Button } from "@components/Button";
import { EquicordDevs, IS_MAC } from "@utils/constants";
import { classNameFactory } from "@utils/css";
import definePlugin, { OptionType } from "@utils/types";
import { useEffect, useState } from "@webpack/common";
@ -15,6 +16,8 @@ import { cleanupCommandPaletteRuntime, registerBuiltInCommands, wrapChatBarChild
import { CommandPaletteSettingsPanel } from "./settingsPanel";
import { openCommandPalette } from "./ui";
const cl = classNameFactory("vc-command-palette-");
const DEFAULT_KEYS = IS_MAC ? ["Meta", "Shift", "P"] : ["Control", "Shift", "P"];
const isRecordingGlobal = false;
@ -37,7 +40,7 @@ function formatKeybind(keybind: string | string[]): string {
function KeybindRecorder() {
const [isListening, setIsListening] = useState(false);
const [error, setError] = useState<string | null>(null);
const currentKeybind = settings.use(["hotkey"]).hotkey;
const currentKeybind = settings.use().hotkey;
useEffect(() => {
if (!isListening) return;
@ -84,76 +87,50 @@ function KeybindRecorder() {
};
}, [isListening]);
const handleReset = () => {
settings.store.hotkey = DEFAULT_KEYS;
setError(null);
};
return (
<div className="vc-command-palette-keybind-input">
<div className="vc-command-palette-keybind-info">
<BaseText size="md" weight="semibold">Command Palette Hotkey</BaseText>
<BaseText size="sm" weight="normal" style={{ color: "var(--text-muted)" }}>
Hotkey used to open the command palette
</BaseText>
{error && (
<BaseText size="xs" weight="normal" className="vc-command-palette-keybind-conflict">
{error}
</BaseText>
)}
<div className={cl("keybind-input")}>
<div className={cl("keybind-info")}>
<BaseText size="md" weight="semibold">Keybind</BaseText>
</div>
<div className="vc-command-palette-keybind-controls">
<div className={cl("keybind-controls")}>
<Button
type="button"
variant="secondary"
className={`vc-command-palette-keybind-button ${isListening ? "listening" : ""}`}
className={`${cl("keybind-button")} ${isListening ? "listening" : ""}`}
onClick={() => setIsListening(true)}
>
{isListening ? (
<BaseText size="sm" weight="normal" style={{ color: "var(--white)", opacity: 0.8 }}>
Press any key...
</BaseText>
) : (
formatKeybind(currentKeybind)
)}
</Button>
<Button size="small" variant="secondary" onClick={handleReset}>
Reset
{isListening ? "Recording..." : formatKeybind(currentKeybind)}
</Button>
</div>
{error && (
<BaseText size="xs" weight="normal" className={cl("keybind-conflict")}>
{error}
</BaseText>
)}
</div>
);
}
export const settings = definePluginSettings({
hotkey: {
description: "Hotkey used to open the command palette",
type: OptionType.COMPONENT,
default: DEFAULT_KEYS,
component: KeybindRecorder
},
visualStyle: {
description: "Palette appearance",
type: OptionType.SELECT,
options: [
{ label: "Classic", value: "classic", default: true },
{ label: "Polished", value: "polished" }
]
},
showTags: {
description: "Display tag chips for commands",
type: OptionType.BOOLEAN,
default: true
},
enableTagFilter: {
description: "Show the tag filter bar",
type: OptionType.BOOLEAN,
default: true
},
customCommands: {
description: "Manage custom command palette entries",
type: OptionType.COMPONENT,
component: CommandPaletteSettingsPanel
},
compactStartEnabled: {
description: "Open the palette in compact mode first.",
type: OptionType.BOOLEAN,
default: true
},
closeAfterExecute: {
description: "Close palette after executing a command.",
type: OptionType.BOOLEAN,
default: true
}
});
@ -224,7 +201,7 @@ function hotkeyUsesModifiers() {
export default definePlugin({
name: "CommandPalette",
description: "Quickly run actions through a searchable command palette",
authors: [EquicordDevs.justjxke, EquicordDevs.Ethan],
authors: [EquicordDevs.justjxke],
settings,
patches: [
{

View file

@ -0,0 +1,237 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import {
EXTENSIONS_CATALOG_CATEGORY_ID,
EXTENSIONS_ROOT_CATEGORY_ID,
HOLY_NOTES_EXTENSION_DETAIL_CATEGORY_ID,
RANDOM_VOICE_EXTENSION_DETAIL_CATEGORY_ID,
SCHEDULED_MESSAGES_EXTENSION_DETAIL_CATEGORY_ID,
SILENT_MESSAGE_TOGGLE_EXTENSION_DETAIL_CATEGORY_ID,
SILENT_TYPING_EXTENSION_DETAIL_CATEGORY_ID
} from "../extensions/catalog";
import type { CommandCategory } from "../registry";
import {
TAG_CONTEXT,
TAG_CORE,
TAG_CUSTOM,
TAG_DEVELOPER,
TAG_FRIENDS,
TAG_GUILDS,
TAG_NAVIGATION,
TAG_PLUGINS,
TAG_SESSION,
TAG_UTILITY
} from "./tags";
export const DEFAULT_CATEGORY_ID = "quick-actions";
export const CUSTOM_COMMANDS_CATEGORY_ID = "custom-commands";
export const SESSION_TOOLS_CATEGORY_ID = "session-tools";
export const CONTEXT_PROVIDER_ID = "context-current";
export const CUSTOM_PROVIDER_ID = "custom-commands";
export const TOOLBOX_ACTIONS_CATEGORY_ID = "plugins-settings";
export const TOOLBOX_ACTIONS_PROVIDER_ID = "plugin-toolbox-actions";
export const CHATBAR_ACTIONS_CATEGORY_ID = "plugins-settings";
export const GUILD_CATEGORY_ID = "guilds-actions";
export const FRIENDS_CATEGORY_ID = "friends-actions";
export const PLUGIN_MANAGER_ROOT_COMMAND_ID = "plugins-manager-root";
export const PLUGIN_MANAGER_ENABLE_COMMAND_ID = "plugins-manager-enable";
export const PLUGIN_MANAGER_DISABLE_COMMAND_ID = "plugins-manager-disable";
export const PLUGIN_MANAGER_SETTINGS_COMMAND_ID = "plugins-manager-settings";
export const MENTION_PROVIDER_ID = "mentions-provider";
export const MENTIONS_CATEGORY_ID = "mentions-actions";
export const RECENTS_CATEGORY_ID = "recent-actions";
export const PINNED_CATEGORY_ID = "pinned-actions";
export const CATEGORY_WEIGHTS = new Map<string, number>([
[DEFAULT_CATEGORY_ID, 100],
[CONTEXT_PROVIDER_ID, 95],
[SESSION_TOOLS_CATEGORY_ID, 90],
["discord-settings", 90],
["updates", 75],
[CUSTOM_COMMANDS_CATEGORY_ID, 80],
["plugins", 45],
["plugins-enable", 45],
["plugins-disable", 45],
["plugins-settings", 45],
[MENTIONS_CATEGORY_ID, 55],
[PINNED_CATEGORY_ID, 72],
[RECENTS_CATEGORY_ID, 70],
[EXTENSIONS_ROOT_CATEGORY_ID, 60],
[EXTENSIONS_CATALOG_CATEGORY_ID, 60],
[SILENT_TYPING_EXTENSION_DETAIL_CATEGORY_ID, 60],
[RANDOM_VOICE_EXTENSION_DETAIL_CATEGORY_ID, 60],
[HOLY_NOTES_EXTENSION_DETAIL_CATEGORY_ID, 60],
[SILENT_MESSAGE_TOGGLE_EXTENSION_DETAIL_CATEGORY_ID, 60],
[SCHEDULED_MESSAGES_EXTENSION_DETAIL_CATEGORY_ID, 60],
[GUILD_CATEGORY_ID, 40],
[FRIENDS_CATEGORY_ID, 40]
]);
export const CATEGORY_GROUP_LABELS = new Map<string | undefined, string>([
[DEFAULT_CATEGORY_ID, "Core Actions"],
[CONTEXT_PROVIDER_ID, "Core Actions"],
[SESSION_TOOLS_CATEGORY_ID, "Core Actions"],
["discord-settings", "Discord Settings"],
["updates", "Updates"],
[CUSTOM_COMMANDS_CATEGORY_ID, "Custom Commands"],
["plugins", "Plugin Controls"],
["plugins-enable", "Plugin Controls"],
["plugins-disable", "Plugin Controls"],
["plugins-settings", "Plugin Controls"],
[MENTIONS_CATEGORY_ID, "Mentions"],
[PINNED_CATEGORY_ID, "Core Actions"],
[RECENTS_CATEGORY_ID, "Core Actions"],
[EXTENSIONS_ROOT_CATEGORY_ID, "Extensions"],
[EXTENSIONS_CATALOG_CATEGORY_ID, "Extensions"],
[SILENT_TYPING_EXTENSION_DETAIL_CATEGORY_ID, "Extensions"],
[RANDOM_VOICE_EXTENSION_DETAIL_CATEGORY_ID, "Extensions"],
[HOLY_NOTES_EXTENSION_DETAIL_CATEGORY_ID, "Extensions"],
[SILENT_MESSAGE_TOGGLE_EXTENSION_DETAIL_CATEGORY_ID, "Extensions"],
[SCHEDULED_MESSAGES_EXTENSION_DETAIL_CATEGORY_ID, "Extensions"],
[GUILD_CATEGORY_ID, "Guilds"],
[FRIENDS_CATEGORY_ID, "Friends"]
]);
export const DEFAULT_CATEGORY_WEIGHT = 50;
export const CATEGORY_DEFAULT_TAGS = new Map<string, string[]>([
[DEFAULT_CATEGORY_ID, [TAG_CORE]],
[CONTEXT_PROVIDER_ID, [TAG_CONTEXT]],
[SESSION_TOOLS_CATEGORY_ID, [TAG_SESSION]],
["discord-settings", [TAG_NAVIGATION]],
["updates", [TAG_DEVELOPER]],
[CUSTOM_COMMANDS_CATEGORY_ID, [TAG_CUSTOM]],
["plugins", [TAG_PLUGINS]],
["plugins-enable", [TAG_PLUGINS]],
["plugins-disable", [TAG_PLUGINS]],
["plugins-settings", [TAG_PLUGINS, TAG_UTILITY]],
[MENTIONS_CATEGORY_ID, [TAG_NAVIGATION]],
[PINNED_CATEGORY_ID, [TAG_CORE, TAG_UTILITY]],
[RECENTS_CATEGORY_ID, [TAG_CORE, TAG_UTILITY]],
[EXTENSIONS_ROOT_CATEGORY_ID, [TAG_PLUGINS, TAG_UTILITY]],
[EXTENSIONS_CATALOG_CATEGORY_ID, [TAG_PLUGINS, TAG_UTILITY]],
[SILENT_TYPING_EXTENSION_DETAIL_CATEGORY_ID, [TAG_PLUGINS, TAG_UTILITY]],
[RANDOM_VOICE_EXTENSION_DETAIL_CATEGORY_ID, [TAG_PLUGINS, TAG_UTILITY]],
[HOLY_NOTES_EXTENSION_DETAIL_CATEGORY_ID, [TAG_PLUGINS, TAG_UTILITY]],
[SILENT_MESSAGE_TOGGLE_EXTENSION_DETAIL_CATEGORY_ID, [TAG_PLUGINS, TAG_UTILITY]],
[SCHEDULED_MESSAGES_EXTENSION_DETAIL_CATEGORY_ID, [TAG_PLUGINS, TAG_UTILITY]],
[GUILD_CATEGORY_ID, [TAG_GUILDS]],
[FRIENDS_CATEGORY_ID, [TAG_FRIENDS]]
]);
export const BUILT_IN_CATEGORIES: CommandCategory[] = [
{
id: DEFAULT_CATEGORY_ID,
label: "Quick Actions",
description: "Common Equicord shortcuts"
},
{
id: "plugins",
label: "Plugins",
description: "Manage Equicord and Vencord plugins"
},
{
id: CONTEXT_PROVIDER_ID,
label: "Current Context",
description: "Actions for the selected channel and guild"
},
{
id: "plugins-enable",
label: "Enable Plugin",
parentId: "plugins"
},
{
id: "plugins-disable",
label: "Disable Plugin",
parentId: "plugins"
},
{
id: "plugins-settings",
label: "Plugin Settings",
parentId: "plugins"
},
{
id: "updates",
label: "Updates",
description: "Stay up to date with Equicord"
},
{
id: "discord-settings",
label: "Discord Settings",
description: "Jump to Discord configuration pages"
},
{
id: CUSTOM_COMMANDS_CATEGORY_ID,
label: "Custom Commands",
description: "User-defined command palette entries"
},
{
id: SESSION_TOOLS_CATEGORY_ID,
label: "Session Tools",
description: "Utilities for managing your Discord session"
},
{
id: GUILD_CATEGORY_ID,
label: "Guilds",
description: "Quickly navigate to your guilds"
},
{
id: FRIENDS_CATEGORY_ID,
label: "Friends",
description: "Quickly DM your friends"
},
{
id: PINNED_CATEGORY_ID,
label: "Pinned Commands",
description: "Commands you pinned for quick access"
},
{
id: MENTIONS_CATEGORY_ID,
label: "Mentions",
description: "Your recent mentions and inbox items"
},
{
id: RECENTS_CATEGORY_ID,
label: "Recent Commands",
description: "Recently executed commands"
},
{
id: EXTENSIONS_ROOT_CATEGORY_ID,
label: "Extensions",
description: "Install extension command packs."
},
{
id: EXTENSIONS_CATALOG_CATEGORY_ID,
label: "Catalog",
parentId: EXTENSIONS_ROOT_CATEGORY_ID
},
{
id: SILENT_TYPING_EXTENSION_DETAIL_CATEGORY_ID,
label: "SilentTyping",
parentId: EXTENSIONS_CATALOG_CATEGORY_ID
},
{
id: RANDOM_VOICE_EXTENSION_DETAIL_CATEGORY_ID,
label: "RandomVoice",
parentId: EXTENSIONS_CATALOG_CATEGORY_ID
},
{
id: HOLY_NOTES_EXTENSION_DETAIL_CATEGORY_ID,
label: "HolyNotes",
parentId: EXTENSIONS_CATALOG_CATEGORY_ID
},
{
id: SILENT_MESSAGE_TOGGLE_EXTENSION_DETAIL_CATEGORY_ID,
label: "SilentMessageToggle",
parentId: EXTENSIONS_CATALOG_CATEGORY_ID
},
{
id: SCHEDULED_MESSAGES_EXTENSION_DETAIL_CATEGORY_ID,
label: "ScheduledMessages",
parentId: EXTENSIONS_CATALOG_CATEGORY_ID
}
];

View file

@ -0,0 +1,21 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
export const TAG_CORE = "Core";
export const TAG_NAVIGATION = "Navigation";
export const TAG_UTILITY = "Utility";
export const TAG_DEVELOPER = "Developer";
export const TAG_CUSTOMIZATION = "Customization";
export const TAG_PLUGINS = "Plugins";
export const TAG_SESSION = "Session";
export const TAG_CONTEXT = "Context";
export const TAG_CUSTOM = "Custom";
export const TAG_GUILDS = "Guilds";
export const TAG_FRIENDS = "Friends";
export function normalizeTag(tag: string): string {
return tag.trim().toLowerCase();
}

View file

@ -0,0 +1,12 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { type CommandEntry,getRecentCommands } from "../registry";
export function getRecentCommandEntries(limit: number): CommandEntry[] {
const max = Math.max(1, limit);
return getRecentCommands().slice(0, max);
}

View file

@ -0,0 +1,245 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import type { ParsedQuery } from "./types";
function normalizeSpaces(value: string): string {
return value.trim().replace(/\s+/g, " ");
}
function normalizeLocaleToken(value: string): string {
return value
.trim()
.toLowerCase()
.normalize("NFD")
.replace(/\p{Diacritic}/gu, "")
.replace(/[.'’]/g, "")
.replace(/\s+/g, " ");
}
const SEND_TO_PREFIXES = [
"send to ",
"enviar a ",
"senden an ",
"invia a ",
"envoyer a ",
"enviar para ",
"отправить в "
];
function extractSendFlags(content: string): { content: string; useFilePicker: boolean; silent: boolean; } {
const useFilePicker = /\[file\]/i.test(content);
const silent = /(^|\s)--silent(\s|$)/i.test(content);
const cleaned = content
.replace(/\[file\]/ig, " ")
.replace(/(^|\s)--silent(\s|$)/ig, " ")
.trim();
return { content: cleaned, useFilePicker, silent };
}
function sanitizeTargetToken(target: string): string {
return target.trim().replace(/[,:;]+$/, "");
}
function parseSendBody(raw: string): ParsedQuery | null {
const body = normalizeSpaces(raw);
if (!body) return null;
const parts = body.split(" ");
const target = sanitizeTargetToken(parts[0] ?? "");
const contentRaw = parts.slice(1).join(" ").trim();
const { content, useFilePicker, silent } = extractSendFlags(contentRaw);
if (!target) return null;
return {
raw,
intent: "send_message",
target,
content,
useFilePicker,
silent
};
}
function parseSendChannel(raw: string): ParsedQuery | null {
const normalized = normalizeSpaces(raw);
const normalizedLocale = normalizeLocaleToken(normalized);
const prefix = SEND_TO_PREFIXES.find(candidate => normalizedLocale.startsWith(candidate));
if (!prefix) return null;
const body = normalized.slice(prefix.length).trim();
if (!body) return null;
const parts = body.split(" ");
if (parts.length < 2) return null;
const target = sanitizeTargetToken(parts[0] ?? "");
const contentRaw = parts.slice(1).join(" ").trim();
const { content, useFilePicker, silent } = extractSendFlags(contentRaw);
if (!target || !content) return null;
return {
raw,
intent: "send_channel",
target,
content,
useFilePicker,
silent
};
}
const SEND_PREFIXES = [
"send message to ",
"send dm to ",
"send message ",
"send dm ",
"message ",
"tell ",
"whisper ",
"pm ",
"msg ",
"dm ",
"send ",
"enviar mensaje a ",
"enviar md a ",
"enviar mensaje ",
"enviar md ",
"mensaje ",
"decir ",
"susurrar ",
"md ",
"enviar ",
"envoyer message a ",
"envoyer mp a ",
"envoyer message ",
"message ",
"chuchoter ",
"mp ",
"envoyer ",
"nachricht an ",
"senden ",
"dm an ",
"dm ",
"invia messaggio a ",
"invia ",
"messaggio ",
"manda ",
"enviar mensagem para ",
"enviar dm para ",
"mensagem ",
"falar ",
"отправить сообщение ",
"отправить "
];
function parseSemanticSend(raw: string): ParsedQuery | null {
const normalized = normalizeLocaleToken(raw);
const prefix = SEND_PREFIXES.find(candidate => normalized.startsWith(candidate));
if (!prefix) return null;
const body = raw.slice(prefix.length).trim();
return parseSendBody(body);
}
export function parseQuery(rawQuery: string): ParsedQuery | null {
const raw = normalizeSpaces(rawQuery);
const lower = raw.toLowerCase();
const normalizedLocale = normalizeLocaleToken(raw);
if (!raw) return null;
if (SEND_TO_PREFIXES.some(prefix => normalizedLocale.startsWith(prefix))) {
return parseSendChannel(raw);
}
if (lower.startsWith("send now ")) {
const target = raw.slice(9).trim();
return { raw, intent: "send_scheduled_now", target };
}
if (lower.startsWith("cancel scheduled ")) {
const target = raw.slice(17).trim();
return { raw, intent: "cancel_scheduled_message", target };
}
if (lower.startsWith("reschedule ")) {
const target = raw.slice(11).trim();
return { raw, intent: "reschedule_message", target };
}
if (lower.startsWith("schedule ")) {
const target = raw.slice(9).trim();
return { raw, intent: "schedule_message", target };
}
const semanticSend = parseSemanticSend(raw);
if (semanticSend) return semanticSend;
if (lower.startsWith("open dm ")) {
const target = raw.slice(8).trim();
if (!target) return null;
return { raw, intent: "open_dm", target };
}
if (lower.startsWith("open dms ")) {
const target = raw.slice(9).trim();
if (!target) return null;
return { raw, intent: "open_dm", target };
}
if (lower === "open dm" || lower === "open dms") {
return { raw, intent: "open_dm", target: "" };
}
if (lower.startsWith("go to ")) {
const target = raw.slice(6).trim();
if (!target) return null;
return { raw, intent: "go_to", target };
}
if (lower === "go to") {
return { raw, intent: "go_to", target: "" };
}
if (lower.startsWith("open settings ")) {
const target = raw.slice(14).trim();
if (!target) return null;
return { raw, intent: "open_settings", target };
}
if (lower.startsWith("toggle plugin ")) {
const target = raw.slice(14).trim();
if (!target) return null;
return { raw, intent: "toggle_plugin", target };
}
if (lower.startsWith("open url ")) {
const target = raw.slice(9).trim();
if (!target) return null;
return { raw, intent: "open_url", target };
}
if (lower.startsWith("create notebook ")) {
const target = raw.slice(16).trim();
return { raw, intent: "create_notebook", target };
}
if (lower.startsWith("delete notebook ")) {
const target = raw.slice(16).trim();
return { raw, intent: "delete_notebook", target };
}
if (lower.startsWith("move note ")) {
const target = raw.slice(10).trim();
return { raw, intent: "move_note", target };
}
if (lower.startsWith("jump to note ")) {
const target = raw.slice(13).trim();
return { raw, intent: "jump_note", target };
}
return null;
}

View file

@ -0,0 +1,563 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { isPluginEnabled, plugins } from "@api/PluginManager";
import type { Plugin } from "@utils/types";
import { ChannelStore, GuildChannelStore, GuildStore, IconUtils, RelationshipStore, SelectedGuildStore, UserStore } from "@webpack/common";
import { getCommandById, getSettingsCommandMetaByRoute } from "../registry";
import type { ResolvedChannel, ResolvedGuild, ResolvedUser } from "./types";
function toMentionId(target: string): string | null {
const mention = target.match(/^<@!?(\d{16,20})>$/);
if (mention?.[1]) return mention[1];
const plain = target.match(/^@?(\d{16,20})$/);
if (plain?.[1]) return plain[1];
return null;
}
function normalizeText(value: string): string {
return value.trim().toLowerCase();
}
function normalizeSearchableText(value: string): string {
return normalizeText(value)
.replace(/[^\p{L}\p{N}]+/gu, " ")
.replace(/\s+/g, " ")
.trim();
}
function scoreTargetMatch(target: string, candidate: string): number {
if (!target || !candidate) return 0;
if (candidate === target) return 240;
if (candidate.startsWith(target)) return 170;
if (candidate.includes(target)) return 110;
return 0;
}
function scoreSearchMatch(target: string, candidate: string): number {
const normalizedTarget = normalizeText(target);
const normalizedCandidate = normalizeText(candidate);
const base = scoreTargetMatch(normalizedTarget, normalizedCandidate);
const searchableTarget = normalizeSearchableText(target);
const searchableCandidate = normalizeSearchableText(candidate);
const searchableScore = scoreTargetMatch(searchableTarget, searchableCandidate);
return Math.max(base, searchableScore);
}
export function resolveUsers(target: string): ResolvedUser[] {
const normalizedTarget = normalizeText(target);
if (!normalizedTarget) return [];
const currentUserId = UserStore.getCurrentUser?.()?.id;
const mentionId = toMentionId(target);
if (mentionId) {
if (currentUserId && mentionId === currentUserId) return [];
const user = UserStore.getUser(mentionId);
if (!user) return [];
return [{
user,
display: user.globalName ?? user.username,
iconUrl: IconUtils.getUserAvatarURL(user)
}];
}
const friendIds = RelationshipStore.getFriendIDs?.() ?? [];
const seen = new Set<string>();
const matches: Array<{ entry: ResolvedUser; score: number; }> = [];
for (const id of friendIds) {
if (seen.has(id)) continue;
if (currentUserId && id === currentUserId) continue;
seen.add(id);
const user = UserStore.getUser(id);
if (!user) continue;
const nickname = RelationshipStore.getNickname?.(id) ?? "";
const display = nickname || user.globalName || user.username;
const candidates = [
user.username,
user.globalName ?? "",
user.tag,
nickname
].filter(Boolean).map(normalizeText);
const score = Math.max(...candidates.map(candidate => scoreTargetMatch(normalizedTarget, candidate)), 0);
if (score <= 0) continue;
matches.push({
entry: {
user,
display,
iconUrl: IconUtils.getUserAvatarURL(user)
},
score
});
}
return matches
.sort((a, b) => b.score - a.score || a.entry.display.localeCompare(b.entry.display))
.map(item => item.entry);
}
interface ChannelStoreWithPrivateChannels {
getSortedPrivateChannels?(): Array<string | { id?: string; channelId?: string; recipients?: string[]; }>;
getMutableGuildChannelsForGuild?(guildId: string): Record<string, { channel?: { id: string; name?: string; guild_id?: string; }; }>;
getMutablePrivateChannels?(): Record<string, PickerChannelLike | PickerChannelWrapper>;
}
const channelStoreWithPrivateChannels = ChannelStore as ChannelStoreWithPrivateChannels;
export function resolveRecentDmUsers(limit = 10): ResolvedUser[] {
const channels = channelStoreWithPrivateChannels.getSortedPrivateChannels?.() ?? [];
const seenUsers = new Set<string>();
const results: ResolvedUser[] = [];
const currentUserId = UserStore.getCurrentUser?.()?.id;
for (const raw of channels) {
const channelId = typeof raw === "string"
? raw
: raw?.id ?? raw?.channelId ?? null;
if (!channelId) continue;
const channel = ChannelStore.getChannel(channelId);
if (!channel || !(channel.isDM?.() ?? false)) continue;
const recipientId = channel.recipients?.[0];
if (!recipientId || seenUsers.has(recipientId)) continue;
if (currentUserId && recipientId === currentUserId) continue;
const user = UserStore.getUser(recipientId);
if (!user) continue;
seenUsers.add(recipientId);
const display = RelationshipStore.getNickname?.(recipientId) || user.globalName || user.username;
results.push({
user,
display,
iconUrl: IconUtils.getUserAvatarURL(user)
});
if (results.length >= limit) break;
}
return results;
}
export function resolveGuildIds(target: string, options?: { includeAllWhenEmpty?: boolean; }): string[] {
const normalizedTarget = normalizeText(target);
const includeAllWhenEmpty = options?.includeAllWhenEmpty ?? false;
if (!normalizedTarget && !includeAllWhenEmpty) return [];
const guildById = GuildStore.getGuild?.(target);
if (guildById) return [guildById.id];
const guilds = Object.values(GuildStore.getGuilds?.() ?? {});
if (!normalizedTarget && includeAllWhenEmpty) {
return guilds
.sort((a, b) => a.name.localeCompare(b.name))
.map(guild => guild.id);
}
const scored = guilds
.map(guild => {
const score = scoreTargetMatch(normalizedTarget, normalizeText(guild.name));
return { guild, score };
})
.filter(item => item.score > 0)
.sort((a, b) => b.score - a.score || a.guild.name.localeCompare(b.guild.name));
return scored.map(item => item.guild.id);
}
export function resolveGuilds(target: string, options?: { includeAllWhenEmpty?: boolean; }): ResolvedGuild[] {
const ids = resolveGuildIds(target, options);
const results: ResolvedGuild[] = [];
for (const id of ids) {
const guild = GuildStore.getGuild?.(id);
if (!guild) continue;
results.push({
id: guild.id,
display: guild.name,
iconUrl: IconUtils.getGuildIconURL({ id: guild.id, icon: guild.icon, size: 64 }) ?? undefined
});
}
return results;
}
export function resolveChannelIds(target: string): string[] {
const normalizedTarget = normalizeText(target);
if (!normalizedTarget) return [];
const mention = target.match(/^<#(\d{16,20})>$/);
if (mention?.[1]) return [mention[1]];
const asId = target.match(/^(\d{16,20})$/)?.[1];
if (asId) {
const channel = ChannelStore.getChannel(asId);
return channel ? [channel.id] : [];
}
const selectedGuildId = SelectedGuildStore.getGuildId?.();
if (!selectedGuildId || selectedGuildId === "@me") return [];
const grouped = channelStoreWithPrivateChannels.getMutableGuildChannelsForGuild?.(selectedGuildId);
if (!grouped) return [];
const scored: Array<{ id: string; score: number; }> = [];
for (const value of Object.values(grouped)) {
const { channel } = value;
if (!channel?.id || !channel.name) continue;
const score = scoreTargetMatch(normalizedTarget, normalizeText(channel.name));
if (score <= 0) continue;
scored.push({ id: channel.id, score });
}
return scored.sort((a, b) => b.score - a.score).map(item => item.id);
}
export function resolveChannels(target: string): ResolvedChannel[] {
const ids = resolveChannelIds(target);
return ids.map(id => {
const channel = ChannelStore.getChannel(id);
const isGroupDm = channel && typeof channel.isGroupDM === "function" ? channel.isGroupDM() : false;
const isDm = channel && typeof channel.isDM === "function" ? channel.isDM() : false;
const name = isGroupDm
? (channel?.name ?? "Group DM")
: channel?.name
? `#${channel.name}`
: `Channel ${id}`;
let iconUrl: string | undefined;
if (isGroupDm && channel) {
iconUrl = IconUtils.getChannelIconURL(channel) ?? undefined;
} else if (isDm && channel?.recipients?.length) {
const recipient = UserStore.getUser(channel.recipients[0]);
if (recipient) iconUrl = IconUtils.getUserAvatarURL(recipient);
} else if (channel?.guild_id) {
const guild = GuildStore.getGuild?.(channel.guild_id);
if (guild) {
iconUrl = IconUtils.getGuildIconURL({ id: guild.id, icon: guild.icon, size: 64 }) ?? undefined;
}
}
return { id, display: name, iconUrl };
});
}
interface PrivateChannelCandidate {
id: string;
display: string;
iconUrl?: string;
score: number;
}
interface PickerChannelLike {
id: string;
name?: string;
guild_id?: string | null;
recipients?: string[];
type?: number;
isDM?(): boolean;
isGroupDM?(): boolean;
}
interface PickerChannelWrapper {
channel?: PickerChannelLike;
}
function isPickerChannelLike(value: unknown): value is PickerChannelLike {
return Boolean(
value
&& typeof value === "object"
&& typeof (value as { id?: unknown; }).id === "string"
);
}
function resolveChannelIdEntry(raw: string | { id?: string; channelId?: string; } | null | undefined): string | null {
if (!raw) return null;
if (typeof raw === "string") return raw;
return raw.id ?? raw.channelId ?? null;
}
function getPrivateChannelIds(): string[] {
const ids: string[] = [];
const seen = new Set<string>();
const sorted = channelStoreWithPrivateChannels.getSortedPrivateChannels?.() ?? [];
for (const raw of sorted) {
const channelId = resolveChannelIdEntry(raw);
if (!channelId || seen.has(channelId)) continue;
seen.add(channelId);
ids.push(channelId);
}
const mutable = channelStoreWithPrivateChannels.getMutablePrivateChannels?.() ?? {};
for (const value of Object.values(mutable)) {
const channel = "channel" in value ? value.channel : value;
if (!isPickerChannelLike(channel)) continue;
const channelId = channel?.id;
if (!channelId || seen.has(channelId)) continue;
seen.add(channelId);
ids.push(channelId);
}
return ids;
}
function getGuildChannelsForPicker(guildId: string): PickerChannelLike[] {
const channels: PickerChannelLike[] = [];
const seen = new Set<string>();
const grouped = channelStoreWithPrivateChannels.getMutableGuildChannelsForGuild?.(guildId) ?? {};
for (const value of Object.values(grouped)) {
const channel = "channel" in value ? value.channel : value;
if (!isPickerChannelLike(channel)) continue;
if (!channel?.id || seen.has(channel.id)) continue;
seen.add(channel.id);
channels.push(channel);
}
const guildChannelCollection = GuildChannelStore.getChannels?.(guildId) ?? {};
for (const value of Object.values(guildChannelCollection)) {
if (!Array.isArray(value)) continue;
for (const entry of value) {
if (typeof entry !== "object" || !entry) continue;
const candidate = "channel" in entry && typeof entry.channel === "object" && entry.channel
? entry.channel as PickerChannelLike
: entry as PickerChannelLike;
if (!candidate.id || seen.has(candidate.id)) continue;
seen.add(candidate.id);
channels.push(candidate);
}
}
return channels;
}
function resolvePrivateChannels(target: string, includeAllWhenEmpty = false): ResolvedChannel[] {
const normalizedTarget = normalizeText(target);
if (!normalizedTarget && !includeAllWhenEmpty) return [];
const candidates: PrivateChannelCandidate[] = [];
const channelIds = getPrivateChannelIds();
for (const channelId of channelIds) {
if (!channelId) continue;
const channel = ChannelStore.getChannel(channelId);
if (!channel) continue;
const isDm = typeof channel.isDM === "function" ? channel.isDM() : channel.type === 1;
const isGroupDm = typeof channel.isGroupDM === "function" ? channel.isGroupDM() : channel.type === 3;
if (!isDm && !isGroupDm) continue;
if (isDm) {
const recipientId = channel.recipients?.[0];
if (!recipientId) continue;
const user = UserStore.getUser(recipientId);
if (!user) continue;
const nickname = RelationshipStore.getNickname?.(recipientId) ?? "";
const display = nickname || user.globalName || user.username;
const score = Math.max(
scoreSearchMatch(normalizedTarget, display),
scoreSearchMatch(normalizedTarget, user.username),
scoreSearchMatch(normalizedTarget, user.globalName ?? ""),
scoreSearchMatch(normalizedTarget, user.tag),
scoreSearchMatch(normalizedTarget, nickname)
);
if (score <= 0 && !includeAllWhenEmpty) continue;
candidates.push({
id: channelId,
display: `@${display}`,
iconUrl: IconUtils.getUserAvatarURL(user),
score: score > 0 ? score : 1
});
continue;
}
const display = channel.name || "Group DM";
const score = scoreSearchMatch(normalizedTarget, display);
if (score <= 0 && !includeAllWhenEmpty) continue;
candidates.push({
id: channelId,
display,
iconUrl: IconUtils.getChannelIconURL(channel) ?? undefined,
score: score > 0 ? score : 1
});
}
return candidates
.sort((left, right) => right.score - left.score || left.display.localeCompare(right.display))
.map(candidate => ({
id: candidate.id,
display: candidate.display,
iconUrl: candidate.iconUrl
}));
}
function resolveGuildChannelsForPicker(target: string, includeAllWhenEmpty = false): ResolvedChannel[] {
const normalizedTarget = normalizeText(target);
if (!normalizedTarget && !includeAllWhenEmpty) return [];
const allGuilds = Object.values(GuildStore.getGuilds?.() ?? {});
if (allGuilds.length === 0) {
const selectedGuildId = SelectedGuildStore.getGuildId?.();
if (!selectedGuildId || selectedGuildId === "@me") return [];
const channels = getGuildChannelsForPicker(selectedGuildId);
if (channels.length === 0) return [];
const results: Array<{ entry: ResolvedChannel; score: number; }> = [];
for (const channel of channels) {
if (!channel?.id || !channel.name) continue;
const score = scoreSearchMatch(normalizedTarget, channel.name);
if (score <= 0 && !includeAllWhenEmpty) continue;
const guild = channel.guild_id ? GuildStore.getGuild?.(channel.guild_id) : null;
const iconUrl = guild
? IconUtils.getGuildIconURL({ id: guild.id, icon: guild.icon, size: 64 }) ?? undefined
: undefined;
results.push({
entry: {
id: channel.id,
display: `#${channel.name}`,
iconUrl
},
score: score > 0 ? score : 1
});
}
return results
.sort((left, right) => right.score - left.score || left.entry.display.localeCompare(right.entry.display))
.map(result => result.entry);
}
const results: Array<{ entry: ResolvedChannel; score: number; }> = [];
for (const guild of allGuilds) {
const channels = getGuildChannelsForPicker(guild.id);
if (channels.length === 0) continue;
for (const channel of channels) {
if (!channel?.id || !channel.name) continue;
const score = scoreSearchMatch(normalizedTarget, channel.name);
if (score <= 0 && !includeAllWhenEmpty) continue;
const iconUrl = IconUtils.getGuildIconURL({ id: guild.id, icon: guild.icon, size: 64 }) ?? undefined;
results.push({
entry: {
id: channel.id,
display: `#${channel.name}`,
iconUrl
},
score: score > 0 ? score : 1
});
}
}
return results
.sort((left, right) => right.score - left.score || left.entry.display.localeCompare(right.entry.display))
.map(result => result.entry);
}
export function resolveAllChannels(target: string, options?: { includeAllWhenEmpty?: boolean; limit?: number; }): ResolvedChannel[] {
const includeAllWhenEmpty = options?.includeAllWhenEmpty ?? false;
const limit = options?.limit ?? 20;
const resolvedGuildChannels = resolveGuildChannelsForPicker(target, includeAllWhenEmpty);
const resolvedPrivateChannels = resolvePrivateChannels(target, includeAllWhenEmpty);
const merged = new Map<string, ResolvedChannel>();
for (const channel of resolvedGuildChannels) {
merged.set(channel.id, channel);
}
for (const channel of resolvedPrivateChannels) {
if (!merged.has(channel.id)) {
merged.set(channel.id, channel);
}
}
return Array.from(merged.values()).slice(0, limit);
}
const SETTINGS_COMMANDS = [
{ id: "settings-account", aliases: ["my account", "account", "profile"] },
{ id: "settings-profiles", aliases: ["profiles", "avatar", "bio"] },
{ id: "settings-privacy", aliases: ["data and privacy", "privacy", "privacy and safety"] },
{ id: "settings-notifications", aliases: ["notifications", "notifs"] },
{ id: "settings-voice", aliases: ["voice", "voice and video", "audio"] },
{ id: "settings-chat", aliases: ["chat", "messages"] },
{ id: "settings-text", aliases: ["text", "text and images", "images"] },
{ id: "settings-appearance", aliases: ["appearance", "theme"] },
{ id: "settings-accessibility", aliases: ["accessibility"] },
{ id: "settings-devices", aliases: ["devices", "sessions"] },
{ id: "settings-connections", aliases: ["connections", "accounts", "integrations"] },
{ id: "settings-authorized-apps", aliases: ["authorized apps", "oauth"] },
{ id: "settings-family-center", aliases: ["family center", "family"] },
{ id: "settings-keybinds", aliases: ["keybinds", "shortcuts"] },
{ id: "settings-advanced", aliases: ["advanced"] }
] as const;
export function resolveSettingsCommandIds(target: string): string[] {
const normalizedTarget = normalizeText(target);
if (!normalizedTarget) return [];
const byRoute = getSettingsCommandMetaByRoute(normalizedTarget);
if (byRoute) return [byRoute.id];
const matches = SETTINGS_COMMANDS
.map(item => {
const score = Math.max(...item.aliases.map(alias => scoreTargetMatch(normalizedTarget, normalizeText(alias))), 0);
return { id: item.id, score };
})
.filter(item => item.score > 0)
.sort((a, b) => b.score - a.score);
return matches
.map(item => item.id)
.filter(id => Boolean(getCommandById(id)));
}
export function resolvePlugins(target: string): Plugin[] {
const normalizedTarget = normalizeText(target);
if (!normalizedTarget) return [];
const all = Object.values(plugins) as Plugin[];
const scored = all
.map(plugin => {
const score = Math.max(
scoreTargetMatch(normalizedTarget, normalizeText(plugin.name)),
scoreTargetMatch(normalizedTarget, normalizeText(plugin.description ?? ""))
);
return { plugin, score };
})
.filter(item => item.score > 0)
.sort((a, b) => b.score - a.score || a.plugin.name.localeCompare(b.plugin.name));
return scored.map(item => item.plugin);
}
export function pluginToggleVerb(plugin: Plugin): string {
return isPluginEnabled(plugin.name) ? "Disable" : "Enable";
}

View file

@ -0,0 +1,69 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import type { User } from "@vencord/discord-types";
export type QueryIntent =
| "send_message"
| "send_channel"
| "open_dm"
| "go_to"
| "open_settings"
| "toggle_plugin"
| "open_url"
| "create_notebook"
| "delete_notebook"
| "move_note"
| "jump_note"
| "schedule_message"
| "reschedule_message"
| "send_scheduled_now"
| "cancel_scheduled_message";
export interface ParsedQuery {
raw: string;
intent: QueryIntent;
target: string;
content?: string;
useFilePicker?: boolean;
silent?: boolean;
}
export interface ResolvedUser {
user: User;
display: string;
iconUrl?: string;
}
export interface ResolvedGuild {
id: string;
display: string;
iconUrl?: string;
}
export interface ResolvedChannel {
id: string;
display: string;
iconUrl?: string;
}
export interface QueryActionCandidate {
id: string;
label: string;
description?: string;
inputPreview?: string;
badge: string;
shortcut?: string;
icon?: React.ComponentType<{ className?: string; size?: string; }>;
iconUrl?: string;
suggestionKind?: "user" | "channel" | "guild" | "generic";
run(): boolean | void | Promise<boolean | void>;
}
export type QueryResolution =
| { type: "none"; }
| { type: "invalid"; reason: string; }
| { type: "candidates"; candidates: QueryActionCandidate[]; };

Some files were not shown because too many files have changed in this diff Show more