Merge branch 'dev' into bun
This commit is contained in:
commit
f5e5b12876
380 changed files with 27348 additions and 12852 deletions
2
.github/workflows/build.yml
vendored
2
.github/workflows/build.yml
vendored
|
|
@ -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
89
.github/workflows/buildDev.yml
vendored
Normal 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"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
12
package.json
12
package.json
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
5
packages/discord-types/src/common/Guild.d.ts
vendored
5
packages/discord-types/src/common/Guild.d.ts
vendored
|
|
@ -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;
|
||||
|
|
|
|||
12
packages/discord-types/src/common/Role.d.ts
vendored
12
packages/discord-types/src/common/Role.d.ts
vendored
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -291,6 +291,8 @@ export interface MessageAttachment {
|
|||
content_type?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
title?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface ReactionEmoji {
|
||||
|
|
|
|||
37
packages/discord-types/src/flux.d.ts
vendored
37
packages/discord-types/src/flux.d.ts
vendored
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
2
packages/discord-types/src/fluxEvents.d.ts
vendored
2
packages/discord-types/src/fluxEvents.d.ts
vendored
File diff suppressed because one or more lines are too long
1
packages/discord-types/src/index.d.ts
vendored
1
packages/discord-types/src/index.d.ts
vendored
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
7
packages/discord-types/src/stores/ApplicationStreamPreviewStore.d.ts
vendored
Normal file
7
packages/discord-types/src/stores/ApplicationStreamPreviewStore.d.ts
vendored
Normal 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"]>;
|
||||
}
|
||||
|
|
@ -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;
|
||||
101
packages/discord-types/src/stores/ExperimentStore.d.ts
vendored
Normal file
101
packages/discord-types/src/stores/ExperimentStore.d.ts
vendored
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>>; }>>;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
33
packages/discord-types/src/stores/UserAffinitiesStore.d.ts
vendored
Normal file
33
packages/discord-types/src/stores/UserAffinitiesStore.d.ts
vendored
Normal 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;
|
||||
}
|
||||
4
packages/discord-types/src/stores/index.d.ts
vendored
4
packages/discord-types/src/stores/index.d.ts
vendored
|
|
@ -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";
|
||||
|
|
|
|||
3
packages/discord-types/src/utils.d.ts
vendored
3
packages/discord-types/src/utils.d.ts
vendored
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
33
scripts/depCheck.mjs
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { 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");
|
||||
}
|
||||
|
|
@ -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][]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
70
src/api/ProfileCollections.tsx
Normal file
70
src/api/ProfileCollections.tsx
Normal 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} />;
|
||||
}
|
||||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
});
|
||||
}
|
||||
|
|
|
|||
47
src/api/SettingsSync/types.ts
Normal file
47
src/api/SettingsSync/types.ts
Normal 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[];
|
||||
}
|
||||
|
|
@ -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 || {};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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={{
|
||||
|
|
|
|||
23
src/components/ExpandableCard.css
Normal file
23
src/components/ExpandableCard.css
Normal 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;
|
||||
}
|
||||
45
src/components/ExpandableCard.tsx
Normal file
45
src/components/ExpandableCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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()"
|
||||
}
|
||||
},
|
||||
|
|
|
|||
37
src/equicordplugins/_api/profileCollections.ts
Normal file
37
src/equicordplugins/_api/profileCollections.ts
Normal 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}),"
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
|
@ -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:\[/,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
|
@ -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} />;
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ export default definePlugin({
|
|||
]
|
||||
},
|
||||
{
|
||||
find: "onlineCount})})]",
|
||||
find: "isBannerVisible:!1})},",
|
||||
replacement: [
|
||||
{
|
||||
// make the button clickable
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
29
src/equicordplugins/channelTabs/util/folderIcons.tsx
Normal file
29
src/equicordplugins/channelTabs/util/folderIcons.tsx
Normal 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;
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
57
src/equicordplugins/channelTabs/util/unread.ts
Normal file
57
src/equicordplugins/channelTabs/util/unread.ts
Normal 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);
|
||||
}
|
||||
78
src/equicordplugins/channelTabs/util/unreadState.ts
Normal file
78
src/equicordplugins/channelTabs/util/unreadState.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
@ -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"
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
158
src/equicordplugins/commandPalette/actions/actionRouting.ts
Normal file
158
src/equicordplugins/commandPalette/actions/actionRouting.ts
Normal 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;
|
||||
}
|
||||
993
src/equicordplugins/commandPalette/actions/executors.ts
Normal file
993
src/equicordplugins/commandPalette/actions/executors.ts
Normal 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" };
|
||||
}
|
||||
}
|
||||
126
src/equicordplugins/commandPalette/actions/sendMessageAction.ts
Normal file
126
src/equicordplugins/commandPalette/actions/sendMessageAction.ts
Normal 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);
|
||||
}
|
||||
1192
src/equicordplugins/commandPalette/calculator/advancedMath.ts
Normal file
1192
src/equicordplugins/commandPalette/calculator/advancedMath.ts
Normal file
File diff suppressed because it is too large
Load diff
268
src/equicordplugins/commandPalette/calculator/evaluator.ts
Normal file
268
src/equicordplugins/commandPalette/calculator/evaluator.ts
Normal 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;
|
||||
}
|
||||
133
src/equicordplugins/commandPalette/calculator/formatters.ts
Normal file
133
src/equicordplugins/commandPalette/calculator/formatters.ts
Normal 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(" ")}`;
|
||||
}
|
||||
22
src/equicordplugins/commandPalette/calculator/index.ts
Normal file
22
src/equicordplugins/commandPalette/calculator/index.ts
Normal 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);
|
||||
}
|
||||
374
src/equicordplugins/commandPalette/calculator/parser.ts
Normal file
374
src/equicordplugins/commandPalette/calculator/parser.ts
Normal 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);
|
||||
}
|
||||
346
src/equicordplugins/commandPalette/calculator/timezones.ts
Normal file
346
src/equicordplugins/commandPalette/calculator/timezones.ts
Normal 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;
|
||||
}
|
||||
119
src/equicordplugins/commandPalette/calculator/types.ts
Normal file
119
src/equicordplugins/commandPalette/calculator/types.ts
Normal 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>;
|
||||
72
src/equicordplugins/commandPalette/customCommandIcons.tsx
Normal file
72
src/equicordplugins/commandPalette/customCommandIcons.tsx
Normal 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;
|
||||
}
|
||||
|
|
@ -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`.
|
||||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
];
|
||||
}
|
||||
|
|
@ -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
|
||||
})
|
||||
]
|
||||
};
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
];
|
||||
}
|
||||
|
|
@ -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
|
||||
})
|
||||
]
|
||||
};
|
||||
}
|
||||
|
|
@ -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
|
||||
})
|
||||
]
|
||||
};
|
||||
}
|
||||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
133
src/equicordplugins/commandPalette/extensions/catalog.ts
Normal file
133
src/equicordplugins/commandPalette/extensions/catalog.ts
Normal 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}`;
|
||||
}
|
||||
10
src/equicordplugins/commandPalette/extensions/index.ts
Normal file
10
src/equicordplugins/commandPalette/extensions/index.ts
Normal 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";
|
||||
192
src/equicordplugins/commandPalette/extensions/providers.ts
Normal file
192
src/equicordplugins/commandPalette/extensions/providers.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
134
src/equicordplugins/commandPalette/extensions/state.ts
Normal file
134
src/equicordplugins/commandPalette/extensions/state.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
}
|
||||
82
src/equicordplugins/commandPalette/extensions/types.ts
Normal file
82
src/equicordplugins/commandPalette/extensions/types.ts
Normal 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;
|
||||
|
|
@ -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: [
|
||||
{
|
||||
|
|
|
|||
237
src/equicordplugins/commandPalette/metadata/categories.ts
Normal file
237
src/equicordplugins/commandPalette/metadata/categories.ts
Normal 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
|
||||
}
|
||||
];
|
||||
21
src/equicordplugins/commandPalette/metadata/tags.ts
Normal file
21
src/equicordplugins/commandPalette/metadata/tags.ts
Normal 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();
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
245
src/equicordplugins/commandPalette/query/parser.ts
Normal file
245
src/equicordplugins/commandPalette/query/parser.ts
Normal 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;
|
||||
}
|
||||
563
src/equicordplugins/commandPalette/query/resolvers.ts
Normal file
563
src/equicordplugins/commandPalette/query/resolvers.ts
Normal 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";
|
||||
}
|
||||
69
src/equicordplugins/commandPalette/query/types.ts
Normal file
69
src/equicordplugins/commandPalette/query/types.ts
Normal 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
Loading…
Add table
Reference in a new issue