diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d189264f6..0f4c12f74 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 diff --git a/.github/workflows/buildDev.yml b/.github/workflows/buildDev.yml new file mode 100644 index 000000000..720dd9e70 --- /dev/null +++ b/.github/workflows/buildDev.yml @@ -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" diff --git a/misc/navIds.md b/misc/navIds.md index d24ed96fc..70892e261 100644 --- a/misc/navIds.md +++ b/misc/navIds.md @@ -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 diff --git a/package.json b/package.json index a4ce6d373..049231b8e 100644 --- a/package.json +++ b/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", diff --git a/packages/discord-types/enums/activity.ts b/packages/discord-types/enums/activity.ts index bc1b2a985..90a7e85ae 100644 --- a/packages/discord-types/enums/activity.ts +++ b/packages/discord-types/enums/activity.ts @@ -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 { diff --git a/packages/discord-types/enums/application.ts b/packages/discord-types/enums/application.ts index b032a2a8f..99a9e1b64 100644 --- a/packages/discord-types/enums/application.ts +++ b/packages/discord-types/enums/application.ts @@ -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, } diff --git a/packages/discord-types/enums/user.ts b/packages/discord-types/enums/user.ts index 0ad2ec6d6..cf9852f6e 100644 --- a/packages/discord-types/enums/user.ts +++ b/packages/discord-types/enums/user.ts @@ -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 { diff --git a/packages/discord-types/package.json b/packages/discord-types/package.json index a9684015b..44993105e 100644 --- a/packages/discord-types/package.json +++ b/packages/discord-types/package.json @@ -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", diff --git a/packages/discord-types/src/common/Guild.d.ts b/packages/discord-types/src/common/Guild.d.ts index e5c8f2e37..79965d187 100644 --- a/packages/discord-types/src/common/Guild.d.ts +++ b/packages/discord-types/src/common/Guild.d.ts @@ -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; diff --git a/packages/discord-types/src/common/Role.d.ts b/packages/discord-types/src/common/Role.d.ts index 1c73f53e1..727186813 100644 --- a/packages/discord-types/src/common/Role.d.ts +++ b/packages/discord-types/src/common/Role.d.ts @@ -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; } diff --git a/packages/discord-types/src/common/messages/Message.d.ts b/packages/discord-types/src/common/messages/Message.d.ts index b347eecbd..0b71979f2 100644 --- a/packages/discord-types/src/common/messages/Message.d.ts +++ b/packages/discord-types/src/common/messages/Message.d.ts @@ -291,6 +291,8 @@ export interface MessageAttachment { content_type?: string; width?: number; height?: number; + title?: string; + description?: string; } export interface ReactionEmoji { diff --git a/packages/discord-types/src/flux.d.ts b/packages/discord-types/src/flux.d.ts index bb5600dd1..4df127a51 100644 --- a/packages/discord-types/src/flux.d.ts +++ b/packages/discord-types/src/flux.d.ts @@ -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(); diff --git a/packages/discord-types/src/fluxEvents.d.ts b/packages/discord-types/src/fluxEvents.d.ts index c210f4b99..9a54b6867 100644 --- a/packages/discord-types/src/fluxEvents.d.ts +++ b/packages/discord-types/src/fluxEvents.d.ts @@ -19,4 +19,4 @@ function makeFluxEventList() { copy(makeFluxEventList()) */ -export type FluxEvents = "ACCESSIBILITY_COLORBLIND_TOGGLE" | "ACCESSIBILITY_DARK_SIDEBAR_TOGGLE" | "ACCESSIBILITY_DESATURATE_ROLES_TOGGLE" | "ACCESSIBILITY_FORCED_COLORS_MODAL_SEEN" | "ACCESSIBILITY_KEYBOARD_MODE_DISABLE" | "ACCESSIBILITY_KEYBOARD_MODE_ENABLE" | "ACCESSIBILITY_LOW_CONTRAST_TOGGLE" | "ACCESSIBILITY_RESET_TO_DEFAULT" | "ACCESSIBILITY_SET_ALWAYS_SHOW_LINK_DECORATIONS" | "ACCESSIBILITY_SET_CONTRAST" | "ACCESSIBILITY_SET_FONT_SIZE" | "ACCESSIBILITY_SET_MESSAGE_GROUP_SPACING" | "ACCESSIBILITY_SET_PREFERS_REDUCED_MOTION" | "ACCESSIBILITY_SET_ROLE_STYLE" | "ACCESSIBILITY_SET_SATURATION" | "ACCESSIBILITY_SET_SYNC_FORCED_COLORS" | "ACCESSIBILITY_SET_ZOOM" | "ACCESSIBILITY_SUBMIT_BUTTON_TOGGLE" | "ACCESSIBILITY_SYNC_PROFILE_THEME_WITH_USER_THEME_TOGGLE" | "ACCESSIBILITY_SYSTEM_COLOR_PREFERENCES_CHANGED" | "ACCESSIBILITY_SYSTEM_PREFERS_CONTRAST_CHANGED" | "ACCESSIBILITY_SYSTEM_PREFERS_CROSSFADES_CHANGED" | "ACCESSIBILITY_SYSTEM_PREFERS_REDUCED_MOTION_CHANGED" | "ACKNOWLEDGE_CHANNEL_SAFETY_WARNING_TOOLTIP" | "ACK_APPROVED_GUILD_JOIN_REQUEST" | "ACTIVE_AV_ERRORS_CHANGED" | "ACTIVE_BOGO_PROMOTION_FETCH" | "ACTIVE_BOGO_PROMOTION_FETCH_FAIL" | "ACTIVE_BOGO_PROMOTION_FETCH_SUCCESS" | "ACTIVE_CHANNELS_FETCH_FAILURE" | "ACTIVE_CHANNELS_FETCH_START" | "ACTIVE_CHANNELS_FETCH_SUCCESS" | "ACTIVE_OUTBOUND_PROMOTIONS_FETCH" | "ACTIVE_OUTBOUND_PROMOTIONS_FETCH_FAIL" | "ACTIVE_OUTBOUND_PROMOTIONS_FETCH_SUCCESS" | "ACTIVITY_INVITE_EDUCATION_DISMISS" | "ACTIVITY_INVITE_MODAL_CLOSE" | "ACTIVITY_INVITE_MODAL_OPEN" | "ACTIVITY_INVITE_MODAL_QUERY" | "ACTIVITY_INVITE_MODAL_SEND" | "ACTIVITY_JOIN" | "ACTIVITY_JOIN_FAILED" | "ACTIVITY_JOIN_LOADING" | "ACTIVITY_LAUNCH_FAIL" | "ACTIVITY_LAYOUT_MODE_UPDATE" | "ACTIVITY_METADATA_UPDATE" | "ACTIVITY_PLAY" | "ACTIVITY_POPOUT_WINDOW_OPEN" | "ACTIVITY_SCREEN_ORIENTATION_UPDATE" | "ACTIVITY_START" | "ACTIVITY_SYNC" | "ACTIVITY_SYNC_STOP" | "ACTIVITY_UPDATE_FAIL" | "ACTIVITY_UPDATE_START" | "ACTIVITY_UPDATE_SUCCESS" | "ADD_STICKER_PREVIEW" | "ADMIN_ONBOARDING_GUIDE_HIDE" | "ADYEN_CASH_APP_PAY_SUBMIT_SUCCESS" | "ADYEN_CREATE_CASH_APP_PAY_COMPONENT_SUCCESS" | "ADYEN_CREATE_CLIENT_SUCCESS" | "ADYEN_TEARDOWN_CLIENT" | "AFK" | "AGE_GATE_FAILURE_MODAL_OPEN" | "AGE_GATE_LOGOUT_UNDERAGE_NEW_USER" | "AGE_GATE_MODAL_CLOSE" | "AGE_GATE_MODAL_OPEN" | "AGE_GATE_SUCCESS_MODAL_OPEN" | "APEX_EXPERIMENT_CLEAR_SERVER_ASSIGNMENTS" | "APEX_EXPERIMENT_OVERRIDE_CLEAR" | "APEX_EXPERIMENT_OVERRIDE_CREATE" | "APEX_EXPERIMENT_OVERRIDE_DELETE" | "APPLICATIONS_FETCH" | "APPLICATIONS_FETCH_FAIL" | "APPLICATIONS_FETCH_SUCCESS" | "APPLICATION_ACTIVITY_STATISTICS_FETCH_FAIL" | "APPLICATION_ACTIVITY_STATISTICS_FETCH_START" | "APPLICATION_ACTIVITY_STATISTICS_FETCH_SUCCESS" | "APPLICATION_ASSETS_FETCH" | "APPLICATION_ASSETS_FETCH_SUCCESS" | "APPLICATION_ASSETS_UPDATE" | "APPLICATION_BRANCHES_FETCH_FAIL" | "APPLICATION_BRANCHES_FETCH_SUCCESS" | "APPLICATION_BUILD_FETCH_START" | "APPLICATION_BUILD_FETCH_SUCCESS" | "APPLICATION_BUILD_NOT_FOUND" | "APPLICATION_BUILD_SIZE_FETCH_FAIL" | "APPLICATION_BUILD_SIZE_FETCH_START" | "APPLICATION_BUILD_SIZE_FETCH_SUCCESS" | "APPLICATION_COMMAND_AUTOCOMPLETE_REQUEST" | "APPLICATION_COMMAND_AUTOCOMPLETE_RESPONSE" | "APPLICATION_COMMAND_EXECUTE_BAD_VERSION" | "APPLICATION_COMMAND_INDEX_FETCH_FAILURE" | "APPLICATION_COMMAND_INDEX_FETCH_REQUEST" | "APPLICATION_COMMAND_INDEX_FETCH_SUCCESS" | "APPLICATION_COMMAND_SET_ACTIVE_COMMAND" | "APPLICATION_COMMAND_SET_PREFERRED_COMMAND" | "APPLICATION_COMMAND_UPDATE_CHANNEL_STATE" | "APPLICATION_COMMAND_UPDATE_OPTIONS" | "APPLICATION_COMMAND_USED" | "APPLICATION_DIRECTORY_FETCH_APPLICATION" | "APPLICATION_DIRECTORY_FETCH_APPLICATION_FAILURE" | "APPLICATION_DIRECTORY_FETCH_APPLICATION_SUCCESS" | "APPLICATION_DIRECTORY_FETCH_CATEGORIES_SUCCESS" | "APPLICATION_DIRECTORY_FETCH_COLLECTIONS" | "APPLICATION_DIRECTORY_FETCH_COLLECTIONS_FAILURE" | "APPLICATION_DIRECTORY_FETCH_COLLECTIONS_SUCCESS" | "APPLICATION_DIRECTORY_FETCH_SEARCH" | "APPLICATION_DIRECTORY_FETCH_SEARCH_FAILURE" | "APPLICATION_DIRECTORY_FETCH_SEARCH_SUCCESS" | "APPLICATION_DIRECTORY_FETCH_SIMILAR_APPLICATIONS" | "APPLICATION_DIRECTORY_FETCH_SIMILAR_APPLICATIONS_FAILURE" | "APPLICATION_DIRECTORY_FETCH_SIMILAR_APPLICATIONS_SUCCESS" | "APPLICATION_FETCH" | "APPLICATION_FETCH_FAIL" | "APPLICATION_FETCH_SUCCESS" | "APPLICATION_STORE_ACCEPT_EULA" | "APPLICATION_STORE_ACCEPT_STORE_TERMS" | "APPLICATION_STORE_CLEAR_DATA" | "APPLICATION_STORE_DIRECTORY_LAYOUT_FETCHING" | "APPLICATION_STORE_DIRECTORY_LAYOUT_FETCH_FAILED" | "APPLICATION_STORE_DIRECTORY_LAYOUT_FETCH_SUCCESS" | "APPLICATION_STORE_LOCATION_CHANGE" | "APPLICATION_STORE_MATURE_AGREE" | "APPLICATION_STORE_RESET_NAVIGATION" | "APPLICATION_SUBSCRIPTIONS_CHANNEL_NOTICE_DISMISSED" | "APPLICATION_SUBSCRIPTIONS_FETCH_ENTITLEMENTS" | "APPLICATION_SUBSCRIPTIONS_FETCH_ENTITLEMENTS_FAILURE" | "APPLICATION_SUBSCRIPTIONS_FETCH_ENTITLEMENTS_SUCCESS" | "APPLICATION_SUBSCRIPTIONS_FETCH_LISTINGS" | "APPLICATION_SUBSCRIPTIONS_FETCH_LISTINGS_FAILURE" | "APPLICATION_SUBSCRIPTIONS_FETCH_LISTINGS_SUCCESS" | "APPLICATION_SUBSCRIPTIONS_FETCH_LISTING_FOR_PLAN_SUCCESS" | "APPLICATION_UPDATE" | "APPLIED_BOOSTS_COOLDOWN_FETCH_SUCCESS" | "APPLIED_GUILD_BOOST_COUNT_RESET" | "APPLIED_GUILD_BOOST_COUNT_UPDATE" | "APP_DM_OPEN" | "APP_ICON_EDITOR_RESET" | "APP_ICON_TRACK_IMPRESSION" | "APP_ICON_UPDATED" | "APP_LAUNCHER_ADD_FAILED_APP_DM_LOAD" | "APP_LAUNCHER_DISMISS" | "APP_LAUNCHER_REMOVE_FAILED_APP_DM_LOAD" | "APP_LAUNCHER_SET_ACTIVE_COMMAND" | "APP_LAUNCHER_SHOW" | "APP_RECOMMENDATIONS_FETCH_RECOMMENDATIONS" | "APP_RECOMMENDATIONS_FETCH_RECOMMENDATIONS_FAILURE" | "APP_RECOMMENDATIONS_FETCH_RECOMMENDATIONS_SUCCESS" | "APP_STATE_UPDATE" | "APP_VIEW_SET_HOME_LINK" | "AUDIO_INPUT_DETECTED" | "AUDIO_RESET" | "AUDIO_SET_ACTIVE_INPUT_PROFILE" | "AUDIO_SET_ATTENUATION" | "AUDIO_SET_AUTOMATIC_GAIN_CONTROL" | "AUDIO_SET_BYPASS_SYSTEM_INPUT_PROCESSING" | "AUDIO_SET_DEBUG_LOGGING" | "AUDIO_SET_DISPLAY_SILENCE_WARNING" | "AUDIO_SET_ECHO_CANCELLATION" | "AUDIO_SET_INPUT_DEVICE" | "AUDIO_SET_INPUT_VOLUME" | "AUDIO_SET_KRISP_MODEL_OVERRIDE" | "AUDIO_SET_KRISP_SUPPRESSION_LEVEL" | "AUDIO_SET_LOCAL_PAN" | "AUDIO_SET_LOCAL_VIDEO_DISABLED" | "AUDIO_SET_LOCAL_VOLUME" | "AUDIO_SET_LOOPBACK" | "AUDIO_SET_MODE" | "AUDIO_SET_NOISE_CANCELLATION" | "AUDIO_SET_NOISE_SUPPRESSION" | "AUDIO_SET_OUTPUT_DEVICE" | "AUDIO_SET_OUTPUT_VOLUME" | "AUDIO_SET_QOS" | "AUDIO_SET_SELF_MUTE" | "AUDIO_SET_SIDECHAIN_COMPRESSION" | "AUDIO_SET_SIDECHAIN_COMPRESSION_STRENGTH" | "AUDIO_SET_SUBSYSTEM" | "AUDIO_SET_TEMPORARY_SELF_MUTE" | "AUDIO_TOGGLE_LOCAL_MUTE" | "AUDIO_TOGGLE_LOCAL_SOUNDBOARD_MUTE" | "AUDIO_TOGGLE_SELF_DEAF" | "AUDIO_TOGGLE_SELF_MUTE" | "AUDIO_VOLUME_CHANGE" | "AUDIT_LOG_FETCH_FAIL" | "AUDIT_LOG_FETCH_NEXT_PAGE_FAIL" | "AUDIT_LOG_FETCH_NEXT_PAGE_START" | "AUDIT_LOG_FETCH_NEXT_PAGE_SUCCESS" | "AUDIT_LOG_FETCH_START" | "AUDIT_LOG_FETCH_SUCCESS" | "AUDIT_LOG_FILTER_BY_ACTION" | "AUDIT_LOG_FILTER_BY_TARGET" | "AUDIT_LOG_FILTER_BY_USER" | "AUTHENTICATOR_CREATE" | "AUTHENTICATOR_DELETE" | "AUTHENTICATOR_UPDATE" | "AUTH_INVITE_UPDATE" | "AUTH_SESSION_CHANGE" | "AUTO_MODERATION_MENTION_RAID_DETECTION" | "AUTO_MODERATION_MENTION_RAID_NOTICE_DISMISS" | "AUTO_UPDATER_QUIT_AND_INSTALL" | "BACKGROUND_SYNC" | "BACKGROUND_SYNC_CHANNEL_MESSAGES" | "BASIC_GUILD_FETCH" | "BASIC_GUILD_FETCH_FAILURE" | "BASIC_GUILD_FETCH_SUCCESS" | "BILLING_CREATE_REFERRAL_SUCCESS" | "BILLING_IP_COUNTRY_CODE_FAILURE" | "BILLING_IP_COUNTRY_CODE_FETCH_START" | "BILLING_IP_LOCATION_FAILURE" | "BILLING_IP_LOCATION_FETCH_START" | "BILLING_LOCALIZED_PRICING_PROMO_FAILURE" | "BILLING_MOST_RECENT_SUBSCRIPTION_FETCH_SUCCESS" | "BILLING_NITRO_AFFINITY_FETCHED" | "BILLING_NITRO_AFFINITY_FETCH_SUCCEEDED" | "BILLING_PAYMENTS_FETCH_SUCCESS" | "BILLING_PAYMENT_FETCH_SUCCESS" | "BILLING_PAYMENT_SOURCES_FETCH_FAIL" | "BILLING_PAYMENT_SOURCES_FETCH_START" | "BILLING_PAYMENT_SOURCES_FETCH_SUCCESS" | "BILLING_PAYMENT_SOURCE_CREATE_FAIL" | "BILLING_PAYMENT_SOURCE_CREATE_START" | "BILLING_PAYMENT_SOURCE_CREATE_SUCCESS" | "BILLING_PAYMENT_SOURCE_FETCH_SUCCESS" | "BILLING_PAYMENT_SOURCE_REMOVE_CLEAR_ERROR" | "BILLING_PAYMENT_SOURCE_REMOVE_FAIL" | "BILLING_PAYMENT_SOURCE_REMOVE_START" | "BILLING_PAYMENT_SOURCE_REMOVE_SUCCESS" | "BILLING_PAYMENT_SOURCE_UPDATE_CLEAR_ERROR" | "BILLING_PAYMENT_SOURCE_UPDATE_FAIL" | "BILLING_PAYMENT_SOURCE_UPDATE_START" | "BILLING_PAYMENT_SOURCE_UPDATE_SUCCESS" | "BILLING_POPUP_BRIDGE_CALLBACK" | "BILLING_POPUP_BRIDGE_STATE_UPDATE" | "BILLING_PREVIOUS_PREMIUM_SUBSCRIPTION_FETCH_SUCCESS" | "BILLING_PURCHASE_TOKEN_AUTH_CLEAR_STATE" | "BILLING_REFERRALS_REMAINING_FETCH_FAIL" | "BILLING_REFERRALS_REMAINING_FETCH_START" | "BILLING_REFERRALS_REMAINING_FETCH_SUCCESS" | "BILLING_REFERRAL_RESOLVE_FAIL" | "BILLING_REFERRAL_RESOLVE_SUCCESS" | "BILLING_REFERRAL_TRIAL_OFFER_UPDATE" | "BILLING_SET_IP_COUNTRY_CODE" | "BILLING_SET_IP_LOCATION" | "BILLING_SET_LOCALIZED_PRICING_PROMO" | "BILLING_SUBSCRIPTION_CANCEL_FAIL" | "BILLING_SUBSCRIPTION_CANCEL_START" | "BILLING_SUBSCRIPTION_CANCEL_SUCCESS" | "BILLING_SUBSCRIPTION_FETCH_FAIL" | "BILLING_SUBSCRIPTION_FETCH_START" | "BILLING_SUBSCRIPTION_FETCH_SUCCESS" | "BILLING_SUBSCRIPTION_RESET" | "BILLING_SUBSCRIPTION_REWARD_ELIGIBILITY_FETCH_FAILURE" | "BILLING_SUBSCRIPTION_REWARD_ELIGIBILITY_FETCH_START" | "BILLING_SUBSCRIPTION_REWARD_ELIGIBILITY_FETCH_SUCCESS" | "BILLING_SUBSCRIPTION_UPDATE_FAIL" | "BILLING_SUBSCRIPTION_UPDATE_START" | "BILLING_SUBSCRIPTION_UPDATE_SUCCESS" | "BILLING_USER_OFFER_ACKNOWLEDGED_SUCCESS" | "BILLING_USER_OFFER_FETCH_FAIL" | "BILLING_USER_OFFER_FETCH_START" | "BILLING_USER_OFFER_FETCH_SUCCESS" | "BILLING_USER_TRIAL_OFFER_ACKNOWLEDGED_SUCCESS" | "BILLING_USER_TRIAL_OFFER_FETCH_SUCCESS" | "BOOSTED_GUILD_GRACE_PERIOD_NOTICE_DISMISS" | "BRAINTREE_CREATE_CLIENT_SUCCESS" | "BRAINTREE_CREATE_PAYPAL_CLIENT_SUCCESS" | "BRAINTREE_CREATE_VENMO_CLIENT_SUCCESS" | "BRAINTREE_TEARDOWN_PAYPAL_CLIENT" | "BRAINTREE_TEARDOWN_VENMO_CLIENT" | "BRAINTREE_TOKENIZE_PAYPAL_FAIL" | "BRAINTREE_TOKENIZE_PAYPAL_START" | "BRAINTREE_TOKENIZE_PAYPAL_SUCCESS" | "BRAINTREE_TOKENIZE_VENMO_FAIL" | "BRAINTREE_TOKENIZE_VENMO_START" | "BRAINTREE_TOKENIZE_VENMO_SUCCESS" | "BROWSER_HANDOFF_BEGIN" | "BROWSER_HANDOFF_FROM_APP" | "BROWSER_HANDOFF_SET_USER" | "BROWSER_HANDOFF_UNAVAILABLE" | "BUILD_OVERRIDE_RESOLVED" | "BULK_ACK" | "BULK_CLEAR_RECENTS" | "BURST_REACTION_ANIMATION_ADD" | "BURST_REACTION_EFFECT_CLEAR" | "BURST_REACTION_EFFECT_PLAY" | "BURST_REACTION_PICKER_ANIMATION_ADD" | "BURST_REACTION_PICKER_ANIMATION_CLEAR" | "CACHED_EMOJIS_LOADED" | "CACHED_STICKERS_LOADED" | "CACHE_LOADED" | "CACHE_LOADED_LAZY" | "CACHE_LOADED_LAZY_NO_CACHE" | "CALL_CHAT_TOASTS_SET_ENABLED" | "CALL_CONNECT" | "CALL_CONNECT_MULTIPLE" | "CALL_CREATE" | "CALL_DELETE" | "CALL_ENQUEUE_RING" | "CALL_UPDATE" | "CANDIDATE_GAMES_CHANGE" | "CATEGORY_COLLAPSE" | "CATEGORY_COLLAPSE_ALL" | "CATEGORY_EXPAND" | "CATEGORY_EXPAND_ALL" | "CERTIFIED_DEVICES_SET" | "CHANGE_LOG_FETCH_FAILED" | "CHANGE_LOG_FETCH_SUCCESS" | "CHANGE_LOG_LOCK" | "CHANGE_LOG_MARK_SEEN" | "CHANGE_LOG_RESOLVED" | "CHANGE_LOG_SET_CONFIG" | "CHANGE_LOG_SET_OVERRIDE" | "CHANGE_LOG_UNLOCK" | "CHANNEL_ACK" | "CHANNEL_CALL_POPOUT_WINDOW_OPEN" | "CHANNEL_COLLAPSE" | "CHANNEL_CREATE" | "CHANNEL_DELETE" | "CHANNEL_FOLLOWER_CREATED" | "CHANNEL_FOLLOWER_STATS_FETCH_FAILURE" | "CHANNEL_FOLLOWER_STATS_FETCH_SUCCESS" | "CHANNEL_FOLLOWING_PUBLISH_BUMP_DISMISSED" | "CHANNEL_FOLLOWING_PUBLISH_BUMP_HIDE_PERMANENTLY" | "CHANNEL_LOCAL_ACK" | "CHANNEL_MEMBER_COUNT_UPDATE" | "CHANNEL_MUTE_EXPIRED" | "CHANNEL_PERMISSIONS_DELETE_OVERWRITE_SUCCESS" | "CHANNEL_PERMISSIONS_PUT_OVERWRITE_SUCCESS" | "CHANNEL_PINS_ACK" | "CHANNEL_PINS_UPDATE" | "CHANNEL_PRELOAD" | "CHANNEL_RECIPIENT_ADD" | "CHANNEL_RECIPIENT_REMOVE" | "CHANNEL_RTC_ACTIVE_CHANNELS" | "CHANNEL_RTC_JUMP_TO_VOICE_CHANNEL_MESSAGE" | "CHANNEL_RTC_SELECT_PARTICIPANT" | "CHANNEL_RTC_UPDATE_CHAT_OPEN" | "CHANNEL_RTC_UPDATE_LAYOUT" | "CHANNEL_RTC_UPDATE_PARTCIPANTS_LIST_OPEN" | "CHANNEL_RTC_UPDATE_PARTICIPANTS_OPEN" | "CHANNEL_RTC_UPDATE_STAGE_STREAM_SIZE" | "CHANNEL_RTC_UPDATE_STAGE_VIDEO_LIMIT_BOOST_UPSELL_DISMISSED" | "CHANNEL_RTC_UPDATE_VOICE_PARTICIPANTS_HIDDEN" | "CHANNEL_SAFETY_WARNING_FEEDBACK" | "CHANNEL_SELECT" | "CHANNEL_SETTINGS_CLOSE" | "CHANNEL_SETTINGS_INIT" | "CHANNEL_SETTINGS_LOADED_INVITES" | "CHANNEL_SETTINGS_OVERWRITE_SELECT" | "CHANNEL_SETTINGS_PERMISSIONS_INIT" | "CHANNEL_SETTINGS_PERMISSIONS_SAVE_SUCCESS" | "CHANNEL_SETTINGS_PERMISSIONS_SELECT_PERMISSION" | "CHANNEL_SETTINGS_PERMISSIONS_SET_ADVANCED_MODE" | "CHANNEL_SETTINGS_PERMISSIONS_SUBMITTING" | "CHANNEL_SETTINGS_PERMISSIONS_UPDATE_PERMISSION" | "CHANNEL_SETTINGS_SET_SECTION" | "CHANNEL_SETTINGS_SUBMIT" | "CHANNEL_SETTINGS_SUBMIT_FAILURE" | "CHANNEL_SETTINGS_SUBMIT_SUCCESS" | "CHANNEL_SETTINGS_UPDATE" | "CHANNEL_STATUSES" | "CHANNEL_TOGGLE_MEMBERS_SECTION" | "CHANNEL_TOGGLE_SUMMARIES_SECTION" | "CHANNEL_UPDATES" | "CHECKING_FOR_UPDATES" | "CHECKOUT_RECOVERY_STATUS_FETCH" | "CHECKOUT_RECOVERY_STATUS_FETCH_FAILURE" | "CHECKOUT_RECOVERY_STATUS_FETCH_SUCCESS" | "CHECK_LAUNCHABLE_GAME" | "CLEAR_CACHES" | "CLEAR_CHANNEL_SAFETY_WARNINGS" | "CLEAR_CONSUMED_ENTITLEMENT" | "CLEAR_CONVERSATION_SUMMARIES" | "CLEAR_INTERACTION_MODAL_STATE" | "CLEAR_LAST_SESSION_VOICE_CHANNEL_ID" | "CLEAR_MENTIONS" | "CLEAR_MESSAGES" | "CLEAR_OLDEST_UNREAD_MESSAGE" | "CLEAR_PENDING_CHANNEL_AND_ROLE_UPDATES" | "CLEAR_REMOTE_DISCONNECT_VOICE_CHANNEL_ID" | "CLEAR_STICKER_PREVIEW" | "CLEAR_THEME_OVERRIDE" | "CLEAR_VIDEO_STREAM_READY_TIMEOUT" | "CLICKER_GAME_ADD_POINTS" | "CLICKER_GAME_PURCHASE_ITEM" | "CLICKER_GAME_PURCHASE_ITEM_UPGRADE" | "CLICKER_GAME_REDEEM_PRIZE_FAIL" | "CLICKER_GAME_REDEEM_PRIZE_START" | "CLICKER_GAME_REDEEM_PRIZE_SUCCESS" | "CLICKER_GAME_RESET" | "CLICKER_GAME_SET_MUTED" | "CLICKER_GAME_SET_VOLUME" | "CLICKER_GAME_UNLOCK_ACHIEVEMENT" | "CLICKER_GAME_UPDATE_ITEM_METADATA" | "CLIENT_THEMES_EDITOR_CLOSE" | "CLIPS_ALLOW_VOICE_RECORDING_UPDATE" | "CLIPS_CLASSIFY_HARDWARE" | "CLIPS_CLEAR_CLIPS_SESSION" | "CLIPS_CLEAR_NEW_CLIP_IDS" | "CLIPS_DELETE_CLIP" | "CLIPS_DISMISS_EDUCATION" | "CLIPS_INIT" | "CLIPS_INIT_FAILURE" | "CLIPS_LOAD_DIRECTORY_SUCCESS" | "CLIPS_RESTART" | "CLIPS_SAVE_ANIMATION_END" | "CLIPS_SAVE_CLIP" | "CLIPS_SAVE_CLIP_ERROR" | "CLIPS_SAVE_CLIP_PLACEHOLDER" | "CLIPS_SAVE_CLIP_PLACEHOLDER_ERROR" | "CLIPS_SAVE_CLIP_START" | "CLIPS_SETTINGS_UPDATE" | "CLIPS_SHOW_CALL_WARNING" | "CLIPS_UPDATE_METADATA" | "CLOSE_AGE_VERIFICATION_MODAL" | "CLOSE_SUSPENDED_USER" | "COLLECTIBLES_CATEGORIES_FETCH" | "COLLECTIBLES_CATEGORIES_FETCH_FAILURE" | "COLLECTIBLES_CATEGORIES_FETCH_SUCCESS" | "COLLECTIBLES_CLAIM" | "COLLECTIBLES_CLAIM_FAILURE" | "COLLECTIBLES_CLAIM_SUCCESS" | "COLLECTIBLES_MARKETING_FETCH" | "COLLECTIBLES_MARKETING_FETCH_SUCCESS" | "COLLECTIBLES_PRODUCT_DETAILS_OPEN" | "COLLECTIBLES_PRODUCT_FETCH" | "COLLECTIBLES_PRODUCT_FETCH_FAILURE" | "COLLECTIBLES_PRODUCT_FETCH_SUCCESS" | "COLLECTIBLES_PURCHASES_FETCH" | "COLLECTIBLES_PURCHASES_FETCH_FAILURE" | "COLLECTIBLES_PURCHASES_FETCH_SUCCESS" | "COLLECTIBLES_SET_SHOP_HOME_CONFIG_OVERRIDE" | "COLLECTIBLES_SHOP_CLOSE" | "COLLECTIBLES_SHOP_HOME_FETCH" | "COLLECTIBLES_SHOP_HOME_FETCH_FAILURE" | "COLLECTIBLES_SHOP_HOME_FETCH_SUCCESS" | "COLLECTIBLES_SHOP_OPEN" | "COLLECTIBLES_SKIP_NUM_CATEGORIES" | "COMMANDS_MIGRATION_NOTICE_DISMISSED" | "COMMANDS_MIGRATION_OVERVIEW_TOOLTIP_DISMISSED" | "COMMANDS_MIGRATION_TOGGLE_TOOLTIP_DISMISSED" | "COMMANDS_MIGRATION_UPDATE_SUCCESS" | "COMPLETE_NEW_MEMBER_ACTION" | "CONNECTED_DEVICE_DONT_SWITCH" | "CONNECTED_DEVICE_IGNORE" | "CONNECTED_DEVICE_NEVER_SHOW_MODAL" | "CONNECTED_DEVICE_SWITCH" | "CONNECTIONS_GRID_MODAL_HIDE" | "CONNECTIONS_GRID_MODAL_SHOW" | "CONNECTION_CLOSED" | "CONNECTION_OPEN" | "CONNECTION_OPEN_STATE_UPDATE" | "CONNECTION_OPEN_SUPPLEMENTAL" | "CONNECTION_RESUMED" | "CONSOLE_COMMAND_UPDATE" | "CONSUMABLES_CLEAR_ERROR" | "CONSUMABLES_ENTITLEMENT_FETCH_COMPLETED" | "CONSUMABLES_ENTITLEMENT_FETCH_FAILED" | "CONSUMABLES_ENTITLEMENT_FETCH_STARTED" | "CONSUMABLES_PRICE_FETCH_FAILED" | "CONSUMABLES_PRICE_FETCH_STARTED" | "CONSUMABLES_PRICE_FETCH_SUCCEEDED" | "CONTENT_INVENTORY_CLEAR_DELETE_HISTORY_ERROR" | "CONTENT_INVENTORY_CLEAR_FEED" | "CONTENT_INVENTORY_DEBUG_CLEAR_IMPRESSIONS" | "CONTENT_INVENTORY_DEBUG_LOG_IMPRESSIONS" | "CONTENT_INVENTORY_DEBUG_TOGGLE_FAST_IMPRESSION_CAPPING" | "CONTENT_INVENTORY_DEBUG_TOGGLE_IMPRESSION_CAPPING" | "CONTENT_INVENTORY_DELETE_OUTBOX_ENTRY_FAILURE" | "CONTENT_INVENTORY_DELETE_OUTBOX_ENTRY_START" | "CONTENT_INVENTORY_DELETE_OUTBOX_ENTRY_SUCCESS" | "CONTENT_INVENTORY_FETCH_OUTBOX_FAILURE" | "CONTENT_INVENTORY_FETCH_OUTBOX_START" | "CONTENT_INVENTORY_FETCH_OUTBOX_SUCCESS" | "CONTENT_INVENTORY_FORCE_SHOW_GAME_SHARING" | "CONTENT_INVENTORY_INBOX_STALE" | "CONTENT_INVENTORY_MANUAL_REFRESH" | "CONTENT_INVENTORY_SET_FEED" | "CONTENT_INVENTORY_SET_FEED_STATE" | "CONTENT_INVENTORY_SET_FILTERS" | "CONTENT_INVENTORY_TOGGLE_FEED_HIDDEN" | "CONTENT_INVENTORY_TRACK_ITEM_IMPRESSIONS" | "CONTEXT_MENU_CLOSE" | "CONTEXT_MENU_OPEN" | "CONVERSATION_SUMMARY_UPDATE" | "CREATE_PENDING_REPLY" | "CREATE_PENDING_SCHEDULED_MESSAGE" | "CREATE_REFERRALS_SUCCESS" | "CREATE_SHALLOW_PENDING_REPLY" | "CREATOR_MONETIZATION_NAG_ACTIVATE_ELIGIBLITY_FETCH_SUCCESS" | "CREATOR_MONETIZATION_PRICE_TIERS_FETCH" | "CREATOR_MONETIZATION_PRICE_TIERS_FETCH_FAILURE" | "CREATOR_MONETIZATION_PRICE_TIERS_FETCH_SUCCESS" | "CURRENT_BUILD_OVERRIDE_RESOLVED" | "CURRENT_USER_UPDATE" | "CUSTOM_ACTIVITY_LINK_FETCH_SUCCESS" | "DCF_DAILY_CAP_OVERRIDE" | "DCF_EVENT_LOGGED" | "DCF_HANDLE_DC_DISMISSED" | "DCF_HANDLE_DC_SHOWN" | "DCF_NEW_USER_MIN_AGE_REQUIRED_OVERRIDE" | "DCF_OVERRIDE_LAST_DC_DISMISSED" | "DCF_RESET" | "DECAY_READ_STATES" | "DELETED_ENTITY_IDS" | "DELETE_PENDING_REPLY" | "DELETE_PENDING_SCHEDULED_MESSAGE" | "DELETE_SUMMARY" | "DETECTABLE_GAME_SUPPLEMENTAL_FETCH" | "DETECTABLE_GAME_SUPPLEMENTAL_FETCH_FAILURE" | "DETECTABLE_GAME_SUPPLEMENTAL_FETCH_SUCCESS" | "DETECTED_OFF_PLATFORM_PREMIUM_PERKS_DISMISS" | "DEVELOPER_ACTIVITY_SHELF_FETCH_FAIL" | "DEVELOPER_ACTIVITY_SHELF_FETCH_START" | "DEVELOPER_ACTIVITY_SHELF_FETCH_SUCCESS" | "DEVELOPER_ACTIVITY_SHELF_MARK_ACTIVITY_USED" | "DEVELOPER_ACTIVITY_SHELF_SET_ACTIVITY_URL_OVERRIDE" | "DEVELOPER_ACTIVITY_SHELF_TOGGLE_USE_ACTIVITY_URL_OVERRIDE" | "DEVELOPER_ACTIVITY_SHELF_UPDATE_FILTER" | "DEVELOPER_OPTIONS_UPDATE_SETTINGS" | "DEVELOPER_TEST_MODE_AUTHORIZATION_FAIL" | "DEVELOPER_TEST_MODE_AUTHORIZATION_START" | "DEVELOPER_TEST_MODE_AUTHORIZATION_SUCCESS" | "DEVELOPER_TEST_MODE_RESET" | "DEVELOPER_TEST_MODE_RESET_ERROR" | "DEV_TOOLS_DESIGN_TOGGLE_SET" | "DEV_TOOLS_DESIGN_TOGGLE_WEB_SET" | "DEV_TOOLS_DEV_SETTING_SET" | "DEV_TOOLS_FRIENDS_LIST_GIFT_INTENTS_SHOWN_RESET" | "DEV_TOOLS_FRIENDS_TAB_BADGE_COOLDOWN_RESET" | "DEV_TOOLS_GIFT_MESSAGE_COOLDOWN_RESET" | "DEV_TOOLS_SETTINGS_UPDATE" | "DEV_TOOLS_SET_FRIEND_ANNIVERSARY_COUNT" | "DISABLE_AUTOMATIC_ACK" | "DISCOVER_CHECKLIST_FETCH_FAILURE" | "DISCOVER_CHECKLIST_FETCH_START" | "DISCOVER_CHECKLIST_FETCH_SUCCESS" | "DISMISS_CHANNEL_SAFETY_WARNINGS" | "DISMISS_FAVORITE_SUGGESTION" | "DISMISS_MEDIA_POST_SHARE_PROMPT" | "DISPATCH_APPLICATION_ADD_TO_INSTALLATIONS" | "DISPATCH_APPLICATION_CANCEL" | "DISPATCH_APPLICATION_ERROR" | "DISPATCH_APPLICATION_INSTALL" | "DISPATCH_APPLICATION_INSTALL_SCRIPTS_PROGRESS_UPDATE" | "DISPATCH_APPLICATION_LAUNCH_SETUP_COMPLETE" | "DISPATCH_APPLICATION_LAUNCH_SETUP_START" | "DISPATCH_APPLICATION_MOVE_UP" | "DISPATCH_APPLICATION_REMOVE_FINISHED" | "DISPATCH_APPLICATION_REPAIR" | "DISPATCH_APPLICATION_STATE_UPDATE" | "DISPATCH_APPLICATION_UNINSTALL" | "DISPATCH_APPLICATION_UPDATE" | "DISPLAYED_INVITE_SHOW" | "DOMAIN_MIGRATION_FAILURE" | "DOMAIN_MIGRATION_SKIP" | "DOMAIN_MIGRATION_START" | "DRAFT_CHANGE" | "DRAFT_CLEAR" | "DRAFT_SAVE" | "EMAIL_SETTINGS_FETCH_SUCCESS" | "EMAIL_SETTINGS_UPDATE" | "EMAIL_SETTINGS_UPDATE_SUCCESS" | "EMBEDDED_ACTIVITY_CLOSE" | "EMBEDDED_ACTIVITY_DEFERRED_OPEN" | "EMBEDDED_ACTIVITY_FETCH_SHELF" | "EMBEDDED_ACTIVITY_FETCH_SHELF_FAIL" | "EMBEDDED_ACTIVITY_FETCH_SHELF_SUCCESS" | "EMBEDDED_ACTIVITY_LAUNCH_FAIL" | "EMBEDDED_ACTIVITY_LAUNCH_START" | "EMBEDDED_ACTIVITY_LAUNCH_SUCCESS" | "EMBEDDED_ACTIVITY_OPEN" | "EMBEDDED_ACTIVITY_SET_CONFIG" | "EMBEDDED_ACTIVITY_SET_FOCUSED_LAYOUT" | "EMBEDDED_ACTIVITY_SET_ORIENTATION_LOCK_STATE" | "EMBEDDED_ACTIVITY_SET_PANEL_MODE" | "EMBEDDED_ACTIVITY_UPDATE" | "EMBEDDED_ACTIVITY_UPDATE_POPOUT_WINDOW_LAYOUT" | "EMBEDDED_ACTIVITY_UPDATE_V2" | "EMOJI_AUTOSUGGESTION_UPDATE" | "EMOJI_DELETE" | "EMOJI_FETCH_FAILURE" | "EMOJI_FETCH_SUCCESS" | "EMOJI_INTERACTION_INITIATED" | "EMOJI_TRACK_USAGE" | "EMOJI_UPLOAD_START" | "EMOJI_UPLOAD_STOP" | "ENABLE_AUTOMATIC_ACK" | "ENTITLEMENTS_FETCH_FOR_USER_FAIL" | "ENTITLEMENTS_FETCH_FOR_USER_START" | "ENTITLEMENTS_FETCH_FOR_USER_SUCCESS" | "ENTITLEMENTS_GIFTABLE_FETCH_SUCCESS" | "ENTITLEMENT_CREATE" | "ENTITLEMENT_DELETE" | "ENTITLEMENT_FETCH_APPLICATION_FAIL" | "ENTITLEMENT_FETCH_APPLICATION_START" | "ENTITLEMENT_FETCH_APPLICATION_SUCCESS" | "ENTITLEMENT_UPDATE" | "EVENT_DIRECTORY_FETCH_FAILURE" | "EVENT_DIRECTORY_FETCH_START" | "EVENT_DIRECTORY_FETCH_SUCCESS" | "EXPERIMENTS_FETCH" | "EXPERIMENTS_FETCH_FAILURE" | "EXPERIMENTS_FETCH_SUCCESS" | "EXPERIMENT_OVERRIDE_BUCKET" | "FAMILY_CENTER_FETCH_START" | "FAMILY_CENTER_HANDLE_TAB_SELECT" | "FAMILY_CENTER_INITIAL_LOAD" | "FAMILY_CENTER_LINKED_USERS_FETCH_SUCCESS" | "FAMILY_CENTER_LINK_CODE_FETCH_SUCCESS" | "FAMILY_CENTER_REQUEST_LINK_REMOVE_SUCCESS" | "FAMILY_CENTER_REQUEST_LINK_SUCCESS" | "FAMILY_CENTER_REQUEST_LINK_UPDATE_SUCCESS" | "FAMILY_CENTER_TEEN_ACTIVITY_FETCH_SUCCESS" | "FAMILY_CENTER_TEEN_ACTIVITY_MORE_FETCH_SUCCESS" | "FEEDBACK_OVERRIDE_CLEAR" | "FEEDBACK_OVERRIDE_SET" | "FETCH_AUTH_SESSIONS_SUCCESS" | "FETCH_CHAT_WALLPAPERS_FAILURE" | "FETCH_CHAT_WALLPAPERS_START" | "FETCH_CHAT_WALLPAPERS_SUCCESS" | "FETCH_GUILD_EVENT" | "FETCH_GUILD_EVENTS_FOR_GUILD" | "FETCH_GUILD_MEMBER_SUPPLEMENTAL_SUCCESS" | "FETCH_INTEGRATION_APPLICATION_IDS_FOR_MY_GUILDS" | "FETCH_INTEGRATION_APPLICATION_IDS_FOR_MY_GUILDS_FAILURE" | "FETCH_INTEGRATION_APPLICATION_IDS_FOR_MY_GUILDS_SUCCESS" | "FETCH_SCHEDULED_MESSAGES" | "FETCH_SCHEDULED_MESSAGES_FAILURE" | "FETCH_SCHEDULED_MESSAGES_SUCCESS" | "FINGERPRINT" | "FORCE_INVISIBLE" | "FORGOT_PASSWORD_REQUEST" | "FORGOT_PASSWORD_SENT" | "FORUM_SEARCH_CLEAR" | "FORUM_SEARCH_FAILURE" | "FORUM_SEARCH_QUERY_UPDATED" | "FORUM_SEARCH_START" | "FORUM_SEARCH_SUCCESS" | "FORUM_UNREADS" | "FRIENDS_LIST_GIFT_INTENTS_SHOWN" | "FRIENDS_SET_INITIAL_SECTION" | "FRIENDS_SET_SECTION" | "FRIENDS_TAB_BADGE_DISMISS" | "FRIEND_INVITES_FETCH_REQUEST" | "FRIEND_INVITES_FETCH_RESPONSE" | "FRIEND_INVITE_CREATE_FAILURE" | "FRIEND_INVITE_CREATE_REQUEST" | "FRIEND_INVITE_CREATE_SUCCESS" | "FRIEND_INVITE_REVOKE_REQUEST" | "FRIEND_INVITE_REVOKE_SUCCESS" | "FRIEND_SUGGESTION_CREATE" | "FRIEND_SUGGESTION_DELETE" | "GAMES_DATABASE_FETCH" | "GAMES_DATABASE_FETCH_FAIL" | "GAMES_DATABASE_UPDATE" | "GAME_CLOUD_SYNC_COMPLETE" | "GAME_CLOUD_SYNC_CONFLICT" | "GAME_CLOUD_SYNC_ERROR" | "GAME_CLOUD_SYNC_START" | "GAME_CLOUD_SYNC_UPDATE" | "GAME_CONSOLE_FETCH_DEVICES_FAIL" | "GAME_CONSOLE_FETCH_DEVICES_START" | "GAME_CONSOLE_FETCH_DEVICES_SUCCESS" | "GAME_CONSOLE_SELECT_DEVICE" | "GAME_DETECTION_DEBUGGING_START" | "GAME_DETECTION_DEBUGGING_STOP" | "GAME_DETECTION_DEBUGGING_TICK" | "GAME_DETECTION_WATCH_CANDIDATE_GAMES_START" | "GAME_ICON_UPDATE" | "GAME_INVITE_CLEAR_UNSEEN" | "GAME_INVITE_CREATE" | "GAME_INVITE_DELETE" | "GAME_INVITE_DELETE_MANY" | "GAME_INVITE_UPDATE_STATUS" | "GAME_LAUNCHABLE_UPDATE" | "GAME_LAUNCH_FAIL" | "GAME_LAUNCH_START" | "GAME_LAUNCH_SUCCESS" | "GAME_PROFILE_OPEN" | "GAME_RELATIONSHIP_ADD" | "GAME_RELATIONSHIP_REMOVE" | "GENERIC_PUSH_NOTIFICATION_SENT" | "GIFT_CODES_FETCH" | "GIFT_CODES_FETCH_FAILURE" | "GIFT_CODES_FETCH_SUCCESS" | "GIFT_CODE_CREATE" | "GIFT_CODE_CREATE_SUCCESS" | "GIFT_CODE_REDEEM" | "GIFT_CODE_REDEEM_FAILURE" | "GIFT_CODE_REDEEM_SUCCESS" | "GIFT_CODE_RESOLVE" | "GIFT_CODE_RESOLVE_FAILURE" | "GIFT_CODE_RESOLVE_SUCCESS" | "GIFT_CODE_REVOKE_SUCCESS" | "GIFT_CODE_UPDATE" | "GIFT_INTENT_FLOW_PURCHASED_GIFT" | "GIF_PICKER_INITIALIZE" | "GIF_PICKER_QUERY" | "GIF_PICKER_QUERY_FAILURE" | "GIF_PICKER_QUERY_SUCCESS" | "GIF_PICKER_SUGGESTIONS_SUCCESS" | "GIF_PICKER_TRENDING_FETCH_SUCCESS" | "GIF_PICKER_TRENDING_SEARCH_TERMS_SUCCESS" | "GLOBAL_DISCOVERY_SERVERS_SEARCH_CLEAR" | "GLOBAL_DISCOVERY_SERVERS_SEARCH_COUNT_FAILURE" | "GLOBAL_DISCOVERY_SERVERS_SEARCH_COUNT_START" | "GLOBAL_DISCOVERY_SERVERS_SEARCH_COUNT_SUCCESS" | "GLOBAL_DISCOVERY_SERVERS_SEARCH_FAILURE" | "GLOBAL_DISCOVERY_SERVERS_SEARCH_LAYOUT_RESET" | "GLOBAL_DISCOVERY_SERVERS_SEARCH_START" | "GLOBAL_DISCOVERY_SERVERS_SEARCH_SUCCESS" | "GUILD_ACK" | "GUILD_ANALYTICS_ENGAGEMENT_OVERVIEW_FETCH_FAILURE" | "GUILD_ANALYTICS_ENGAGEMENT_OVERVIEW_FETCH_SUCCESS" | "GUILD_ANALYTICS_GROWTH_ACTIVATION_OVERVIEW_FETCH_FAILURE" | "GUILD_ANALYTICS_GROWTH_ACTIVATION_OVERVIEW_FETCH_SUCCESS" | "GUILD_ANALYTICS_GROWTH_ACTIVATION_RETENTION_FETCH_FAILURE" | "GUILD_ANALYTICS_GROWTH_ACTIVATION_RETENTION_FETCH_SUCCESS" | "GUILD_APPLICATIONS_FETCH_SUCCESS" | "GUILD_APPLICATION_COMMAND_INDEX_UPDATE" | "GUILD_APPLIED_BOOSTS_FETCH_SUCCESS" | "GUILD_APPLIED_BOOSTS_UPDATE" | "GUILD_APPLY_BOOST_FAIL" | "GUILD_APPLY_BOOST_START" | "GUILD_APPLY_BOOST_SUCCESS" | "GUILD_BAN_ADD" | "GUILD_BAN_REMOVE" | "GUILD_BOOST_SLOTS_FETCH" | "GUILD_BOOST_SLOTS_FETCH_SUCCESS" | "GUILD_BOOST_SLOT_CREATE" | "GUILD_BOOST_SLOT_UPDATE" | "GUILD_BOOST_SLOT_UPDATE_SUCCESS" | "GUILD_CREATE" | "GUILD_DELETE" | "GUILD_DIRECTORY_ADMIN_ENTRIES_FETCH_SUCCESS" | "GUILD_DIRECTORY_CACHED_SEARCH" | "GUILD_DIRECTORY_CATEGORY_SELECT" | "GUILD_DIRECTORY_COUNTS_FETCH_SUCCESS" | "GUILD_DIRECTORY_ENTRY_CREATE" | "GUILD_DIRECTORY_ENTRY_DELETE" | "GUILD_DIRECTORY_ENTRY_UPDATE" | "GUILD_DIRECTORY_FETCH_FAILURE" | "GUILD_DIRECTORY_FETCH_START" | "GUILD_DIRECTORY_FETCH_SUCCESS" | "GUILD_DIRECTORY_SEARCH_CLEAR" | "GUILD_DIRECTORY_SEARCH_FAILURE" | "GUILD_DIRECTORY_SEARCH_START" | "GUILD_DIRECTORY_SEARCH_SUCCESS" | "GUILD_DISCOVERY_CATEGORY_ADD" | "GUILD_DISCOVERY_CATEGORY_DELETE" | "GUILD_DISCOVERY_CATEGORY_FETCH_SUCCESS" | "GUILD_DISCOVERY_CATEGORY_UPDATE_FAIL" | "GUILD_DISCOVERY_METADATA_FETCH_FAIL" | "GUILD_DISCOVERY_SLUG_FETCH_FAIL" | "GUILD_DISCOVERY_SLUG_FETCH_SUCCESS" | "GUILD_EMOJIS_UPDATE" | "GUILD_FEATURE_ACK" | "GUILD_FOLDER_COLLAPSE" | "GUILD_FOLDER_CREATE_LOCAL" | "GUILD_FOLDER_DELETE_LOCAL" | "GUILD_FOLDER_EDIT_LOCAL" | "GUILD_GEO_RESTRICTED" | "GUILD_HOME_SETTINGS_FETCH_FAIL" | "GUILD_HOME_SETTINGS_FETCH_START" | "GUILD_HOME_SETTINGS_FETCH_SUCCESS" | "GUILD_HOME_SETTINGS_TOGGLE_ENABLED" | "GUILD_HOME_SETTINGS_UPDATE_FAIL" | "GUILD_HOME_SETTINGS_UPDATE_START" | "GUILD_HOME_SETTINGS_UPDATE_SUCCESS" | "GUILD_IDENTITY_SETTINGS_CLEAR_ERRORS" | "GUILD_IDENTITY_SETTINGS_INIT" | "GUILD_IDENTITY_SETTINGS_RESET_ALL_PENDING" | "GUILD_IDENTITY_SETTINGS_RESET_AND_CLOSE_FORM" | "GUILD_IDENTITY_SETTINGS_RESET_PENDING_MEMBER_CHANGES" | "GUILD_IDENTITY_SETTINGS_RESET_PENDING_PROFILE_CHANGES" | "GUILD_IDENTITY_SETTINGS_SET_GUILD" | "GUILD_IDENTITY_SETTINGS_SET_PENDING_AVATAR" | "GUILD_IDENTITY_SETTINGS_SET_PENDING_AVATAR_DECORATION" | "GUILD_IDENTITY_SETTINGS_SET_PENDING_BANNER" | "GUILD_IDENTITY_SETTINGS_SET_PENDING_BIO" | "GUILD_IDENTITY_SETTINGS_SET_PENDING_NICKNAME" | "GUILD_IDENTITY_SETTINGS_SET_PENDING_PROFILE_EFFECT_ID" | "GUILD_IDENTITY_SETTINGS_SET_PENDING_PRONOUNS" | "GUILD_IDENTITY_SETTINGS_SET_PENDING_THEME_COLORS" | "GUILD_IDENTITY_SETTINGS_SUBMIT" | "GUILD_IDENTITY_SETTINGS_SUBMIT_FAILURE" | "GUILD_IDENTITY_SETTINGS_SUBMIT_SUCCESS" | "GUILD_INTEGRATIONS_UPDATE" | "GUILD_JOIN" | "GUILD_JOIN_REQUESTS_BULK_ACTION" | "GUILD_JOIN_REQUESTS_FETCH_FAILURE" | "GUILD_JOIN_REQUESTS_FETCH_START" | "GUILD_JOIN_REQUESTS_FETCH_SUCCESS" | "GUILD_JOIN_REQUESTS_SET_APPLICATION_TAB" | "GUILD_JOIN_REQUESTS_SET_SELECTED" | "GUILD_JOIN_REQUESTS_SET_SORT_ORDER" | "GUILD_JOIN_REQUEST_BY_ID_FETCH_SUCCESS" | "GUILD_JOIN_REQUEST_CREATE" | "GUILD_JOIN_REQUEST_DELETE" | "GUILD_JOIN_REQUEST_UPDATE" | "GUILD_LOCAL_RING_START" | "GUILD_MEMBERS_CHUNK_BATCH" | "GUILD_MEMBERS_REQUEST" | "GUILD_MEMBER_ADD" | "GUILD_MEMBER_LIST_UPDATE" | "GUILD_MEMBER_PROFILE_UPDATE" | "GUILD_MEMBER_REMOVE" | "GUILD_MEMBER_REMOVE_LOCAL" | "GUILD_MEMBER_UPDATE" | "GUILD_MEMBER_UPDATE_LOCAL" | "GUILD_MOVE_BY_ID" | "GUILD_MUTE_EXPIRED" | "GUILD_NEW_MEMBER_ACTIONS_DELETE_SUCCESS" | "GUILD_NEW_MEMBER_ACTIONS_FETCH_FAIL" | "GUILD_NEW_MEMBER_ACTIONS_FETCH_START" | "GUILD_NEW_MEMBER_ACTIONS_FETCH_SUCCESS" | "GUILD_NEW_MEMBER_ACTION_UPDATE_SUCCESS" | "GUILD_NSFW_AGREE" | "GUILD_ONBOARDING_COMPLETE" | "GUILD_ONBOARDING_PROMPTS_FETCH_FAILURE" | "GUILD_ONBOARDING_PROMPTS_FETCH_START" | "GUILD_ONBOARDING_PROMPTS_FETCH_SUCCESS" | "GUILD_ONBOARDING_PROMPTS_LOCAL_UPDATE" | "GUILD_ONBOARDING_SELECT_OPTION" | "GUILD_ONBOARDING_SET_STEP" | "GUILD_ONBOARDING_START" | "GUILD_ONBOARDING_UPDATE_RESPONSES_SUCCESS" | "GUILD_POWERUPS_ACK_NOTIFICATION" | "GUILD_POWERUPS_RESET_NOTIFICATIONS" | "GUILD_POWERUP_CATALOG_FETCH_SUCCESS" | "GUILD_POWERUP_ENTITLEMENTS_CREATE" | "GUILD_POWERUP_ENTITLEMENTS_DELETE" | "GUILD_PRODUCTS_FETCH" | "GUILD_PRODUCTS_FETCH_FAILURE" | "GUILD_PRODUCTS_FETCH_SUCCESS" | "GUILD_PRODUCT_CREATE" | "GUILD_PRODUCT_DELETE" | "GUILD_PRODUCT_FETCH" | "GUILD_PRODUCT_FETCH_FAILURE" | "GUILD_PRODUCT_FETCH_SUCCESS" | "GUILD_PRODUCT_UPDATE" | "GUILD_PROFILE_FETCH" | "GUILD_PROFILE_FETCH_FAILURE" | "GUILD_PROFILE_FETCH_SUCCESS" | "GUILD_PROFILE_UPDATE" | "GUILD_PROFILE_UPDATE_FAILURE" | "GUILD_PROFILE_UPDATE_SUCCESS" | "GUILD_PROFILE_UPDATE_VISIBILITY" | "GUILD_PROFILE_UPDATE_VISIBILITY_FAILURE" | "GUILD_PROFILE_UPDATE_VISIBILITY_SUCCESS" | "GUILD_PROGRESS_COMPLETED_SEEN" | "GUILD_PROGRESS_DISMISS" | "GUILD_PROGRESS_INITIALIZE" | "GUILD_PROMPT_VIEWED" | "GUILD_RESOURCE_CHANNEL_UPDATE_SUCCESS" | "GUILD_RING_START" | "GUILD_RING_STOP" | "GUILD_ROLE_CONNECTIONS_CONFIGURATIONS_FETCH_SUCCESS" | "GUILD_ROLE_CONNECTIONS_MODAL_SHOW" | "GUILD_ROLE_CONNECTION_ELIGIBILITY_FETCH_SUCCESS" | "GUILD_ROLE_CREATE" | "GUILD_ROLE_DELETE" | "GUILD_ROLE_MEMBER_ADD" | "GUILD_ROLE_MEMBER_BULK_ADD" | "GUILD_ROLE_MEMBER_COUNT_FETCH_SUCCESS" | "GUILD_ROLE_MEMBER_COUNT_UPDATE" | "GUILD_ROLE_MEMBER_REMOVE" | "GUILD_ROLE_SUBSCRIPTIONS_CREATE_LISTING" | "GUILD_ROLE_SUBSCRIPTIONS_DELETE_GROUP_LISTING" | "GUILD_ROLE_SUBSCRIPTIONS_DELETE_LISTING" | "GUILD_ROLE_SUBSCRIPTIONS_FETCH_LISTINGS" | "GUILD_ROLE_SUBSCRIPTIONS_FETCH_LISTINGS_FAILURE" | "GUILD_ROLE_SUBSCRIPTIONS_FETCH_LISTINGS_SUCCESS" | "GUILD_ROLE_SUBSCRIPTIONS_FETCH_LISTING_FOR_PLAN" | "GUILD_ROLE_SUBSCRIPTIONS_FETCH_LISTING_FOR_PLAN_SUCCESS" | "GUILD_ROLE_SUBSCRIPTIONS_FETCH_RESTRICTIONS" | "GUILD_ROLE_SUBSCRIPTIONS_FETCH_RESTRICTIONS_ABORTED" | "GUILD_ROLE_SUBSCRIPTIONS_FETCH_RESTRICTIONS_FAILURE" | "GUILD_ROLE_SUBSCRIPTIONS_FETCH_RESTRICTIONS_SUCCESS" | "GUILD_ROLE_SUBSCRIPTIONS_FETCH_TEMPLATES" | "GUILD_ROLE_SUBSCRIPTIONS_STASH_TEMPLATE_CHANNELS" | "GUILD_ROLE_SUBSCRIPTIONS_UPDATE_GROUP_LISTING" | "GUILD_ROLE_SUBSCRIPTIONS_UPDATE_LISTING" | "GUILD_ROLE_SUBSCRIPTIONS_UPDATE_SUBSCRIPTIONS_SETTINGS" | "GUILD_ROLE_SUBSCRIPTIONS_UPDATE_SUBSCRIPTION_TRIAL" | "GUILD_ROLE_UPDATE" | "GUILD_SCHEDULED_EVENT_CREATE" | "GUILD_SCHEDULED_EVENT_DELETE" | "GUILD_SCHEDULED_EVENT_EXCEPTIONS_DELETE" | "GUILD_SCHEDULED_EVENT_EXCEPTION_CREATE" | "GUILD_SCHEDULED_EVENT_EXCEPTION_DELETE" | "GUILD_SCHEDULED_EVENT_EXCEPTION_UPDATE" | "GUILD_SCHEDULED_EVENT_RSVPS_FETCH_SUCESS" | "GUILD_SCHEDULED_EVENT_UPDATE" | "GUILD_SCHEDULED_EVENT_USERS_FETCH_SUCCESS" | "GUILD_SCHEDULED_EVENT_USER_ADD" | "GUILD_SCHEDULED_EVENT_USER_COUNTS_FETCH_SUCCESS" | "GUILD_SCHEDULED_EVENT_USER_REMOVE" | "GUILD_SEARCH_RECENT_MEMBERS" | "GUILD_SETTINGS_CANCEL_CHANGES" | "GUILD_SETTINGS_CLOSE" | "GUILD_SETTINGS_DEFAULT_CHANNELS_RESET" | "GUILD_SETTINGS_DEFAULT_CHANNELS_SAVE_FAILED" | "GUILD_SETTINGS_DEFAULT_CHANNELS_SAVE_SUCCESS" | "GUILD_SETTINGS_DEFAULT_CHANNELS_SUBMIT" | "GUILD_SETTINGS_DEFAULT_CHANNELS_TOGGLE" | "GUILD_SETTINGS_INIT" | "GUILD_SETTINGS_JOIN_RULES_APPLY_SET_PENDING_FORM_FIELDS" | "GUILD_SETTINGS_JOIN_RULES_INVITE_SET_PENDING_RULES" | "GUILD_SETTINGS_JOIN_RULES_SET_CONTENT_LEVEL" | "GUILD_SETTINGS_JOIN_RULES_SET_SELECTED_TYPE" | "GUILD_SETTINGS_LOADED_BANS" | "GUILD_SETTINGS_LOADED_BANS_BATCH" | "GUILD_SETTINGS_LOADED_INTEGRATIONS" | "GUILD_SETTINGS_LOADED_INVITES" | "GUILD_SETTINGS_ONBOARDING_ADD_NEW_MEMBER_ACTION" | "GUILD_SETTINGS_ONBOARDING_ADD_RESOURCE_CHANNEL" | "GUILD_SETTINGS_ONBOARDING_DELETE_NEW_MEMBER_ACTION" | "GUILD_SETTINGS_ONBOARDING_DELETE_RESOURCE_CHANNEL" | "GUILD_SETTINGS_ONBOARDING_DISMISS_RESOURCE_CHANNEL_SUGGESTION" | "GUILD_SETTINGS_ONBOARDING_EDUCATION_UPSELL_DISMISSED" | "GUILD_SETTINGS_ONBOARDING_HOME_SETTINGS_RESET" | "GUILD_SETTINGS_ONBOARDING_PROMPTS_EDIT" | "GUILD_SETTINGS_ONBOARDING_PROMPTS_ERRORS" | "GUILD_SETTINGS_ONBOARDING_PROMPTS_RESET" | "GUILD_SETTINGS_ONBOARDING_PROMPTS_SAVE_FAILED" | "GUILD_SETTINGS_ONBOARDING_PROMPTS_SAVE_SUCCESS" | "GUILD_SETTINGS_ONBOARDING_PROMPTS_SUBMIT" | "GUILD_SETTINGS_ONBOARDING_REORDER_NEW_MEMBER_ACTION" | "GUILD_SETTINGS_ONBOARDING_REORDER_RESOURCE_CHANNEL" | "GUILD_SETTINGS_ONBOARDING_SET_MODE" | "GUILD_SETTINGS_ONBOARDING_STEP" | "GUILD_SETTINGS_ONBOARDING_UPDATE_NEW_MEMBER_ACTION" | "GUILD_SETTINGS_ONBOARDING_UPDATE_RESOURCE_CHANNEL" | "GUILD_SETTINGS_ONBOARDING_UPDATE_WELCOME_MESSAGE" | "GUILD_SETTINGS_OPEN" | "GUILD_SETTINGS_PROFILE_UPDATE" | "GUILD_SETTINGS_ROLES_CLEAR_PERMISSIONS" | "GUILD_SETTINGS_ROLES_INIT" | "GUILD_SETTINGS_ROLES_ROLE_STYLE_UPDATE" | "GUILD_SETTINGS_ROLES_SAVE_FAIL" | "GUILD_SETTINGS_ROLES_SAVE_SUCCESS" | "GUILD_SETTINGS_ROLES_SORT_UPDATE" | "GUILD_SETTINGS_ROLES_SUBMITTING" | "GUILD_SETTINGS_ROLES_UPDATE_COLOR" | "GUILD_SETTINGS_ROLES_UPDATE_COLORS" | "GUILD_SETTINGS_ROLES_UPDATE_DESCRIPTION" | "GUILD_SETTINGS_ROLES_UPDATE_NAME" | "GUILD_SETTINGS_ROLES_UPDATE_PERMISSIONS" | "GUILD_SETTINGS_ROLES_UPDATE_PERMISSION_SET" | "GUILD_SETTINGS_ROLES_UPDATE_ROLE_CONNECTION_CONFIGURATIONS" | "GUILD_SETTINGS_ROLES_UPDATE_ROLE_ICON" | "GUILD_SETTINGS_ROLES_UPDATE_SETTINGS" | "GUILD_SETTINGS_ROLE_SELECT" | "GUILD_SETTINGS_SAFETY_PAGE" | "GUILD_SETTINGS_SAFETY_SET_SUBSECTION" | "GUILD_SETTINGS_SAVE_ROUTE_STACK" | "GUILD_SETTINGS_SET_MFA_SUCCESS" | "GUILD_SETTINGS_SET_SEARCH_QUERY" | "GUILD_SETTINGS_SET_SECTION" | "GUILD_SETTINGS_SET_VANITY_URL" | "GUILD_SETTINGS_SET_WIDGET" | "GUILD_SETTINGS_SUBMIT" | "GUILD_SETTINGS_SUBMIT_FAILURE" | "GUILD_SETTINGS_SUBMIT_SUCCESS" | "GUILD_SETTINGS_UPDATE" | "GUILD_SETTINGS_VANITY_URL_ERROR" | "GUILD_SETTINGS_VANITY_URL_RESET" | "GUILD_SETTINGS_VANITY_URL_SET" | "GUILD_SETTINGS_WIDGET_UPDATE" | "GUILD_SOUNDBOARD_FETCH" | "GUILD_SOUNDBOARD_SOUNDS_UPDATE" | "GUILD_SOUNDBOARD_SOUND_CREATE" | "GUILD_SOUNDBOARD_SOUND_DELETE" | "GUILD_SOUNDBOARD_SOUND_PLAY_END" | "GUILD_SOUNDBOARD_SOUND_PLAY_LOCALLY" | "GUILD_SOUNDBOARD_SOUND_PLAY_START" | "GUILD_SOUNDBOARD_SOUND_UPDATE" | "GUILD_STICKERS_CREATE_SUCCESS" | "GUILD_STICKERS_FETCH_SUCCESS" | "GUILD_STICKERS_UPDATE" | "GUILD_STOP_LURKING" | "GUILD_STOP_LURKING_FAILURE" | "GUILD_SUBSCRIPTIONS" | "GUILD_SUBSCRIPTIONS_ADD_MEMBER_UPDATES" | "GUILD_SUBSCRIPTIONS_CHANNEL" | "GUILD_SUBSCRIPTIONS_FLUSH" | "GUILD_SUBSCRIPTIONS_MEMBERS_ADD" | "GUILD_SUBSCRIPTIONS_MEMBERS_REMOVE" | "GUILD_SUBSCRIPTIONS_REMOVE_MEMBER_UPDATES" | "GUILD_TAG_CHANGED_COACHMARK_SEEN" | "GUILD_TEMPLATE_ACCEPT" | "GUILD_TEMPLATE_ACCEPT_FAILURE" | "GUILD_TEMPLATE_ACCEPT_SUCCESS" | "GUILD_TEMPLATE_CREATE_SUCCESS" | "GUILD_TEMPLATE_DELETE_SUCCESS" | "GUILD_TEMPLATE_DIRTY_TOOLTIP_HIDE" | "GUILD_TEMPLATE_DIRTY_TOOLTIP_REFRESH" | "GUILD_TEMPLATE_LOAD_FOR_GUILD_SUCCESS" | "GUILD_TEMPLATE_MODAL_HIDE" | "GUILD_TEMPLATE_MODAL_SHOW" | "GUILD_TEMPLATE_PROMOTION_TOOLTIP_HIDE" | "GUILD_TEMPLATE_RESOLVE" | "GUILD_TEMPLATE_RESOLVE_FAILURE" | "GUILD_TEMPLATE_RESOLVE_SUCCESS" | "GUILD_TEMPLATE_SYNC_SUCCESS" | "GUILD_TOGGLE_COLLAPSE_MUTED" | "GUILD_TOP_READ_CHANNELS_FETCH_SUCCESS" | "GUILD_UNAPPLY_BOOST_FAIL" | "GUILD_UNAPPLY_BOOST_START" | "GUILD_UNAPPLY_BOOST_SUCCESS" | "GUILD_UNAVAILABLE" | "GUILD_UNLOCKED_POWERUPS_FETCH_SUCCESS" | "GUILD_UPDATE" | "GUILD_UPDATE_DISCOVERY_METADATA" | "GUILD_UPDATE_DISCOVERY_METADATA_FAIL" | "GUILD_UPDATE_DISCOVERY_METADATA_FROM_SERVER" | "GUILD_VERIFICATION_CHECK" | "HABITUAL_DND_CLEAR" | "HIDE_ACTION_SHEET" | "HIDE_ACTION_SHEET_QUICK_SWITCHER" | "HIDE_KEYBOARD_SHORTCUTS" | "HIGH_FIVE_COMPLETE" | "HIGH_FIVE_COMPLETE_CLEAR" | "HIGH_FIVE_QUEUE" | "HIGH_FIVE_REMOVE" | "HIGH_FIVE_SET_ENABLED" | "HOTSPOT_HIDE" | "HOTSPOT_OVERRIDE_CLEAR" | "HOTSPOT_OVERRIDE_SET" | "HYPESQUAD_ONLINE_MEMBERSHIP_JOIN_SUCCESS" | "HYPESQUAD_ONLINE_MEMBERSHIP_LEAVE_SUCCESS" | "IDLE" | "IMPERSONATE_STOP" | "IMPERSONATE_UPDATE" | "INBOX_OPEN" | "INCOMING_CALL_MOVE" | "INITIALIZE_MEMBER_SAFETY_STORE" | "INITIATE_AGE_VERIFICATION" | "INSTALLATION_LOCATION_ADD" | "INSTALLATION_LOCATION_FETCH_METADATA" | "INSTALLATION_LOCATION_REMOVE" | "INSTALLATION_LOCATION_UPDATE" | "INSTANT_INVITE_CLEAR" | "INSTANT_INVITE_CREATE" | "INSTANT_INVITE_CREATE_FAILURE" | "INSTANT_INVITE_CREATE_SUCCESS" | "INSTANT_INVITE_REVOKE_SUCCESS" | "INTEGRATION_CREATE" | "INTEGRATION_DELETE" | "INTEGRATION_PERMISSION_SETTINGS_APPLICATION_PERMISSIONS_FETCH_FAILURE" | "INTEGRATION_PERMISSION_SETTINGS_CLEAR" | "INTEGRATION_PERMISSION_SETTINGS_COMMANDS_FETCH_FAILURE" | "INTEGRATION_PERMISSION_SETTINGS_COMMANDS_FETCH_SUCCESS" | "INTEGRATION_PERMISSION_SETTINGS_COMMAND_UPDATE" | "INTEGRATION_PERMISSION_SETTINGS_EDIT" | "INTEGRATION_PERMISSION_SETTINGS_INIT" | "INTEGRATION_PERMISSION_SETTINGS_RESET" | "INTEGRATION_QUERY" | "INTEGRATION_QUERY_FAILURE" | "INTEGRATION_QUERY_SUCCESS" | "INTEGRATION_SETTINGS_INIT" | "INTEGRATION_SETTINGS_SAVE_FAILURE" | "INTEGRATION_SETTINGS_SAVE_SUCCESS" | "INTEGRATION_SETTINGS_SET_SECTION" | "INTEGRATION_SETTINGS_START_EDITING_COMMAND" | "INTEGRATION_SETTINGS_START_EDITING_INTEGRATION" | "INTEGRATION_SETTINGS_START_EDITING_WEBHOOK" | "INTEGRATION_SETTINGS_STOP_EDITING_COMMAND" | "INTEGRATION_SETTINGS_STOP_EDITING_INTEGRATION" | "INTEGRATION_SETTINGS_STOP_EDITING_WEBHOOK" | "INTEGRATION_SETTINGS_SUBMITTING" | "INTEGRATION_SETTINGS_UPDATE_INTEGRATION" | "INTEGRATION_SETTINGS_UPDATE_WEBHOOK" | "INTERACTION_CREATE" | "INTERACTION_FAILURE" | "INTERACTION_IFRAME_MODAL_CLOSE" | "INTERACTION_IFRAME_MODAL_CREATE" | "INTERACTION_IFRAME_MODAL_KEY_CREATE" | "INTERACTION_MODAL_CREATE" | "INTERACTION_QUEUE" | "INTERACTION_SUCCESS" | "INVITE_ACCEPT" | "INVITE_ACCEPT_FAILURE" | "INVITE_ACCEPT_SUCCESS" | "INVITE_APP_NOT_OPENED" | "INVITE_APP_OPENED" | "INVITE_APP_OPENING" | "INVITE_MODAL_CLOSE" | "INVITE_MODAL_ERROR" | "INVITE_MODAL_OPEN" | "INVITE_RESOLVE" | "INVITE_RESOLVE_FAILURE" | "INVITE_RESOLVE_SUCCESS" | "INVITE_SUGGESTIONS_SEARCH" | "KEYBINDS_ADD_KEYBIND" | "KEYBINDS_DELETE_KEYBIND" | "KEYBINDS_ENABLE_ALL_KEYBINDS" | "KEYBINDS_REGISTER_GLOBAL_KEYBIND_ACTIONS" | "KEYBINDS_SET_KEYBIND" | "KEYBOARD_NAVIGATION_EXPLAINER_MODAL_SEEN" | "LAB_FEATURE_TOGGLE" | "LAYER_POP" | "LAYER_POP_ALL" | "LAYER_PUSH" | "LAYOUT_CREATE" | "LAYOUT_CREATE_WIDGETS" | "LAYOUT_DELETE_ALL_WIDGETS" | "LAYOUT_DELETE_WIDGET" | "LAYOUT_SET_PINNED" | "LAYOUT_SET_TOP_WIDGET" | "LAYOUT_SET_WIDGET_META" | "LAYOUT_UPDATE_WIDGET" | "LIBRARY_APPLICATIONS_TEST_MODE_ENABLED" | "LIBRARY_APPLICATION_ACTIVE_BRANCH_UPDATE" | "LIBRARY_APPLICATION_ACTIVE_LAUNCH_OPTION_UPDATE" | "LIBRARY_APPLICATION_FILTER_UPDATE" | "LIBRARY_APPLICATION_FLAGS_UPDATE_START" | "LIBRARY_APPLICATION_FLAGS_UPDATE_SUCCESS" | "LIBRARY_APPLICATION_UPDATE" | "LIBRARY_FETCH_SUCCESS" | "LIBRARY_TABLE_ACTIVE_ROW_ID_UPDATE" | "LIBRARY_TABLE_SORT_UPDATE" | "LIVE_CHANNEL_NOTICE_HIDE" | "LOAD_ARCHIVED_THREADS" | "LOAD_ARCHIVED_THREADS_FAIL" | "LOAD_ARCHIVED_THREADS_SUCCESS" | "LOAD_CHANNELS" | "LOAD_DATA_HARVEST_TYPE_FAILURE" | "LOAD_DATA_HARVEST_TYPE_START" | "LOAD_FORUM_POSTS" | "LOAD_FRIEND_SUGGESTIONS_FAILURE" | "LOAD_FRIEND_SUGGESTIONS_SUCCESS" | "LOAD_GUILD_AFFINITIES_SUCCESS" | "LOAD_ICYMI_HYDRATED" | "LOAD_INVITE_SUGGESTIONS" | "LOAD_MESSAGES" | "LOAD_MESSAGES_AROUND_SUCCESS" | "LOAD_MESSAGES_FAILURE" | "LOAD_MESSAGES_SUCCESS" | "LOAD_MESSAGES_SUCCESS_CACHED" | "LOAD_MESSAGE_INTERACTION_DATA_SUCCESS" | "LOAD_MESSAGE_REQUESTS_SUPPLEMENTAL_DATA_ERROR" | "LOAD_MESSAGE_REQUESTS_SUPPLEMENTAL_DATA_SUCCESS" | "LOAD_NOTIFICATION_CENTER_ITEMS" | "LOAD_NOTIFICATION_CENTER_ITEMS_FAILURE" | "LOAD_NOTIFICATION_CENTER_ITEMS_SUCCESS" | "LOAD_PINNED_MESSAGES" | "LOAD_PINNED_MESSAGES_FAILURE" | "LOAD_PINNED_MESSAGES_SUCCESS" | "LOAD_RECENT_MENTIONS" | "LOAD_RECENT_MENTIONS_FAILURE" | "LOAD_RECENT_MENTIONS_SUCCESS" | "LOAD_REGIONS" | "LOAD_RELATIONSHIPS_FAILURE" | "LOAD_RELATIONSHIPS_SUCCESS" | "LOAD_THREADS_SUCCESS" | "LOAD_USER_AFFINITIES_V2" | "LOAD_USER_AFFINITIES_V2_FAILURE" | "LOAD_USER_AFFINITIES_V2_SUCCESS" | "LOCAL_ACTIVITY_UPDATE" | "LOCAL_MESSAGES_LOADED" | "LOCAL_MESSAGE_CREATE" | "LOGIN" | "LOGIN_ACCOUNT_DISABLED" | "LOGIN_ACCOUNT_SCHEDULED_FOR_DELETION" | "LOGIN_ATTEMPTED" | "LOGIN_FAILURE" | "LOGIN_MFA" | "LOGIN_MFA_STEP" | "LOGIN_PASSWORD_RECOVERY_PHONE_VERIFICATION" | "LOGIN_PHONE_IP_AUTHORIZATION_REQUIRED" | "LOGIN_RESET" | "LOGIN_STATUS_RESET" | "LOGIN_SUCCESS" | "LOGIN_SUSPENDED_USER" | "LOGOUT" | "LOGOUT_AUTH_SESSIONS_SUCCESS" | "MASKED_LINK_ADD_TRUSTED_DOMAIN" | "MASKED_LINK_ADD_TRUSTED_PROTOCOL" | "MAX_MEMBER_COUNT_NOTICE_DISMISS" | "MEDIA_ENGINE_APPLY_MEDIA_FILTER_SETTINGS" | "MEDIA_ENGINE_APPLY_MEDIA_FILTER_SETTINGS_ERROR" | "MEDIA_ENGINE_APPLY_MEDIA_FILTER_SETTINGS_START" | "MEDIA_ENGINE_CONNECTION_STATS" | "MEDIA_ENGINE_CONNECTION_STATS_HISTORY_RESET" | "MEDIA_ENGINE_DEVICES" | "MEDIA_ENGINE_INTERACTION_REQUIRED" | "MEDIA_ENGINE_NOISE_CANCELLATION_ERROR" | "MEDIA_ENGINE_NOISE_CANCELLATION_ERROR_RESET" | "MEDIA_ENGINE_PERMISSION" | "MEDIA_ENGINE_SET_AEC_DUMP" | "MEDIA_ENGINE_SET_AUDIO_ENABLED" | "MEDIA_ENGINE_SET_ENABLE_HARDWARE_MUTE_NOTICE" | "MEDIA_ENGINE_SET_EXPERIMENTAL_ENCODERS" | "MEDIA_ENGINE_SET_EXPERIMENTAL_SOUNDSHARE" | "MEDIA_ENGINE_SET_GO_LIVE_SOURCE" | "MEDIA_ENGINE_SET_HARDWARE_ENCODING" | "MEDIA_ENGINE_SET_OPEN_H264" | "MEDIA_ENGINE_SET_USE_SYSTEM_SCREENSHARE_PICKER" | "MEDIA_ENGINE_SET_VIDEO_DEVICE" | "MEDIA_ENGINE_SET_VIDEO_ENABLED" | "MEDIA_ENGINE_SET_VIDEO_HOOK" | "MEDIA_ENGINE_SOUNDSHARE_FAILED" | "MEDIA_ENGINE_SOUNDSHARE_TRANSMITTING" | "MEDIA_ENGINE_VIDEO_SOURCE_QUALITY_CHANGED" | "MEDIA_ENGINE_VIDEO_STATE_CHANGED" | "MEDIA_ENGINE_VOICE_ACTIVITY_DETECTION_ERROR" | "MEDIA_PLAYBACK_POSITION_UPDATE" | "MEDIA_PLAYBACK_RATE_UPDATE" | "MEDIA_POST_EMBED_FETCH" | "MEDIA_POST_EMBED_FETCH_FAILURE" | "MEDIA_POST_EMBED_FETCH_SUCCESS" | "MEDIA_SESSION_JOINED" | "MEMBER_SAFETY_GUILD_MEMBER_SEARCH_SUCCESS" | "MEMBER_SAFETY_GUILD_MEMBER_UPDATE_BATCH" | "MEMBER_SAFETY_NEW_MEMBER_TIMESTAMP_REFRESH" | "MEMBER_SAFETY_PAGINATION_TOKEN_UPDATE" | "MEMBER_SAFETY_PAGINATION_UPDATE" | "MEMBER_SAFETY_SEARCH_STATE_UPDATE" | "MEMBER_VERIFICATION_FORM_FETCH_FAIL" | "MEMBER_VERIFICATION_FORM_UPDATE" | "MESSAGE_ACK" | "MESSAGE_ACKED" | "MESSAGE_CREATE" | "MESSAGE_DELETE" | "MESSAGE_DELETE_BULK" | "MESSAGE_EDIT_FAILED_AUTOMOD" | "MESSAGE_END_EDIT" | "MESSAGE_EXPLICIT_CONTENT_FP_CREATE" | "MESSAGE_EXPLICIT_CONTENT_FP_SUBMIT" | "MESSAGE_EXPLICIT_CONTENT_SCAN_TIMEOUT" | "MESSAGE_GIFT_INTENT_SHOWN" | "MESSAGE_LENGTH_UPSELL" | "MESSAGE_NOTIFICATION_SHOWN" | "MESSAGE_PREVIEWS_LOADED" | "MESSAGE_PREVIEWS_LOCALLY_LOADED" | "MESSAGE_REACTION_ADD" | "MESSAGE_REACTION_ADD_MANY" | "MESSAGE_REACTION_ADD_USERS" | "MESSAGE_REACTION_REMOVE" | "MESSAGE_REACTION_REMOVE_ALL" | "MESSAGE_REACTION_REMOVE_EMOJI" | "MESSAGE_REMINDER_DUE" | "MESSAGE_REQUEST_ACCEPT_OPTIMISTIC" | "MESSAGE_REQUEST_ACK" | "MESSAGE_REQUEST_CLEAR_ACK" | "MESSAGE_REVEAL" | "MESSAGE_SEND_FAILED" | "MESSAGE_SEND_FAILED_AUTOMOD" | "MESSAGE_START_EDIT" | "MESSAGE_UPDATE" | "MESSAGE_UPDATE_EDIT" | "MFA_CLEAR_BACKUP_CODES" | "MFA_DISABLE_SUCCESS" | "MFA_ENABLE_SUCCESS" | "MFA_SEEN_BACKUP_CODE_PROMPT" | "MFA_SEND_VERIFICATION_KEY" | "MFA_SMS_TOGGLE" | "MFA_SMS_TOGGLE_COMPLETE" | "MFA_VIEW_BACKUP_CODES" | "MFA_WEBAUTHN_CREDENTIALS_LOADED" | "MOBILE_NATIVE_UPDATE_CHECK_FINISHED" | "MOBILE_WEB_SIDEBAR_CLOSE" | "MOBILE_WEB_SIDEBAR_OPEN" | "MODAL_POP" | "MODAL_PUSH" | "MOD_VIEW_SEARCH_FINISH" | "MULTI_ACCOUNT_INVALIDATE_PUSH_SYNC_TOKENS" | "MULTI_ACCOUNT_MOBILE_EXPERIMENT_UPDATE" | "MULTI_ACCOUNT_MOVE_ACCOUNT" | "MULTI_ACCOUNT_REMOVE_ACCOUNT" | "MULTI_ACCOUNT_UPDATE_PUSH_SYNC_TOKEN" | "MULTI_ACCOUNT_VALIDATE_TOKEN_FAILURE" | "MULTI_ACCOUNT_VALIDATE_TOKEN_REQUEST" | "MULTI_ACCOUNT_VALIDATE_TOKEN_SUCCESS" | "MUTUAL_FRIENDS_FETCH_FAILURE" | "MUTUAL_FRIENDS_FETCH_START" | "MUTUAL_FRIENDS_FETCH_SUCCESS" | "NATIVE_APP_MODAL_OPENED" | "NATIVE_APP_MODAL_OPENING" | "NATIVE_APP_MODAL_OPEN_FAILED" | "NATIVE_SCREEN_SHARE_PICKER_CANCEL" | "NATIVE_SCREEN_SHARE_PICKER_ERROR" | "NATIVE_SCREEN_SHARE_PICKER_PRESENT" | "NATIVE_SCREEN_SHARE_PICKER_RELEASE" | "NATIVE_SCREEN_SHARE_PICKER_UPDATE" | "NEWLY_ADDED_EMOJI_SEEN_ACKNOWLEDGED" | "NEWLY_ADDED_EMOJI_SEEN_PENDING" | "NEWLY_ADDED_EMOJI_SEEN_UPDATED" | "NEW_PAYMENT_SOURCE_ADDRESS_INFO_UPDATE" | "NEW_PAYMENT_SOURCE_CARD_INFO_UPDATE" | "NEW_PAYMENT_SOURCE_CLEAR_ERROR" | "NEW_PAYMENT_SOURCE_STRIPE_PAYMENT_REQUEST_UPDATE" | "NOTICE_DISABLE" | "NOTICE_DISMISS" | "NOTICE_SHOW" | "NOTIFICATIONS_SET_DESKTOP_TYPE" | "NOTIFICATIONS_SET_DISABLED_SOUNDS" | "NOTIFICATIONS_SET_DISABLE_UNREAD_BADGE" | "NOTIFICATIONS_SET_NOTIFY_MESSAGES_IN_SELECTED_CHANNEL" | "NOTIFICATIONS_SET_PERMISSION_STATE" | "NOTIFICATIONS_SET_TASKBAR_FLASH" | "NOTIFICATIONS_SET_TTS_TYPE" | "NOTIFICATIONS_TOGGLE_ALL_DISABLED" | "NOTIFICATION_CENTER_CLEAR_GUILD_MENTIONS" | "NOTIFICATION_CENTER_ITEMS_ACK" | "NOTIFICATION_CENTER_ITEMS_ACK_FAILURE" | "NOTIFICATION_CENTER_ITEMS_LOCAL_ACK" | "NOTIFICATION_CENTER_ITEM_COMPLETED" | "NOTIFICATION_CENTER_ITEM_CREATE" | "NOTIFICATION_CENTER_ITEM_DELETE" | "NOTIFICATION_CENTER_ITEM_DELETE_FAILURE" | "NOTIFICATION_CENTER_REFRESH" | "NOTIFICATION_CENTER_SET_ACTIVE" | "NOTIFICATION_CENTER_SET_TAB" | "NOTIFICATION_CENTER_TAB_FOCUSED" | "NOTIFICATION_CLICK" | "NOTIFICATION_CREATE" | "NOTIFICATION_SETTINGS_UPDATE" | "NOW_PLAYING_MOUNTED" | "NOW_PLAYING_UNMOUNTED" | "NUF_COMPLETE" | "NUF_NEW_USER" | "OAUTH2_TOKEN_REVOKE" | "ONLINE_GUILD_MEMBER_COUNT_UPDATE" | "OUTBOUND_PROMOTIONS_SEEN" | "OUTBOUND_PROMOTION_NOTICE_DISMISS" | "OVERLAY_ACTIVATE_REGION" | "OVERLAY_CALL_PRIVATE_CHANNEL" | "OVERLAY_CRASHED" | "OVERLAY_DEACTIVATE_ALL_REGIONS" | "OVERLAY_DISABLE_EXTERNAL_LINK_ALERT" | "OVERLAY_FOCUSED" | "OVERLAY_FORCE_RENDER_MODE" | "OVERLAY_INCOMPATIBLE_APP" | "OVERLAY_INITIALIZE" | "OVERLAY_JOIN_GAME" | "OVERLAY_MESSAGE_EVENT_ACTION" | "OVERLAY_NOTIFICATION_EVENT" | "OVERLAY_READY" | "OVERLAY_RELOAD" | "OVERLAY_RENDER_DEBUG_CLEAR_TRACKED_PIDS" | "OVERLAY_RENDER_DEBUG_MODE" | "OVERLAY_SELECT_CALL" | "OVERLAY_SELECT_CHANNEL" | "OVERLAY_SET_ASSOCIATED_GAME" | "OVERLAY_SET_AVATAR_SIZE_MODE" | "OVERLAY_SET_CLICK_ZONES" | "OVERLAY_SET_DISABLE_CLICKABLE_REGIONS" | "OVERLAY_SET_DISPLAY_NAME_MODE" | "OVERLAY_SET_DISPLAY_USER_MODE" | "OVERLAY_SET_ENABLED" | "OVERLAY_SET_GAME_INVITE_NOTIFICATION" | "OVERLAY_SET_GPU_BOOST_REQUESTED" | "OVERLAY_SET_INPUT_LOCKED" | "OVERLAY_SET_INVITE_MESSAGE" | "OVERLAY_SET_LIMITED_INTERACTION_OVERRIDE" | "OVERLAY_SET_NOTIFICATION_DISABLED_SETTING" | "OVERLAY_SET_NOTIFICATION_POSITION_MODE" | "OVERLAY_SET_NOT_IDLE" | "OVERLAY_SET_PREVIEW_IN_GAME_MODE" | "OVERLAY_SET_SHOW_KEYBIND_INDICATORS" | "OVERLAY_SET_TEXT_WIDGET_OPACITY" | "OVERLAY_SOUNDBOARD_SOUNDS_FETCH_REQUEST" | "OVERLAY_START_SESSION" | "OVERLAY_SUCCESSFULLY_SHOWN" | "OVERLAY_UPDATE_OVERLAY_METHOD" | "OVERLAY_UPDATE_OVERLAY_STATE" | "OVERLAY_WIDGET_CHANGED" | "PASSIVE_UPDATE_V2" | "PASSWORDLESS_FAILURE" | "PASSWORDLESS_START" | "PASSWORD_UPDATED" | "PAYMENT_AUTHENTICATION_CLEAR_ERROR" | "PAYMENT_AUTHENTICATION_ERROR" | "PAYMENT_UPDATE" | "PERMISSION_CLEAR_ELEVATED_PROCESS" | "PERMISSION_CLEAR_PTT_ADMIN_WARNING" | "PERMISSION_CLEAR_SUPPRESS_WARNING" | "PERMISSION_CLEAR_VAD_WARNING" | "PERMISSION_CONTINUE_NONELEVATED_PROCESS" | "PERMISSION_REQUEST_ELEVATED_PROCESS" | "PHONE_SET_COUNTRY_CODE" | "PICTURE_IN_PICTURE_CLOSE" | "PICTURE_IN_PICTURE_HIDE" | "PICTURE_IN_PICTURE_MOVE" | "PICTURE_IN_PICTURE_OPEN" | "PICTURE_IN_PICTURE_RESIZE" | "PICTURE_IN_PICTURE_SHOW" | "PICTURE_IN_PICTURE_UPDATE_RECT" | "PICTURE_IN_PICTURE_UPDATE_SELECTED_WINDOW" | "POGGERMODE_ACHIEVEMENT_UNLOCK" | "POGGERMODE_SETTINGS_UPDATE" | "POGGERMODE_TEMPORARILY_DISABLED" | "POGGERMODE_UPDATE_COMBO" | "POGGERMODE_UPDATE_MESSAGE_COMBO" | "POPOUT_WINDOW_ADD_STYLESHEET" | "POPOUT_WINDOW_CLOSE" | "POPOUT_WINDOW_OPEN" | "POPOUT_WINDOW_SET_ALWAYS_ON_TOP" | "POST_CONNECTION_OPEN" | "POTIONS_SET_CONFETTI_MODE" | "POTIONS_TRIGGER_MESSAGE_CONFETTI" | "PREMIUM_MARKETING_DATA_READY" | "PREMIUM_MARKETING_PREVIEW" | "PREMIUM_PAYMENT_ERROR_CLEAR" | "PREMIUM_PAYMENT_MODAL_CLOSE" | "PREMIUM_PAYMENT_MODAL_OPEN" | "PREMIUM_PAYMENT_SUBSCRIBE_FAIL" | "PREMIUM_PAYMENT_SUBSCRIBE_START" | "PREMIUM_PAYMENT_SUBSCRIBE_SUCCESS" | "PREMIUM_PAYMENT_UPDATE_FAIL" | "PREMIUM_PAYMENT_UPDATE_SUCCESS" | "PREMIUM_REQUIRED_MODAL_CLOSE" | "PREMIUM_REQUIRED_MODAL_OPEN" | "PRESENCES_REPLACE" | "PRESENCE_SUBSCRIPTIONS_ADD" | "PRESENCE_UPDATES" | "PRIVATE_CHANNEL_RECIPIENTS_ADD_USER" | "PRIVATE_CHANNEL_RECIPIENTS_INVITE_CLOSE" | "PRIVATE_CHANNEL_RECIPIENTS_INVITE_OPEN" | "PRIVATE_CHANNEL_RECIPIENTS_INVITE_QUERY" | "PRIVATE_CHANNEL_RECIPIENTS_INVITE_SELECT" | "PRIVATE_CHANNEL_RECIPIENTS_REMOVE_USER" | "PROFILE_CUSTOMIZATION_OPEN_PREVIEW_MODAL" | "PROFILE_EFFECTS_SET_TRY_IT_OUT" | "PROXY_BLOCKED_REQUEST" | "PUBLIC_UPSELL_NOTICE_DISMISS" | "PURCHASED_ITEMS_FESTIVITY_FETCH_WOW_MOMENT_MEDIA_FAILURE" | "PURCHASED_ITEMS_FESTIVITY_FETCH_WOW_MOMENT_MEDIA_SUCCESS" | "PURCHASED_ITEMS_FESTIVITY_IS_FETCHING_WOW_MOMENT_MEDIA" | "PURCHASED_ITEMS_FESTIVITY_SET_CAN_PLAY_WOW_MOMENT" | "PUSH_NOTIFICATION_CLICK" | "QUESTS_CLAIM_REWARD_BEGIN" | "QUESTS_CLAIM_REWARD_FAILURE" | "QUESTS_CLAIM_REWARD_SUCCESS" | "QUESTS_DELIVERY_OVERRIDE" | "QUESTS_DISMISS_CONTENT_BEGIN" | "QUESTS_DISMISS_CONTENT_FAILURE" | "QUESTS_DISMISS_CONTENT_SUCCESS" | "QUESTS_DISMISS_PROGRESS_TRACKING_FAILURE_NOTICE" | "QUESTS_ENROLL_BEGIN" | "QUESTS_ENROLL_FAILURE" | "QUESTS_ENROLL_SUCCESS" | "QUESTS_FETCH_CLAIMED_QUESTS_BEGIN" | "QUESTS_FETCH_CLAIMED_QUESTS_FAILURE" | "QUESTS_FETCH_CLAIMED_QUESTS_SUCCESS" | "QUESTS_FETCH_CURRENT_QUESTS_BEGIN" | "QUESTS_FETCH_CURRENT_QUESTS_FAILURE" | "QUESTS_FETCH_CURRENT_QUESTS_SUCCESS" | "QUESTS_FETCH_QUEST_TO_DELIVER_BEGIN" | "QUESTS_FETCH_QUEST_TO_DELIVER_FAILURE" | "QUESTS_FETCH_QUEST_TO_DELIVER_SUCCESS" | "QUESTS_FETCH_REWARD_CODE_BEGIN" | "QUESTS_FETCH_REWARD_CODE_FAILURE" | "QUESTS_FETCH_REWARD_CODE_SUCCESS" | "QUESTS_PREVIEW_UPDATE_SUCCESS" | "QUESTS_SELECT_TASK_PLATFORM" | "QUESTS_SEND_HEARTBEAT_FAILURE" | "QUESTS_SEND_HEARTBEAT_SUCCESS" | "QUESTS_UPDATE_OPTIMISTIC_PROGRESS" | "QUESTS_USER_COMPLETION_UPDATE" | "QUESTS_USER_STATUS_UPDATE" | "QUEUE_INTERACTION_COMPONENT_STATE" | "QUICKSWITCHER_HIDE" | "QUICKSWITCHER_SEARCH" | "QUICKSWITCHER_SELECT" | "QUICKSWITCHER_SHOW" | "QUICKSWITCHER_SWITCH_TO" | "RECEIVE_CHANNEL_AFFINITIES" | "RECEIVE_CHANNEL_SUMMARIES" | "RECEIVE_CHANNEL_SUMMARIES_BULK" | "RECEIVE_CHANNEL_SUMMARY" | "RECENT_MENTION_DELETE" | "RECOMPUTE_READ_STATES" | "REFERRALS_FETCH_ELIGIBLE_USER_FAIL" | "REFERRALS_FETCH_ELIGIBLE_USER_START" | "REFERRALS_FETCH_ELIGIBLE_USER_SUCCESS" | "REGISTER" | "REGISTER_SUCCESS" | "RELATIONSHIP_ADD" | "RELATIONSHIP_IGNORE_USER_SUCCESS" | "RELATIONSHIP_PENDING_INCOMING_REMOVED" | "RELATIONSHIP_REMOVE" | "RELATIONSHIP_UPDATE" | "REMOTE_COMMAND" | "REMOTE_SESSION_CONNECT" | "REMOTE_SESSION_DISCONNECT" | "REMOVE_AUTOMOD_MESSAGE_NOTICE" | "REPORT_AV_ERROR" | "REPORT_TO_MOD_REPORT_MESSAGE_SUCCESS" | "REQUEST_CHANNEL_AFFINITIES" | "REQUEST_CHANNEL_SUMMARIES" | "REQUEST_CHANNEL_SUMMARIES_BULK" | "REQUEST_CHANNEL_SUMMARY" | "REQUEST_FORUM_UNREADS" | "REQUEST_SOUNDBOARD_SOUNDS" | "RESET_NOTIFICATION_CENTER" | "RESET_PAYMENT_ID" | "RESET_PREVIEW_CLIENT_THEME" | "RESET_SOCKET" | "RESORT_THREADS" | "RPC_APP_AUTHENTICATED" | "RPC_APP_CONNECTED" | "RPC_APP_DISCONNECTED" | "RPC_NOTIFICATION_CREATE" | "RPC_SERVER_READY" | "RTC_CONNECTION_CLIENT_CONNECT" | "RTC_CONNECTION_CLIENT_DISCONNECT" | "RTC_CONNECTION_FLAGS" | "RTC_CONNECTION_LOSS_RATE" | "RTC_CONNECTION_PING" | "RTC_CONNECTION_PLATFORM" | "RTC_CONNECTION_REMOTE_VIDEO_SINK_WANTS" | "RTC_CONNECTION_ROSTER_MAP_UPDATE" | "RTC_CONNECTION_SECURE_FRAMES_UPDATE" | "RTC_CONNECTION_STATE" | "RTC_CONNECTION_UPDATE_ID" | "RTC_CONNECTION_USERS_MERGED" | "RTC_CONNECTION_VIDEO" | "RTC_DEBUG_MODAL_CLOSE" | "RTC_DEBUG_MODAL_OPEN" | "RTC_DEBUG_MODAL_OPEN_REPLAY" | "RTC_DEBUG_MODAL_OPEN_REPLAY_AT_PATH" | "RTC_DEBUG_MODAL_SET_SECTION" | "RTC_DEBUG_MODAL_UPDATE_VIDEO_OUTPUT" | "RTC_DEBUG_POPOUT_WINDOW_OPEN" | "RTC_DEBUG_SET_RECORDING_FLAG" | "RTC_DEBUG_SET_SIMULCAST_OVERRIDE" | "RTC_LATENCY_TEST_COMPLETE" | "RUNNING_GAMES_CHANGE" | "RUNNING_GAME_ADD_OVERRIDE" | "RUNNING_GAME_DELETE_ENTRY" | "RUNNING_GAME_EDIT_NAME" | "RUNNING_GAME_TOGGLE_DETECTION" | "RUNNING_GAME_TOGGLE_OVERLAY" | "RUNNING_STREAMER_TOOLS_CHANGE" | "SAFETY_HUB_APPEAL_CLOSE" | "SAFETY_HUB_APPEAL_OPEN" | "SAFETY_HUB_APPEAL_SIGNAL_CUSTOM_INPUT_CHANGE" | "SAFETY_HUB_APPEAL_SIGNAL_SELECT" | "SAFETY_HUB_AUTOMATED_UNDERAGE_APPEAL_MODAL_CLOSE" | "SAFETY_HUB_AUTOMATED_UNDERAGE_APPEAL_MODAL_OPEN" | "SAFETY_HUB_AUTOMATED_UNDERAGE_APPEAL_START_POLL" | "SAFETY_HUB_AUTOMATED_UNDERAGE_APPEAL_SUBMIT_SUCCESS" | "SAFETY_HUB_CHECK_AUTOMATED_UNDERAGE_APPEAL_FAILURE" | "SAFETY_HUB_CHECK_AUTOMATED_UNDERAGE_APPEAL_START" | "SAFETY_HUB_CHECK_AUTOMATED_UNDERAGE_APPEAL_SUCCESS" | "SAFETY_HUB_FETCH_CLASSIFICATION_FAILURE" | "SAFETY_HUB_FETCH_CLASSIFICATION_START" | "SAFETY_HUB_FETCH_CLASSIFICATION_SUCCESS" | "SAFETY_HUB_FETCH_FAILURE" | "SAFETY_HUB_FETCH_START" | "SAFETY_HUB_FETCH_SUCCESS" | "SAFETY_HUB_REQUEST_AUTOMATED_UNDERAGE_APPEAL_FAILURE" | "SAFETY_HUB_REQUEST_AUTOMATED_UNDERAGE_APPEAL_START" | "SAFETY_HUB_REQUEST_AUTOMATED_UNDERAGE_APPEAL_SUCCESS" | "SAFETY_HUB_REQUEST_REVIEW_FAILURE" | "SAFETY_HUB_REQUEST_REVIEW_START" | "SAFETY_HUB_REQUEST_REVIEW_SUCCESS" | "SAVED_MESSAGES_UPDATE" | "SAVED_MESSAGE_CREATE" | "SAVED_MESSAGE_DELETE" | "SAVE_LAST_NON_VOICE_ROUTE" | "SAVE_LAST_ROUTE" | "SCHEDULED_MESSAGES_CREATE_SUCCESS" | "SCHEDULED_MESSAGES_DELETE_FAILURE" | "SCHEDULED_MESSAGES_DELETE_START" | "SCHEDULED_MESSAGES_DELETE_SUCCESS" | "SEARCH_ADD_HISTORY" | "SEARCH_AUTOCOMPLETE_QUERY_UPDATE" | "SEARCH_CLEAR_HISTORY" | "SEARCH_EDITOR_STATE_CHANGE" | "SEARCH_EDITOR_STATE_CLEAR" | "SEARCH_ENSURE_SEARCH_STATE" | "SEARCH_FINISH" | "SEARCH_INDEXING" | "SEARCH_MESSAGES_CLEAR_ALL" | "SEARCH_MESSAGES_FAILURE" | "SEARCH_MESSAGES_INDEXING" | "SEARCH_MESSAGES_START" | "SEARCH_MESSAGES_SUCCESS" | "SEARCH_RECENT_MESSAGES_CLEAR" | "SEARCH_REMOVE_HISTORY" | "SEARCH_RESULTS_QUERY_UPDATE" | "SEARCH_SCREEN_OPEN" | "SEARCH_SET_SHOW_BLOCKED_RESULTS" | "SEARCH_SET_SHOW_NO_RESULTS_ALT" | "SEARCH_START" | "SECURE_FRAMES_SETTINGS_UPDATE" | "SECURE_FRAMES_TRANSIENT_KEY_CREATE" | "SECURE_FRAMES_TRANSIENT_KEY_DELETE" | "SECURE_FRAMES_UPLOADED_KEY_VERSION_ADD" | "SECURE_FRAMES_UPLOADED_KEY_VERSION_CLEAR" | "SECURE_FRAMES_USER_VERIFIED_KEYS_DELETE" | "SECURE_FRAMES_VERIFIED_KEY_CREATE" | "SECURE_FRAMES_VERIFIED_KEY_DELETE" | "SELECTIVELY_SYNCED_USER_SETTINGS_UPDATE" | "SELECT_HOME_RESOURCE_CHANNEL" | "SELECT_NEW_MEMBER_ACTION_CHANNEL" | "SELF_PRESENCE_STORE_UPDATE" | "SESSIONS_REPLACE" | "SET_CHANNEL_BITRATE" | "SET_CHANNEL_VIDEO_QUALITY_MODE" | "SET_CONSENT_REQUIRED" | "SET_CREATED_AT_OVERRIDE" | "SET_GUILD_FOLDER_EXPANDED" | "SET_GUILD_LEADERBOARD" | "SET_HIGHLIGHTED_SUMMARY" | "SET_INTERACTION_COMPONENT_STATE" | "SET_LOCATION_METADATA" | "SET_NATIVE_PERMISSION" | "SET_PENDING_REPLY_SHOULD_MENTION" | "SET_PREMIUM_TYPE_OVERRIDE" | "SET_PREVIOUS_GO_LIVE_SETTINGS" | "SET_RECENTLY_ACTIVE_COLLAPSED" | "SET_RECENT_MENTIONS_FILTER" | "SET_RECENT_MENTIONS_STALE" | "SET_RPC_NOTIFICATION_SETTINGS" | "SET_SELECTED_SUMMARY" | "SET_SOUNDPACK" | "SET_STREAM_APP_INTENT" | "SET_SUMMARY_FEEDBACK" | "SET_THEME_OVERRIDE" | "SET_TTS_SPEECH_RATE" | "SET_USER_LEADERBOARD_LAST_UPDATE_REQUESTED" | "SET_VAD_PERMISSION" | "SHARED_CANVAS_CLEAR_DRAWABLES" | "SHARED_CANVAS_DRAW_LINE_POINT" | "SHARED_CANVAS_SET_DRAW_MODE" | "SHARED_CANVAS_UPDATE_EMOJI_HOSE" | "SHARED_CANVAS_UPDATE_LINE_POINTS" | "SHOW_ACTION_SHEET" | "SHOW_ACTION_SHEET_QUICK_SWITCHER" | "SHOW_KEYBOARD_SHORTCUTS" | "SIDEBAR_CLOSE" | "SIDEBAR_CLOSE_GUILD" | "SIDEBAR_CREATE_THREAD" | "SIDEBAR_VIEW_CHANNEL" | "SIDEBAR_VIEW_GUILD" | "SKUS_FETCH_SUCCESS" | "SKU_FETCH_FAIL" | "SKU_FETCH_START" | "SKU_FETCH_SUCCESS" | "SKU_PURCHASE_AWAIT_CONFIRMATION" | "SKU_PURCHASE_CLEAR_ERROR" | "SKU_PURCHASE_FAIL" | "SKU_PURCHASE_MODAL_CLOSE" | "SKU_PURCHASE_MODAL_OPEN" | "SKU_PURCHASE_PREVIEW_FETCH" | "SKU_PURCHASE_PREVIEW_FETCH_FAILURE" | "SKU_PURCHASE_PREVIEW_FETCH_SUCCESS" | "SKU_PURCHASE_SHOW_CONFIRMATION_STEP" | "SKU_PURCHASE_START" | "SKU_PURCHASE_SUCCESS" | "SKU_PURCHASE_UPDATE_IS_GIFT" | "SLOWMODE_RESET_COOLDOWN" | "SLOWMODE_SET_COOLDOWN" | "SOUNDBOARD_FETCH_DEFAULT_SOUNDS" | "SOUNDBOARD_FETCH_DEFAULT_SOUNDS_SUCCESS" | "SOUNDBOARD_MUTE_JOIN_SOUND" | "SOUNDBOARD_SET_OVERLAY_ENABLED" | "SOUNDBOARD_SOUNDS_RECEIVED" | "SPEAKING" | "SPEAKING_MESSAGE" | "SPEAK_MESSAGE" | "SPEAK_TEXT" | "SPELLCHECK_LEARN_WORD" | "SPELLCHECK_TOGGLE" | "SPELLCHECK_UNLEARN_WORD" | "SPOTIFY_ACCOUNT_ACCESS_TOKEN" | "SPOTIFY_ACCOUNT_ACCESS_TOKEN_REVOKE" | "SPOTIFY_NEW_TRACK" | "SPOTIFY_PLAYER_PAUSE" | "SPOTIFY_PLAYER_PLAY" | "SPOTIFY_PLAYER_STATE" | "SPOTIFY_PROFILE_UPDATE" | "SPOTIFY_SET_ACTIVE_DEVICE" | "SPOTIFY_SET_DEVICES" | "SPOTIFY_SET_PROTOCOL_REGISTERED" | "STAGE_INSTANCE_CREATE" | "STAGE_INSTANCE_DELETE" | "STAGE_INSTANCE_UPDATE" | "STAGE_MUSIC_MUTE" | "STAGE_MUSIC_PLAY" | "START_SESSION" | "STATUS_PAGE_INCIDENT" | "STATUS_PAGE_SCHEDULED_MAINTENANCE" | "STATUS_PAGE_SCHEDULED_MAINTENANCE_ACK" | "STICKER_FETCH_SUCCESS" | "STICKER_PACKS_FETCH_START" | "STICKER_PACKS_FETCH_SUCCESS" | "STICKER_PACK_FETCH_SUCCESS" | "STICKER_TRACK_USAGE" | "STOP_SPEAKING" | "STORE_LISTINGS_FETCH_FAIL" | "STORE_LISTINGS_FETCH_START" | "STORE_LISTINGS_FETCH_SUCCESS" | "STORE_LISTING_FETCH_SUCCESS" | "STREAMER_MODE_UPDATE" | "STREAMING_UPDATE" | "STREAM_CLOSE" | "STREAM_CREATE" | "STREAM_DELETE" | "STREAM_LAYOUT_UPDATE" | "STREAM_PREVIEW_FETCH_FAIL" | "STREAM_PREVIEW_FETCH_START" | "STREAM_PREVIEW_FETCH_SUCCESS" | "STREAM_SERVER_UPDATE" | "STREAM_SET_PAUSED" | "STREAM_START" | "STREAM_STOP" | "STREAM_TIMED_OUT" | "STREAM_UPDATE" | "STREAM_UPDATE_SELF_HIDDEN" | "STREAM_UPDATE_SETTINGS" | "STREAM_WATCH" | "STRIPE_TOKEN_FAILURE" | "SUBSCRIPTION_PLANS_FETCH" | "SUBSCRIPTION_PLANS_FETCH_FAILURE" | "SUBSCRIPTION_PLANS_FETCH_SUCCESS" | "SUBSCRIPTION_PLANS_RESET" | "SURVEY_FETCHED" | "SURVEY_HIDE" | "SURVEY_OVERRIDE" | "SURVEY_SEEN" | "SYSTEM_THEME_CHANGE" | "THERMAL_STATE_CHANGE" | "THREAD_CREATE" | "THREAD_CREATE_LOCAL" | "THREAD_DELETE" | "THREAD_LIST_SYNC" | "THREAD_MEMBERS_UPDATE" | "THREAD_MEMBER_LIST_UPDATE" | "THREAD_MEMBER_LOCAL_UPDATE" | "THREAD_MEMBER_UPDATE" | "THREAD_SETTINGS_DRAFT_CHANGE" | "THREAD_UPDATE" | "TOGGLE_GUILD_EXPANDED_STATE" | "TOGGLE_GUILD_FOLDER_EXPAND" | "TOGGLE_OVERLAY_CANVAS" | "TOGGLE_TOPICS_BAR" | "TOP_EMOJIS_FETCH" | "TOP_EMOJIS_FETCH_SUCCESS" | "TRUNCATE_MENTIONS" | "TRUNCATE_MESSAGES" | "TRY_ACK" | "TUTORIAL_INDICATOR_DISMISS" | "TUTORIAL_INDICATOR_HIDE" | "TUTORIAL_INDICATOR_SHOW" | "TUTORIAL_INDICATOR_SUPPRESS_ALL" | "TYPING_START" | "TYPING_START_LOCAL" | "TYPING_STOP" | "TYPING_STOP_LOCAL" | "UNSYNCED_USER_SETTINGS_UPDATE" | "UNVERIFIED_GAME_UPDATE" | "UPCOMING_GUILD_EVENT_NOTICE_HIDE" | "UPCOMING_GUILD_EVENT_NOTICE_SEEN" | "UPDATE_AVAILABLE" | "UPDATE_BACKGROUND_GRADIENT_PRESET" | "UPDATE_CHANNEL_DIMENSIONS" | "UPDATE_CHANNEL_LIST_DIMENSIONS" | "UPDATE_CHANNEL_LIST_SUBTITLES" | "UPDATE_CHAT_WALLPAPER_FLAG_COMPLETE" | "UPDATE_CHAT_WALLPAPER_FLAG_START" | "UPDATE_CHAT_WALLPAPER_OVERRIDES" | "UPDATE_CLIENT_PREMIUM_TYPE" | "UPDATE_CONSENTS" | "UPDATE_DATA_HARVEST_TYPE" | "UPDATE_DOWNLOADED" | "UPDATE_ERROR" | "UPDATE_GUILD_LIST_DIMENSIONS" | "UPDATE_MANUALLY" | "UPDATE_MOBILE_PENDING_THEME_INDEX" | "UPDATE_NOT_AVAILABLE" | "UPDATE_STRANGER_STATUS" | "UPDATE_THEME_PREFERENCES" | "UPDATE_TOKEN" | "UPDATE_VISIBLE_MESSAGES" | "UPLOAD_ATTACHMENT_ADD_FILES" | "UPLOAD_ATTACHMENT_CLEAR_ALL_FILES" | "UPLOAD_ATTACHMENT_POP_FILE" | "UPLOAD_ATTACHMENT_REMOVE_FILE" | "UPLOAD_ATTACHMENT_REMOVE_FILES" | "UPLOAD_ATTACHMENT_SET_FILE" | "UPLOAD_ATTACHMENT_SET_UPLOADS" | "UPLOAD_ATTACHMENT_UPDATE_FILE" | "UPLOAD_CANCEL_REQUEST" | "UPLOAD_COMPLETE" | "UPLOAD_COMPRESSION_PROGRESS" | "UPLOAD_FAIL" | "UPLOAD_FILE_UPDATE" | "UPLOAD_ITEM_CANCEL_REQUEST" | "UPLOAD_PROGRESS" | "UPLOAD_RESTORE_FAILED_UPLOAD" | "UPLOAD_START" | "USER_ACTIVITY_STATISTICS_FETCH_SUCCESS" | "USER_APPLICATION_REMOVE" | "USER_APPLICATION_UPDATE" | "USER_APPLIED_BOOSTS_FETCH_START" | "USER_APPLIED_BOOSTS_FETCH_SUCCESS" | "USER_AUTHORIZED_APPS_REQUEST" | "USER_AUTHORIZED_APPS_UPDATE" | "USER_CONNECTIONS_CALLBACK" | "USER_CONNECTIONS_INTEGRATION_JOINING" | "USER_CONNECTIONS_INTEGRATION_JOINING_ERROR" | "USER_CONNECTIONS_UPDATE" | "USER_CONNECTION_UPDATE" | "USER_GUILD_JOIN_REQUEST_COACHMARK_CLEAR" | "USER_GUILD_JOIN_REQUEST_COACHMARK_SHOW" | "USER_GUILD_JOIN_REQUEST_COOLDOWN_FETCH" | "USER_GUILD_JOIN_REQUEST_UPDATE" | "USER_GUILD_SETTINGS_CHANNEL_UPDATE" | "USER_GUILD_SETTINGS_CHANNEL_UPDATE_BULK" | "USER_GUILD_SETTINGS_FULL_UPDATE" | "USER_GUILD_SETTINGS_GUILD_AND_CHANNELS_UPDATE" | "USER_GUILD_SETTINGS_GUILD_UPDATE" | "USER_GUILD_SETTINGS_REMOVE_PENDING_CHANNEL_UPDATES" | "USER_JOIN_REQUEST_GUILDS_FETCH" | "USER_NON_CHANNEL_ACK" | "USER_NOTE_LOAD_START" | "USER_NOTE_UPDATE" | "USER_PAYMENT_BROWSER_CHECKOUT_DONE" | "USER_PAYMENT_BROWSER_CHECKOUT_STARTED" | "USER_PAYMENT_CLIENT_ADD" | "USER_PROFILE_EFFECTS_FETCH" | "USER_PROFILE_EFFECTS_FETCH_FAILURE" | "USER_PROFILE_EFFECTS_FETCH_SUCCESS" | "USER_PROFILE_FETCH_FAILURE" | "USER_PROFILE_FETCH_START" | "USER_PROFILE_FETCH_SUCCESS" | "USER_PROFILE_MODAL_CLOSE" | "USER_PROFILE_MODAL_OPEN" | "USER_PROFILE_PIN_BADGES_ON_CLIENT" | "USER_PROFILE_SIDEBAR_TOGGLE_SECTION" | "USER_PROFILE_UPDATE_FAILURE" | "USER_PROFILE_UPDATE_START" | "USER_PROFILE_UPDATE_SUCCESS" | "USER_REQUIRED_ACTION_UPDATE" | "USER_SETTINGS_ACCOUNT_CLOSE" | "USER_SETTINGS_ACCOUNT_INIT" | "USER_SETTINGS_ACCOUNT_RESET_AND_CLOSE_FORM" | "USER_SETTINGS_ACCOUNT_RESET_PENDING_LEGACY_USERNAME_DISABLED" | "USER_SETTINGS_ACCOUNT_SET_PENDING_ACCENT_COLOR" | "USER_SETTINGS_ACCOUNT_SET_PENDING_AVATAR" | "USER_SETTINGS_ACCOUNT_SET_PENDING_AVATAR_DECORATION" | "USER_SETTINGS_ACCOUNT_SET_PENDING_BANNER" | "USER_SETTINGS_ACCOUNT_SET_PENDING_BIO" | "USER_SETTINGS_ACCOUNT_SET_PENDING_GLOBAL_NAME" | "USER_SETTINGS_ACCOUNT_SET_PENDING_LEGACY_USERNAME_DISABLED" | "USER_SETTINGS_ACCOUNT_SET_PENDING_NAMEPLATE" | "USER_SETTINGS_ACCOUNT_SET_PENDING_PROFILE_EFFECT_ID" | "USER_SETTINGS_ACCOUNT_SET_PENDING_PRONOUNS" | "USER_SETTINGS_ACCOUNT_SET_PENDING_THEME_COLORS" | "USER_SETTINGS_ACCOUNT_SET_SINGLE_TRY_IT_OUT_COLLECTIBLES_ITEM" | "USER_SETTINGS_ACCOUNT_SET_TRY_IT_OUT_AVATAR" | "USER_SETTINGS_ACCOUNT_SET_TRY_IT_OUT_AVATAR_DECORATION" | "USER_SETTINGS_ACCOUNT_SET_TRY_IT_OUT_BANNER" | "USER_SETTINGS_ACCOUNT_SET_TRY_IT_OUT_PRESET" | "USER_SETTINGS_ACCOUNT_SET_TRY_IT_OUT_PROFILE_EFFECT_ID" | "USER_SETTINGS_ACCOUNT_SET_TRY_IT_OUT_THEME_COLORS" | "USER_SETTINGS_ACCOUNT_SUBMIT" | "USER_SETTINGS_ACCOUNT_SUBMIT_FAILURE" | "USER_SETTINGS_ACCOUNT_SUBMIT_SUCCESS" | "USER_SETTINGS_CLEAR_ERRORS" | "USER_SETTINGS_LOCALE_OVERRIDE" | "USER_SETTINGS_MODAL_CLEAR_SCROLL_POSITION" | "USER_SETTINGS_MODAL_CLEAR_SUBSECTION" | "USER_SETTINGS_MODAL_CLOSE" | "USER_SETTINGS_MODAL_INIT" | "USER_SETTINGS_MODAL_OPEN" | "USER_SETTINGS_MODAL_RESET" | "USER_SETTINGS_MODAL_SET_SECTION" | "USER_SETTINGS_MODAL_SUBMIT" | "USER_SETTINGS_MODAL_SUBMIT_COMPLETE" | "USER_SETTINGS_MODAL_SUBMIT_FAILURE" | "USER_SETTINGS_MODAL_UPDATE_ACCOUNT" | "USER_SETTINGS_OVERRIDE_APPLY" | "USER_SETTINGS_OVERRIDE_CLEAR" | "USER_SETTINGS_PROTO_ENQUEUE_UPDATE" | "USER_SETTINGS_PROTO_LOAD_IF_NECESSARY" | "USER_SETTINGS_PROTO_UPDATE" | "USER_SETTINGS_PROTO_UPDATE_EDIT_INFO" | "USER_SETTINGS_RESET_ALL_PENDING" | "USER_SETTINGS_RESET_ALL_TRY_IT_OUT" | "USER_SETTINGS_RESET_PENDING_ACCOUNT_CHANGES" | "USER_SETTINGS_RESET_PENDING_AVATAR_DECORATION" | "USER_SETTINGS_RESET_PENDING_PRIMARY_GUILD_CHANGES" | "USER_SETTINGS_RESET_PENDING_PROFILE_CHANGES" | "USER_SETTINGS_SET_PENDING_PRIMARY_GUILD_ID" | "USER_SOUNDBOARD_SET_VOLUME" | "USER_UPDATE" | "VIDEO_FILTER_ASSETS_FETCH_SUCCESS" | "VIDEO_FILTER_ASSET_DELETE_SUCCESS" | "VIDEO_FILTER_ASSET_UPLOAD_SUCCESS" | "VIDEO_SAVE_LAST_USED_BACKGROUND_OPTION" | "VIDEO_SIZE_UPDATE" | "VIDEO_STREAM_READY_TIMEOUT" | "VIEW_HISTORY_MARK_VIEW" | "VIRTUAL_CURRENCY_BALANCE_FETCH" | "VIRTUAL_CURRENCY_BALANCE_FETCH_FAIL" | "VIRTUAL_CURRENCY_BALANCE_FETCH_SUCCESS" | "VIRTUAL_CURRENCY_BALANCE_UPDATE" | "VIRTUAL_CURRENCY_EARNED_ORBS_COACHMARK_CLOSE" | "VIRTUAL_CURRENCY_EARNED_ORBS_COACHMARK_OPEN" | "VIRTUAL_CURRENCY_ONBOARDING_MODAL_OPEN" | "VIRTUAL_CURRENCY_ONBOARDING_MODAL_RESET" | "VIRTUAL_CURRENCY_REDEEM_FAIL" | "VIRTUAL_CURRENCY_REDEEM_START" | "VIRTUAL_CURRENCY_REDEEM_SUCCESS" | "VIRTUAL_CURRENCY_SET_BALANCE_PILL_OVERLAY" | "VOICE_CATEGORY_COLLAPSE" | "VOICE_CATEGORY_EXPAND" | "VOICE_CHANNEL_EFFECT_CLEAR" | "VOICE_CHANNEL_EFFECT_RECENT_EMOJI" | "VOICE_CHANNEL_EFFECT_SEND" | "VOICE_CHANNEL_EFFECT_SENT_LOCAL" | "VOICE_CHANNEL_EFFECT_TOGGLE_ANIMATION_TYPE" | "VOICE_CHANNEL_EFFECT_UPDATE_TIME_STAMP" | "VOICE_CHANNEL_SELECT" | "VOICE_CHANNEL_STATUS_UPDATE" | "VOICE_FILTER_APPLIED" | "VOICE_FILTER_APPLY_FAILED" | "VOICE_FILTER_CATALOG_FETCH_FAILED" | "VOICE_FILTER_CATALOG_FETCH_SUCCESS" | "VOICE_FILTER_DEV_TOOLS_SET_UPDATE_TIME" | "VOICE_FILTER_DOWNLOAD_FAILED" | "VOICE_FILTER_DOWNLOAD_PROGRESS" | "VOICE_FILTER_DOWNLOAD_STARTED" | "VOICE_FILTER_FILE_READY" | "VOICE_FILTER_LAGGING" | "VOICE_FILTER_LOOPBACK_TOGGLE" | "VOICE_FILTER_NATIVE_MODULE_STATE_CHANGE" | "VOICE_FILTER_REQUEST_SWITCH" | "VOICE_FILTER_UPDATE_LIMITED_TIME_VOICES" | "VOICE_SERVER_UPDATE" | "VOICE_STATE_UPDATES" | "WAIT_FOR_REMOTE_SESSION" | "WEBHOOKS_FETCHING" | "WEBHOOKS_UPDATE" | "WEBHOOK_CREATE" | "WEBHOOK_DELETE" | "WEBHOOK_UPDATE" | "WELCOME_SCREEN_FETCH_FAIL" | "WELCOME_SCREEN_FETCH_START" | "WELCOME_SCREEN_FETCH_SUCCESS" | "WELCOME_SCREEN_SETTINGS_CLEAR" | "WELCOME_SCREEN_SETTINGS_RESET" | "WELCOME_SCREEN_SETTINGS_UPDATE" | "WELCOME_SCREEN_SUBMIT" | "WELCOME_SCREEN_SUBMIT_FAILURE" | "WELCOME_SCREEN_SUBMIT_SUCCESS" | "WELCOME_SCREEN_UPDATE" | "WELCOME_SCREEN_VIEW" | "WINDOW_FOCUS" | "WINDOW_FULLSCREEN_CHANGE" | "WINDOW_HIDDEN" | "WINDOW_INIT" | "WINDOW_RESIZED" | "WINDOW_UNLOAD" | "WINDOW_VISIBILITY_CHANGE" | "WRITE_CACHES"; +export type FluxEvents = "ACCESSIBILITY_COLORBLIND_TOGGLE" | "ACCESSIBILITY_DARK_SIDEBAR_TOGGLE" | "ACCESSIBILITY_DESATURATE_ROLES_TOGGLE" | "ACCESSIBILITY_FORCED_COLORS_MODAL_SEEN" | "ACCESSIBILITY_KEYBOARD_MODE_DISABLE" | "ACCESSIBILITY_KEYBOARD_MODE_ENABLE" | "ACCESSIBILITY_LOW_CONTRAST_TOGGLE" | "ACCESSIBILITY_RESET_TO_DEFAULT" | "ACCESSIBILITY_SET_ALWAYS_SHOW_LINK_DECORATIONS" | "ACCESSIBILITY_SET_CONTRAST" | "ACCESSIBILITY_SET_CONTRAST_MODE" | "ACCESSIBILITY_SET_DISPLAY_NAME_STYLES_ENABLED" | "ACCESSIBILITY_SET_ENABLE_CUSTOM_CURSOR" | "ACCESSIBILITY_SET_FONT_SIZE" | "ACCESSIBILITY_SET_MESSAGE_GROUP_SPACING" | "ACCESSIBILITY_SET_PREFERS_REDUCED_MOTION" | "ACCESSIBILITY_SET_ROLE_STYLE" | "ACCESSIBILITY_SET_SATURATION" | "ACCESSIBILITY_SET_SWITCH_ICONS_ENABLED" | "ACCESSIBILITY_SET_SYNC_FORCED_COLORS" | "ACCESSIBILITY_SET_ZOOM" | "ACCESSIBILITY_SUBMIT_BUTTON_TOGGLE" | "ACCESSIBILITY_SYNC_PROFILE_THEME_WITH_USER_THEME_TOGGLE" | "ACCESSIBILITY_SYSTEM_COLOR_PREFERENCES_CHANGED" | "ACCESSIBILITY_SYSTEM_PREFERS_CONTRAST_CHANGED" | "ACCESSIBILITY_SYSTEM_PREFERS_CROSSFADES_CHANGED" | "ACCESSIBILITY_SYSTEM_PREFERS_REDUCED_MOTION_CHANGED" | "ACKNOWLEDGE_CHANNEL_SAFETY_WARNING_TOOLTIP" | "ACK_APPROVED_GUILD_JOIN_REQUEST" | "ACTIVE_AV_ERRORS_CHANGED" | "ACTIVE_BOGO_PROMOTION_FETCH" | "ACTIVE_BOGO_PROMOTION_FETCH_FAIL" | "ACTIVE_BOGO_PROMOTION_FETCH_SUCCESS" | "ACTIVE_CHANNELS_FETCH_FAILURE" | "ACTIVE_CHANNELS_FETCH_START" | "ACTIVE_CHANNELS_FETCH_SUCCESS" | "ACTIVE_PROMOTIONS_FETCH" | "ACTIVE_PROMOTIONS_FETCH_FAIL" | "ACTIVE_PROMOTIONS_FETCH_SUCCESS" | "ACTIVITY_INVITE_EDUCATION_DISMISS" | "ACTIVITY_INVITE_MODAL_CLOSE" | "ACTIVITY_INVITE_MODAL_OPEN" | "ACTIVITY_INVITE_MODAL_QUERY" | "ACTIVITY_INVITE_MODAL_SEND" | "ACTIVITY_JOIN" | "ACTIVITY_JOIN_FAILED" | "ACTIVITY_JOIN_LOADING" | "ACTIVITY_LAUNCH_FAIL" | "ACTIVITY_LAYOUT_MODE_UPDATE" | "ACTIVITY_METADATA_UPDATE" | "ACTIVITY_PLAY" | "ACTIVITY_POPOUT_WINDOW_OPEN" | "ACTIVITY_SCREEN_ORIENTATION_UPDATE" | "ACTIVITY_START" | "ACTIVITY_SYNC" | "ACTIVITY_SYNC_STOP" | "ACTIVITY_UPDATE_FAIL" | "ACTIVITY_UPDATE_START" | "ACTIVITY_UPDATE_SUCCESS" | "ACTIVITY_USER_ACTION" | "ADD_STICKER_PREVIEW" | "ADD_USERS_TO_GROUP" | "ADMIN_ONBOARDING_GUIDE_HIDE" | "ADYEN_CASH_APP_PAY_SUBMIT_SUCCESS" | "ADYEN_CREATE_CASH_APP_PAY_COMPONENT_SUCCESS" | "ADYEN_CREATE_CLIENT_SUCCESS" | "ADYEN_TEARDOWN_CLIENT" | "AD_PERSONALIZATION_TOGGLES_RESTRICTED" | "AFK" | "AGE_GATE_FAILURE_MODAL_OPEN" | "AGE_GATE_LOGOUT_UNDERAGE_NEW_USER" | "AGE_GATE_MODAL_CLOSE" | "AGE_GATE_MODAL_OPEN" | "AGE_GATE_PREVENT_UNDERAGE_REGISTRATION" | "AGE_GATE_SUCCESS_MODAL_OPEN" | "AGE_VERIFICATION_CHECK_RESULT_SET" | "AGE_VERIFICATION_METHODS_LOAD_FAILURE" | "AGE_VERIFICATION_METHODS_LOAD_START" | "AGE_VERIFICATION_METHODS_LOAD_SUCCESS" | "AGE_VERIFICATION_RESET" | "ANALYTICS_LOG_CLEAR" | "APEX_EXPERIMENTS_FETCH_FAILURE" | "APEX_EXPERIMENTS_FETCH_START" | "APEX_EXPERIMENTS_FETCH_SUCCESS" | "APEX_EXPERIMENTS_METADATA_FETCH_SUCCESS" | "APEX_EXPERIMENT_CLEAR_FOR_TESTS" | "APEX_EXPERIMENT_OVERRIDE_CLEAR" | "APEX_EXPERIMENT_OVERRIDE_CREATE" | "APEX_EXPERIMENT_OVERRIDE_DELETE" | "APEX_EXPERIMENT_SESSION_OVERRIDE_CREATE" | "APEX_EXPERIMENT_SESSION_OVERRIDE_DELETE" | "APPLICATIONS_FETCH" | "APPLICATIONS_FETCH_FAIL" | "APPLICATIONS_FETCH_SUCCESS" | "APPLICATION_ACTIVITY_STATISTICS_FETCH_FAIL" | "APPLICATION_ACTIVITY_STATISTICS_FETCH_START" | "APPLICATION_ACTIVITY_STATISTICS_FETCH_SUCCESS" | "APPLICATION_ASSETS_FETCH" | "APPLICATION_ASSETS_FETCH_SUCCESS" | "APPLICATION_ASSETS_UPDATE" | "APPLICATION_BRANCHES_FETCH_FAIL" | "APPLICATION_BRANCHES_FETCH_SUCCESS" | "APPLICATION_BUILD_FETCH_START" | "APPLICATION_BUILD_FETCH_SUCCESS" | "APPLICATION_BUILD_NOT_FOUND" | "APPLICATION_BUILD_SIZE_FETCH_FAIL" | "APPLICATION_BUILD_SIZE_FETCH_START" | "APPLICATION_BUILD_SIZE_FETCH_SUCCESS" | "APPLICATION_COMMAND_AUTOCOMPLETE_REQUEST" | "APPLICATION_COMMAND_AUTOCOMPLETE_RESPONSE" | "APPLICATION_COMMAND_EXECUTE_BAD_VERSION" | "APPLICATION_COMMAND_INDEX_FETCH_FAILURE" | "APPLICATION_COMMAND_INDEX_FETCH_REQUEST" | "APPLICATION_COMMAND_INDEX_FETCH_SUCCESS" | "APPLICATION_COMMAND_SET_ACTIVE_COMMAND" | "APPLICATION_COMMAND_SET_PREFERRED_COMMAND" | "APPLICATION_COMMAND_UPDATE_CHANNEL_STATE" | "APPLICATION_COMMAND_UPDATE_OPTIONS" | "APPLICATION_COMMAND_USED" | "APPLICATION_DIRECTORY_FETCH_APPLICATION" | "APPLICATION_DIRECTORY_FETCH_APPLICATION_FAILURE" | "APPLICATION_DIRECTORY_FETCH_APPLICATION_SUCCESS" | "APPLICATION_DIRECTORY_FETCH_CATEGORIES_SUCCESS" | "APPLICATION_DIRECTORY_FETCH_COLLECTIONS" | "APPLICATION_DIRECTORY_FETCH_COLLECTIONS_FAILURE" | "APPLICATION_DIRECTORY_FETCH_COLLECTIONS_SUCCESS" | "APPLICATION_DIRECTORY_FETCH_SEARCH" | "APPLICATION_DIRECTORY_FETCH_SEARCH_FAILURE" | "APPLICATION_DIRECTORY_FETCH_SEARCH_SUCCESS" | "APPLICATION_DIRECTORY_FETCH_SIMILAR_APPLICATIONS" | "APPLICATION_DIRECTORY_FETCH_SIMILAR_APPLICATIONS_FAILURE" | "APPLICATION_DIRECTORY_FETCH_SIMILAR_APPLICATIONS_SUCCESS" | "APPLICATION_FETCH" | "APPLICATION_FETCH_FAIL" | "APPLICATION_FETCH_SUCCESS" | "APPLICATION_STORE_ACCEPT_EULA" | "APPLICATION_STORE_ACCEPT_STORE_TERMS" | "APPLICATION_STORE_CLEAR_DATA" | "APPLICATION_STORE_DIRECTORY_LAYOUT_FETCHING" | "APPLICATION_STORE_DIRECTORY_LAYOUT_FETCH_FAILED" | "APPLICATION_STORE_DIRECTORY_LAYOUT_FETCH_SUCCESS" | "APPLICATION_STORE_LOCATION_CHANGE" | "APPLICATION_STORE_MATURE_AGREE" | "APPLICATION_STORE_RESET_NAVIGATION" | "APPLICATION_SUBSCRIPTIONS_CHANNEL_NOTICE_DISMISSED" | "APPLICATION_SUBSCRIPTIONS_FETCH_ENTITLEMENTS" | "APPLICATION_SUBSCRIPTIONS_FETCH_ENTITLEMENTS_FAILURE" | "APPLICATION_SUBSCRIPTIONS_FETCH_ENTITLEMENTS_SUCCESS" | "APPLICATION_SUBSCRIPTIONS_FETCH_LISTINGS" | "APPLICATION_SUBSCRIPTIONS_FETCH_LISTINGS_FAILURE" | "APPLICATION_SUBSCRIPTIONS_FETCH_LISTINGS_SUCCESS" | "APPLICATION_SUBSCRIPTIONS_FETCH_LISTING_FOR_PLAN_SUCCESS" | "APPLICATION_UPDATE" | "APPLIED_BOOSTS_COOLDOWN_FETCH_SUCCESS" | "APPLIED_GUILD_BOOST_COUNT_RESET" | "APPLIED_GUILD_BOOST_COUNT_UPDATE" | "APP_DM_OPEN" | "APP_ICON_EDITOR_RESET" | "APP_ICON_TRACK_IMPRESSION" | "APP_ICON_UPDATED" | "APP_LAUNCHER_DISMISS" | "APP_LAUNCHER_SET_ACTIVE_COMMAND" | "APP_LAUNCHER_SHOW" | "APP_STATE_UPDATE" | "APP_STATE_UPDATE_WILL_BECOME_ACTIVE" | "APP_VIEW_SET_HOME_LINK" | "AUDIO_INPUT_DETECTED" | "AUDIO_INPUT_DEVICE_OS_CONFIG_FETCHED" | "AUDIO_RESET" | "AUDIO_SET_ACTIVE_INPUT_PROFILE" | "AUDIO_SET_ATTENUATION" | "AUDIO_SET_AUTOMATIC_GAIN_CONTROL" | "AUDIO_SET_BYPASS_SYSTEM_INPUT_PROCESSING" | "AUDIO_SET_DEBUG_LOGGING" | "AUDIO_SET_DISPLAY_SILENCE_WARNING" | "AUDIO_SET_ECHO_CANCELLATION" | "AUDIO_SET_INPUT_DEVICE" | "AUDIO_SET_INPUT_VOLUME" | "AUDIO_SET_KRISP_MODEL_OVERRIDE" | "AUDIO_SET_KRISP_SUPPRESSION_LEVEL" | "AUDIO_SET_LOCAL_PAN" | "AUDIO_SET_LOCAL_VIDEO_DISABLED" | "AUDIO_SET_LOCAL_VOLUME" | "AUDIO_SET_LOOPBACK" | "AUDIO_SET_MODE" | "AUDIO_SET_NOISE_CANCELLATION" | "AUDIO_SET_NOISE_CANCELLATION_ENABLE_STATS" | "AUDIO_SET_NOISE_SUPPRESSION" | "AUDIO_SET_OUTPUT_DEVICE" | "AUDIO_SET_OUTPUT_VOLUME" | "AUDIO_SET_QOS" | "AUDIO_SET_SELF_MUTE" | "AUDIO_SET_SIDECHAIN_COMPRESSION" | "AUDIO_SET_SIDECHAIN_COMPRESSION_STRENGTH" | "AUDIO_SET_SUBSYSTEM" | "AUDIO_SET_TEMPORARY_SELF_MUTE" | "AUDIO_TOGGLE_LOCAL_MUTE" | "AUDIO_TOGGLE_LOCAL_SOUNDBOARD_MUTE" | "AUDIO_TOGGLE_SELF_DEAF" | "AUDIO_TOGGLE_SELF_MUTE" | "AUDIO_VOLUME_CHANGE" | "AUDIT_LOG_FETCH_FAIL" | "AUDIT_LOG_FETCH_NEXT_PAGE_FAIL" | "AUDIT_LOG_FETCH_NEXT_PAGE_START" | "AUDIT_LOG_FETCH_NEXT_PAGE_SUCCESS" | "AUDIT_LOG_FETCH_START" | "AUDIT_LOG_FETCH_SUCCESS" | "AUDIT_LOG_FILTER_BY_ACTION" | "AUDIT_LOG_FILTER_BY_TARGET" | "AUDIT_LOG_FILTER_BY_USER" | "AUTHENTICATOR_CREATE" | "AUTHENTICATOR_DELETE" | "AUTHENTICATOR_UPDATE" | "AUTH_INVITE_UPDATE" | "AUTH_SESSION_CHANGE" | "AUTO_MODERATION_MENTION_RAID_DETECTION" | "AUTO_MODERATION_MENTION_RAID_NOTICE_DISMISS" | "AUTO_UPDATER_QUIT_AND_INSTALL" | "BACKGROUND_SYNC" | "BACKGROUND_SYNC_CHANNEL_MESSAGES" | "BASIC_GUILD_FETCH" | "BASIC_GUILD_FETCH_FAILURE" | "BASIC_GUILD_FETCH_SUCCESS" | "BILLING_CREATE_REFERRAL_SUCCESS" | "BILLING_IP_COUNTRY_CODE_FAILURE" | "BILLING_IP_COUNTRY_CODE_FETCH_START" | "BILLING_IP_LOCATION_FAILURE" | "BILLING_IP_LOCATION_FETCH_START" | "BILLING_MOST_RECENT_SUBSCRIPTION_FETCH_FAIL" | "BILLING_MOST_RECENT_SUBSCRIPTION_FETCH_START" | "BILLING_MOST_RECENT_SUBSCRIPTION_FETCH_SUCCESS" | "BILLING_NITRO_AFFINITY_FETCHED" | "BILLING_NITRO_AFFINITY_FETCH_SUCCEEDED" | "BILLING_PAYMENTS_FETCH_SUCCESS" | "BILLING_PAYMENT_FETCH_SUCCESS" | "BILLING_PAYMENT_SOURCES_FETCH_FAIL" | "BILLING_PAYMENT_SOURCES_FETCH_START" | "BILLING_PAYMENT_SOURCES_FETCH_SUCCESS" | "BILLING_PAYMENT_SOURCE_CREATE_FAIL" | "BILLING_PAYMENT_SOURCE_CREATE_START" | "BILLING_PAYMENT_SOURCE_CREATE_SUCCESS" | "BILLING_PAYMENT_SOURCE_FETCH_SUCCESS" | "BILLING_PAYMENT_SOURCE_REMOVE_CLEAR_ERROR" | "BILLING_PAYMENT_SOURCE_REMOVE_FAIL" | "BILLING_PAYMENT_SOURCE_REMOVE_START" | "BILLING_PAYMENT_SOURCE_REMOVE_SUCCESS" | "BILLING_PAYMENT_SOURCE_UPDATE_CLEAR_ERROR" | "BILLING_PAYMENT_SOURCE_UPDATE_FAIL" | "BILLING_PAYMENT_SOURCE_UPDATE_START" | "BILLING_PAYMENT_SOURCE_UPDATE_SUCCESS" | "BILLING_POPUP_BRIDGE_CALLBACK" | "BILLING_POPUP_BRIDGE_STATE_UPDATE" | "BILLING_PREVIOUS_PREMIUM_SUBSCRIPTION_FETCH_SUCCESS" | "BILLING_PURCHASE_TOKEN_AUTH_CLEAR_STATE" | "BILLING_REFERRALS_REMAINING_FETCH_FAIL" | "BILLING_REFERRALS_REMAINING_FETCH_START" | "BILLING_REFERRALS_REMAINING_FETCH_SUCCESS" | "BILLING_REFERRAL_RESOLVE_FAIL" | "BILLING_REFERRAL_RESOLVE_SUCCESS" | "BILLING_REFERRAL_TRIAL_OFFER_UPDATE" | "BILLING_SET_IP_COUNTRY_CODE" | "BILLING_SET_IP_LOCATION" | "BILLING_SUBSCRIPTION_CANCEL_FAIL" | "BILLING_SUBSCRIPTION_CANCEL_START" | "BILLING_SUBSCRIPTION_CANCEL_SUCCESS" | "BILLING_SUBSCRIPTION_FETCH_FAIL" | "BILLING_SUBSCRIPTION_FETCH_START" | "BILLING_SUBSCRIPTION_FETCH_SUCCESS" | "BILLING_SUBSCRIPTION_RESET" | "BILLING_SUBSCRIPTION_REWARD_ELIGIBILITY_FETCH_FAILURE" | "BILLING_SUBSCRIPTION_REWARD_ELIGIBILITY_FETCH_START" | "BILLING_SUBSCRIPTION_REWARD_ELIGIBILITY_FETCH_SUCCESS" | "BILLING_SUBSCRIPTION_UPDATE_FAIL" | "BILLING_SUBSCRIPTION_UPDATE_START" | "BILLING_SUBSCRIPTION_UPDATE_SUCCESS" | "BILLING_USER_OFFER_ACKNOWLEDGED_SUCCESS" | "BILLING_USER_OFFER_FETCH_FAIL" | "BILLING_USER_OFFER_FETCH_START" | "BILLING_USER_OFFER_FETCH_SUCCESS" | "BILLING_USER_TRIAL_OFFER_ACKNOWLEDGED_SUCCESS" | "BILLING_USER_TRIAL_OFFER_FETCH_SUCCESS" | "BRAINTREE_CREATE_CLIENT_SUCCESS" | "BRAINTREE_CREATE_PAYPAL_CLIENT_SUCCESS" | "BRAINTREE_CREATE_VENMO_CLIENT_SUCCESS" | "BRAINTREE_TEARDOWN_PAYPAL_CLIENT" | "BRAINTREE_TEARDOWN_VENMO_CLIENT" | "BRAINTREE_TOKENIZE_PAYPAL_FAIL" | "BRAINTREE_TOKENIZE_PAYPAL_START" | "BRAINTREE_TOKENIZE_PAYPAL_SUCCESS" | "BRAINTREE_TOKENIZE_VENMO_FAIL" | "BRAINTREE_TOKENIZE_VENMO_START" | "BRAINTREE_TOKENIZE_VENMO_SUCCESS" | "BROWSER_HANDOFF_BEGIN" | "BROWSER_HANDOFF_FROM_APP" | "BROWSER_HANDOFF_SET_USER" | "BROWSER_HANDOFF_UNAVAILABLE" | "BUILD_OVERRIDE_RESOLVED" | "BULK_ACK" | "BULK_CLEAR_RECENTS" | "BURST_REACTION_ANIMATION_ADD" | "BURST_REACTION_EFFECT_CLEAR" | "BURST_REACTION_EFFECT_PLAY" | "BURST_REACTION_PICKER_ANIMATION_ADD" | "BURST_REACTION_PICKER_ANIMATION_CLEAR" | "CACHED_EMOJIS_LOADED" | "CACHED_STICKERS_LOADED" | "CACHE_LOADED" | "CACHE_LOADED_LAZY" | "CACHE_LOADED_LAZY_NO_CACHE" | "CALL_CHAT_TOASTS_SET_ENABLED" | "CALL_CONNECT" | "CALL_CONNECT_MULTIPLE" | "CALL_CREATE" | "CALL_DELETE" | "CALL_ENQUEUE_RING" | "CALL_FEEDBACK_TUTORIAL_DISMISS" | "CALL_FEEDBACK_TUTORIAL_SHOW" | "CALL_TILE_POPOUT_WINDOW_OPEN" | "CALL_UPDATE" | "CANDIDATE_GAMES_CHANGE" | "CATEGORY_COLLAPSE" | "CATEGORY_COLLAPSE_ALL" | "CATEGORY_EXPAND" | "CATEGORY_EXPAND_ALL" | "CERTIFIED_DEVICES_SET" | "CHANGE_LOG_FETCH_FAILED" | "CHANGE_LOG_FETCH_SUCCESS" | "CHANGE_LOG_LOCK" | "CHANGE_LOG_MARK_SEEN" | "CHANGE_LOG_SET_CONFIG" | "CHANGE_LOG_SET_OVERRIDE" | "CHANGE_LOG_UNLOCK" | "CHANNEL_ACK" | "CHANNEL_CALL_POPOUT_WINDOW_OPEN" | "CHANNEL_COLLAPSE" | "CHANNEL_CREATE" | "CHANNEL_DELETE" | "CHANNEL_FOLLOWER_CREATED" | "CHANNEL_FOLLOWER_STATS_FETCH_FAILURE" | "CHANNEL_FOLLOWER_STATS_FETCH_SUCCESS" | "CHANNEL_FOLLOWING_PUBLISH_BUMP_DISMISSED" | "CHANNEL_FOLLOWING_PUBLISH_BUMP_HIDE_PERMANENTLY" | "CHANNEL_INFO" | "CHANNEL_LOCAL_ACK" | "CHANNEL_MEMBER_COUNT_UPDATE" | "CHANNEL_MUTE_EXPIRED" | "CHANNEL_OPEN_CONVERSATIONS_SECTION" | "CHANNEL_PERMISSIONS_DELETE_OVERWRITE_SUCCESS" | "CHANNEL_PERMISSIONS_PUT_OVERWRITE_SUCCESS" | "CHANNEL_PINS_ACK" | "CHANNEL_PINS_UPDATE" | "CHANNEL_PRELOAD" | "CHANNEL_RECIPIENT_ADD" | "CHANNEL_RECIPIENT_REMOVE" | "CHANNEL_RTC_ACTIVE_CHANNELS" | "CHANNEL_RTC_JUMP_TO_VOICE_CHANNEL_MESSAGE" | "CHANNEL_RTC_POPOUT_PARTICIPANT" | "CHANNEL_RTC_RETURN_PARTICIPANT" | "CHANNEL_RTC_SELECT_PARTICIPANT" | "CHANNEL_RTC_UPDATE_CHAT_OPEN" | "CHANNEL_RTC_UPDATE_LAYOUT" | "CHANNEL_RTC_UPDATE_PARTICIPANTS_OPEN" | "CHANNEL_RTC_UPDATE_STAGE_STREAM_SIZE" | "CHANNEL_RTC_UPDATE_STAGE_VIDEO_LIMIT_BOOST_UPSELL_DISMISSED" | "CHANNEL_RTC_UPDATE_VOICE_PARTICIPANTS_HIDDEN" | "CHANNEL_SAFETY_WARNING_FEEDBACK" | "CHANNEL_SELECT" | "CHANNEL_SETTINGS_CLOSE" | "CHANNEL_SETTINGS_INIT" | "CHANNEL_SETTINGS_LOADED_INVITES" | "CHANNEL_SETTINGS_OVERWRITE_SELECT" | "CHANNEL_SETTINGS_PERMISSIONS_INIT" | "CHANNEL_SETTINGS_PERMISSIONS_SAVE_SUCCESS" | "CHANNEL_SETTINGS_PERMISSIONS_SELECT_PERMISSION" | "CHANNEL_SETTINGS_PERMISSIONS_SET_ADVANCED_MODE" | "CHANNEL_SETTINGS_PERMISSIONS_SUBMITTING" | "CHANNEL_SETTINGS_PERMISSIONS_UPDATE_PERMISSION" | "CHANNEL_SETTINGS_SET_SECTION" | "CHANNEL_SETTINGS_SUBMIT" | "CHANNEL_SETTINGS_SUBMIT_FAILURE" | "CHANNEL_SETTINGS_SUBMIT_SUCCESS" | "CHANNEL_SETTINGS_UPDATE" | "CHANNEL_STATUSES" | "CHANNEL_TOGGLE_CONVERSATIONS_SECTION" | "CHANNEL_TOGGLE_MEMBERS_SECTION" | "CHANNEL_TOGGLE_SUMMARIES_SECTION" | "CHANNEL_UPDATES" | "CHECKING_FOR_UPDATES" | "CHECKOUT_RECOVERY_STATUS_FETCH" | "CHECKOUT_RECOVERY_STATUS_FETCH_FAILURE" | "CHECKOUT_RECOVERY_STATUS_FETCH_SUCCESS" | "CHECK_LAUNCHABLE_GAME" | "CLEAR_CACHES" | "CLEAR_CHANNEL_SAFETY_WARNINGS" | "CLEAR_CONVERSATION_SUMMARIES" | "CLEAR_HANG_STATUS" | "CLEAR_ICYMI_READ_STATES" | "CLEAR_INTERACTION_MODAL_STATE" | "CLEAR_LAST_SESSION_VOICE_CHANNEL_ID" | "CLEAR_MENTIONS" | "CLEAR_MESSAGES" | "CLEAR_OLDEST_UNREAD_MESSAGE" | "CLEAR_PENDING_CHANNEL_AND_ROLE_UPDATES" | "CLEAR_REMOTE_DISCONNECT_VOICE_CHANNEL_ID" | "CLEAR_STICKER_PREVIEW" | "CLEAR_THEME_OVERRIDE" | "CLEAR_VIDEO_STREAM_READY_TIMEOUT" | "CLIENT_THEMES_EDITOR_CLOSE" | "CLIPS_ALLOW_VOICE_RECORDING_UPDATE" | "CLIPS_CLASSIFY_HARDWARE" | "CLIPS_CLEAR_CLIPS_SESSION" | "CLIPS_CLEAR_NEW_CLIP_IDS" | "CLIPS_DELETE_CLIP" | "CLIPS_DISMISS_EDUCATION" | "CLIPS_INIT" | "CLIPS_INIT_FAILURE" | "CLIPS_LOAD_DIRECTORY_SUCCESS" | "CLIPS_RESTART" | "CLIPS_SAVE_ANIMATION_END" | "CLIPS_SAVE_CLIP" | "CLIPS_SAVE_CLIP_ERROR" | "CLIPS_SAVE_CLIP_PLACEHOLDER" | "CLIPS_SAVE_CLIP_PLACEHOLDER_ERROR" | "CLIPS_SAVE_CLIP_START" | "CLIPS_SETTINGS_UPDATE" | "CLIPS_SET_EXPORTING" | "CLIPS_SHOW_CALL_WARNING" | "CLIPS_UPDATE_METADATA" | "CLOSE_AGE_VERIFICATION_MODAL" | "CLOSE_SUSPENDED_USER" | "CMS_LAYOUT_FETCH" | "CMS_LAYOUT_FETCH_FAILURE" | "CMS_LAYOUT_FETCH_SUCCESS" | "CMS_TEMPLATE_FETCH" | "CMS_TEMPLATE_FETCH_FAILURE" | "CMS_TEMPLATE_FETCH_SUCCESS" | "COLLECTIBLES_CATEGORIES_FETCH" | "COLLECTIBLES_CATEGORIES_FETCH_FAILURE" | "COLLECTIBLES_CATEGORIES_FETCH_SUCCESS" | "COLLECTIBLES_CLAIM" | "COLLECTIBLES_CLAIM_FAILURE" | "COLLECTIBLES_CLAIM_SUCCESS" | "COLLECTIBLES_MARKETING_FETCH" | "COLLECTIBLES_MARKETING_FETCH_SUCCESS" | "COLLECTIBLES_PRODUCT_DETAILS_OPEN" | "COLLECTIBLES_PRODUCT_FETCH" | "COLLECTIBLES_PRODUCT_FETCH_FAILURE" | "COLLECTIBLES_PRODUCT_FETCH_SUCCESS" | "COLLECTIBLES_PURCHASES_FETCH" | "COLLECTIBLES_PURCHASES_FETCH_FAILURE" | "COLLECTIBLES_PURCHASES_FETCH_SUCCESS" | "COLLECTIBLES_SET_SHOP_HOME_CONFIG_OVERRIDE" | "COLLECTIBLES_SET_SHOP_LAYOUT_URL_OVERRIDE" | "COLLECTIBLES_SHOP_CLOSE" | "COLLECTIBLES_SHOP_HOME_FETCH" | "COLLECTIBLES_SHOP_HOME_FETCH_FAILURE" | "COLLECTIBLES_SHOP_HOME_FETCH_SUCCESS" | "COLLECTIBLES_SHOP_OPEN" | "COLLECTIBLES_SHOP_TAB_LAYOUT_FETCH" | "COLLECTIBLES_SHOP_TAB_LAYOUT_FETCH_FAILURE" | "COLLECTIBLES_SHOP_TAB_LAYOUT_FETCH_SUCCESS" | "COLLECTIBLES_SKIP_NUM_CATEGORIES" | "COLLECTIBLES_USER_DISCOUNTS_EXPIRED" | "COMMANDS_MIGRATION_NOTICE_DISMISSED" | "COMMANDS_MIGRATION_OVERVIEW_TOOLTIP_DISMISSED" | "COMMANDS_MIGRATION_TOGGLE_TOOLTIP_DISMISSED" | "COMMANDS_MIGRATION_UPDATE_SUCCESS" | "COMPLETE_NEW_MEMBER_ACTION" | "CONNECTED_DEVICE_DONT_SWITCH" | "CONNECTED_DEVICE_IGNORE" | "CONNECTED_DEVICE_NEVER_SHOW_MODAL" | "CONNECTED_DEVICE_SWITCH" | "CONNECTIONS_GRID_MODAL_HIDE" | "CONNECTIONS_GRID_MODAL_SHOW" | "CONNECTION_CLOSED" | "CONNECTION_INTERRUPTED" | "CONNECTION_OPEN" | "CONNECTION_OPEN_STATE_UPDATE" | "CONNECTION_OPEN_SUPPLEMENTAL" | "CONNECTION_RESUMED" | "CONSOLE_COMMAND_UPDATE" | "CONTENT_INVENTORY_CLEAR_DELETE_HISTORY_ERROR" | "CONTENT_INVENTORY_CLEAR_FEED" | "CONTENT_INVENTORY_DEBUG_CLEAR_IMPRESSIONS" | "CONTENT_INVENTORY_DEBUG_LOG_IMPRESSIONS" | "CONTENT_INVENTORY_DEBUG_TOGGLE_FAST_IMPRESSION_CAPPING" | "CONTENT_INVENTORY_DEBUG_TOGGLE_IMPRESSION_CAPPING" | "CONTENT_INVENTORY_DELETE_OUTBOX_ENTRY_FAILURE" | "CONTENT_INVENTORY_DELETE_OUTBOX_ENTRY_START" | "CONTENT_INVENTORY_DELETE_OUTBOX_ENTRY_SUCCESS" | "CONTENT_INVENTORY_FETCH_OUTBOX_FAILURE" | "CONTENT_INVENTORY_FETCH_OUTBOX_START" | "CONTENT_INVENTORY_FETCH_OUTBOX_SUCCESS" | "CONTENT_INVENTORY_FORCE_SHOW_GAME_SHARING" | "CONTENT_INVENTORY_INBOX_STALE" | "CONTENT_INVENTORY_MANUAL_REFRESH" | "CONTENT_INVENTORY_SET_FEED" | "CONTENT_INVENTORY_SET_FEED_STATE" | "CONTENT_INVENTORY_SET_FILTERS" | "CONTENT_INVENTORY_TOGGLE_FEED_HIDDEN" | "CONTENT_INVENTORY_TRACK_ITEM_IMPRESSIONS" | "CONTEXT_MENU_CLOSE" | "CONTEXT_MENU_OPEN" | "CONVERSATIONS_FETCH_FAILURE" | "CONVERSATIONS_FETCH_START" | "CONVERSATIONS_FETCH_SUCCESS" | "CONVERSATIONS_HOVER_CONVERSATION" | "CONVERSATIONS_SCROLL_TO" | "CONVERSATIONS_TOGGLE_HIGHLIGHTING" | "CONVERSATION_SUMMARY_UPDATE" | "CREATE_FRIEND_GROUP" | "CREATE_PENDING_REPLY" | "CREATE_PENDING_SCHEDULED_MESSAGE" | "CREATE_REFERRALS_SUCCESS" | "CREATE_SHALLOW_PENDING_REPLY" | "CREATOR_MONETIZATION_NAG_ACTIVATE_ELIGIBLITY_FETCH_SUCCESS" | "CREATOR_MONETIZATION_PRICE_TIERS_FETCH" | "CREATOR_MONETIZATION_PRICE_TIERS_FETCH_FAILURE" | "CREATOR_MONETIZATION_PRICE_TIERS_FETCH_SUCCESS" | "CREATOR_MONETIZATION_SET_HAS_SEEN_ONBOARDING_FOR_GUILD" | "CURRENT_BUILD_OVERRIDE_RESOLVED" | "CURRENT_USER_UPDATE" | "CUSTOM_ACTIVITY_LINK_FETCH_SUCCESS" | "DCF_DAILY_CAP_OVERRIDE" | "DCF_EVENT_LOGGED" | "DCF_HANDLE_DC_DISMISSED" | "DCF_HANDLE_DC_SHOWN" | "DCF_NEW_USER_MIN_AGE_REQUIRED_OVERRIDE" | "DCF_OVERRIDE_LAST_DC_DISMISSED" | "DCF_RESET" | "DECAY_READ_STATES" | "DELETED_ENTITY_IDS" | "DELETE_FRIEND_GROUP" | "DELETE_INVALID_HANG_STATUSES" | "DELETE_PENDING_REPLY" | "DELETE_PENDING_SCHEDULED_MESSAGE" | "DELETE_SUMMARY" | "DETECTED_OFF_PLATFORM_PREMIUM_PERKS_DISMISS" | "DEVELOPER_ACTIVITY_SHELF_FETCH_FAIL" | "DEVELOPER_ACTIVITY_SHELF_FETCH_START" | "DEVELOPER_ACTIVITY_SHELF_FETCH_SUCCESS" | "DEVELOPER_ACTIVITY_SHELF_MARK_ACTIVITY_USED" | "DEVELOPER_ACTIVITY_SHELF_SET_ACTIVITY_URL_OVERRIDE" | "DEVELOPER_ACTIVITY_SHELF_TOGGLE_USE_ACTIVITY_URL_OVERRIDE" | "DEVELOPER_ACTIVITY_SHELF_UPDATE_FILTER" | "DEVELOPER_OPTIONS_UPDATE_SETTINGS" | "DEVELOPER_TEST_MODE_AUTHORIZATION_FAIL" | "DEVELOPER_TEST_MODE_AUTHORIZATION_START" | "DEVELOPER_TEST_MODE_AUTHORIZATION_SUCCESS" | "DEVELOPER_TEST_MODE_RESET" | "DEVELOPER_TEST_MODE_RESET_ERROR" | "DEV_TOOLS_DESIGN_TOGGLE_SET" | "DEV_TOOLS_DESIGN_TOGGLE_WEB_SET" | "DEV_TOOLS_DEV_SETTING_SET" | "DEV_TOOLS_FRIENDS_LIST_GIFT_INTENTS_SHOWN_RESET" | "DEV_TOOLS_GIFT_MESSAGE_COOLDOWN_RESET" | "DEV_TOOLS_GIFT_UNREAD_NOTIFICATION_COOLDOWN_RESET" | "DEV_TOOLS_PROFILE_POPOUT_GIFT_INTENTS_DISMISS_RESET" | "DEV_TOOLS_RESET_CURRENT_DATE" | "DEV_TOOLS_SETTINGS_UPDATE" | "DEV_TOOLS_SET_CURRENT_DATE" | "DEV_TOOLS_SET_FRIEND_ANNIVERSARY_COUNT" | "DISABLE_AUTOMATIC_ACK" | "DISCOVER_CHECKLIST_FETCH_FAILURE" | "DISCOVER_CHECKLIST_FETCH_START" | "DISCOVER_CHECKLIST_FETCH_SUCCESS" | "DISMISS_CHANNEL_SAFETY_WARNINGS" | "DISMISS_FAVORITE_SUGGESTION" | "DISMISS_MEDIA_POST_SHARE_PROMPT" | "DISPATCH_APPLICATION_ADD_TO_INSTALLATIONS" | "DISPATCH_APPLICATION_CANCEL" | "DISPATCH_APPLICATION_ERROR" | "DISPATCH_APPLICATION_INSTALL" | "DISPATCH_APPLICATION_INSTALL_SCRIPTS_PROGRESS_UPDATE" | "DISPATCH_APPLICATION_LAUNCH_SETUP_COMPLETE" | "DISPATCH_APPLICATION_LAUNCH_SETUP_START" | "DISPATCH_APPLICATION_MOVE_UP" | "DISPATCH_APPLICATION_REMOVE_FINISHED" | "DISPATCH_APPLICATION_REPAIR" | "DISPATCH_APPLICATION_STATE_UPDATE" | "DISPATCH_APPLICATION_UNINSTALL" | "DISPATCH_APPLICATION_UPDATE" | "DISPLAYED_INVITE_SHOW" | "DOMAIN_MIGRATION_FAILURE" | "DOMAIN_MIGRATION_SKIP" | "DOMAIN_MIGRATION_START" | "DRAFT_CHANGE" | "DRAFT_CLEAR" | "DRAFT_SAVE" | "EMAIL_SETTINGS_FETCH_SUCCESS" | "EMAIL_SETTINGS_UPDATE" | "EMAIL_SETTINGS_UPDATE_SUCCESS" | "EMBEDDED_ACTIVITY_CLOSE" | "EMBEDDED_ACTIVITY_DEFERRED_OPEN" | "EMBEDDED_ACTIVITY_FETCH_SHELF" | "EMBEDDED_ACTIVITY_FETCH_SHELF_FAIL" | "EMBEDDED_ACTIVITY_FETCH_SHELF_SUCCESS" | "EMBEDDED_ACTIVITY_LAUNCH_CANCEL" | "EMBEDDED_ACTIVITY_LAUNCH_FAIL" | "EMBEDDED_ACTIVITY_LAUNCH_SET_PROXY_TICKET" | "EMBEDDED_ACTIVITY_LAUNCH_START" | "EMBEDDED_ACTIVITY_LAUNCH_SUCCESS" | "EMBEDDED_ACTIVITY_OPEN" | "EMBEDDED_ACTIVITY_SET_CONFIG" | "EMBEDDED_ACTIVITY_SET_FOCUSED_LAYOUT" | "EMBEDDED_ACTIVITY_SET_ORIENTATION_LOCK_STATE" | "EMBEDDED_ACTIVITY_SET_PANEL_MODE" | "EMBEDDED_ACTIVITY_SET_PROXY_TICKET_REFRESHING" | "EMBEDDED_ACTIVITY_UPDATE" | "EMBEDDED_ACTIVITY_UPDATE_CONNECTED_PROXY_TICKET" | "EMBEDDED_ACTIVITY_UPDATE_POPOUT_WINDOW_LAYOUT" | "EMBEDDED_ACTIVITY_UPDATE_V2" | "EMOJI_CACHE_RAW_EMOJI_ASSET" | "EMOJI_DELETE" | "EMOJI_FETCH_FAILURE" | "EMOJI_FETCH_SUCCESS" | "EMOJI_TRACK_USAGE" | "EMOJI_UPLOAD_START" | "EMOJI_UPLOAD_STOP" | "ENABLE_AUTOMATIC_ACK" | "ENTITLEMENTS_FETCH_FOR_USER_FAIL" | "ENTITLEMENTS_FETCH_FOR_USER_START" | "ENTITLEMENTS_FETCH_FOR_USER_SUCCESS" | "ENTITLEMENTS_GIFTABLE_FETCH_SUCCESS" | "ENTITLEMENT_CREATE" | "ENTITLEMENT_DELETE" | "ENTITLEMENT_FETCH_APPLICATION_FAIL" | "ENTITLEMENT_FETCH_APPLICATION_START" | "ENTITLEMENT_FETCH_APPLICATION_SUCCESS" | "ENTITLEMENT_UPDATE" | "EULA_FETCH_SUCCESS" | "EVENT_BANNER_DISMISS" | "EXPERIMENTS_FETCH" | "EXPERIMENTS_FETCH_FAILURE" | "EXPERIMENTS_FETCH_SUCCESS" | "EXPERIMENT_OVERRIDE_BUCKET" | "FAMILY_CENTER_FETCH_START" | "FAMILY_CENTER_HANDLE_TAB_SELECT" | "FAMILY_CENTER_INITIAL_LOAD" | "FAMILY_CENTER_LINKED_USERS_FETCH_SUCCESS" | "FAMILY_CENTER_LINK_CODE_FETCH_SUCCESS" | "FAMILY_CENTER_REQUEST_LINK_REMOVE_SUCCESS" | "FAMILY_CENTER_REQUEST_LINK_SUCCESS" | "FAMILY_CENTER_REQUEST_LINK_UPDATE_SUCCESS" | "FAMILY_CENTER_TEEN_ACTIVITY_FETCH_SUCCESS" | "FAMILY_CENTER_TEEN_ACTIVITY_MORE_FETCH_SUCCESS" | "FAMILY_CENTER_TEEN_CONSENTS_UPDATE_SUCCESS" | "FAMILY_CENTER_TEEN_SETTINGS_AND_CONSENTS_FETCH_SUCCESS" | "FAMILY_CENTER_TEEN_SETTINGS_FETCH_START" | "FAMILY_CENTER_TEEN_UPDATE_SETTINGS_SUCCESS" | "FEEDBACK_OVERRIDE_CLEAR" | "FEEDBACK_OVERRIDE_SET" | "FETCH_AUTH_SESSIONS_SUCCESS" | "FETCH_CHANNEL_INFO" | "FETCH_GUILD_EVENT" | "FETCH_GUILD_EVENTS_FOR_GUILD" | "FETCH_GUILD_MEMBER_SUPPLEMENTAL_SUCCESS" | "FETCH_INTEGRATION_APPLICATION_IDS_FOR_MY_GUILDS" | "FETCH_INTEGRATION_APPLICATION_IDS_FOR_MY_GUILDS_FAILURE" | "FETCH_INTEGRATION_APPLICATION_IDS_FOR_MY_GUILDS_SUCCESS" | "FETCH_SCHEDULED_MESSAGES" | "FETCH_SCHEDULED_MESSAGES_FAILURE" | "FETCH_SCHEDULED_MESSAGES_SUCCESS" | "FINGERPRINT" | "FORCE_INVISIBLE" | "FORGOT_PASSWORD_REQUEST" | "FORGOT_PASSWORD_SENT" | "FORUM_SEARCH_CLEAR" | "FORUM_SEARCH_FAILURE" | "FORUM_SEARCH_QUERY_UPDATED" | "FORUM_SEARCH_START" | "FORUM_SEARCH_SUCCESS" | "FORUM_UNREADS" | "FRAME_LAUNCH" | "FRAME_LAUNCH_FAIL" | "FRAME_LAUNCH_START" | "FRAME_SET_ORIENTATION_LOCK_STATE" | "FRAME_SET_PANEL_MODE" | "FRAME_SET_PROXY_TICKET_REFRESHING" | "FRAME_STOP" | "FRAME_UPDATE_LAYOUT_MODE" | "FRAME_UPDATE_PROXY_TICKET" | "FRIENDS_LIST_GIFT_INTENTS_SHOWN" | "FRIENDS_LIST_POPOUT_MOUNTED" | "FRIENDS_SET_INITIAL_SECTION" | "FRIENDS_SET_SECTION" | "FRIEND_INVITES_FETCH_REQUEST" | "FRIEND_INVITES_FETCH_RESPONSE" | "FRIEND_INVITE_CREATE_FAILURE" | "FRIEND_INVITE_CREATE_REQUEST" | "FRIEND_INVITE_CREATE_SUCCESS" | "FRIEND_INVITE_REVOKE_REQUEST" | "FRIEND_INVITE_REVOKE_SUCCESS" | "FRIEND_ONLINE_TIMER_REPORTED" | "FRIEND_SUGGESTION_CREATE" | "FRIEND_SUGGESTION_DELETE" | "GAMES_BLOCKLIST_FETCH" | "GAMES_BLOCKLIST_FETCH_FAIL" | "GAMES_BLOCKLIST_UPDATE" | "GAMES_DATABASE_FETCH" | "GAMES_DATABASE_FETCH_FAIL" | "GAMES_DATABASE_UPDATE" | "GAME_CLOUD_SYNC_COMPLETE" | "GAME_CLOUD_SYNC_CONFLICT" | "GAME_CLOUD_SYNC_ERROR" | "GAME_CLOUD_SYNC_START" | "GAME_CLOUD_SYNC_UPDATE" | "GAME_CONSOLE_FETCH_DEVICES_FAIL" | "GAME_CONSOLE_FETCH_DEVICES_START" | "GAME_CONSOLE_FETCH_DEVICES_SUCCESS" | "GAME_CONSOLE_SELECT_DEVICE" | "GAME_DETECTION_DEBUGGING_START" | "GAME_DETECTION_DEBUGGING_STOP" | "GAME_DETECTION_DEBUGGING_TICK" | "GAME_DETECTION_WATCH_CANDIDATE_GAMES_START" | "GAME_FETCH" | "GAME_FETCH_FAILURE" | "GAME_FETCH_SUCCESS" | "GAME_ICON_UPDATE" | "GAME_LAUNCHABLE_UPDATE" | "GAME_LAUNCH_FAIL" | "GAME_LAUNCH_START" | "GAME_LAUNCH_SUCCESS" | "GAME_PROFILE_GET_SIMILAR_GAMES_ERROR" | "GAME_PROFILE_GET_SIMILAR_GAMES_SUCCESS" | "GAME_PROFILE_OPEN" | "GAME_RELATIONSHIP_ADD" | "GAME_RELATIONSHIP_REMOVE" | "GAME_SERVER_CREATE" | "GAME_SERVER_DELETE" | "GAME_SERVER_FETCH_CATALOG_SUCCESS" | "GAME_SERVER_FETCH_GAME_INSTRUCTIONS_SUCCESS" | "GAME_SERVER_FETCH_GLOBAL_CATALOG_SUCCESS" | "GAME_SERVER_FETCH_INSTANCES_SUCCESS" | "GAME_SERVER_FETCH_REGIONS_SUCCESS" | "GAME_SERVER_REGION_PING_STATE_RESET" | "GAME_SERVER_REGION_PING_STATE_UPDATE" | "GAME_SERVER_UPDATE" | "GAME_SERVER_UPDATE_INSTANCE_SUCCESS" | "GAME_UPSELL_DISMISS" | "GENERATED_POOL_BY_ID_FETCH_SUCCESS" | "GENERATED_POOL_REMOVE_FROM_LIST" | "GENERIC_IAP_END_CONNECTION" | "GENERIC_IAP_INIT_CONNECTION" | "GENERIC_IAP_INIT_CONNECTION_FAILED" | "GENERIC_IAP_SET_STORE_FRONT" | "GENERIC_PUSH_NOTIFICATION_SENT" | "GIFT_CODES_FETCH" | "GIFT_CODES_FETCH_FAILURE" | "GIFT_CODES_FETCH_SUCCESS" | "GIFT_CODE_CREATE" | "GIFT_CODE_CREATE_SUCCESS" | "GIFT_CODE_REDEEM" | "GIFT_CODE_REDEEM_FAILURE" | "GIFT_CODE_REDEEM_SUCCESS" | "GIFT_CODE_RESOLVE" | "GIFT_CODE_RESOLVE_FAILURE" | "GIFT_CODE_RESOLVE_SUCCESS" | "GIFT_CODE_REVOKE_SUCCESS" | "GIFT_CODE_UPDATE" | "GIFT_INTENT_FLOW_PURCHASED_GIFT" | "GIFT_UNREAD_NOTIFICATION_DISMISS" | "GIF_PICKER_INITIALIZE" | "GIF_PICKER_QUERY" | "GIF_PICKER_QUERY_FAILURE" | "GIF_PICKER_QUERY_SUCCESS" | "GIF_PICKER_SUGGESTIONS_SUCCESS" | "GIF_PICKER_TRENDING_FETCH_SUCCESS" | "GIF_PICKER_TRENDING_SEARCH_TERMS_SUCCESS" | "GLOBAL_DISCOVERY_SERVERS_SEARCH_CLEAR" | "GLOBAL_DISCOVERY_SERVERS_SEARCH_COUNT_FAILURE" | "GLOBAL_DISCOVERY_SERVERS_SEARCH_COUNT_START" | "GLOBAL_DISCOVERY_SERVERS_SEARCH_COUNT_SUCCESS" | "GLOBAL_DISCOVERY_SERVERS_SEARCH_FAILURE" | "GLOBAL_DISCOVERY_SERVERS_SEARCH_LAYOUT_RESET" | "GLOBAL_DISCOVERY_SERVERS_SEARCH_START" | "GLOBAL_DISCOVERY_SERVERS_SEARCH_SUCCESS" | "GUILD_ACK" | "GUILD_ANALYTICS_ENGAGEMENT_OVERVIEW_FETCH_FAILURE" | "GUILD_ANALYTICS_ENGAGEMENT_OVERVIEW_FETCH_SUCCESS" | "GUILD_ANALYTICS_GROWTH_ACTIVATION_OVERVIEW_FETCH_FAILURE" | "GUILD_ANALYTICS_GROWTH_ACTIVATION_OVERVIEW_FETCH_SUCCESS" | "GUILD_ANALYTICS_GROWTH_ACTIVATION_RETENTION_FETCH_FAILURE" | "GUILD_ANALYTICS_GROWTH_ACTIVATION_RETENTION_FETCH_SUCCESS" | "GUILD_APPLICATIONS_FETCH_SUCCESS" | "GUILD_APPLICATION_COMMAND_INDEX_UPDATE" | "GUILD_APPLIED_BOOSTS_FETCH_SUCCESS" | "GUILD_APPLIED_BOOSTS_UPDATE" | "GUILD_APPLY_BOOST_FAIL" | "GUILD_APPLY_BOOST_START" | "GUILD_APPLY_BOOST_SUCCESS" | "GUILD_BAN_ADD" | "GUILD_BAN_REMOVE" | "GUILD_BOOST_ENTITLEMENTS_FETCH_SUCCESS" | "GUILD_BOOST_SLOTS_FETCH" | "GUILD_BOOST_SLOTS_FETCH_SUCCESS" | "GUILD_BOOST_SLOT_CREATE" | "GUILD_BOOST_SLOT_UPDATE" | "GUILD_BOOST_SLOT_UPDATE_SUCCESS" | "GUILD_BULK_BAN_FAILED" | "GUILD_BULK_BAN_STARTED" | "GUILD_BULK_BAN_UPDATE" | "GUILD_CREATE" | "GUILD_DELETE" | "GUILD_DIRECTORY_ADMIN_ENTRIES_FETCH_SUCCESS" | "GUILD_DIRECTORY_CACHED_SEARCH" | "GUILD_DIRECTORY_CATEGORY_SELECT" | "GUILD_DIRECTORY_COUNTS_FETCH_SUCCESS" | "GUILD_DIRECTORY_ENTRY_CREATE" | "GUILD_DIRECTORY_ENTRY_DELETE" | "GUILD_DIRECTORY_ENTRY_UPDATE" | "GUILD_DIRECTORY_FETCH_FAILURE" | "GUILD_DIRECTORY_FETCH_START" | "GUILD_DIRECTORY_FETCH_SUCCESS" | "GUILD_DIRECTORY_SEARCH_CLEAR" | "GUILD_DIRECTORY_SEARCH_FAILURE" | "GUILD_DIRECTORY_SEARCH_START" | "GUILD_DIRECTORY_SEARCH_SUCCESS" | "GUILD_DISCOVERY_CATEGORY_ADD" | "GUILD_DISCOVERY_CATEGORY_DELETE" | "GUILD_DISCOVERY_CATEGORY_FETCH_SUCCESS" | "GUILD_DISCOVERY_CATEGORY_UPDATE_FAIL" | "GUILD_DISCOVERY_METADATA_FETCH_FAIL" | "GUILD_DISCOVERY_SLUG_FETCH_FAIL" | "GUILD_DISCOVERY_SLUG_FETCH_SUCCESS" | "GUILD_EMOJIS_UPDATE" | "GUILD_FEATURE_ACK" | "GUILD_FOLDER_COLLAPSE" | "GUILD_FOLDER_CREATE_LOCAL" | "GUILD_FOLDER_DELETE_LOCAL" | "GUILD_FOLDER_EDIT_LOCAL" | "GUILD_GEO_RESTRICTED" | "GUILD_HOME_SETTINGS_FETCH_FAIL" | "GUILD_HOME_SETTINGS_FETCH_START" | "GUILD_HOME_SETTINGS_FETCH_SUCCESS" | "GUILD_HOME_SETTINGS_TOGGLE_ENABLED" | "GUILD_HOME_SETTINGS_UPDATE_FAIL" | "GUILD_HOME_SETTINGS_UPDATE_START" | "GUILD_HOME_SETTINGS_UPDATE_SUCCESS" | "GUILD_INTEGRATIONS_UPDATE" | "GUILD_JOIN" | "GUILD_JOIN_REQUESTS_BULK_ACTION" | "GUILD_JOIN_REQUESTS_FETCH_FAILURE" | "GUILD_JOIN_REQUESTS_FETCH_START" | "GUILD_JOIN_REQUESTS_FETCH_SUCCESS" | "GUILD_JOIN_REQUESTS_SET_APPLICATION_TAB" | "GUILD_JOIN_REQUESTS_SET_SELECTED" | "GUILD_JOIN_REQUESTS_SET_SORT_ORDER" | "GUILD_JOIN_REQUEST_BY_ID_FETCH_SUCCESS" | "GUILD_JOIN_REQUEST_CREATE" | "GUILD_JOIN_REQUEST_DELETE" | "GUILD_JOIN_REQUEST_UPDATE" | "GUILD_LOCAL_RING_START" | "GUILD_MEMBERS_CHUNK_BATCH" | "GUILD_MEMBERS_REQUEST" | "GUILD_MEMBER_ADD" | "GUILD_MEMBER_LIST_UPDATE" | "GUILD_MEMBER_PROFILE_UPDATE" | "GUILD_MEMBER_REMOVE" | "GUILD_MEMBER_REMOVE_LOCAL" | "GUILD_MEMBER_UPDATE" | "GUILD_MEMBER_UPDATE_LOCAL" | "GUILD_MOVE_BY_ID" | "GUILD_MUTE_EXPIRED" | "GUILD_NEW_MEMBER_ACTIONS_DELETE_SUCCESS" | "GUILD_NEW_MEMBER_ACTIONS_FETCH_FAIL" | "GUILD_NEW_MEMBER_ACTIONS_FETCH_START" | "GUILD_NEW_MEMBER_ACTIONS_FETCH_SUCCESS" | "GUILD_NEW_MEMBER_ACTION_UPDATE_SUCCESS" | "GUILD_NSFW_AGREE" | "GUILD_ONBOARDING_COMPLETE" | "GUILD_ONBOARDING_PROMPTS_FETCH_FAILURE" | "GUILD_ONBOARDING_PROMPTS_FETCH_START" | "GUILD_ONBOARDING_PROMPTS_FETCH_SUCCESS" | "GUILD_ONBOARDING_PROMPTS_LOCAL_UPDATE" | "GUILD_ONBOARDING_SELECT_OPTION" | "GUILD_ONBOARDING_SET_STEP" | "GUILD_ONBOARDING_START" | "GUILD_ONBOARDING_UPDATE_RESPONSES_SUCCESS" | "GUILD_POWERUPS_ACK_NOTIFICATION" | "GUILD_POWERUPS_RESET_NOTIFICATIONS" | "GUILD_POWERUP_CATALOG_FETCH_SUCCESS" | "GUILD_POWERUP_ENTITLEMENTS_CREATE" | "GUILD_POWERUP_ENTITLEMENTS_DELETE" | "GUILD_PRODUCTS_FETCH" | "GUILD_PRODUCTS_FETCH_FAILURE" | "GUILD_PRODUCTS_FETCH_SUCCESS" | "GUILD_PRODUCT_CREATE" | "GUILD_PRODUCT_DELETE" | "GUILD_PRODUCT_FETCH" | "GUILD_PRODUCT_FETCH_FAILURE" | "GUILD_PRODUCT_FETCH_SUCCESS" | "GUILD_PRODUCT_UPDATE" | "GUILD_PROFILE_FETCH" | "GUILD_PROFILE_FETCH_FAILURE" | "GUILD_PROFILE_FETCH_SUCCESS" | "GUILD_PROFILE_UPDATE" | "GUILD_PROFILE_UPDATE_FAILURE" | "GUILD_PROFILE_UPDATE_SUCCESS" | "GUILD_PROFILE_UPDATE_VISIBILITY" | "GUILD_PROFILE_UPDATE_VISIBILITY_FAILURE" | "GUILD_PROFILE_UPDATE_VISIBILITY_SUCCESS" | "GUILD_PROGRESS_COMPLETED_SEEN" | "GUILD_PROGRESS_DISMISS" | "GUILD_PROGRESS_INITIALIZE" | "GUILD_PROMPT_VIEWED" | "GUILD_RESOURCE_CHANNEL_UPDATE_SUCCESS" | "GUILD_RING_START" | "GUILD_RING_STOP" | "GUILD_ROLE_CONNECTIONS_CONFIGURATIONS_FETCH_SUCCESS" | "GUILD_ROLE_CONNECTIONS_MODAL_SHOW" | "GUILD_ROLE_CONNECTION_ELIGIBILITY_FETCH_SUCCESS" | "GUILD_ROLE_CREATE" | "GUILD_ROLE_DELETE" | "GUILD_ROLE_MEMBER_ADD" | "GUILD_ROLE_MEMBER_BULK_ADD" | "GUILD_ROLE_MEMBER_COUNT_FETCH_SUCCESS" | "GUILD_ROLE_MEMBER_COUNT_UPDATE" | "GUILD_ROLE_MEMBER_REMOVE" | "GUILD_ROLE_SUBSCRIPTIONS_CREATE_LISTING" | "GUILD_ROLE_SUBSCRIPTIONS_DELETE_GROUP_LISTING" | "GUILD_ROLE_SUBSCRIPTIONS_DELETE_LISTING" | "GUILD_ROLE_SUBSCRIPTIONS_FETCH_LISTINGS" | "GUILD_ROLE_SUBSCRIPTIONS_FETCH_LISTINGS_FAILURE" | "GUILD_ROLE_SUBSCRIPTIONS_FETCH_LISTINGS_SUCCESS" | "GUILD_ROLE_SUBSCRIPTIONS_FETCH_LISTING_FOR_PLAN" | "GUILD_ROLE_SUBSCRIPTIONS_FETCH_LISTING_FOR_PLAN_SUCCESS" | "GUILD_ROLE_SUBSCRIPTIONS_FETCH_RESTRICTIONS" | "GUILD_ROLE_SUBSCRIPTIONS_FETCH_RESTRICTIONS_ABORTED" | "GUILD_ROLE_SUBSCRIPTIONS_FETCH_RESTRICTIONS_FAILURE" | "GUILD_ROLE_SUBSCRIPTIONS_FETCH_RESTRICTIONS_SUCCESS" | "GUILD_ROLE_SUBSCRIPTIONS_FETCH_TEMPLATES" | "GUILD_ROLE_SUBSCRIPTIONS_STASH_TEMPLATE_CHANNELS" | "GUILD_ROLE_SUBSCRIPTIONS_UPDATE_GROUP_LISTING" | "GUILD_ROLE_SUBSCRIPTIONS_UPDATE_LISTING" | "GUILD_ROLE_SUBSCRIPTIONS_UPDATE_SUBSCRIPTIONS_SETTINGS" | "GUILD_ROLE_SUBSCRIPTIONS_UPDATE_SUBSCRIPTION_TRIAL" | "GUILD_ROLE_UPDATE" | "GUILD_SCHEDULED_EVENT_CREATE" | "GUILD_SCHEDULED_EVENT_DELETE" | "GUILD_SCHEDULED_EVENT_EXCEPTIONS_DELETE" | "GUILD_SCHEDULED_EVENT_EXCEPTION_CREATE" | "GUILD_SCHEDULED_EVENT_EXCEPTION_DELETE" | "GUILD_SCHEDULED_EVENT_EXCEPTION_UPDATE" | "GUILD_SCHEDULED_EVENT_RSVPS_FETCH_SUCESS" | "GUILD_SCHEDULED_EVENT_UPDATE" | "GUILD_SCHEDULED_EVENT_USERS_FETCH_SUCCESS" | "GUILD_SCHEDULED_EVENT_USER_ADD" | "GUILD_SCHEDULED_EVENT_USER_COUNTS_FETCH_SUCCESS" | "GUILD_SCHEDULED_EVENT_USER_REMOVE" | "GUILD_SEARCH_RECENT_MEMBERS" | "GUILD_SETTINGS_CANCEL_CHANGES" | "GUILD_SETTINGS_CLOSE" | "GUILD_SETTINGS_DEFAULT_CHANNELS_RESET" | "GUILD_SETTINGS_DEFAULT_CHANNELS_SAVE_FAILED" | "GUILD_SETTINGS_DEFAULT_CHANNELS_SAVE_SUCCESS" | "GUILD_SETTINGS_DEFAULT_CHANNELS_SUBMIT" | "GUILD_SETTINGS_DEFAULT_CHANNELS_TOGGLE" | "GUILD_SETTINGS_INIT" | "GUILD_SETTINGS_JOIN_RULES_APPLY_SET_PENDING_FORM_FIELDS" | "GUILD_SETTINGS_JOIN_RULES_INVITE_SET_PENDING_RULES" | "GUILD_SETTINGS_JOIN_RULES_SET_CONTENT_LEVEL" | "GUILD_SETTINGS_JOIN_RULES_SET_SELECTED_TYPE" | "GUILD_SETTINGS_LOADED_BANS" | "GUILD_SETTINGS_LOADED_BANS_BATCH" | "GUILD_SETTINGS_LOADED_INTEGRATIONS" | "GUILD_SETTINGS_LOADED_INVITES" | "GUILD_SETTINGS_ONBOARDING_ADD_NEW_MEMBER_ACTION" | "GUILD_SETTINGS_ONBOARDING_ADD_RESOURCE_CHANNEL" | "GUILD_SETTINGS_ONBOARDING_ALLOWED_APPLICATIONS_FETCH_FAILURE" | "GUILD_SETTINGS_ONBOARDING_ALLOWED_APPLICATIONS_FETCH_START" | "GUILD_SETTINGS_ONBOARDING_ALLOWED_APPLICATIONS_FETCH_SUCCESS" | "GUILD_SETTINGS_ONBOARDING_CLEAR_NEW_MEMBER_ACTION_PENDING_DATA" | "GUILD_SETTINGS_ONBOARDING_CONNECTIONS_ADD" | "GUILD_SETTINGS_ONBOARDING_CONNECTIONS_REMOVE" | "GUILD_SETTINGS_ONBOARDING_CONNECTIONS_REORDER" | "GUILD_SETTINGS_ONBOARDING_CONNECTIONS_RESET" | "GUILD_SETTINGS_ONBOARDING_CONNECTIONS_SAVE_FAILED" | "GUILD_SETTINGS_ONBOARDING_CONNECTIONS_SAVE_SUCCESS" | "GUILD_SETTINGS_ONBOARDING_CONNECTIONS_SUBMIT" | "GUILD_SETTINGS_ONBOARDING_CONNECTIONS_UPDATE" | "GUILD_SETTINGS_ONBOARDING_DELETE_NEW_MEMBER_ACTION" | "GUILD_SETTINGS_ONBOARDING_DELETE_RESOURCE_CHANNEL" | "GUILD_SETTINGS_ONBOARDING_DISMISS_RESOURCE_CHANNEL_SUGGESTION" | "GUILD_SETTINGS_ONBOARDING_EDUCATION_UPSELL_DISMISSED" | "GUILD_SETTINGS_ONBOARDING_HOME_SETTINGS_RESET" | "GUILD_SETTINGS_ONBOARDING_PROMPTS_EDIT" | "GUILD_SETTINGS_ONBOARDING_PROMPTS_ERRORS" | "GUILD_SETTINGS_ONBOARDING_PROMPTS_RESET" | "GUILD_SETTINGS_ONBOARDING_PROMPTS_SAVE_FAILED" | "GUILD_SETTINGS_ONBOARDING_PROMPTS_SAVE_SUCCESS" | "GUILD_SETTINGS_ONBOARDING_PROMPTS_SUBMIT" | "GUILD_SETTINGS_ONBOARDING_REORDER_NEW_MEMBER_ACTION" | "GUILD_SETTINGS_ONBOARDING_REORDER_RESOURCE_CHANNEL" | "GUILD_SETTINGS_ONBOARDING_SET_MODE" | "GUILD_SETTINGS_ONBOARDING_STEP" | "GUILD_SETTINGS_ONBOARDING_UPDATE_NEW_MEMBER_ACTION" | "GUILD_SETTINGS_ONBOARDING_UPDATE_NEW_MEMBER_ACTION_PENDING_DATA" | "GUILD_SETTINGS_ONBOARDING_UPDATE_RESOURCE_CHANNEL" | "GUILD_SETTINGS_ONBOARDING_UPDATE_WELCOME_MESSAGE" | "GUILD_SETTINGS_OPEN" | "GUILD_SETTINGS_PIN_PERMISSION_MIGRATED" | "GUILD_SETTINGS_PROFILE_UPDATE" | "GUILD_SETTINGS_ROLES_CLEAR_PERMISSIONS" | "GUILD_SETTINGS_ROLES_INIT" | "GUILD_SETTINGS_ROLES_ROLE_STYLE_UPDATE" | "GUILD_SETTINGS_ROLES_SAVE_FAIL" | "GUILD_SETTINGS_ROLES_SAVE_SUCCESS" | "GUILD_SETTINGS_ROLES_SORT_UPDATE" | "GUILD_SETTINGS_ROLES_SUBMITTING" | "GUILD_SETTINGS_ROLES_UPDATE_COLOR" | "GUILD_SETTINGS_ROLES_UPDATE_COLORS" | "GUILD_SETTINGS_ROLES_UPDATE_DESCRIPTION" | "GUILD_SETTINGS_ROLES_UPDATE_NAME" | "GUILD_SETTINGS_ROLES_UPDATE_PERMISSIONS" | "GUILD_SETTINGS_ROLES_UPDATE_PERMISSION_SET" | "GUILD_SETTINGS_ROLES_UPDATE_ROLE_CONNECTION_CONFIGURATIONS" | "GUILD_SETTINGS_ROLES_UPDATE_ROLE_ICON" | "GUILD_SETTINGS_ROLES_UPDATE_SETTINGS" | "GUILD_SETTINGS_ROLE_SELECT" | "GUILD_SETTINGS_SAFETY_PAGE" | "GUILD_SETTINGS_SAFETY_SET_SUBSECTION" | "GUILD_SETTINGS_SAVE_ROUTE_STACK" | "GUILD_SETTINGS_SET_MFA_SUCCESS" | "GUILD_SETTINGS_SET_SEARCH_QUERY" | "GUILD_SETTINGS_SET_SECTION" | "GUILD_SETTINGS_SET_VANITY_URL" | "GUILD_SETTINGS_SET_WIDGET" | "GUILD_SETTINGS_SLOWMODE_PERMISSION_MIGRATED" | "GUILD_SETTINGS_SUBMIT" | "GUILD_SETTINGS_SUBMIT_FAILURE" | "GUILD_SETTINGS_SUBMIT_SUCCESS" | "GUILD_SETTINGS_UPDATE" | "GUILD_SETTINGS_VANITY_URL_ERROR" | "GUILD_SETTINGS_VANITY_URL_RESET" | "GUILD_SETTINGS_VANITY_URL_SET" | "GUILD_SETTINGS_WIDGET_UPDATE" | "GUILD_SOUNDBOARD_FETCH" | "GUILD_SOUNDBOARD_SOUNDS_UPDATE" | "GUILD_SOUNDBOARD_SOUND_CREATE" | "GUILD_SOUNDBOARD_SOUND_DELETE" | "GUILD_SOUNDBOARD_SOUND_PLAY_END" | "GUILD_SOUNDBOARD_SOUND_PLAY_LOCALLY" | "GUILD_SOUNDBOARD_SOUND_PLAY_START" | "GUILD_SOUNDBOARD_SOUND_UPDATE" | "GUILD_STICKERS_CREATE_SUCCESS" | "GUILD_STICKERS_FETCH_SUCCESS" | "GUILD_STICKERS_UPDATE" | "GUILD_STICKER_FETCH_SUCCESS" | "GUILD_STOP_LURKING" | "GUILD_STOP_LURKING_FAILURE" | "GUILD_SUBSCRIPTIONS" | "GUILD_SUBSCRIPTIONS_ADD_MEMBER_UPDATES" | "GUILD_SUBSCRIPTIONS_CHANNEL" | "GUILD_SUBSCRIPTIONS_FLUSH" | "GUILD_SUBSCRIPTIONS_MEMBERS_ADD" | "GUILD_SUBSCRIPTIONS_MEMBERS_REMOVE" | "GUILD_SUBSCRIPTIONS_REMOVE_MEMBER_UPDATES" | "GUILD_TAG_CHANGED_COACHMARK_SEEN" | "GUILD_TEMPLATE_ACCEPT" | "GUILD_TEMPLATE_ACCEPT_FAILURE" | "GUILD_TEMPLATE_ACCEPT_SUCCESS" | "GUILD_TEMPLATE_CREATE_SUCCESS" | "GUILD_TEMPLATE_DELETE_SUCCESS" | "GUILD_TEMPLATE_DIRTY_TOOLTIP_HIDE" | "GUILD_TEMPLATE_DIRTY_TOOLTIP_REFRESH" | "GUILD_TEMPLATE_LOAD_FOR_GUILD_SUCCESS" | "GUILD_TEMPLATE_MODAL_HIDE" | "GUILD_TEMPLATE_MODAL_SHOW" | "GUILD_TEMPLATE_PROMOTION_TOOLTIP_HIDE" | "GUILD_TEMPLATE_RESOLVE" | "GUILD_TEMPLATE_RESOLVE_FAILURE" | "GUILD_TEMPLATE_RESOLVE_SUCCESS" | "GUILD_TEMPLATE_SYNC_SUCCESS" | "GUILD_TOGGLE_COLLAPSE_MUTED" | "GUILD_TOP_READ_CHANNELS_FETCH_SUCCESS" | "GUILD_UNAPPLY_BOOST_FAIL" | "GUILD_UNAPPLY_BOOST_START" | "GUILD_UNAPPLY_BOOST_SUCCESS" | "GUILD_UNAVAILABLE" | "GUILD_UPDATE" | "GUILD_UPDATE_DISCOVERY_METADATA" | "GUILD_UPDATE_DISCOVERY_METADATA_FAIL" | "GUILD_UPDATE_DISCOVERY_METADATA_FROM_SERVER" | "GUILD_VERIFICATION_CHECK" | "HABITUAL_DND_CLEAR" | "HANGOUT_WINDOW_IMAGE_SELECTED" | "HAVEN_CONNECT" | "HAVEN_DISCONNECT" | "HAVEN_GOT_ASSETS" | "HAVEN_UPDATE" | "HIDE_ACTION_SHEET" | "HIDE_ACTION_SHEET_QUICK_SWITCHER" | "HIDE_KEYBOARD_SHORTCUTS" | "HOTSPOT_HIDE" | "HOTSPOT_OVERRIDE_CLEAR" | "HOTSPOT_OVERRIDE_SET" | "HUB_VERIFY_EMAIL_FAILURE" | "HUB_VERIFY_EMAIL_SUCCESS" | "IAP_LOAD_GENERIC_PRODUCTS" | "ICYMI_ACK_ITEMS" | "ICYMI_CUSTOM_SCORES_UPDATED" | "ICYMI_FEEDBACK_GIVEN" | "ICYMI_JOINED_RECOMMENDED_GUILD" | "ICYMI_SCROLL_EVENT" | "ICYMI_SET_FOCUSED_TAB" | "ICYMI_SET_REFRESHING" | "ICYMI_SET_VIDEOS_MUTED" | "ICYMI_TAB_OPENED" | "ICYMI_TAKE_SURVEY" | "IDLE" | "IMPERSONATE_STOP" | "IMPERSONATE_UPDATE" | "INBOX_OPEN" | "INCOMING_CALL_MOVE" | "INITIALIZE_MEMBER_SAFETY_STORE" | "INITIATE_AGE_VERIFICATION" | "INSTALLATION_ID" | "INSTALLATION_LOCATION_ADD" | "INSTALLATION_LOCATION_FETCH_METADATA" | "INSTALLATION_LOCATION_REMOVE" | "INSTALLATION_LOCATION_UPDATE" | "INSTANT_INVITE_CLEAR" | "INSTANT_INVITE_CREATE" | "INSTANT_INVITE_CREATE_FAILURE" | "INSTANT_INVITE_CREATE_SUCCESS" | "INSTANT_INVITE_REVOKE_SUCCESS" | "INTEGRATION_CREATE" | "INTEGRATION_DELETE" | "INTEGRATION_PERMISSION_SETTINGS_APPLICATION_PERMISSIONS_FETCH_FAILURE" | "INTEGRATION_PERMISSION_SETTINGS_CLEAR" | "INTEGRATION_PERMISSION_SETTINGS_COMMANDS_FETCH_FAILURE" | "INTEGRATION_PERMISSION_SETTINGS_COMMANDS_FETCH_SUCCESS" | "INTEGRATION_PERMISSION_SETTINGS_COMMAND_UPDATE" | "INTEGRATION_PERMISSION_SETTINGS_EDIT" | "INTEGRATION_PERMISSION_SETTINGS_INIT" | "INTEGRATION_PERMISSION_SETTINGS_RESET" | "INTEGRATION_QUERY" | "INTEGRATION_QUERY_FAILURE" | "INTEGRATION_QUERY_SUCCESS" | "INTEGRATION_SETTINGS_INIT" | "INTEGRATION_SETTINGS_SAVE_FAILURE" | "INTEGRATION_SETTINGS_SAVE_SUCCESS" | "INTEGRATION_SETTINGS_SET_SECTION" | "INTEGRATION_SETTINGS_START_EDITING_COMMAND" | "INTEGRATION_SETTINGS_START_EDITING_INTEGRATION" | "INTEGRATION_SETTINGS_START_EDITING_WEBHOOK" | "INTEGRATION_SETTINGS_STOP_EDITING_COMMAND" | "INTEGRATION_SETTINGS_STOP_EDITING_INTEGRATION" | "INTEGRATION_SETTINGS_STOP_EDITING_WEBHOOK" | "INTEGRATION_SETTINGS_SUBMITTING" | "INTEGRATION_SETTINGS_UPDATE_INTEGRATION" | "INTEGRATION_SETTINGS_UPDATE_WEBHOOK" | "INTERACTION_CREATE" | "INTERACTION_FAILURE" | "INTERACTION_IFRAME_MODAL_CLOSE" | "INTERACTION_IFRAME_MODAL_CREATE" | "INTERACTION_IFRAME_MODAL_KEY_CREATE" | "INTERACTION_MODAL_CREATE" | "INTERACTION_QUEUE" | "INTERACTION_SUCCESS" | "INVITE_ACCEPT" | "INVITE_ACCEPT_FAILURE" | "INVITE_ACCEPT_SUCCESS" | "INVITE_APP_NOT_OPENED" | "INVITE_APP_OPENED" | "INVITE_APP_OPENING" | "INVITE_FRIEND_MEMBERS_FETCH_FAILURE" | "INVITE_FRIEND_MEMBERS_FETCH_SUCCESS" | "INVITE_MODAL_CLOSE" | "INVITE_MODAL_ERROR" | "INVITE_MODAL_OPEN" | "INVITE_RESOLVE" | "INVITE_RESOLVE_FAILURE" | "INVITE_RESOLVE_SUCCESS" | "INVITE_SUGGESTIONS_SEARCH" | "KEYBINDS_ADD_KEYBIND" | "KEYBINDS_DELETE_KEYBIND" | "KEYBINDS_ENABLE_ALL_KEYBINDS" | "KEYBINDS_REGISTER_GLOBAL_KEYBIND_ACTIONS" | "KEYBINDS_SET_KEYBIND" | "KEYBOARD_NAVIGATION_EXPLAINER_MODAL_SEEN" | "LAB_FEATURE_TOGGLE" | "LAYER_POP" | "LAYER_POP_ALL" | "LAYER_PUSH" | "LAYOUT_CREATE" | "LAYOUT_CREATE_WIDGETS" | "LAYOUT_DELETE_ALL_WIDGETS" | "LAYOUT_DELETE_WIDGET" | "LAYOUT_SET_DEFAULT_CONFIG" | "LAYOUT_SET_PINNED" | "LAYOUT_SET_TOP_WIDGET" | "LAYOUT_SET_WIDGET_META" | "LAYOUT_SHOW_OVERLAY_EXTRAS_HINT" | "LAYOUT_UPDATE_WIDGET" | "LIBRARY_APPLICATIONS_TEST_MODE_ENABLED" | "LIBRARY_APPLICATION_ACTIVE_BRANCH_UPDATE" | "LIBRARY_APPLICATION_ACTIVE_LAUNCH_OPTION_UPDATE" | "LIBRARY_APPLICATION_FILTER_UPDATE" | "LIBRARY_APPLICATION_FLAGS_UPDATE_START" | "LIBRARY_APPLICATION_FLAGS_UPDATE_SUCCESS" | "LIBRARY_APPLICATION_UPDATE" | "LIBRARY_FETCH_SUCCESS" | "LIBRARY_TABLE_ACTIVE_ROW_ID_UPDATE" | "LIBRARY_TABLE_SORT_UPDATE" | "LIVE_CHANNEL_NOTICE_HIDE" | "LOAD_ARCHIVED_THREADS" | "LOAD_ARCHIVED_THREADS_FAIL" | "LOAD_ARCHIVED_THREADS_SUCCESS" | "LOAD_CHANNELS" | "LOAD_DATA_HARVEST_TYPE_FAILURE" | "LOAD_DATA_HARVEST_TYPE_START" | "LOAD_FORUM_POSTS" | "LOAD_FRIEND_SUGGESTIONS_FAILURE" | "LOAD_FRIEND_SUGGESTIONS_SUCCESS" | "LOAD_GUILD_AFFINITIES_SUCCESS" | "LOAD_ICYMI_CURRENT_STATUS_MEDIA" | "LOAD_ICYMI_CUSTOM_SCORES" | "LOAD_ICYMI_DEHYDRATED" | "LOAD_ICYMI_FROM_NOTIFICATION" | "LOAD_ICYMI_HYDRATED" | "LOAD_ICYMI_HYDRATED_ATTEMPT" | "LOAD_ICYMI_HYDRATED_FAILED" | "LOAD_ICYMI_RECOMMENDED_GUILDS" | "LOAD_INVITE_SUGGESTIONS" | "LOAD_MESSAGES" | "LOAD_MESSAGES_AROUND_SUCCESS" | "LOAD_MESSAGES_FAILURE" | "LOAD_MESSAGES_SUCCESS" | "LOAD_MESSAGES_SUCCESS_CACHED" | "LOAD_MESSAGE_INTERACTION_DATA_SUCCESS" | "LOAD_MESSAGE_REQUESTS_SUPPLEMENTAL_DATA_ERROR" | "LOAD_MESSAGE_REQUESTS_SUPPLEMENTAL_DATA_SUCCESS" | "LOAD_NOTIFICATION_CENTER_ITEMS" | "LOAD_NOTIFICATION_CENTER_ITEMS_FAILURE" | "LOAD_NOTIFICATION_CENTER_ITEMS_SUCCESS" | "LOAD_PINNED_MESSAGES" | "LOAD_PINNED_MESSAGES_FAILURE" | "LOAD_PINNED_MESSAGES_SUCCESS" | "LOAD_RECENT_MENTIONS" | "LOAD_RECENT_MENTIONS_FAILURE" | "LOAD_RECENT_MENTIONS_SUCCESS" | "LOAD_REGIONS" | "LOAD_RELATIONSHIPS_FAILURE" | "LOAD_RELATIONSHIPS_SUCCESS" | "LOAD_THREADS_SUCCESS" | "LOAD_USER_AFFINITIES_V2" | "LOAD_USER_AFFINITIES_V2_FAILURE" | "LOAD_USER_AFFINITIES_V2_SUCCESS" | "LOCAL_ACTIVITY_UPDATE" | "LOCAL_MESSAGES_LOADED" | "LOCAL_MESSAGE_CREATE" | "LOGIN" | "LOGIN_ACCOUNT_DISABLED" | "LOGIN_ACCOUNT_SCHEDULED_FOR_DELETION" | "LOGIN_ATTEMPTED" | "LOGIN_FAILURE" | "LOGIN_MFA" | "LOGIN_MFA_STEP" | "LOGIN_PASSWORD_RECOVERY_PHONE_VERIFICATION" | "LOGIN_PHONE_IP_AUTHORIZATION_REQUIRED" | "LOGIN_RESET" | "LOGIN_STATUS_RESET" | "LOGIN_SUCCESS" | "LOGIN_SUSPENDED_USER" | "LOGOUT" | "LOGOUT_AUTH_SESSIONS_SUCCESS" | "MASKED_LINK_ADD_TRUSTED_DOMAIN" | "MASKED_LINK_ADD_TRUSTED_PROTOCOL" | "MAX_MEMBER_COUNT_NOTICE_DISMISS" | "MEDIA_ENGINE_APPLY_MEDIA_FILTER_SETTINGS" | "MEDIA_ENGINE_APPLY_MEDIA_FILTER_SETTINGS_ERROR" | "MEDIA_ENGINE_APPLY_MEDIA_FILTER_SETTINGS_START" | "MEDIA_ENGINE_CONNECTION_STATS" | "MEDIA_ENGINE_CONNECTION_STATS_HISTORY_RESET" | "MEDIA_ENGINE_DEVICES" | "MEDIA_ENGINE_INTERACTION_REQUIRED" | "MEDIA_ENGINE_MF_AVAILABILITY_CHECKED" | "MEDIA_ENGINE_NOISE_CANCELLATION_ERROR" | "MEDIA_ENGINE_NOISE_CANCELLATION_ERROR_RESET" | "MEDIA_ENGINE_PERMISSION" | "MEDIA_ENGINE_RESET_SETTINGS" | "MEDIA_ENGINE_SET_AEC_DUMP" | "MEDIA_ENGINE_SET_AUDIO_ENABLED" | "MEDIA_ENGINE_SET_ENABLE_HARDWARE_MUTE_NOTICE" | "MEDIA_ENGINE_SET_EXPERIMENTAL_SOUNDSHARE" | "MEDIA_ENGINE_SET_GO_LIVE_SOURCE" | "MEDIA_ENGINE_SET_OPENH264_ENABLED" | "MEDIA_ENGINE_SET_USE_SYSTEM_SCREENSHARE_PICKER" | "MEDIA_ENGINE_SET_VIDEO_DEVICE" | "MEDIA_ENGINE_SET_VIDEO_ENABLED" | "MEDIA_ENGINE_SET_VIDEO_HOOK" | "MEDIA_ENGINE_SOUNDSHARE_FAILED" | "MEDIA_ENGINE_SOUNDSHARE_TRANSMITTING" | "MEDIA_ENGINE_VIDEO_SOURCE_QUALITY_CHANGED" | "MEDIA_ENGINE_VIDEO_STATE_CHANGED" | "MEDIA_ENGINE_VOICE_ACTIVITY_DETECTION_ERROR" | "MEDIA_PLAYBACK_POSITION_UPDATE" | "MEDIA_PLAYBACK_RATE_UPDATE" | "MEDIA_POST_EMBED_FETCH" | "MEDIA_POST_EMBED_FETCH_FAILURE" | "MEDIA_POST_EMBED_FETCH_SUCCESS" | "MEDIA_SESSION_JOINED" | "MEMBER_SAFETY_GUILD_MEMBER_SEARCH_SUCCESS" | "MEMBER_SAFETY_GUILD_MEMBER_UPDATE_BATCH" | "MEMBER_SAFETY_NEW_MEMBER_TIMESTAMP_REFRESH" | "MEMBER_SAFETY_PAGINATION_TOKEN_UPDATE" | "MEMBER_SAFETY_PAGINATION_UPDATE" | "MEMBER_SAFETY_SEARCH_STATE_UPDATE" | "MEMBER_VERIFICATION_FORM_FETCH_FAIL" | "MEMBER_VERIFICATION_FORM_UPDATE" | "MESSAGE_ACK" | "MESSAGE_ACKED" | "MESSAGE_CREATE" | "MESSAGE_DELETE" | "MESSAGE_DELETE_BULK" | "MESSAGE_EDIT_FAILED_AUTOMOD" | "MESSAGE_END_EDIT" | "MESSAGE_EXPLICIT_CONTENT_FP_CREATE" | "MESSAGE_EXPLICIT_CONTENT_FP_SUBMIT" | "MESSAGE_EXPLICIT_CONTENT_SCAN_TIMEOUT" | "MESSAGE_GIFT_INTENT_SHOWN" | "MESSAGE_LENGTH_UPSELL" | "MESSAGE_NOTIFICATION_SHOWN" | "MESSAGE_PREVIEWS_LOADED" | "MESSAGE_PREVIEWS_LOCALLY_LOADED" | "MESSAGE_REACTION_ADD" | "MESSAGE_REACTION_ADD_MANY" | "MESSAGE_REACTION_ADD_USERS" | "MESSAGE_REACTION_REMOVE" | "MESSAGE_REACTION_REMOVE_ALL" | "MESSAGE_REACTION_REMOVE_EMOJI" | "MESSAGE_REMINDER_DUE" | "MESSAGE_REQUEST_ACCEPT_OPTIMISTIC" | "MESSAGE_REQUEST_ACK" | "MESSAGE_REQUEST_CLEAR_ACK" | "MESSAGE_REVEAL" | "MESSAGE_SEND_FAILED" | "MESSAGE_SEND_FAILED_AUTOMOD" | "MESSAGE_START_EDIT" | "MESSAGE_UPDATE" | "MESSAGE_UPDATE_EDIT" | "MFA_CLEAR_BACKUP_CODES" | "MFA_DISABLE_SUCCESS" | "MFA_ENABLE_SUCCESS" | "MFA_SEEN_BACKUP_CODE_PROMPT" | "MFA_SEND_VERIFICATION_KEY" | "MFA_SMS_TOGGLE" | "MFA_SMS_TOGGLE_COMPLETE" | "MFA_VIEW_BACKUP_CODES" | "MFA_WEBAUTHN_CREDENTIALS_LOADED" | "MOBILE_NATIVE_UPDATE_CHECK_FINISHED" | "MOBILE_WEB_SIDEBAR_CLOSE" | "MOBILE_WEB_SIDEBAR_OPEN" | "MODAL_POP" | "MODAL_PUSH" | "MOD_VIEW_SEARCH_MESSAGES_SUCCESS" | "MULTI_ACCOUNT_INVALIDATE_PUSH_SYNC_TOKENS" | "MULTI_ACCOUNT_MOVE_ACCOUNT" | "MULTI_ACCOUNT_REMOVE_ACCOUNT" | "MULTI_ACCOUNT_UPDATE_PUSH_SYNC_TOKEN" | "MULTI_ACCOUNT_VALIDATE_TOKEN_FAILURE" | "MULTI_ACCOUNT_VALIDATE_TOKEN_REQUEST" | "MULTI_ACCOUNT_VALIDATE_TOKEN_SUCCESS" | "MUTUAL_FRIENDS_FETCH_FAILURE" | "MUTUAL_FRIENDS_FETCH_START" | "MUTUAL_FRIENDS_FETCH_SUCCESS" | "NATIVE_APP_MODAL_OPENED" | "NATIVE_APP_MODAL_OPENING" | "NATIVE_APP_MODAL_OPEN_FAILED" | "NATIVE_SCREEN_SHARE_PICKER_CANCEL" | "NATIVE_SCREEN_SHARE_PICKER_ERROR" | "NATIVE_SCREEN_SHARE_PICKER_PRESENT" | "NATIVE_SCREEN_SHARE_PICKER_RELEASE" | "NATIVE_SCREEN_SHARE_PICKER_UPDATE" | "NEWLY_ADDED_EMOJI_SEEN_ACKNOWLEDGED" | "NEWLY_ADDED_EMOJI_SEEN_PENDING" | "NEWLY_ADDED_EMOJI_SEEN_UPDATED" | "NEW_PAYMENT_SOURCE_ADDRESS_INFO_UPDATE" | "NEW_PAYMENT_SOURCE_CARD_INFO_UPDATE" | "NEW_PAYMENT_SOURCE_CLEAR_ERROR" | "NEW_PAYMENT_SOURCE_STRIPE_PAYMENT_REQUEST_UPDATE" | "NON_GAMES_DATABASE_FETCH" | "NON_GAMES_DATABASE_FETCH_FAIL" | "NON_GAMES_DATABASE_UPDATE" | "NOTICE_DISABLE" | "NOTICE_DISMISS" | "NOTICE_SHOW" | "NOTIFICATIONS_INBOX_CLOSE" | "NOTIFICATIONS_INBOX_ITEM_ACK" | "NOTIFICATIONS_INBOX_ITEM_CLICK" | "NOTIFICATIONS_INBOX_LOAD_MORE_INBOX_FAILURE" | "NOTIFICATIONS_INBOX_LOAD_MORE_INBOX_START" | "NOTIFICATIONS_INBOX_LOAD_MORE_INBOX_SUCCESS" | "NOTIFICATIONS_INBOX_OPEN" | "NOTIFICATIONS_INBOX_SET_DEV_OVERRIDES" | "NOTIFICATIONS_SET_DESKTOP_TYPE" | "NOTIFICATIONS_SET_DISABLED_SOUNDS" | "NOTIFICATIONS_SET_DISABLE_UNREAD_BADGE" | "NOTIFICATIONS_SET_NOTIFY_MESSAGES_IN_SELECTED_CHANNEL" | "NOTIFICATIONS_SET_PERMISSION_STATE" | "NOTIFICATIONS_SET_TASKBAR_FLASH" | "NOTIFICATIONS_SET_TTS_TYPE" | "NOTIFICATIONS_TOGGLE_ALL_DISABLED" | "NOTIFICATION_CENTER_CLEAR_GUILD_MENTIONS" | "NOTIFICATION_CENTER_ITEMS_ACK" | "NOTIFICATION_CENTER_ITEMS_ACK_FAILURE" | "NOTIFICATION_CENTER_ITEMS_LOCAL_ACK" | "NOTIFICATION_CENTER_ITEM_COMPLETED" | "NOTIFICATION_CENTER_ITEM_CREATE" | "NOTIFICATION_CENTER_ITEM_DELETE" | "NOTIFICATION_CENTER_ITEM_DELETE_FAILURE" | "NOTIFICATION_CENTER_REFRESH" | "NOTIFICATION_CENTER_SET_ACTIVE" | "NOTIFICATION_CENTER_SET_TAB" | "NOTIFICATION_CENTER_TAB_FOCUSED" | "NOTIFICATION_CLICK" | "NOTIFICATION_CREATE" | "NOTIFICATION_SETTINGS_UPDATE" | "NOW_PLAYING_MOUNTED" | "NOW_PLAYING_UNMOUNTED" | "NUF_COMPLETE" | "NUF_NEW_USER" | "OAUTH2_TOKEN_CREATE" | "OAUTH2_TOKEN_DELETE" | "OAUTH2_TOKEN_REVOKE" | "ONLINE_GUILD_MEMBER_COUNT_UPDATE" | "ORDER_CREATE_FAIL" | "ORDER_CREATE_START" | "ORDER_CREATE_SUCCESS" | "ORDER_DISCARD_SUCCESS" | "ORDER_UPDATE_FAIL" | "ORDER_UPDATE_START" | "ORDER_UPDATE_SUCCESS" | "OUTBOUND_PROMOTIONS_SEEN" | "OUTBOUND_PROMOTION_NOTICE_DISMISS" | "OVERLAY_ACTIVATE_REGION" | "OVERLAY_ADD_DEBUG_BREADCRUMB" | "OVERLAY_ADD_LOGS_BATCH" | "OVERLAY_CALL_PRIVATE_CHANNEL" | "OVERLAY_CRASHED" | "OVERLAY_DEACTIVATE_ALL_REGIONS" | "OVERLAY_DISABLE_EXTERNAL_LINK_ALERT" | "OVERLAY_FOCUSED" | "OVERLAY_FORCE_RENDER_MODE" | "OVERLAY_FRIENDS_WIDGET_SET_FAVORITE" | "OVERLAY_FRIENDS_WIDGET_TOGGLE_SECTION_COLLAPSED" | "OVERLAY_INCOMPATIBLE_APP" | "OVERLAY_INITIALIZE" | "OVERLAY_JOIN_GAME" | "OVERLAY_MESSAGE_EVENT_ACTION" | "OVERLAY_MOUNTED" | "OVERLAY_NOTIFICATION_EVENT" | "OVERLAY_OOP_POPOUT_INITIALIZATION_STAGE_CHANGED" | "OVERLAY_OOP_UI_INITIALIZED" | "OVERLAY_OOP_UI_SHOW_INACTIVE_SUCCESS" | "OVERLAY_READY" | "OVERLAY_RELOAD" | "OVERLAY_RENDER_DEBUG_CLEAR_TRACKED_PIDS" | "OVERLAY_RENDER_DEBUG_MODE" | "OVERLAY_SELECT_CALL" | "OVERLAY_SELECT_CHANNEL" | "OVERLAY_SET_ASSOCIATED_GAME" | "OVERLAY_SET_AVATAR_SIZE_MODE" | "OVERLAY_SET_CLICK_ZONES" | "OVERLAY_SET_DETAILED_LOGGING" | "OVERLAY_SET_DISABLE_CLICKABLE_REGIONS" | "OVERLAY_SET_DISPLAY_NAME_MODE" | "OVERLAY_SET_DISPLAY_USER_MODE" | "OVERLAY_SET_ENABLED" | "OVERLAY_SET_GAME_INVITE_NOTIFICATION" | "OVERLAY_SET_GPU_BOOST_REQUESTED" | "OVERLAY_SET_INPUT_LOCKED" | "OVERLAY_SET_INVITE_MESSAGE" | "OVERLAY_SET_LIMITED_INTERACTION_OVERRIDE" | "OVERLAY_SET_MODULE_LOGGING" | "OVERLAY_SET_NOTIFICATION_DISABLED_SETTING" | "OVERLAY_SET_NOTIFICATION_POSITION_MODE" | "OVERLAY_SET_NOT_IDLE" | "OVERLAY_SET_PREVIEW_IN_GAME_MODE" | "OVERLAY_SET_STATE_DEBUGGING" | "OVERLAY_SET_TEXT_WIDGET_OPACITY" | "OVERLAY_SOUNDBOARD_SOUNDS_FETCH_REQUEST" | "OVERLAY_START_SESSION" | "OVERLAY_SUCCESSFULLY_SHOWN" | "OVERLAY_TEXT_CHAT_ACK_CHANNEL" | "OVERLAY_TEXT_CHAT_ADD_OR_UPDATE_CHANNEL" | "OVERLAY_TEXT_CHAT_REMOVE_ALL_CHANNELS" | "OVERLAY_TEXT_CHAT_REMOVE_CHANNEL" | "OVERLAY_TEXT_CHAT_SELECT_CHANNEL" | "OVERLAY_TEXT_CHAT_SET_VOICE_CHAT_MINIMIZED" | "OVERLAY_TRACKED_GAME_UPDATE" | "OVERLAY_TRACK_STATE_CHANGED" | "OVERLAY_UI_FOCUSED_PID" | "OVERLAY_UPDATE_NOTIFICATION_STATUS" | "OVERLAY_UPDATE_OVERLAY_METHOD" | "OVERLAY_UPDATE_OVERLAY_STATE" | "OVERLAY_V3_CREATE_WINDOW_HANDLE_SUCCESS" | "OVERLAY_V3_LOAD_NATIVE_MODULE" | "OVERLAY_V3_LOAD_NATIVE_MODULE_FAILED" | "OVERLAY_V3_LOAD_NATIVE_MODULE_SUCCESS" | "OVERLAY_V3_NATIVE_DESTROY_HOST_WINDOW" | "OVERLAY_V3_NATIVE_FOCUS_GAINED" | "OVERLAY_V3_NATIVE_FOCUS_LOST" | "OVERLAY_V3_NATIVE_REFRESH_HOST_WINDOW" | "OVERLAY_V3_NATIVE_SUCCESSFULLY_SHOWN" | "OVERLAY_V3_NATIVE_TRACK_GAME" | "OVERLAY_V3_NATIVE_WINDOW_HANDLE_INITIALIZED" | "OVERLAY_V3_POST_CREATE_POPOUT" | "OVERLAY_V3_PRE_CREATE_POPOUT" | "OVERLAY_V3_WINDOW_CREATION_FAILURE" | "OVERLAY_WIDGET_CHANGED" | "OWNED_APPLICATION_BRANCHES_FETCH_SUCCESS" | "PACK_STICKER_FETCH_SUCCESS" | "PASSIVE_UPDATE_V1" | "PASSIVE_UPDATE_V2" | "PASSWORDLESS_FAILURE" | "PASSWORDLESS_START" | "PASSWORD_UPDATED" | "PAYMENT_AUTHENTICATION_CLEAR_ERROR" | "PAYMENT_AUTHENTICATION_ERROR" | "PAYMENT_MODAL_OPEN" | "PAYMENT_SOURCE_CREATION_CONTEXT_CLEAR" | "PAYMENT_SOURCE_CREATION_CONTEXT_FETCH_FAIL" | "PAYMENT_SOURCE_CREATION_CONTEXT_FETCH_START" | "PAYMENT_SOURCE_CREATION_CONTEXT_FETCH_SUCCESS" | "PAYMENT_UPDATE" | "PERMISSION_CLEAR_ELEVATED_PROCESS" | "PERMISSION_CLEAR_PTT_ADMIN_WARNING" | "PERMISSION_CLEAR_SUPPRESS_WARNING" | "PERMISSION_CLEAR_VAD_WARNING" | "PERMISSION_CONTINUE_NONELEVATED_PROCESS" | "PERMISSION_REQUEST_ELEVATED_PROCESS" | "PHONE_SET_COUNTRY_CODE" | "PICTURE_IN_PICTURE_CLOSE" | "PICTURE_IN_PICTURE_HIDE" | "PICTURE_IN_PICTURE_MOVE" | "PICTURE_IN_PICTURE_OPEN" | "PICTURE_IN_PICTURE_RESIZE" | "PICTURE_IN_PICTURE_SHOW" | "PICTURE_IN_PICTURE_UPDATE_RECT" | "PICTURE_IN_PICTURE_UPDATE_SELECTED_WINDOW" | "POGGERMODE_ACHIEVEMENT_UNLOCK" | "POGGERMODE_SETTINGS_UPDATE" | "POGGERMODE_TEMPORARILY_DISABLED" | "POGGERMODE_UPDATE_COMBO" | "POGGERMODE_UPDATE_MESSAGE_COMBO" | "POPOUT_WINDOW_ADD_STYLESHEET" | "POPOUT_WINDOW_CLOSE" | "POPOUT_WINDOW_OPEN" | "POPOUT_WINDOW_SET_ALWAYS_ON_TOP" | "POST_CONNECTION_OPEN" | "PREMIUM_GROUP_ACCEPT_INVITE_FAIL" | "PREMIUM_GROUP_ACCEPT_INVITE_START" | "PREMIUM_GROUP_ACCEPT_INVITE_SUCCESS" | "PREMIUM_GROUP_INVITES_FETCH_FAIL" | "PREMIUM_GROUP_INVITES_FETCH_START" | "PREMIUM_GROUP_INVITES_FETCH_SUCCESS" | "PREMIUM_GROUP_INVITE_FETCH_FAIL" | "PREMIUM_GROUP_INVITE_FETCH_START" | "PREMIUM_GROUP_INVITE_FETCH_SUCCESS" | "PREMIUM_GROUP_INVITE_USERS_FAILURE" | "PREMIUM_GROUP_INVITE_USERS_START" | "PREMIUM_GROUP_INVITE_USERS_SUCCESS" | "PREMIUM_GROUP_MEMBERSHIP_FETCH_FAILURE" | "PREMIUM_GROUP_MEMBERSHIP_FETCH_START" | "PREMIUM_GROUP_MEMBERSHIP_FETCH_SUCCESS" | "PREMIUM_GROUP_MEMBERSHIP_NOT_FOUND" | "PREMIUM_GROUP_MEMBERSHIP_REQUEST" | "PREMIUM_GROUP_MEMBERS_FETCH_FAILURE" | "PREMIUM_GROUP_MEMBERS_FETCH_START" | "PREMIUM_GROUP_MEMBERS_FETCH_SUCCESS" | "PREMIUM_GROUP_MEMBERS_REQUEST" | "PREMIUM_GROUP_REMOVE_INVITE_FAILURE" | "PREMIUM_GROUP_REMOVE_INVITE_START" | "PREMIUM_GROUP_REMOVE_INVITE_SUCCESS" | "PREMIUM_GROUP_REMOVE_MEMBER_FAILURE" | "PREMIUM_GROUP_REMOVE_MEMBER_START" | "PREMIUM_GROUP_REMOVE_MEMBER_SUCCESS" | "PREMIUM_MARKETING_DATA_READY" | "PREMIUM_MARKETING_PREVIEW" | "PREMIUM_PAYMENT_ERROR_CLEAR" | "PREMIUM_PAYMENT_MODAL_CLOSE" | "PREMIUM_PAYMENT_MODAL_OPEN" | "PREMIUM_PAYMENT_SUBSCRIBE_FAIL" | "PREMIUM_PAYMENT_SUBSCRIBE_START" | "PREMIUM_PAYMENT_SUBSCRIBE_SUCCESS" | "PREMIUM_PAYMENT_UPDATE_FAIL" | "PREMIUM_PAYMENT_UPDATE_SUCCESS" | "PREMIUM_REQUIRED_MODAL_CLOSE" | "PREMIUM_REQUIRED_MODAL_OPEN" | "PRESENCES_REPLACE" | "PRESENCE_SUBSCRIPTIONS_ADD" | "PRESENCE_UPDATES" | "PRIVATE_CHANNEL_RECIPIENTS_ADD_USER" | "PRIVATE_CHANNEL_RECIPIENTS_INVITE_CLOSE" | "PRIVATE_CHANNEL_RECIPIENTS_INVITE_OPEN" | "PRIVATE_CHANNEL_RECIPIENTS_INVITE_QUERY" | "PRIVATE_CHANNEL_RECIPIENTS_INVITE_SELECT" | "PRIVATE_CHANNEL_RECIPIENTS_REMOVE_USER" | "PROFILE_CUSTOMIZATION_OPEN_PREVIEW_MODAL" | "PROFILE_POPOUT_GIFT_INTENTS_DISMISS" | "PROGRAM_REWARDS_FETCH" | "PROGRAM_REWARDS_FETCH_FAILURE" | "PROGRAM_REWARDS_FETCH_SUCCESS" | "PUBLIC_UPSELL_NOTICE_DISMISS" | "PURCHASED_ITEMS_FESTIVITY_FETCH_WOW_MOMENT_MEDIA_FAILURE" | "PURCHASED_ITEMS_FESTIVITY_FETCH_WOW_MOMENT_MEDIA_SUCCESS" | "PURCHASED_ITEMS_FESTIVITY_IS_FETCHING_WOW_MOMENT_MEDIA" | "PURCHASED_ITEMS_FESTIVITY_SET_CAN_PLAY_WOW_MOMENT" | "PUSH_NOTIFICATION_CLICK" | "PUSH_TO_TALK_STATE_CHANGE" | "QUALTRICS_SURVEY_FETCH_SUCCESS" | "QUESTS_CLAIM_REWARD_BEGIN" | "QUESTS_CLAIM_REWARD_FAILURE" | "QUESTS_CLAIM_REWARD_SUCCESS" | "QUESTS_DELIVERY_OVERRIDE" | "QUESTS_DISMISS_CONTENT_BEGIN" | "QUESTS_DISMISS_CONTENT_FAILURE" | "QUESTS_DISMISS_CONTENT_SUCCESS" | "QUESTS_DISMISS_PROGRESS_TRACKING_FAILURE_NOTICE" | "QUESTS_ENROLL_BEGIN" | "QUESTS_ENROLL_FAILURE" | "QUESTS_ENROLL_SUCCESS" | "QUESTS_FETCH_CLAIMED_QUESTS_BEGIN" | "QUESTS_FETCH_CLAIMED_QUESTS_FAILURE" | "QUESTS_FETCH_CLAIMED_QUESTS_SUCCESS" | "QUESTS_FETCH_CURRENT_QUESTS_BEGIN" | "QUESTS_FETCH_CURRENT_QUESTS_FAILURE" | "QUESTS_FETCH_CURRENT_QUESTS_SUCCESS" | "QUESTS_FETCH_EARNED_QUEST_TO_DELIVER_BEGIN" | "QUESTS_FETCH_EARNED_QUEST_TO_DELIVER_FAILURE" | "QUESTS_FETCH_EARNED_QUEST_TO_DELIVER_SUCCESS" | "QUESTS_FETCH_PREVIEW_BEGIN" | "QUESTS_FETCH_PREVIEW_FAILURE" | "QUESTS_FETCH_PREVIEW_SUCCESS" | "QUESTS_FETCH_QUEST_HOME_HERO_BEGIN" | "QUESTS_FETCH_QUEST_HOME_HERO_FAILURE" | "QUESTS_FETCH_QUEST_HOME_HERO_SUCCESS" | "QUESTS_FETCH_QUEST_TO_DELIVER_BEGIN" | "QUESTS_FETCH_QUEST_TO_DELIVER_FAILURE" | "QUESTS_FETCH_QUEST_TO_DELIVER_SUCCESS" | "QUESTS_FETCH_REWARD_CODE_BEGIN" | "QUESTS_FETCH_REWARD_CODE_FAILURE" | "QUESTS_FETCH_REWARD_CODE_SUCCESS" | "QUESTS_PREVIEW_UPDATE_SUCCESS" | "QUESTS_RESET_OPTIMISTIC_PROGRESS" | "QUESTS_SELECT_TASK_PLATFORM" | "QUESTS_SEND_HEARTBEAT_FAILURE" | "QUESTS_SEND_HEARTBEAT_SUCCESS" | "QUESTS_UPDATE_OPTIMISTIC_PROGRESS" | "QUESTS_USER_COMPLETION_UPDATE" | "QUESTS_USER_STATUS_UPDATE" | "QUEST_APPLICATION_START_TIMER" | "QUEUE_INTERACTION_COMPONENT_STATE" | "QUICKSWITCHER_HIDE" | "QUICKSWITCHER_SEARCH" | "QUICKSWITCHER_SELECT" | "QUICKSWITCHER_SHOW" | "QUICKSWITCHER_SWITCH_TO" | "REACTION_NOTIFICATION_SENT" | "RECEIVE_CHANNEL_AFFINITIES" | "RECEIVE_CHANNEL_SUMMARIES" | "RECEIVE_CHANNEL_SUMMARIES_BULK" | "RECEIVE_CHANNEL_SUMMARY" | "RECENT_AVATARS_FETCH_FAILURE" | "RECENT_AVATARS_FETCH_START" | "RECENT_AVATARS_FETCH_SUCCESS" | "RECENT_AVATARS_UPDATE" | "RECENT_AVATAR_DELETE" | "RECENT_MENTION_DELETE" | "RECOMPUTE_READ_STATES" | "REFERRALS_FETCH_ELIGIBLE_USER_FAIL" | "REFERRALS_FETCH_ELIGIBLE_USER_START" | "REFERRALS_FETCH_ELIGIBLE_USER_SUCCESS" | "REFRESH_THEME" | "REGISTER" | "REGISTER_SUCCESS" | "RELATIONSHIP_ADD" | "RELATIONSHIP_IGNORE_USER_SUCCESS" | "RELATIONSHIP_PENDING_INCOMING_REMOVED" | "RELATIONSHIP_REMOVE" | "RELATIONSHIP_UPDATE" | "RELOAD_ICYMI" | "REMOTE_COMMAND" | "REMOTE_SESSION_CONNECT" | "REMOTE_SESSION_DISCONNECT" | "REMOVE_AUTOMOD_MESSAGE_NOTICE" | "REMOVE_USERS_FROM_GROUP" | "REORDER_FRIEND_GROUPS" | "REPLY_NUDGES_INITIALIZED" | "REPLY_NUDGE_CLEAR" | "REPLY_NUDGE_SET" | "REPORT_AV_ERROR" | "REPORT_TO_MOD_REPORT_MESSAGE_SUCCESS" | "REQUEST_CHANNEL_AFFINITIES" | "REQUEST_CHANNEL_SUMMARIES" | "REQUEST_CHANNEL_SUMMARIES_BULK" | "REQUEST_CHANNEL_SUMMARY" | "REQUEST_FORUM_UNREADS" | "REQUEST_SOUNDBOARD_SOUNDS" | "RESET_HANG_STATUS_STATE" | "RESET_NOTIFICATION_CENTER" | "RESET_PAYMENT_ID" | "RESET_PREVIEW_CLIENT_THEME" | "RESET_SOCKET" | "RESORT_THREADS" | "ROBLOX_SUBGAME_APPLICATION_FETCH_SUCCESS" | "ROBLOX_SUBGAME_UPDATE" | "ROUTE_CHANGED" | "RPC_APP_AUTHENTICATED" | "RPC_APP_CONNECTED" | "RPC_APP_DISCONNECTED" | "RPC_NOTIFICATION_CREATE" | "RPC_SERVER_READY" | "RTC_CONNECTION_CLIENT_CONNECT" | "RTC_CONNECTION_CLIENT_DISCONNECT" | "RTC_CONNECTION_FLAGS" | "RTC_CONNECTION_LOSS_RATE" | "RTC_CONNECTION_PING" | "RTC_CONNECTION_PLATFORM" | "RTC_CONNECTION_REMOTE_VIDEO_SINK_WANTS" | "RTC_CONNECTION_ROSTER_MAP_UPDATE" | "RTC_CONNECTION_SECURE_FRAMES_UPDATE" | "RTC_CONNECTION_STATE" | "RTC_CONNECTION_UPDATE_ID" | "RTC_CONNECTION_USERS_MERGED" | "RTC_CONNECTION_VIDEO" | "RTC_DEBUG_MODAL_CLOSE" | "RTC_DEBUG_MODAL_OPEN" | "RTC_DEBUG_MODAL_OPEN_REPLAY" | "RTC_DEBUG_MODAL_OPEN_REPLAY_AT_PATH" | "RTC_DEBUG_MODAL_SET_SECTION" | "RTC_DEBUG_MODAL_UPDATE_VIDEO_OUTPUT" | "RTC_DEBUG_POPOUT_WINDOW_OPEN" | "RTC_DEBUG_SET_RECORDING_FLAG" | "RTC_DEBUG_SET_SIMULCAST_OVERRIDE" | "RTC_LATENCY_TEST_COMPLETE" | "RUNNING_GAMES_CHANGE" | "RUNNING_GAME_ADD_OVERRIDE" | "RUNNING_GAME_DELETE_ENTRY" | "RUNNING_GAME_EDIT_NAME" | "RUNNING_GAME_TOGGLE_DETECTION" | "RUNNING_GAME_TOGGLE_OVERLAY" | "RUNNING_NON_GAMES_CHANGE" | "RUNNING_STREAMER_TOOLS_CHANGE" | "SAFETY_HUB_APPEAL_CLOSE" | "SAFETY_HUB_APPEAL_OPEN" | "SAFETY_HUB_APPEAL_SIGNAL_CUSTOM_INPUT_CHANGE" | "SAFETY_HUB_APPEAL_SIGNAL_SELECT" | "SAFETY_HUB_AUTOMATED_UNDERAGE_APPEAL_MODAL_CLOSE" | "SAFETY_HUB_AUTOMATED_UNDERAGE_APPEAL_MODAL_OPEN" | "SAFETY_HUB_AUTOMATED_UNDERAGE_APPEAL_START_POLL" | "SAFETY_HUB_AUTOMATED_UNDERAGE_APPEAL_SUBMIT_SUCCESS" | "SAFETY_HUB_CHECK_AUTOMATED_UNDERAGE_APPEAL_FAILURE" | "SAFETY_HUB_CHECK_AUTOMATED_UNDERAGE_APPEAL_START" | "SAFETY_HUB_CHECK_AUTOMATED_UNDERAGE_APPEAL_SUCCESS" | "SAFETY_HUB_FETCH_CLASSIFICATION_FAILURE" | "SAFETY_HUB_FETCH_CLASSIFICATION_START" | "SAFETY_HUB_FETCH_CLASSIFICATION_SUCCESS" | "SAFETY_HUB_FETCH_FAILURE" | "SAFETY_HUB_FETCH_START" | "SAFETY_HUB_FETCH_SUCCESS" | "SAFETY_HUB_REQUEST_AUTOMATED_UNDERAGE_APPEAL_FAILURE" | "SAFETY_HUB_REQUEST_AUTOMATED_UNDERAGE_APPEAL_START" | "SAFETY_HUB_REQUEST_AUTOMATED_UNDERAGE_APPEAL_SUCCESS" | "SAFETY_HUB_REQUEST_REVIEW_FAILURE" | "SAFETY_HUB_REQUEST_REVIEW_START" | "SAFETY_HUB_REQUEST_REVIEW_SUCCESS" | "SAVED_CUSTOM_THEMES_FETCH_FAILURE" | "SAVED_CUSTOM_THEMES_FETCH_START" | "SAVED_CUSTOM_THEMES_FETCH_SUCCESS" | "SAVED_MESSAGES_UPDATE" | "SAVED_MESSAGE_CREATE" | "SAVED_MESSAGE_DELETE" | "SAVE_LAST_NON_VOICE_ROUTE" | "SAVE_LAST_ROUTE" | "SCHEDULED_MESSAGES_CREATE_SUCCESS" | "SCHEDULED_MESSAGES_DELETE_FAILURE" | "SCHEDULED_MESSAGES_DELETE_START" | "SCHEDULED_MESSAGES_DELETE_SUCCESS" | "SCHEDULED_MESSAGES_UPDATE_SUCCESS" | "SEARCH_AUTOCOMPLETE_INITIALIZE" | "SEARCH_AUTOCOMPLETE_QUERY_UPDATE" | "SEARCH_EDITOR_STATE_CHANGE" | "SEARCH_EDITOR_STATE_CLEAR" | "SEARCH_ENSURE_SEARCH_STATE" | "SEARCH_HISTORY_WEB_ADD_ITEM" | "SEARCH_HISTORY_WEB_CLEAR_ITEMS" | "SEARCH_HISTORY_WEB_REMOVE_ITEM" | "SEARCH_MESSAGES_CLEAR" | "SEARCH_MESSAGES_CLEAR_ALL" | "SEARCH_MESSAGES_FAILURE" | "SEARCH_MESSAGES_INDEXING" | "SEARCH_MESSAGES_START" | "SEARCH_MESSAGES_SUCCESS" | "SEARCH_RECENT_MESSAGES_CLEAR" | "SEARCH_RESULTS_QUERY_UPDATE" | "SEARCH_SEARCH_MODE_UPDATE" | "SEARCH_SET_SHOW_BLOCKED_RESULTS" | "SEARCH_SET_SHOW_NO_RESULTS_ALT" | "SEARCH_TOKENS_REFRESHED" | "SECURE_FRAMES_SETTINGS_UPDATE" | "SECURE_FRAMES_TRANSIENT_KEY_CREATE" | "SECURE_FRAMES_TRANSIENT_KEY_DELETE" | "SECURE_FRAMES_UPLOADED_KEY_VERSION_ADD" | "SECURE_FRAMES_UPLOADED_KEY_VERSION_CLEAR" | "SECURE_FRAMES_USER_VERIFIED_KEYS_DELETE" | "SECURE_FRAMES_VERIFIED_KEY_CREATE" | "SECURE_FRAMES_VERIFIED_KEY_DELETE" | "SELECTIVELY_SYNCED_USER_SETTINGS_UPDATE" | "SELECT_HOME_RESOURCE_CHANNEL" | "SELECT_NEW_MEMBER_ACTION_CHANNEL" | "SELF_PRESENCE_STORE_UPDATE" | "SESSIONS_REPLACE" | "SET_ANALYTICS_TOKEN" | "SET_CHANNEL_BITRATE" | "SET_CHANNEL_VIDEO_QUALITY_MODE" | "SET_CONSENT_REQUIRED" | "SET_CREATED_AT_OVERRIDE" | "SET_GUILD_FOLDER_EXPANDED" | "SET_HIGHLIGHTED_SUMMARY" | "SET_ICYMI_FILTERS" | "SET_INTERACTION_COMPONENT_STATE" | "SET_LOCATION_METADATA" | "SET_MESSAGE_ACTIVITY_INVITE_COVER_IMAGE_URL" | "SET_NATIVE_PERMISSION" | "SET_PENDING_REPLY_SHOULD_MENTION" | "SET_PREMIUM_TYPE_OVERRIDE" | "SET_RECENTLY_ACTIVE_COLLAPSED" | "SET_RECENT_MENTIONS_FILTER" | "SET_RECENT_MENTIONS_STALE" | "SET_RPC_NOTIFICATION_SETTINGS" | "SET_SELECTED_SUMMARY" | "SET_SOCIAL_LAYER_STOREFRONT_STATE" | "SET_SOUNDPACK" | "SET_STREAM_APP_INTENT" | "SET_SUMMARY_FEEDBACK" | "SET_THEME_OVERRIDE" | "SET_THREADS_ALSO_SEND_TO_CHANNEL" | "SET_TRACK_TRIGGERS" | "SET_TTS_SPEECH_RATE" | "SET_VAD_PERMISSION" | "SHOW_ACTION_SHEET" | "SHOW_ACTION_SHEET_QUICK_SWITCHER" | "SHOW_KEYBOARD_SHORTCUTS" | "SIDEBAR_CLOSE" | "SIDEBAR_CLOSE_GUILD" | "SIDEBAR_CREATE_THREAD" | "SIDEBAR_SET_SELECTED_SEARCH_CONTEXT" | "SIDEBAR_VIEW_CHANNEL" | "SIDEBAR_VIEW_GUILD" | "SKUS_FETCH_SUCCESS" | "SKU_FETCH_FAIL" | "SKU_FETCH_START" | "SKU_FETCH_SUCCESS" | "SKU_PURCHASE_AWAIT_CONFIRMATION" | "SKU_PURCHASE_CLEAR_ERROR" | "SKU_PURCHASE_FAIL" | "SKU_PURCHASE_MODAL_CLOSE" | "SKU_PURCHASE_MODAL_OPEN" | "SKU_PURCHASE_PREVIEW_FETCH" | "SKU_PURCHASE_PREVIEW_FETCH_FAILURE" | "SKU_PURCHASE_PREVIEW_FETCH_SUCCESS" | "SKU_PURCHASE_SHOW_CONFIRMATION_STEP" | "SKU_PURCHASE_START" | "SKU_PURCHASE_SUCCESS" | "SKU_PURCHASE_UPDATE_IS_GIFT" | "SLOWMODE_RESET_COOLDOWN" | "SLOWMODE_SET_COOLDOWN" | "SOCIAL_LAYER_SKU_PURCHASE_ELIGIBILITY_CHECK_CREATE" | "SOCIAL_LAYER_SKU_PURCHASE_ELIGIBILITY_CHECK_FAILURE" | "SOCIAL_LAYER_SKU_PURCHASE_ELIGIBILITY_CHECK_START" | "SOCIAL_LAYER_SKU_PURCHASE_ELIGIBILITY_RESPONSE" | "SOCIAL_LAYER_STOREFRONT_ANNOUNCEMENT_FETCH_FAILURE" | "SOCIAL_LAYER_STOREFRONT_ANNOUNCEMENT_FETCH_START" | "SOCIAL_LAYER_STOREFRONT_ANNOUNCEMENT_FETCH_SUCCESS" | "SOCIAL_LAYER_STOREFRONT_CONFIG_FETCH_FAILURE" | "SOCIAL_LAYER_STOREFRONT_CONFIG_FETCH_START" | "SOCIAL_LAYER_STOREFRONT_CONFIG_FETCH_SUCCESS" | "SOCIAL_LAYER_STOREFRONT_LOAD" | "SOCIAL_LAYER_STOREFRONT_LOAD_FAILURE" | "SOCIAL_LAYER_STOREFRONT_LOAD_SUCCESS" | "SOCIAL_LAYER_STOREFRONT_PARTIAL_LOAD_SUCCESS" | "SOCIAL_LAYER_STOREFRONT_RECOMMENDATIONS_FETCH_FAILURE" | "SOCIAL_LAYER_STOREFRONT_RECOMMENDATIONS_FETCH_START" | "SOCIAL_LAYER_STOREFRONT_RECOMMENDATIONS_FETCH_SUCCESS" | "SOUNDBOARD_FETCH_DEFAULT_SOUNDS" | "SOUNDBOARD_FETCH_DEFAULT_SOUNDS_SUCCESS" | "SOUNDBOARD_MUTE_JOIN_SOUND" | "SOUNDBOARD_SET_OVERLAY_ENABLED" | "SOUNDBOARD_SOUNDS_RECEIVED" | "SPEAKING" | "SPEAKING_MESSAGE" | "SPEAK_MESSAGE" | "SPEAK_TEXT" | "SPELLCHECK_LEARN_WORD" | "SPELLCHECK_TOGGLE" | "SPELLCHECK_UNLEARN_WORD" | "SPOTIFY_ACCOUNT_ACCESS_TOKEN" | "SPOTIFY_ACCOUNT_ACCESS_TOKEN_REVOKE" | "SPOTIFY_NEW_TRACK" | "SPOTIFY_PLAYER_PAUSE" | "SPOTIFY_PLAYER_PLAY" | "SPOTIFY_PLAYER_STATE" | "SPOTIFY_PROFILE_UPDATE" | "SPOTIFY_SET_ACTIVE_DEVICE" | "SPOTIFY_SET_DEVICES" | "SPOTIFY_SET_PROTOCOL_REGISTERED" | "STAGE_INSTANCE_CREATE" | "STAGE_INSTANCE_DELETE" | "STAGE_INSTANCE_UPDATE" | "STAGE_MUSIC_MUTE" | "STAGE_MUSIC_PLAY" | "START_SESSION" | "STATUS_PAGE_INCIDENT" | "STATUS_PAGE_SCHEDULED_MAINTENANCE" | "STATUS_PAGE_SCHEDULED_MAINTENANCE_ACK" | "STICKER_PACKS_FETCH_START" | "STICKER_PACKS_FETCH_SUCCESS" | "STICKER_PACK_FETCH_SUCCESS" | "STICKER_TRACK_USAGE" | "STOP_SPEAKING" | "STOREFRONT_COLLECTION_WITH_PRODUCTS_FETCH" | "STOREFRONT_COLLECTION_WITH_PRODUCTS_FETCH_FAILURE" | "STOREFRONT_COLLECTION_WITH_PRODUCTS_FETCH_SUCCESS" | "STOREFRONT_PRODUCTS_BY_SKU_IDS_FETCH" | "STOREFRONT_PRODUCTS_BY_SKU_IDS_FETCH_FAILURE" | "STOREFRONT_PRODUCTS_BY_SKU_IDS_FETCH_SUCCESS" | "STOREFRONT_PRODUCT_BY_SKU_ID_FETCH" | "STOREFRONT_PRODUCT_BY_SKU_ID_FETCH_FAILURE" | "STOREFRONT_PRODUCT_BY_SKU_ID_FETCH_SUCCESS" | "STOREFRONT_PRODUCT_WITH_SKUS_FETCH" | "STOREFRONT_PRODUCT_WITH_SKUS_FETCH_FAILURE" | "STOREFRONT_PRODUCT_WITH_SKUS_FETCH_SUCCESS" | "STOREFRONT_SKU_PRICES_FETCH" | "STOREFRONT_SKU_PRICES_FETCH_FAILURE" | "STOREFRONT_SKU_PRICES_FETCH_SUCCESS" | "STORE_LISTINGS_FETCH_FAIL" | "STORE_LISTINGS_FETCH_START" | "STORE_LISTINGS_FETCH_SUCCESS" | "STORE_LISTING_FETCH_SUCCESS" | "STREAMER_MODE_UPDATE" | "STREAMING_UPDATE" | "STREAM_CLOSE" | "STREAM_CREATE" | "STREAM_DELETE" | "STREAM_LAYOUT_UPDATE" | "STREAM_PREVIEW_FETCH_FAIL" | "STREAM_PREVIEW_FETCH_START" | "STREAM_PREVIEW_FETCH_SUCCESS" | "STREAM_SERVER_UPDATE" | "STREAM_SET_PAUSED" | "STREAM_START" | "STREAM_STOP" | "STREAM_TIMED_OUT" | "STREAM_UPDATE" | "STREAM_UPDATE_SELF_HIDDEN" | "STREAM_UPDATE_SETTINGS" | "STREAM_WATCH" | "STRIPE_TOKEN_FAILURE" | "SUBSCRIPTION_PLANS_FETCH" | "SUBSCRIPTION_PLANS_FETCH_FAILURE" | "SUBSCRIPTION_PLANS_FETCH_SUCCESS" | "SUBSCRIPTION_PLANS_RESET" | "SUMMARIZE_THREAD_FAILURE" | "SUMMARIZE_THREAD_START" | "SUMMARIZE_THREAD_SUCCESS" | "SURVEY_FETCHED" | "SURVEY_HIDE" | "SURVEY_OVERRIDE" | "SURVEY_SEEN" | "SYSTEM_SERVICE_INITIALIZE" | "SYSTEM_THEME_CHANGE" | "THERMAL_STATE_CHANGE" | "THREAD_CREATE" | "THREAD_CREATE_LOCAL" | "THREAD_DELETE" | "THREAD_LIST_SYNC" | "THREAD_MEMBERS_UPDATE" | "THREAD_MEMBER_LIST_UPDATE" | "THREAD_MEMBER_LOCAL_UPDATE" | "THREAD_MEMBER_UPDATE" | "THREAD_SETTINGS_DRAFT_CHANGE" | "THREAD_UPDATE" | "TOGGLE_GUILD_EXPANDED_STATE" | "TOGGLE_GUILD_FOLDER_EXPAND" | "TOGGLE_TOPICS_BAR" | "TOP_EMOJIS_FETCH" | "TOP_EMOJIS_FETCH_SUCCESS" | "TRACK" | "TRACK_TRIGGER" | "TRUNCATE_MENTIONS" | "TRUNCATE_MESSAGES" | "TRY_ACK" | "TUTORIAL_INDICATOR_DISMISS" | "TUTORIAL_INDICATOR_HIDE" | "TUTORIAL_INDICATOR_SHOW" | "TUTORIAL_INDICATOR_SUPPRESS_ALL" | "TYPING_START" | "TYPING_START_LOCAL" | "TYPING_STOP" | "TYPING_STOP_LOCAL" | "UNCLAIMED_GAMES_FETCH" | "UNCLAIMED_GAMES_FETCH_FAIL" | "UNCLAIMED_GAMES_FETCH_SUCCESS" | "UNENROLLED_ACTIVITY_QUEST_AUTO_ENROLL" | "UNENROLLED_ACTIVITY_QUEST_DISMISS" | "UNIQUE_USERNAME_ATTEMPT_FAILURE" | "UNIQUE_USERNAME_ATTEMPT_SUCCESS" | "UNIQUE_USERNAME_REGISTRATION_SUGGESTIONS_SUCCESS" | "UNIQUE_USERNAME_SUGGESTIONS_RESET" | "UNIQUE_USERNAME_SUGGESTIONS_SUCCESS" | "UNSYNCED_USER_SETTINGS_UPDATE" | "UNVERIFIED_GAME_UPDATE" | "UPCOMING_GUILD_EVENT_NOTICE_HIDE" | "UPCOMING_GUILD_EVENT_NOTICE_SEEN" | "UPDATE_AVAILABLE" | "UPDATE_BACKGROUND_GRADIENT_PRESET" | "UPDATE_CHANNEL_DIMENSIONS" | "UPDATE_CHANNEL_LIST_DIMENSIONS" | "UPDATE_CHANNEL_LIST_SUBTITLES" | "UPDATE_CLIENT_PREMIUM_TYPE" | "UPDATE_CONSENTS" | "UPDATE_DATA_HARVEST_TYPE" | "UPDATE_DOWNLOADED" | "UPDATE_ERROR" | "UPDATE_FAVORITE_HANG_STATUS" | "UPDATE_FRIEND_GROUP" | "UPDATE_GUILD_LIST_DIMENSIONS" | "UPDATE_HANG_STATUS" | "UPDATE_HANG_STATUS_CUSTOM" | "UPDATE_HANG_STATUS_GAME_ACTIVITY" | "UPDATE_MANUALLY" | "UPDATE_MOBILE_PENDING_THEME_INDEX" | "UPDATE_NOT_AVAILABLE" | "UPDATE_STRANGER_STATUS" | "UPDATE_THEME_PREFERENCES" | "UPDATE_TOKEN" | "UPDATE_VISIBLE_MESSAGES" | "UPLOAD_ATTACHMENT_ADD_FILES" | "UPLOAD_ATTACHMENT_CLEAR_ALL_FILES" | "UPLOAD_ATTACHMENT_POP_FILE" | "UPLOAD_ATTACHMENT_REMOVE_FILE" | "UPLOAD_ATTACHMENT_REMOVE_FILES" | "UPLOAD_ATTACHMENT_SET_FILE" | "UPLOAD_ATTACHMENT_SET_UPLOADS" | "UPLOAD_ATTACHMENT_UPDATE_FILE" | "UPLOAD_CANCEL_REQUEST" | "UPLOAD_COMPLETE" | "UPLOAD_COMPRESSION_PROGRESS" | "UPLOAD_FAIL" | "UPLOAD_FILE_UPDATE" | "UPLOAD_ITEM_CANCEL_REQUEST" | "UPLOAD_PROGRESS" | "UPLOAD_RESTORE_FAILED_UPLOAD" | "UPLOAD_START" | "USER_ACTIVITY_STATISTICS_FETCH_SUCCESS" | "USER_APPLICATION_IDENTITY_FETCH_USER_FAILURE" | "USER_APPLICATION_IDENTITY_FETCH_USER_START" | "USER_APPLICATION_IDENTITY_FETCH_USER_SUCCESS" | "USER_APPLICATION_IDENTITY_REMOVE" | "USER_APPLICATION_REMOVE" | "USER_APPLICATION_UPDATE" | "USER_APPLIED_BOOSTS_FETCH_START" | "USER_APPLIED_BOOSTS_FETCH_SUCCESS" | "USER_AUTHORIZED_APPS_REQUEST" | "USER_AUTHORIZED_APPS_REQUEST_BY_ID" | "USER_AUTHORIZED_APPS_UPDATE" | "USER_AUTHORIZED_APPS_UPDATE_BY_ID" | "USER_CONNECTIONS_CALLBACK" | "USER_CONNECTIONS_INTEGRATION_JOINING" | "USER_CONNECTIONS_INTEGRATION_JOINING_ERROR" | "USER_CONNECTIONS_UPDATE" | "USER_CONNECTION_UPDATE" | "USER_GUILD_JOIN_REQUEST_COACHMARK_CLEAR" | "USER_GUILD_JOIN_REQUEST_COACHMARK_SHOW" | "USER_GUILD_JOIN_REQUEST_COOLDOWN_FETCH" | "USER_GUILD_JOIN_REQUEST_UPDATE" | "USER_GUILD_SETTINGS_CHANNEL_UPDATE" | "USER_GUILD_SETTINGS_CHANNEL_UPDATE_BULK" | "USER_GUILD_SETTINGS_FULL_UPDATE" | "USER_GUILD_SETTINGS_GUILD_AND_CHANNELS_UPDATE" | "USER_GUILD_SETTINGS_GUILD_UPDATE" | "USER_GUILD_SETTINGS_REMOVE_PENDING_CHANNEL_UPDATES" | "USER_JOIN_REQUEST_GUILDS_FETCH" | "USER_NON_CHANNEL_ACK" | "USER_NOTE_LOAD_START" | "USER_NOTE_UPDATE" | "USER_PAYMENT_BROWSER_CHECKOUT_DONE" | "USER_PAYMENT_BROWSER_CHECKOUT_STARTED" | "USER_PAYMENT_CLIENT_ADD" | "USER_PROFILE_APPLICATION_WIDGET_CONFIGS_FETCH_SUCCESS" | "USER_PROFILE_FETCH_FAILURE" | "USER_PROFILE_FETCH_START" | "USER_PROFILE_FETCH_SUCCESS" | "USER_PROFILE_MODAL_CLOSE" | "USER_PROFILE_MODAL_OPEN" | "USER_PROFILE_PIN_BADGES_ON_CLIENT" | "USER_PROFILE_SETTINGS_CLEAR_ERRORS" | "USER_PROFILE_SETTINGS_CLOSE" | "USER_PROFILE_SETTINGS_INIT" | "USER_PROFILE_SETTINGS_RESET_AND_CLOSE_FORM" | "USER_PROFILE_SETTINGS_RESET_PENDING_ACCOUNT_CHANGES" | "USER_PROFILE_SETTINGS_RESET_PENDING_CHANGES" | "USER_PROFILE_SETTINGS_RESET_PENDING_LEGACY_USERNAME_DISABLED" | "USER_PROFILE_SETTINGS_RESET_PENDING_PRIMARY_GUILD_CHANGES" | "USER_PROFILE_SETTINGS_RESET_PENDING_PROFILE_CHANGES" | "USER_PROFILE_SETTINGS_RESET_TRY_IT_OUT_CHANGES" | "USER_PROFILE_SETTINGS_SET_GUILD" | "USER_PROFILE_SETTINGS_SET_PENDING_ACCENT_COLOR" | "USER_PROFILE_SETTINGS_SET_PENDING_AVATAR" | "USER_PROFILE_SETTINGS_SET_PENDING_BANNER" | "USER_PROFILE_SETTINGS_SET_PENDING_BIO" | "USER_PROFILE_SETTINGS_SET_PENDING_COLLECTIBLES_ITEM" | "USER_PROFILE_SETTINGS_SET_PENDING_DISPLAY_NAME_STYLES" | "USER_PROFILE_SETTINGS_SET_PENDING_GLOBAL_NAME" | "USER_PROFILE_SETTINGS_SET_PENDING_LEGACY_USERNAME_DISABLED" | "USER_PROFILE_SETTINGS_SET_PENDING_NICKNAME" | "USER_PROFILE_SETTINGS_SET_PENDING_PRIMARY_GUILD_ID" | "USER_PROFILE_SETTINGS_SET_PENDING_PRONOUNS" | "USER_PROFILE_SETTINGS_SET_PENDING_THEME_COLORS" | "USER_PROFILE_SETTINGS_SET_TRY_IT_OUT_AVATAR" | "USER_PROFILE_SETTINGS_SET_TRY_IT_OUT_AVATAR_DECORATION" | "USER_PROFILE_SETTINGS_SET_TRY_IT_OUT_BANNER" | "USER_PROFILE_SETTINGS_SET_TRY_IT_OUT_DISPLAY_NAME_STYLES" | "USER_PROFILE_SETTINGS_SET_TRY_IT_OUT_PRESET" | "USER_PROFILE_SETTINGS_SET_TRY_IT_OUT_PROFILE_EFFECT" | "USER_PROFILE_SETTINGS_SET_TRY_IT_OUT_THEME_COLORS" | "USER_PROFILE_SETTINGS_SUBMIT" | "USER_PROFILE_SETTINGS_SUBMIT_FAILURE" | "USER_PROFILE_SETTINGS_SUBMIT_SUCCESS" | "USER_PROFILE_SIDEBAR_TOGGLE_SECTION" | "USER_PROFILE_UPDATE_FAILURE" | "USER_PROFILE_UPDATE_START" | "USER_PROFILE_UPDATE_SUCCESS" | "USER_REQUIRED_ACTION_UPDATE" | "USER_RESTRICTED_SCHEDULE_UPDATE" | "USER_SETTINGS_LOCALE_OVERRIDE" | "USER_SETTINGS_MODAL_CLEAR_SCROLL_POSITION" | "USER_SETTINGS_MODAL_CLEAR_SUBSECTION" | "USER_SETTINGS_MODAL_CLOSE" | "USER_SETTINGS_MODAL_INIT" | "USER_SETTINGS_MODAL_OPEN" | "USER_SETTINGS_MODAL_RESET" | "USER_SETTINGS_MODAL_SET_SECTION" | "USER_SETTINGS_MODAL_SUBMIT" | "USER_SETTINGS_MODAL_SUBMIT_COMPLETE" | "USER_SETTINGS_MODAL_SUBMIT_FAILURE" | "USER_SETTINGS_MODAL_UPDATE_ACCOUNT" | "USER_SETTINGS_OVERRIDE_APPLY" | "USER_SETTINGS_OVERRIDE_CLEAR" | "USER_SETTINGS_PROTO_ENQUEUE_UPDATE" | "USER_SETTINGS_PROTO_LOAD_IF_NECESSARY" | "USER_SETTINGS_PROTO_UPDATE" | "USER_SETTINGS_PROTO_UPDATE_EDIT_INFO" | "USER_SOUNDBOARD_SET_VOLUME" | "USER_UPDATE" | "VIDEO_FILTER_ASSETS_FETCH_SUCCESS" | "VIDEO_FILTER_ASSET_DELETE_SUCCESS" | "VIDEO_FILTER_ASSET_UPLOAD_SUCCESS" | "VIDEO_SAVE_LAST_USED_BACKGROUND_OPTION" | "VIDEO_SIZE_UPDATE" | "VIDEO_STREAM_READY_TIMEOUT" | "VIEW_HISTORY_MARK_VIEW" | "VIRTUAL_CURRENCY_BALANCE_FETCH" | "VIRTUAL_CURRENCY_BALANCE_FETCH_FAIL" | "VIRTUAL_CURRENCY_BALANCE_FETCH_SUCCESS" | "VIRTUAL_CURRENCY_BALANCE_UPDATE" | "VIRTUAL_CURRENCY_ONBOARDING_MODAL_OPEN" | "VIRTUAL_CURRENCY_ONBOARDING_MODAL_RESET" | "VIRTUAL_CURRENCY_REDEEM_FAIL" | "VIRTUAL_CURRENCY_REDEEM_START" | "VIRTUAL_CURRENCY_REDEEM_SUCCESS" | "VIRTUAL_CURRENCY_SET_BALANCE_PILL_OVERLAY" | "VOICE_CATEGORY_COLLAPSE" | "VOICE_CATEGORY_EXPAND" | "VOICE_CHANNEL_EFFECT_CLEAR" | "VOICE_CHANNEL_EFFECT_RECENT_EMOJI" | "VOICE_CHANNEL_EFFECT_SEND" | "VOICE_CHANNEL_EFFECT_SENT_LOCAL" | "VOICE_CHANNEL_EFFECT_TOGGLE_ANIMATION_TYPE" | "VOICE_CHANNEL_EFFECT_UPDATE_TIME_STAMP" | "VOICE_CHANNEL_SELECT" | "VOICE_CHANNEL_START_TIME_UPDATE" | "VOICE_CHANNEL_STATUS_UPDATE" | "VOICE_FILTER_APPLIED" | "VOICE_FILTER_APPLY_FAILED" | "VOICE_FILTER_CATALOG_FETCH_FAILED" | "VOICE_FILTER_CATALOG_FETCH_SUCCESS" | "VOICE_FILTER_DEV_TOOLS_SET_UPDATE_TIME" | "VOICE_FILTER_DOWNLOAD_FAILED" | "VOICE_FILTER_DOWNLOAD_PROGRESS" | "VOICE_FILTER_DOWNLOAD_STARTED" | "VOICE_FILTER_FILE_READY" | "VOICE_FILTER_LAGGING" | "VOICE_FILTER_LOOPBACK_TOGGLE" | "VOICE_FILTER_NATIVE_MODULE_STATE_CHANGE" | "VOICE_FILTER_REQUEST_SWITCH" | "VOICE_FILTER_UPDATE_LIMITED_TIME_VOICES" | "VOICE_INVITE_SUGGESTIONS_DISMISS_POPOVER" | "VOICE_SERVER_UPDATE" | "VOICE_STATE_UPDATES" | "WAIT_FOR_REMOTE_SESSION" | "WEBAUTHN_CLEAR_REGISTER_TRIGGER" | "WEBAUTHN_TRIGGER_REGISTER" | "WEBHOOKS_FETCHING" | "WEBHOOKS_UPDATE" | "WEBHOOK_CREATE" | "WEBHOOK_DELETE" | "WEBHOOK_UPDATE" | "WELCOME_SCREEN_FETCH_FAIL" | "WELCOME_SCREEN_FETCH_START" | "WELCOME_SCREEN_FETCH_SUCCESS" | "WELCOME_SCREEN_SETTINGS_CLEAR" | "WELCOME_SCREEN_SETTINGS_RESET" | "WELCOME_SCREEN_SETTINGS_UPDATE" | "WELCOME_SCREEN_SUBMIT" | "WELCOME_SCREEN_SUBMIT_FAILURE" | "WELCOME_SCREEN_SUBMIT_SUCCESS" | "WELCOME_SCREEN_UPDATE" | "WELCOME_SCREEN_VIEW" | "WIDGET_PENDING_CLEAR" | "WIDGET_PENDING_SAVE_FAILURE" | "WIDGET_PENDING_SAVE_START" | "WIDGET_PENDING_SAVE_SUCCESS" | "WIDGET_PENDING_SET" | "WIDGET_SUGGESTED_FETCH_FAILURE" | "WIDGET_SUGGESTED_FETCH_START" | "WIDGET_SUGGESTED_FETCH_SUCCESS" | "WIDGET_SUGGESTED_REMOVE_GAME" | "WINDOW_FOCUS" | "WINDOW_FULLSCREEN_CHANGE" | "WINDOW_HIDDEN" | "WINDOW_INIT" | "WINDOW_RESIZED" | "WINDOW_UNLOAD" | "WINDOW_VISIBILITY_CHANGE" | "WISHLIST_ADD_SKU_FAILURE" | "WISHLIST_ADD_SKU_SUCCESS" | "WISHLIST_FETCH_FAILURE" | "WISHLIST_FETCH_START" | "WISHLIST_FETCH_SUCCESS" | "WISHLIST_GIFT_SENT" | "WISHLIST_ITEM_PURCHASED" | "WISHLIST_RECOMMENDATIONS_FETCH_FAILURE" | "WISHLIST_RECOMMENDATIONS_FETCH_START" | "WISHLIST_RECOMMENDATIONS_FETCH_SUCCESS" | "WISHLIST_REMOVE_SKU_FAILURE" | "WISHLIST_REMOVE_SKU_START" | "WISHLIST_REMOVE_SKU_SUCCESS" | "WISHLIST_REORDER_FAILURE" | "WISHLIST_REORDER_START" | "WISHLIST_REORDER_SUCCESS" | "WISHLIST_UPDATE_VISIBILITY_FAILURE" | "WISHLIST_UPDATE_VISIBILITY_SUCCESS" | "WISHLIST_USER_DISCOUNTS_RESPONSE_SUCCESS" | "WOW_MOMENT_CONFIRMATION_SET_IS_DISPLAYING_WOW_MOMENT_CONFIRMATION" | "WRITE_CACHES"; diff --git a/packages/discord-types/src/index.d.ts b/packages/discord-types/src/index.d.ts index a56a943c4..23ab505d8 100644 --- a/packages/discord-types/src/index.d.ts +++ b/packages/discord-types/src/index.d.ts @@ -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"; diff --git a/packages/discord-types/src/passiveupdatestate.d.ts b/packages/discord-types/src/passiveupdatestate.d.ts deleted file mode 100644 index b7f5e6a23..000000000 --- a/packages/discord-types/src/passiveupdatestate.d.ts +++ /dev/null @@ -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[]; -} diff --git a/packages/discord-types/src/stores/ApplicationStreamPreviewStore.d.ts b/packages/discord-types/src/stores/ApplicationStreamPreviewStore.d.ts new file mode 100644 index 000000000..cddb63f9c --- /dev/null +++ b/packages/discord-types/src/stores/ApplicationStreamPreviewStore.d.ts @@ -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; + getPreviewURLForStreamKey: (streamKey: string) => ReturnType; +} diff --git a/src/plugins/biggerStreamPreview/webpack/types/stores.ts b/packages/discord-types/src/stores/ApplicationStreamingStore.d.ts similarity index 51% rename from src/plugins/biggerStreamPreview/webpack/types/stores.ts rename to packages/discord-types/src/stores/ApplicationStreamingStore.d.ts index e11ede504..a764d1467 100644 --- a/src/plugins/biggerStreamPreview/webpack/types/stores.ts +++ b/packages/discord-types/src/stores/ApplicationStreamingStore.d.ts @@ -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 . -*/ - -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; - getPreviewURLForStreamKey: (streamKey: string) => ReturnType; -} - -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; diff --git a/packages/discord-types/src/stores/ExperimentStore.d.ts b/packages/discord-types/src/stores/ExperimentStore.d.ts new file mode 100644 index 000000000..27152dc02 --- /dev/null +++ b/packages/discord-types/src/stores/ExperimentStore.d.ts @@ -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; + 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; + getAllExperimentOverrideDescriptors(): ExperimentAssignmentOverride; + getAllUserExperimentDescriptors(): Record; + getExperimentOverrideDescriptor(experimentId: string): ExperimentAssignmentOverride; + getGuildExperimentBucket(experimentId: string, guildId: string): number; + getGuildExperimentDescriptor(experimentId: string, guildId: string): ExperimentAssignmentOverride; + getGuildExperiments(): Record; + 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; +} diff --git a/packages/discord-types/src/stores/PendingReplyStore.d.ts b/packages/discord-types/src/stores/PendingReplyStore.d.ts index 1819277cb..afbd5bb4c 100644 --- a/packages/discord-types/src/stores/PendingReplyStore.d.ts +++ b/packages/discord-types/src/stores/PendingReplyStore.d.ts @@ -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; getPendingReply(channelId: string): PendingReply | undefined; + /** Discord doesn't use this method. Also seems to always return undefined */ + getPendingReplyActionSource(channelId: string): unknown; } diff --git a/packages/discord-types/src/stores/PresenceStore.d.ts b/packages/discord-types/src/stores/PresenceStore.d.ts index c0462dce9..f94df8f49 100644 --- a/packages/discord-types/src/stores/PresenceStore.d.ts +++ b/packages/discord-types/src/stores/PresenceStore.d.ts @@ -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>; }>>; diff --git a/packages/discord-types/src/stores/SpotifyStore.d.ts b/packages/discord-types/src/stores/SpotifyStore.d.ts index b37da9ac3..64cf08c5d 100644 --- a/packages/discord-types/src/stores/SpotifyStore.d.ts +++ b/packages/discord-types/src/stores/SpotifyStore.d.ts @@ -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 { diff --git a/packages/discord-types/src/stores/UserAffinitiesStore.d.ts b/packages/discord-types/src/stores/UserAffinitiesStore.d.ts new file mode 100644 index 000000000..d81209bf2 --- /dev/null +++ b/packages/discord-types/src/stores/UserAffinitiesStore.d.ts @@ -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; + getUserAffinity(userId: string): UserAffinity; + isFetching(): boolean; + isHighlyAffinedVCUser(userId: string): boolean; + shouldFetch(): boolean; +} diff --git a/packages/discord-types/src/stores/index.d.ts b/packages/discord-types/src/stores/index.d.ts index 5c4b2472a..77c14eef0 100644 --- a/packages/discord-types/src/stores/index.d.ts +++ b/packages/discord-types/src/stores/index.d.ts @@ -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"; diff --git a/packages/discord-types/src/utils.d.ts b/packages/discord-types/src/utils.d.ts index df0621606..1dbebbb26 100644 --- a/packages/discord-types/src/utils.d.ts +++ b/packages/discord-types/src/utils.d.ts @@ -31,6 +31,7 @@ export type Parser = Record< | "parseGuildEventDescription" | "parseAutoModerationSystemMessage" | "parseForumPostGuidelines" + | "parseForumPostMostRecentMessage" | "parseVoiceChannelStatus", (content: string, inline?: boolean, state?: Record) => 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; diff --git a/packages/vencord-types/package.json b/packages/vencord-types/package.json index aff1aabb2..34c7f7bfe 100644 --- a/packages/vencord-types/package.json +++ b/packages/vencord-types/package.json @@ -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" } } diff --git a/scripts/build/common.mjs b/scripts/build/common.mjs index 7a2994f18..7ca75a2f3 100644 --- a/scripts/build/common.mjs +++ b/scripts/build/common.mjs @@ -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); }); + } } } diff --git a/scripts/depCheck.mjs b/scripts/depCheck.mjs new file mode 100644 index 000000000..0a333a900 --- /dev/null +++ b/scripts/depCheck.mjs @@ -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"); +} diff --git a/src/api/DataStore/index.ts b/src/api/DataStore/index.ts index 80ebb065f..3cdb50c7e 100644 --- a/src/api/DataStore/index.ts +++ b/src/api/DataStore/index.ts @@ -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>>(); + +interface MemoryStore { + __memoryStore: true; + get(key: IDBValidKey): T | undefined; + put(value: unknown, key: IDBValidKey): void; + delete(key: IDBValidKey): void; + clear(): void; + getAllKeys(): KeyType[]; + getAll(): T[]; + entries(): [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(promise: Promise, fallback: T): Promise { + 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(); + database.set(storeName, data); + } + + return { + __memoryStore: true, + get: (key: IDBValidKey) => data.get(key) as T | undefined, + put: (value, key) => { data.set(key, value); }, + delete: key => { data.delete(key); }, + clear: () => { data.clear(); }, + getAllKeys: () => Array.from(data.keys()) as KeyType[], + getAll: () => Array.from(data.values()) as T[], + entries: () => Array.from(data.entries()) as [KeyType, ValueType][] + }; +} + +function isMemoryStore(store: StoreLike): store is MemoryStore { + return "__memoryStore" in store; +} + export function promisifyRequest( request: IDBRequest | IDBTransaction, ): Promise { return new Promise((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 & 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 = ( txMode: IDBTransactionMode, - callback: (store: IDBObjectStore) => T | PromiseLike, + callback: (store: StoreLike) => T | PromiseLike, ) => Promise; 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( key: IDBValidKey, customStore = defaultGetStore(), ): Promise { - 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 { - 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 { - 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( keys: IDBValidKey[], customStore = defaultGetStore(), ): Promise { - 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( key: IDBValidKey, updater: (oldValue: T | undefined) => T, customStore = defaultGetStore(), ): Promise { - 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( 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 { - 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 { - 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 { - 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( customStore = defaultGetStore(), ): Promise { - return customStore("readonly", store => { - // Fast path for modern browsers + return withFailureFallback(customStore("readonly", store => { + if (isMemoryStore(store)) return store.getAllKeys(); + if (store.getAllKeys) { return promisifyRequest( store.getAllKeys() as unknown as IDBRequest, @@ -221,59 +253,45 @@ export function keys( } 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(customStore = defaultGetStore()): Promise { - return customStore("readonly", store => { - // Fast path for modern browsers + return withFailureFallback(customStore("readonly", store => { + if (isMemoryStore(store)) return store.getAll(); + if (store.getAll) { return promisifyRequest(store.getAll() as IDBRequest); } 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( 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(); + if (store.getAll && store.getAllKeys) { return Promise.all([ promisifyRequest( store.getAllKeys() as unknown as IDBRequest, ), promisifyRequest(store.getAll() as IDBRequest), - ]).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][]); } diff --git a/src/api/HeaderBar.tsx b/src/api/HeaderBar.tsx index 3b2aa3447..161904c9b 100644 --- a/src/api/HeaderBar.tsx +++ b/src/api/HeaderBar.tsx @@ -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; export interface HeaderBarButtonProps { @@ -101,7 +101,7 @@ export function HeaderBarButton(props: HeaderBarButtonProps & { ref?: React.RefO {({ onMouseEnter, onMouseLeave }) => ( `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 = [ "onBeforeMessageEdit", "onBeforeMessageSend", "onMessageClick", - "renderChatBarButton", "renderMemberListDecorator", "renderMessageAccessory", "renderMessageDecoration", "renderMessagePopoverButton", + "renderMemberListDecorator", "renderMessageAccessory", "renderMessageDecoration", // Custom - "renderNicknameIcon" + "renderNicknameIcon", "renderProfileCollection" ]; const neededApiPlugins = new Set(); @@ -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; diff --git a/src/api/ProfileCollections.tsx b/src/api/ProfileCollections.tsx new file mode 100644 index 000000000..2a8a16783 --- /dev/null +++ b/src/api/ProfileCollections.tsx @@ -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(); +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) => ( + *
Custom content for {props.user.id}
+ * )); + */ +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 }]) => ( + logger.error(`Failed to render profile collection: ${id}`, e.error)}> + + + )); +} + +/** @internal Injected by ProfileCollectionAPI patch (do NOT call directly) */ +export function renderProfileCollections(props: any) { + return ; +} diff --git a/src/api/Settings.ts b/src/api/Settings.ts index f0db98933..1951a2a3e 100644 --- a/src/api/Settings.ts +++ b/src/api/Settings.ts @@ -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; + themeActivationModes: Partial>; 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, diff --git a/src/api/SettingsSync/cloudSetup.tsx b/src/api/SettingsSync/cloudSetup.tsx index 20a21859d..3cbc97656 100644 --- a/src/api/SettingsSync/cloudSetup.tsx +++ b/src/api/SettingsSync/cloudSetup.tsx @@ -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; } diff --git a/src/api/SettingsSync/cloudSync.ts b/src/api/SettingsSync/cloudSync.ts index 91dc199c0..4d3ef986e 100644 --- a/src/api/SettingsSync/cloudSync.ts +++ b/src/api/SettingsSync/cloudSync.ts @@ -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 { + 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 { + 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 { + return await DataStore.get(MANIFEST_STORE_KEY) ?? []; +} + +async function saveLocalManifest(manifest: ManifestEntry[]) { + await DataStore.set(MANIFEST_STORE_KEY, manifest); +} + +async function buildLocalData(): Promise> { + const encoder = new TextEncoder(); + const data = new Map(); + + 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 { + 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, + }); + + 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 - }); - - 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)", }); } diff --git a/src/api/SettingsSync/types.ts b/src/api/SettingsSync/types.ts new file mode 100644 index 000000000..9d64de3c8 --- /dev/null +++ b/src/api/SettingsSync/types.ts @@ -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[]; +} diff --git a/src/api/Themes.ts b/src/api/Themes.ts index 725ad5b86..b4f41cbf9 100644 --- a/src/api/Themes.ts +++ b/src/api/Themes.ts @@ -16,16 +16,26 @@ * along with this program. If not, see . */ -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(); - 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 || {}; diff --git a/src/api/index.ts b/src/api/index.ts index 3f237e6e7..eb1d32335 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -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; diff --git a/src/components/BaseText.css b/src/components/BaseText.css index ebaba6455..f86bd1ba7 100644 --- a/src/components/BaseText.css +++ b/src/components/BaseText.css @@ -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); +} \ No newline at end of file diff --git a/src/components/BaseText.tsx b/src/components/BaseText.tsx index 49a81b05c..dcc7512f1 100644 --- a/src/components/BaseText.tsx +++ b/src/components/BaseText.tsx @@ -82,6 +82,7 @@ export type BaseTextProps = ComponentPropsWithoutRe selectable?: boolean; lineClamp?: number; tabularNumbers?: boolean; + defaultColor?: boolean; }; export function BaseText(props: BaseTextProps): ReactNode { @@ -93,6 +94,7 @@ export function BaseText(props: BaseTextProps): Re selectable = false, lineClamp, tabularNumbers = false, + defaultColor = true, children, className, style, @@ -107,6 +109,7 @@ export function BaseText(props: BaseTextProps): Re lineClamp === 1 && textCls("line-clamp-1"), lineClamp != null && lineClamp > 1 && textCls("line-clamp"), tabularNumbers && textCls("tabular-numbers"), + defaultColor && textCls("defaultColor"), className )} style={{ diff --git a/src/components/ExpandableCard.css b/src/components/ExpandableCard.css new file mode 100644 index 000000000..54ad0f712 --- /dev/null +++ b/src/components/ExpandableCard.css @@ -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; +} \ No newline at end of file diff --git a/src/components/ExpandableCard.tsx b/src/components/ExpandableCard.tsx new file mode 100644 index 000000000..31ee11a24 --- /dev/null +++ b/src/components/ExpandableCard.tsx @@ -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 ( + + setExpanded(c => !c)} > + {children} + + + + {expanded + ?
+ +
+ : null + } +
+ ); +} diff --git a/src/components/Flex.tsx b/src/components/Flex.tsx index e2b9a71be..d86489f6c 100644 --- a/src/components/Flex.tsx +++ b/src/components/Flex.tsx @@ -21,16 +21,18 @@ import type { CSSProperties, HTMLAttributes } from "react"; export interface FlexProps extends HTMLAttributes { 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, diff --git a/src/components/Icons.tsx b/src/components/Icons.tsx index 835ab7373..16f1e2bcf 100644 --- a/src/components/Icons.tsx +++ b/src/components/Icons.tsx @@ -714,7 +714,7 @@ export function CloudUploadIcon(props: IconProps) { ); } -export const ClockIcon = (props?: any) => { +export function ClockIcon(props: IconProps) { return ( { /> ); -}; +} + +export function DownArrow(props: IconProps) { + return ( + + + + ); +} + +export function RightArrow(props: IconProps) { + return ( + + + + ); +} export const QrCodeIcon = (props?: any) => { return ( diff --git a/src/components/ThemeSettings/ThemesTab.tsx b/src/components/ThemeSettings/ThemesTab.tsx index 190ad5a27..2c4a734c9 100644 --- a/src/components/ThemeSettings/ThemesTab.tsx +++ b/src/components/ThemeSettings/ThemesTab.tsx @@ -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 ( + + {onActivationModeChange && ( + + {themeActivationModeOptions.map(option => ( + { + setSelectedMode(option.value); + onActivationModeChange(option.value); + }} + /> + ))} + + )} + {children} + + ); +} + 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, () => ( + + {theme.vars && ( + openModal(modalProps => + ) + } + /> + )} + {IS_WEB && ( + <> + + + + )} + + )); + }; + + const canOpenThemeMenu = !!theme.vars || !!onActivationModeChange || IS_WEB; + return ( )} - {theme.vars && ( -
openModal(modalProps => - ) - }> + {canOpenThemeMenu && ( +
)} - {IS_WEB && ( -
- -
- )} } footer={ @@ -204,16 +302,26 @@ function UserCSSThemeCard({ theme, enabled, onChange, onDelete, onSettingsReset • )} {!!theme.supportURL && Support} + {activationMode !== "always" && ( + <> + {!!(theme.homepageURL || theme.supportURL) && •} + {getThemeActivationModeLabel(activationMode)} + + )} } /> ); } -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, () => ( - + {onPin && ( )} - + )); }; @@ -353,6 +461,12 @@ function OtherThemeCard({ theme, enabled, onChange, onDelete, showDeleteButton, Discord Server )} + {activationMode !== "always" && ( + <> + {!!(theme.website || theme.invite) && •} + {getThemeActivationModeLabel(activationMode)} + + )} } @@ -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(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)} /> ); })} diff --git a/src/components/settings/tabs/updater/Components.tsx b/src/components/settings/tabs/updater/Components.tsx index 8d9f34afb..d6f38bf4f 100644 --- a/src/components/settings/tabs/updater/Components.tsx +++ b/src/components/settings/tabs/updater/Components.tsx @@ -81,7 +81,6 @@ export function Updatable(props: CommonProps) { <> + + + )} + + ); +} + function Updater() { const settings = useSettings(["autoUpdate", "autoUpdateNotification"]); @@ -54,6 +85,7 @@ function Updater() { return ( + Update Preferences 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. diff --git a/src/debug/loadLazyChunks.ts b/src/debug/loadLazyChunks.ts index 32ab77041..44e483faf 100644 --- a/src/debug/loadLazyChunks.ts +++ b/src/debug/loadLazyChunks.ts @@ -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(tasks: (() => Promise)[], batchSize = 8): Promise { - 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) { diff --git a/src/equicordplugins/_api/headerBar.ts b/src/equicordplugins/_api/headerBar.ts index ff05d2ee6..e9f50be8f 100644 --- a/src/equicordplugins/_api/headerBar.ts +++ b/src/equicordplugins/_api/headerBar.ts @@ -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()" } }, diff --git a/src/equicordplugins/_api/profileCollections.ts b/src/equicordplugins/_api/profileCollections.ts new file mode 100644 index 000000000..388a76ad8 --- /dev/null +++ b/src/equicordplugins/_api/profileCollections.ts @@ -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})," + } + } + ] +}); diff --git a/src/equicordplugins/_api/userArea.ts b/src/equicordplugins/_api/userArea.ts index 53cf52cde..112ac9bce 100644 --- a/src/equicordplugins/_api/userArea.ts +++ b/src/equicordplugins/_api/userArea.ts @@ -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:\[/, diff --git a/src/equicordplugins/absRPC/index.tsx b/src/equicordplugins/absRPC/index.tsx deleted file mode 100644 index 8f389f20c..000000000 --- a/src/equicordplugins/absRPC/index.tsx +++ /dev/null @@ -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; - }; - 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 { - 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: () => ( - <> - How to connect to AudioBookShelf - - Enter your AudioBookShelf server URL, username, and password to display your currently playing audiobooks as Discord Rich Presence. -

- The plugin will automatically authenticate and fetch your listening progress. -
- - ), - - settings, - - start() { - this.updatePresence(); - updateInterval = setInterval(() => { this.updatePresence(); }, 10000); - }, - - stop() { - clearInterval(updateInterval); - updateInterval = undefined; - }, - - async authenticate(): Promise { - 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 { - 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 { - 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, - }; - } -}); diff --git a/src/equicordplugins/betterAudioPlayer/index.tsx b/src/equicordplugins/betterAudioPlayer/index.tsx index 7c0204ecd..1692b2d85 100644 --- a/src/equicordplugins/betterAudioPlayer/index.tsx +++ b/src/equicordplugins/betterAudioPlayer/index.tsx @@ -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; + 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): 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, 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, 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 { + 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; src: string; }) { + const canvasRef = React.useRef(null); + const audioCtxRef = React.useRef(null); + const analyserRef = React.useRef(null); + const animFrameRef = React.useRef(0); + const setupDoneRef = React.useRef(false); + const blobUrlRef = React.useRef(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 | null = null; + let frequencyData: Uint8Array | 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 ( + + ); } 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 ; }, }); diff --git a/src/equicordplugins/betterAudioPlayer/style.css b/src/equicordplugins/betterAudioPlayer/styles.css similarity index 69% rename from src/equicordplugins/betterAudioPlayer/style.css rename to src/equicordplugins/betterAudioPlayer/styles.css index 7ce03332a..2bfaba0fd 100644 --- a/src/equicordplugins/betterAudioPlayer/style.css +++ b/src/equicordplugins/betterAudioPlayer/styles.css @@ -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; +} \ No newline at end of file diff --git a/src/equicordplugins/betterCommands/index.ts b/src/equicordplugins/betterCommands/index.ts index 63bf1eb45..6c7df169f 100644 --- a/src/equicordplugins/betterCommands/index.ts +++ b/src/equicordplugins/betterCommands/index.ts @@ -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", diff --git a/src/equicordplugins/betterInvites/index.tsx b/src/equicordplugins/betterInvites/index.tsx index 550e4fd25..76b2facfa 100644 --- a/src/equicordplugins/betterInvites/index.tsx +++ b/src/equicordplugins/betterInvites/index.tsx @@ -46,7 +46,7 @@ export default definePlugin({ ] }, { - find: "onlineCount})})]", + find: "isBannerVisible:!1})},", replacement: [ { // make the button clickable diff --git a/src/equicordplugins/channelTabs/components/BookmarkContainer.tsx b/src/equicordplugins/channelTabs/components/BookmarkContainer.tsx index b2abc1c71..b671049b8 100644 --- a/src/equicordplugins/channelTabs/components/BookmarkContainer.tsx +++ b/src/equicordplugins/channelTabs/components/BookmarkContainer.tsx @@ -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 ( - - - - ); + if (isBookmarkFolder(bookmark)) { + const IconComponent = getDiscordFolderIcon(bookmark.iconName); + + const fill = bookmark.iconName === "CircleShieldIcon" ? "var(--background-base-low)" : bookmark.iconColor; + const customIconSize = 16; + + return IconComponent + ? + : + + ; + } // 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(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 (
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(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?
{ @@ -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>(new Set()); + const [isSearchOpen, setIsSearchOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const searchInputRef = useRef(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 (
@@ -570,52 +675,121 @@ export default function BookmarkContainer(props: BasicChannelTabsProps & { userI } {Array.isArray(bookmarks) && bookmarks.length > 0 && bookmarks.flatMap((bookmark, i) => { - const items = [ + if (!isBookmarkFolder(bookmark)) { + if (searchActive && !matchesSearch(bookmark.name)) return []; + + return [ + + ]; + } + + 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 = [ 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( ); }); } - return items; + return folderItems; }) } +
+
+ { + if (!searchQuery.trim()) setIsSearchOpen(false); + }} + onKeyDown={e => { + if (e.key !== "Escape") return; + if (searchQuery.trim()) { + setSearchQuery(""); + } else { + setIsSearchOpen(false); + } + }} + /> +
+ + {p => } + +
+ {p => + ))} +
+ + + + + + ); +} + +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 <> + Folder Name + + Folder Color +
+ setColor(intToColor(value))} + showEyeDropper={false} + /> +
+ Folder Icon + + + {iconName && } + ; +} + 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 }: { Edit Bookmark - Bookmark Name - setName(v)} - /> - {isBookmarkFolder(bookmark) && <> - Folder Color - readOptionValue(option) === categoryId} + select={option => { + const value = readOptionValue(option); + onChange({ ...command, categoryId: value || undefined }); + }} + serialize={option => option.value} + /> + + Choose where this command appears in the palette. + + {categoryId && ( + + {categoryLabel} + · {categoryId} + + )} +
+ +
+ readOptionValue(option) === command.action.type} + select={option => { + const nextType = readOptionValue(option) as typeof availableOptions[number]["value"]; + const nextTemplate = getTemplateByActionType(nextType); + onChange(nextTemplate.apply(command)); + }} + serialize={option => option.value} + /> + + {template.description} + + + {command.action.type === "command" && ( + + )} + {command.action.type === "settings" && ( + + )} + {command.action.type === "url" && ( + + )} + {command.action.type === "macro" && ( + + )} +
+ ); +} diff --git a/src/equicordplugins/commandPalette/settings/customCommands/steps/BasicsStep.tsx b/src/equicordplugins/commandPalette/settings/customCommands/steps/BasicsStep.tsx new file mode 100644 index 000000000..952b54906 --- /dev/null +++ b/src/equicordplugins/commandPalette/settings/customCommands/steps/BasicsStep.tsx @@ -0,0 +1,33 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { TextInput } from "@webpack/common"; + +import type { CustomCommandDefinition } from "../../../registry"; + +interface BasicsStepProps { + command: CustomCommandDefinition; + onChange(next: CustomCommandDefinition): void; +} + +export function BasicsStep({ command, onChange }: BasicsStepProps) { + return ( +
+ onChange({ ...command, label: value })} + /> + onChange({ ...command, description: value })} + /> +
+ ); +} diff --git a/src/equicordplugins/commandPalette/settings/customCommands/steps/ReviewStep.tsx b/src/equicordplugins/commandPalette/settings/customCommands/steps/ReviewStep.tsx new file mode 100644 index 000000000..6cafe3cb6 --- /dev/null +++ b/src/equicordplugins/commandPalette/settings/customCommands/steps/ReviewStep.tsx @@ -0,0 +1,83 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { Paragraph } from "@components/Paragraph"; + +import { type CustomCommandDefinition, getCommandById, getSettingsCommandMetaByRoute } from "../../../registry"; +import type { ValidationIssue } from "../types"; +import { summarizeCommandAction } from "../validation"; + +interface ReviewStepProps { + command: CustomCommandDefinition; + issues: ValidationIssue[]; +} + +export function ReviewStep({ command, issues }: ReviewStepProps) { + const description = command.description?.trim(); + const keywords = command.keywords?.filter(Boolean) ?? []; + const actionDetails: string[] = []; + + if (command.action.type === "command") { + const targetId = command.action.commandId.trim(); + const target = targetId ? getCommandById(targetId) : null; + actionDetails.push(`Target command: ${target?.label ?? "Unknown"} (${targetId || "not set"})`); + } else if (command.action.type === "settings") { + const route = command.action.route.trim(); + const target = route ? getSettingsCommandMetaByRoute(route) : undefined; + actionDetails.push(`Settings page: ${(target?.label ?? route) || "not set"}`); + } else if (command.action.type === "url") { + actionDetails.push(`URL: ${command.action.url.trim() || "not set"}`); + actionDetails.push(`Open mode: ${command.action.openExternal ? "External browser" : "Discord when possible"}`); + } else if (command.action.type === "macro") { + actionDetails.push(`Macro steps: ${command.action.steps.length}`); + for (const [index, step] of command.action.steps.entries()) { + const target = getCommandById(step); + actionDetails.push(`${index + 1}. ${target?.label ?? "Unknown command"} (${step})`); + } + } + + return ( +
+ + {`When run, this command ${summarizeCommandAction(command).toLowerCase()}`} + + {description && ( + + Description: {description} + + )} + {keywords.length > 0 && ( + + Keywords: {keywords.join(", ")} + + )} + {actionDetails.map((line, index) => ( + + {line} + + ))} + {command.showConfirmation && ( + + Confirmation required before running. + + )} + {issues.length === 0 && ( + + Looks good. This command is ready to save. + + )} + {issues.map((issue, index) => ( + + {issue.message} + + ))} +
+ ); +} diff --git a/src/equicordplugins/commandPalette/settings/customCommands/templates.ts b/src/equicordplugins/commandPalette/settings/customCommands/templates.ts new file mode 100644 index 000000000..2d235ae2c --- /dev/null +++ b/src/equicordplugins/commandPalette/settings/customCommands/templates.ts @@ -0,0 +1,58 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import type { CustomCommandDefinition } from "../../registry"; +import type { CommandTemplateId } from "./types"; + +export interface TemplateConfig { + id: CommandTemplateId; + label: string; + description: string; + apply(command: CustomCommandDefinition): CustomCommandDefinition; +} + +export const TEMPLATE_CONFIGS: TemplateConfig[] = [ + { + id: "command", + label: "Alias", + description: "Run another palette command.", + apply: command => ({ + ...command, + action: { type: "command", commandId: "" } + }) + }, + { + id: "settings", + label: "Open Settings Page", + description: "Jump directly to a Discord settings page.", + apply: command => ({ + ...command, + action: { type: "settings", route: "My Account" } + }) + }, + { + id: "url", + label: "Quicklink", + description: "Open a link in Discord or externally.", + apply: command => ({ + ...command, + action: { type: "url", url: "https://", openExternal: true } + }) + }, + { + id: "macro", + label: "Sequence", + description: "Run multiple commands in order.", + apply: command => ({ + ...command, + action: { type: "macro", steps: [] } + }) + } +]; + +export function getTemplateByActionType(actionType: CommandTemplateId): TemplateConfig { + return TEMPLATE_CONFIGS.find(template => template.id === actionType) ?? TEMPLATE_CONFIGS[0]; +} diff --git a/src/equicordplugins/commandPalette/settings/customCommands/types.ts b/src/equicordplugins/commandPalette/settings/customCommands/types.ts new file mode 100644 index 000000000..0a9491273 --- /dev/null +++ b/src/equicordplugins/commandPalette/settings/customCommands/types.ts @@ -0,0 +1,30 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import type { CustomCommandDefinition } from "../../registry"; + +export type WizardStepId = "basics" | "action" | "review"; + +export interface ValidationIssue { + field: string; + message: string; + severity: "error" | "warning"; +} + +export type CommandTemplateId = "command" | "settings" | "url" | "macro"; + +export interface CategoryOption { + label: string; + value: string; + description?: string; +} + +export interface CustomCommandDraftState { + draft: CustomCommandDefinition; + wizardStep: 0 | 1 | 2; + showAdvanced: boolean; + isCollapsed: boolean; +} diff --git a/src/equicordplugins/commandPalette/settings/customCommands/validation.ts b/src/equicordplugins/commandPalette/settings/customCommands/validation.ts new file mode 100644 index 000000000..d763fdf1e --- /dev/null +++ b/src/equicordplugins/commandPalette/settings/customCommands/validation.ts @@ -0,0 +1,123 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { type CustomCommandDefinition, getCommandById, getSettingsCommandMetaByRoute } from "../../registry"; +import type { ValidationIssue } from "./types"; + +export function validateCustomCommand(command: CustomCommandDefinition): ValidationIssue[] { + const issues: ValidationIssue[] = []; + + if (!command.label.trim()) { + issues.push({ field: "label", message: "Enter a command name.", severity: "error" }); + } + + switch (command.action.type) { + case "command": { + const targetId = command.action.commandId.trim(); + if (!targetId) { + issues.push({ field: "action.commandId", message: "Pick a command to run.", severity: "error" }); + break; + } + + if (!getCommandById(targetId)) { + issues.push({ field: "action.commandId", message: "That command does not exist in the palette.", severity: "error" }); + } + break; + } + case "settings": { + const route = command.action.route.trim(); + if (!route) { + issues.push({ field: "action.route", message: "Pick a settings page.", severity: "error" }); + break; + } + + if (!getSettingsCommandMetaByRoute(route)) { + issues.push({ field: "action.route", message: "That settings page is not available right now.", severity: "warning" }); + } + break; + } + case "url": { + const url = command.action.url.trim(); + if (!url) { + issues.push({ field: "action.url", message: "Enter a URL.", severity: "error" }); + break; + } + + if (!url.startsWith("http://") && !url.startsWith("https://")) { + issues.push({ field: "action.url", message: "Use a URL that starts with http:// or https://.", severity: "error" }); + break; + } + + try { + new URL(url); + } catch { + issues.push({ field: "action.url", message: "Enter a valid URL.", severity: "error" }); + } + break; + } + case "macro": { + const { steps } = command.action; + if (!steps.length) { + issues.push({ field: "action.steps", message: "Add at least one macro step.", severity: "error" }); + break; + } + + const missing = steps.filter(step => !getCommandById(step)); + if (missing.length) { + issues.push({ + field: "action.steps", + message: `Some macro steps do not exist: ${missing.join(", ")}.`, + severity: "error" + }); + } + break; + } + } + + return issues; +} + +export function hasStepBlockingError(command: CustomCommandDefinition, step: 0 | 1 | 2): boolean { + const issues = validateCustomCommand(command).filter(issue => issue.severity === "error"); + if (!issues.length) return false; + + if (step === 0) { + return issues.some(issue => issue.field === "label"); + } + + if (step === 1) { + return issues.some(issue => issue.field.startsWith("action.")); + } + + return issues.length > 0; +} + +export function summarizeCommandAction(command: CustomCommandDefinition): string { + switch (command.action.type) { + case "command": { + const target = command.action.commandId.trim(); + if (!target) return "Runs another palette command."; + const entry = getCommandById(target); + return entry + ? `Runs “${entry.label}” (${entry.id}).` + : `Runs command ID “${target}”.`; + } + case "settings": + return `Opens Discord settings page “${command.action.route || "(not set)"}”.`; + case "url": + return `Opens URL “${command.action.url || "(not set)"}” ${command.action.openExternal ? "externally" : "inside Discord when possible"}.`; + case "macro": { + if (!command.action.steps.length) return "Runs a macro with no configured steps."; + const stepSummary = command.action.steps.map((step, index) => { + const entry = getCommandById(step); + return `${index + 1}. ${entry?.label ?? "Unknown command"} (${step})`; + }).join(" "); + return `Runs macro steps: ${stepSummary}`; + } + default: + return "Runs a custom command action."; + } +} diff --git a/src/equicordplugins/commandPalette/settingsPanel.tsx b/src/equicordplugins/commandPalette/settingsPanel.tsx index 38f74437e..cc212c36e 100644 --- a/src/equicordplugins/commandPalette/settingsPanel.tsx +++ b/src/equicordplugins/commandPalette/settingsPanel.tsx @@ -4,526 +4,8 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ -import { Button, TextButton } from "@components/Button"; -import { Heading } from "@components/Heading"; -import { Paragraph } from "@components/Paragraph"; -import { Switch } from "@components/Switch"; -import { React, Select, TextInput } from "@webpack/common"; - -import { openCommandPicker } from "./CommandPicker"; -import { - type CustomCommandDefinition, - getCategoryPath, - getCommandById, - getCustomCommandsSnapshot, - getSettingsCommandMetaById, - getSettingsCommandMetaByRoute, - listAllTags, - listCategories, - normalizeTag, - saveCustomCommands, - subscribeCustomCommands, - subscribeRegistry -} from "./registry"; - -const ACTION_OPTIONS = [ - { label: "Run Command", value: "command" }, - { label: "Open Settings Page", value: "settings" }, - { label: "Open URL", value: "url" }, - { label: "Run Macro", value: "macro" } -] as const; - -type ActionType = typeof ACTION_OPTIONS[number]["value"]; - -const ACTION_HELP_TEXT: Record = { - command: "Alias another palette command by ID. Use the picker if you’re unsure of the identifier.", - settings: "Jump directly to a Discord settings page. Choose a page from the picker.", - url: "Open a link. Use https:// links for best compatibility.", - macro: "Run a sequence of commands in order. Add steps via the picker." -}; - -type CategoryOption = { label: string; value: string; description?: string; }; - -function buildCategoryOptions(): CategoryOption[] { - const categories = listCategories(); - const options: CategoryOption[] = categories.map(category => { - const path = getCategoryPath(category.id); - const label = path.length ? path.map(item => item.label).join(" › ") : category.label; - return { - label: `${label} (${category.id})`, - value: category.id, - description: category.description - } satisfies CategoryOption; - }); - return [ - { - label: "Auto (use default placement)", - value: "" - }, - ...options - ]; -} - -function createDefaultCommand(): CustomCommandDefinition { - const baseId = `custom-${Math.random().toString(36).slice(2, 8)}`; - return { - id: baseId, - label: "New Command", - keywords: [], - tags: [], - categoryId: undefined, - description: "", - danger: false, - action: { - type: "command", - commandId: "" - } - }; -} - -function normalizeTagList(values: string[]): string[] { - const seen = new Set(); - const result: string[] = []; - for (const raw of values) { - const trimmed = raw.trim(); - if (!trimmed) continue; - const slug = normalizeTag(trimmed); - if (!slug || seen.has(slug)) continue; - seen.add(slug); - result.push(trimmed); - } - return result; -} - -function duplicateWithUpdates(command: CustomCommandDefinition, updates: Partial): CustomCommandDefinition { - const nextTags = updates.tags !== undefined ? normalizeTagList(updates.tags) : command.tags; - return { - ...command, - ...updates, - keywords: updates.keywords ?? command.keywords, - tags: nextTags, - action: updates.action ?? command.action - }; -} - -type TemplateConfig = { - label: string; - description: string; - apply(command: CustomCommandDefinition): CustomCommandDefinition; -}; - -const TEMPLATE_CONFIGS: TemplateConfig[] = [ - { - label: "Alias", - description: "Mirror an existing command", - apply: command => duplicateWithUpdates(command, { - action: { type: "command", commandId: "" } - }) - }, - { - label: "Settings", - description: "Open Discord settings", - apply: command => duplicateWithUpdates(command, { - action: { type: "settings", route: "My Account" } - }) - }, - { - label: "Link", - description: "Open an external URL", - apply: command => duplicateWithUpdates(command, { - action: { type: "url", url: "https://", openExternal: true } - }) - }, - { - label: "Macro", - description: "Run multiple commands", - apply: command => duplicateWithUpdates(command, { - action: { type: "macro", steps: [] } - }) - } -]; - -function parseListInput(value: string): string[] { - const parts = value - .split(",") - .map(item => item.trim()); - return normalizeTagList(parts); -} - -function renderActionFields(command: CustomCommandDefinition, onChange: (action: CustomCommandDefinition["action"]) => void) { - const { action } = command; - - switch (action.type) { - case "command": - const trimmedId = action.commandId.trim(); - let commandError: string | undefined; - if (!trimmedId) { - commandError = "Enter a command ID or choose one below."; - } else if (!getCommandById(trimmedId)) { - commandError = "No command matches this ID."; - } - return ( -
- onChange({ type: "command", commandId: value })} - /> - -
- ); - case "settings": { - const selectedSettings = action.route ? getSettingsCommandMetaByRoute(action.route) : undefined; - const currentSummary = selectedSettings - ? `${selectedSettings.label} (${selectedSettings.route})` - : action.route || "No page selected"; - - return ( -
- - Current page: {currentSummary} - - -
- ); - } - case "url": { - let urlError: string | undefined; - const trimmedUrl = action.url.trim(); - if (trimmedUrl && !trimmedUrl.startsWith("http://") && !trimmedUrl.startsWith("https://")) { - urlError = "Use http:// or https:// links."; - } else if (trimmedUrl) { - try { - new URL(trimmedUrl); - } catch { - urlError = "Enter a valid URL."; - } - } - return ( -
- onChange({ type: "url", url: value, openExternal: action.openExternal })} - /> -
- onChange({ type: "url", url: action.url, openExternal: value })} - /> - Open externally -
-
- ); - } - case "macro": - const invalidSteps = action.steps.filter(step => !getCommandById(step)); - return ( -
- onChange({ type: "macro", steps: parseListInput(value) })} - /> - - {action.steps.length > 0 && ( -
- {action.steps.map((step, index) => ( - { - const next = [...action.steps]; - next.splice(index, 1); - onChange({ type: "macro", steps: next }); - }} - > - {step} ✕ - - ))} -
- )} - {invalidSteps.length > 0 && ( - - Unknown command IDs: {invalidSteps.join(", ")} - - )} -
- ); - default: - return null; - } -} +import { CustomCommandsPanel } from "./settings/customCommands/CustomCommandsPanel"; export function CommandPaletteSettingsPanel() { - const [commands, setCommands] = React.useState(() => getCustomCommandsSnapshot()); - const [tagMeta, setTagMeta] = React.useState(() => listAllTags()); - const [categoryOptions, setCategoryOptions] = React.useState(() => buildCategoryOptions()); - const [advancedOpen, setAdvancedOpen] = React.useState>({}); - const [collapsed, setCollapsed] = React.useState>({}); - - React.useEffect(() => { - return subscribeCustomCommands(next => setCommands(next)); - }, []); - - React.useEffect(() => { - setTagMeta(listAllTags()); - setCategoryOptions(buildCategoryOptions()); - return subscribeRegistry(() => { - setTagMeta(listAllTags()); - setCategoryOptions(buildCategoryOptions()); - }); - }, []); - - const updateCommands = React.useCallback((next: CustomCommandDefinition[]) => { - setCommands(next); - void saveCustomCommands(next); - }, []); - - const handleAdd = () => { - updateCommands([...commands, createDefaultCommand()]); - }; - - const handleRemove = (id: string) => { - updateCommands(commands.filter(command => command.id !== id)); - }; - - const handleUpdate = (id: string, updater: (command: CustomCommandDefinition) => CustomCommandDefinition) => { - updateCommands(commands.map(command => (command.id === id ? updater(command) : command))); - }; - - React.useEffect(() => { - setAdvancedOpen(prev => { - const next = { ...prev }; - for (const key of Object.keys(next)) { - if (!commands.some(command => command.id === key)) { - delete next[key]; - } - } - return next; - }); - setCollapsed(prev => { - const next = { ...prev }; - for (const key of Object.keys(next)) { - if (!commands.some(command => command.id === key)) { - delete next[key]; - } - } - return next; - }); - }, [commands]); - - return ( -
- Custom Commands - - {/* Hank, don't abbreviate cyberpunk hank */} - 1) Name the command · 2) Add optional description/keywords/tags/category · 3) Choose an action and fill its details (IDs should match existing palette commands for aliases and macros). - -
- {commands.map(command => { - const manualAdvanced = advancedOpen[command.id]; - const hasAdvancedContent = Boolean(command.description?.trim()?.length || (command.keywords?.length ?? 0) || (command.tags?.length ?? 0) || command.categoryId || command.danger); - const showAdvanced = manualAdvanced ?? hasAdvancedContent; - const categoryId = command.categoryId ?? ""; - const categoryPath = categoryId ? getCategoryPath(categoryId) : []; - const categoryLabel = categoryId - ? (categoryPath.length ? categoryPath.map(item => item.label).join(" › ") : categoryId) - : "Auto (default)"; - const isCollapsed = collapsed[command.id] ?? false; - - return ( -
-
- - {command.label || "Untitled Command"} - - setCollapsed(prev => ({ ...prev, [command.id]: !isCollapsed }))} - > - {isCollapsed ? "Expand" : "Collapse"} - -
- - {!isCollapsed && ( - <> - handleUpdate(command.id, prev => duplicateWithUpdates(prev, { label: value }))} - /> - -
-
- value === categoryId} - select={value => handleUpdate(command.id, prev => duplicateWithUpdates(prev, { categoryId: value || undefined }))} - serialize={option => option.value} - /> - - Choose where this command appears in the palette. - - {categoryId && ( -
- - {categoryLabel} - · {categoryId} - -
- )} -
-
- handleUpdate(command.id, prev => duplicateWithUpdates(prev, { danger: value }))} - /> - Show as dangerous -
-
- )} - - )} - - -
- ); - })} -
- -
- ); + return ; } diff --git a/src/equicordplugins/commandPalette/style.css b/src/equicordplugins/commandPalette/style.css index cb4096291..86a74c731 100644 --- a/src/equicordplugins/commandPalette/style.css +++ b/src/equicordplugins/commandPalette/style.css @@ -4,693 +4,799 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ -/* cl = vc-command-palette- */ +/* Command Palette Modal Container */ +.vc-command-palette { + --cp-bg: #0f0f10; + --cp-bg-secondary: #18181a; + --cp-bg-tertiary: #1f1f21; + --cp-bg-dropdown: #0b0b0d; + --cp-bg-hover: #252528; + --cp-bg-selected: #2a2a2e; + --cp-text-primary: #f0f0f0; + --cp-text-secondary: #a0a0a0; + --cp-text-muted: #6b6b6b; + --cp-border: rgb(255 255 255 / 12%); + --cp-border-strong: rgb(255 255 255 / 24%); + --cp-accent: #5865f2; + --cp-surface-dropdown: #0b0b0d; + --cp-surface-dropdown-hover: #232327; + --cp-dropdown-border: rgb(255 255 255 / 15%); + --cp-dropdown-shadow: 0 10px 28px rgb(0 0 0 / 38%); + --cp-radius: 14px; + --cp-radius-sm: 8px; + --cp-shadow: 0 16px 44px rgb(0 0 0 / 38%), 0 6px 18px rgb(0 0 0 / 28%); -/* stylelint-disable no-descending-specificity */ + width: min(900px, 92vw) !important; + max-width: min(900px, 92vw) !important; + background: var(--cp-bg) !important; + border-radius: var(--cp-radius) !important; + box-shadow: var(--cp-shadow) !important; + border: 1px solid var(--cp-border) !important; + overflow: visible !important; + box-sizing: border-box; + will-change: opacity, transform; + transform: translateZ(0); + backface-visibility: hidden; + isolation: isolate; +} -/* shared layout */ -.vc-command-palette-root { - width: 540px; - border-radius: 12px; - color: var(--text-normal, #dcddde); - font-family: var(--font-primary); +.vc-command-palette-shell { + display: flex; + flex-direction: column; + width: 100%; + max-width: 100%; + min-height: 67vh; + max-height: 67vh; + background: var(--cp-bg); + border-radius: var(--cp-radius); + box-sizing: border-box; + position: relative; overflow: hidden; - opacity: 0; - transform: translate3d(0, 12px, 0) scale(0.98); - transition: opacity 120ms ease-out, transform 120ms ease-out; - will-change: transform, opacity; } -.vc-command-palette-root-visible { - opacity: 1; - transform: translate3d(0, 0, 0) scale(1); +/* Input Area */ +.vc-command-palette-input { + padding: 12px 22px 10px; + border-bottom: 1px solid var(--cp-border); + position: relative; } -.vc-command-palette-container { - display: flex; - flex-direction: column; - gap: 12px; - padding: 12px; +.vc-command-palette-main-input input { + background: transparent !important; + border: none !important; + color: var(--cp-text-primary) !important; + font-size: 20px !important; + font-weight: 400 !important; + padding: 0 0 2px !important; + min-height: 48px !important; + height: 48px !important; + box-shadow: none !important; } -.vc-command-palette-header { - display: flex; - flex-direction: column; - gap: 8px; +.vc-command-palette-main-input input::placeholder { + color: var(--cp-text-muted) !important; } -.vc-command-palette-header-top { +/* Query Preview */ +.vc-command-palette-query-preview { + position: absolute; + left: 22px; + right: 22px; + top: 50%; + transform: translateY(-50%); + z-index: 20; display: flex; align-items: center; - justify-content: space-between; - gap: 8px; + gap: 18px; + width: calc(100% - 44px); + pointer-events: none; + overflow: visible; } -.vc-command-palette-title { - font-size: 12px; - font-weight: 600; - letter-spacing: 0.08em; - text-transform: uppercase; +.vc-command-palette-query-preview-without-label { + justify-content: flex-start; } -.vc-command-palette-header-trail { - display: inline-flex; - align-items: center; - gap: 6px; - font-size: 12px; - max-width: 70%; -} - -.vc-command-palette-header-trail-path { +.vc-command-palette-query-preview-command-label { + color: var(--cp-text-muted); + font-size: 20px; + font-weight: 400; + line-height: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + flex-shrink: 1; + min-width: 0; + max-width: min(56%, 420px); } -.vc-command-palette-back { - display: inline-flex; - align-items: center; - justify-content: center; - width: 24px; - height: 24px; - border-radius: 999px; - border: 1px solid transparent; - cursor: pointer; - font-size: 14px; - transition: background 0.18s ease, border-color 0.18s ease, color 0.18s ease, transform 0.18s ease; -} - -.vc-command-palette-search { +.vc-command-palette-query-preview-prompt { + position: relative; display: flex; align-items: center; + gap: 10px; + pointer-events: auto; + flex-shrink: 0; + min-width: 0; +} + +.vc-command-palette-query-prompt-capsule { + display: inline-flex; + align-items: center; gap: 8px; - border-radius: 8px; + width: auto; + max-width: min(68vw, 420px); + min-height: 38px; padding: 0 12px; - border: 1px solid transparent; -} - -.vc-command-palette-prompt { - font-size: 18px; - font-weight: 600; -} - -.vc-command-palette-search input { - flex: 1; - background: transparent; - color: var(--text-normal); - font-size: 16px; - padding: 12px 0; - outline: none; - box-shadow: none; - width: 464px; - border: none !important -} - -.vc-command-picker-search input { - width: 100%; -} - -.vc-command-palette-search input::placeholder { - color: var(--text-muted); -} - -.vc-command-palette-tag-filter-wrapper { - display: flex; - flex-direction: column; - gap: 6px; - padding: 0 4px; -} - -.vc-command-palette-tag-filter { - display: flex; - flex-wrap: wrap; - gap: 6px; -} - -.vc-command-palette-tag-button { - position: relative; - display: inline-flex; - align-items: center; - gap: 6px; - padding: 4px 10px; - border-radius: 999px; - border: 1px solid transparent; - font-size: 12px; - cursor: pointer; - transition: transform 0.15s ease, background 0.15s ease, border-color 0.15s ease, color 0.15s ease; -} - -.vc-command-palette-tag-label { - font-weight: 500; -} - -.vc-command-palette-tag-count, -.vc-command-palette-tag-hotkey { - font-size: 11px; -} - -.vc-command-palette-tag-indicator { - width: 8px; - height: 8px; - border-radius: 50%; + border-radius: 11px; + border: 1px solid var(--cp-border-strong); + background: rgb(255 255 255 / 5%); + color: var(--cp-text-secondary); + box-sizing: border-box; flex-shrink: 0; -} - -.vc-command-palette-tag-status { - font-size: 12px; - color: var(--text-muted); - padding: 0 6px; -} - -.vc-command-palette-list { - display: flex; - flex-direction: column; - max-height: 460px; - overflow-y: auto; - border-radius: 8px; - scrollbar-width: none; -} - -.vc-command-palette-section { - font-size: 11px; - font-weight: 600; - letter-spacing: 0.08em; - text-transform: uppercase; - pointer-events: none; -} - -.vc-command-palette-item { - position: relative; - display: flex; - border: none; - background: transparent; + pointer-events: auto; + appearance: none; + font: inherit; + line-height: 1; text-align: left; - font-family: inherit; cursor: pointer; - outline: none; - color: var(--text-normal); + min-width: 0; } -.vc-command-palette-item:focus-visible { - outline: none; +.vc-command-palette-query-prompt-capsule-active { + background: rgb(255 255 255 / 7%); + cursor: text; } -.vc-command-palette-item-content { - display: flex; - justify-content: space-between; - align-items: center; +.vc-command-palette-query-prompt-capsule-label { + color: var(--cp-text-secondary); + font-size: 17px; + font-weight: 500; + line-height: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 1; + min-width: 0; +} + +.vc-command-palette-prompt-active-input { + background: transparent !important; + border: none !important; + border-radius: 0 !important; + padding: 0 !important; + min-height: 0 !important; + height: auto !important; + box-shadow: none !important; +} + +.vc-command-palette-prompt-active-input input { + background: transparent !important; + border: none !important; + border-radius: 0 !important; + box-shadow: none !important; + color: var(--cp-text-primary) !important; + font-size: 17px !important; + font-weight: 500 !important; + line-height: 1 !important; + padding: 0 !important; + min-height: 24px !important; + height: 24px !important; + width: 100% !important; +} + +.vc-command-palette-prompt-active-input input::placeholder { + color: var(--cp-text-secondary) !important; + font-size: 17px !important; + font-weight: 500 !important; + line-height: 1 !important; + opacity: 1; +} + +.vc-command-palette-query-prompt-capsule .vc-command-palette-prompt-active-input { + flex: 1; + min-width: 0; width: 100%; - gap: 12px; - padding: 12px 16px; - border-radius: 8px; - border: 1px solid transparent; - transition: background 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease; } -.vc-command-palette-item.vc-command-palette-category .vc-command-palette-category-indicator { - font-size: 18px; +.vc-command-palette-query-preview-dropdown { + position: absolute; + top: calc(100% + 10px); + left: 0; + width: min(440px, 72vw); + pointer-events: auto; + margin-top: 0; + z-index: 70; } -.vc-command-palette-label { - font-weight: 600; +/* Shared Dropdown */ +.vc-command-palette-dropdown { + border: 1px solid var(--cp-dropdown-border); + border-radius: 10px; + background: var(--cp-surface-dropdown); + box-shadow: var(--cp-dropdown-shadow); + overflow: hidden; } -.vc-command-palette-label-danger { - color: inherit; -} - -.vc-command-palette-text { +.vc-command-palette-dropdown-list { + max-height: 280px; + min-height: 0; + overflow: hidden auto; + overscroll-behavior: contain; + padding: 8px; display: flex; flex-direction: column; gap: 4px; } -.vc-command-palette-description { - font-size: 13px; - color: var(--text-muted); -} - -.vc-command-palette-meta { - font-size: 12px; - color: var(--text-muted); -} - -.vc-command-palette-tags { - margin-top: 6px; +.vc-command-palette-dropdown-item { + width: 100%; + border: none; + border-radius: 8px; + background: transparent; + color: var(--cp-text-primary); + text-align: left; display: flex; - flex-wrap: wrap; - gap: 6px; -} - -.vc-command-palette-tag-chip { - display: inline-flex; align-items: center; - padding: 2px 8px; - border-radius: 999px; - font-size: 11px; - border: 1px solid transparent; - transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease; -} - -.vc-command-palette-pin { - font-size: 16px; - color: rgb(255 255 255 / 32%); + gap: 10px; + padding: 8px 10px; cursor: pointer; - user-select: none; - transition: color 0.18s ease, transform 0.18s ease; } -.vc-command-palette-pin:hover { - color: var(--brand-experiment, #5865f2); - transform: scale(1.05); +.vc-command-palette-dropdown-item:hover, +.vc-command-palette-dropdown-item-selected { + background: var(--cp-surface-dropdown-hover); } -.vc-command-palette-pin-active { - color: var(--brand-experiment, #5865f2); -} - -.vc-command-palette-empty { - padding: 48px 0; - text-align: center; - color: var(--text-muted); - font-size: 14px; -} - -/* polished theme (so like v2) */ -.vc-command-palette-style-polished.vc-command-palette-root { - background: linear-gradient(140deg, rgb(31 33 36 / 94%), rgb(16 17 20 / 92%)); - border: 1px solid rgb(255 255 255 / 5%); - box-shadow: 0 38px 90px rgb(0 0 0 / 55%); - backdrop-filter: blur(22px) saturate(115%); - border-radius: 14px; -} - -.vc-command-palette-style-polished .vc-command-palette-container { - gap: 10px; - padding: 14px; -} - -.vc-command-palette-style-polished .vc-command-palette-header { - gap: 10px; - padding: 12px; - border-radius: 12px; - background: rgb(49 51 56 / 58%); - border: 1px solid rgb(255 255 255 / 6%); - box-shadow: inset 0 1px 0 rgb(255 255 255 / 6%); - backdrop-filter: blur(16px); -} - -.vc-command-palette-style-polished .vc-command-palette-title { - letter-spacing: 0.12em; - color: var(--text-muted, #a5a6ab); -} - -.vc-command-palette-style-polished .vc-command-palette-header-trail { - padding: 2px 10px 2px 6px; - border-radius: 999px; - background: rgb(255 255 255 / 8%); - border: 1px solid rgb(255 255 255 / 10%); - color: var(--text-muted, #a5a6ab); -} - -.vc-command-palette-style-polished .vc-command-palette-back { +.vc-command-palette-dropdown-icon { width: 22px; height: 22px; - background: rgb(32 34 37 / 85%); - border: 1px solid transparent; - color: var(--text-normal, #dcddde); + border-radius: 999px; + background: var(--cp-bg-secondary); + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + overflow: hidden; + color: var(--cp-text-secondary); } -.vc-command-palette-style-polished .vc-command-palette-back:hover, -.vc-command-palette-style-polished .vc-command-palette-back:focus-visible { - background: rgb(255 255 255 / 18%); - box-shadow: 0 6px 14px rgb(0 0 0 / 28%); - transform: translateY(-1px); +.vc-command-palette-dropdown-icon img { + width: 100%; + height: 100%; + object-fit: cover; } -.vc-command-palette-style-polished .vc-command-palette-search { - gap: 10px; - padding: 0 14px; - border-radius: 10px; - background: rgb(20 21 24 / 92%); - border: 1px solid rgb(255 255 255 / 6%); - box-shadow: inset 0 1px 0 rgb(255 255 255 / 8%); -} - -.vc-command-palette-style-polished .vc-command-palette-prompt { - color: var(--text-muted, #a5a6ab); -} - -.vc-command-palette-style-polished .vc-command-palette-tag-button { - --tag-chip-color: rgb(88 101 242 / 100%); - --tag-chip-glow: rgb(88 101 242 / 24%); - - padding: 5px 12px; - border: 1px solid rgb(255 255 255 / 12%); - background: var(--tag-chip-glow, rgb(255 255 255 / 6%)); - color: var(--tag-chip-color); - backdrop-filter: blur(10px); -} - -.vc-command-palette-style-polished .vc-command-palette-tag-button:hover, -.vc-command-palette-style-polished .vc-command-palette-tag-button:focus-visible { - transform: translateY(-1px); - border-color: rgb(255 255 255 / 18%); - box-shadow: 0 8px 18px rgb(0 0 0 / 20%); -} - -.vc-command-palette-style-polished .vc-command-palette-tag-button.vc-command-palette-active { - background: linear-gradient(135deg, rgb(255 255 255 / 18%), rgb(255 255 255 / 4%)), var(--tag-chip-glow, rgb(255 255 255 / 14%)); - border-color: var(--tag-chip-color); - box-shadow: inset 0 0 0 1px var(--tag-chip-color), 0 10px 24px var(--tag-chip-glow); -} - -.vc-command-palette-style-polished .vc-command-palette-tag-count, -.vc-command-palette-style-polished .vc-command-palette-tag-hotkey { - color: var(--tag-chip-color); - opacity: 0.75; -} - -.vc-command-palette-style-polished .vc-command-palette-tag-button.vc-command-palette-active .vc-command-palette-tag-count, -.vc-command-palette-style-polished .vc-command-palette-tag-button.vc-command-palette-active .vc-command-palette-tag-hotkey { - color: var(--tag-chip-color); - opacity: 1; -} - -.vc-command-palette-style-polished .vc-command-palette-tag-indicator { - background: var(--tag-chip-color, rgb(88 101 242 / 100%)); - box-shadow: 0 0 0 2px var(--tag-chip-glow, rgb(88 101 242 / 28%)); -} - -.vc-command-palette-style-polished .vc-command-palette-list { - gap: 6px; - padding: 8px; - border-radius: 12px; - border: 1px solid rgb(255 255 255 / 6%); - background: rgb(14 15 18 / 82%); - backdrop-filter: blur(12px); -} - -.vc-command-palette-style-polished .vc-command-palette-section { - padding: 6px 10px; - color: rgb(255 255 255 / 38%); -} - -.vc-command-palette-style-polished .vc-command-palette-item-content { - border-radius: 10px; - background: linear-gradient(135deg, rgb(255 255 255 / 5%), rgb(255 255 255 / 2%)); - padding: 12px 14px; -} - - -.vc-command-palette-style-polished .vc-command-palette-item.vc-command-palette-category .vc-command-palette-item-content { - background: linear-gradient(135deg, rgb(255 255 255 / 6%), rgb(255 255 255 / 2%)); - border-color: rgb(255 255 255 / 12%); -} - -.vc-command-palette-style-polished .vc-command-palette-item.vc-command-palette-category .vc-command-palette-category-indicator { - color: rgb(255 255 255 / 45%); -} - -.vc-command-palette-style-polished .vc-command-palette-item.vc-command-palette-pinned .vc-command-palette-item-content { - background: linear-gradient(120deg, rgb(var(--brand-experiment-500-rgb, 88 101 242) / 18%), rgb(var(--brand-experiment-500-rgb, 88 101 242) / 6%)); - border-color: rgb(var(--brand-experiment-500-rgb, 88 101 242) / 38%); - box-shadow: 0 10px 26px rgb(var(--brand-experiment-500-rgb, 88 101 242) / 22%); -} - -.vc-command-palette-style-polished .vc-command-palette-item.vc-command-palette-selected .vc-command-palette-item-content { - background: linear-gradient(100deg, rgb(88 101 242 / 28%), rgb(88 101 242 / 10%)); - border-color: rgb(88 101 242 / 55%); - box-shadow: 0 12px 30px rgb(88 101 242 / 28%), 0 0 0 1px rgb(88 101 242 / 55%); - transform: translate3d(0, -1px, 0); -} - -.vc-command-palette-style-polished .vc-command-palette-item.vc-command-palette-chosen .vc-command-palette-item-content { - background: linear-gradient(100deg, rgb(67 181 129 / 30%), rgb(67 181 129 / 10%)); - border-color: rgb(67 181 129 / 50%); - box-shadow: 0 12px 30px rgb(67 181 129 / 25%), 0 0 0 1px rgb(67 181 129 / 50%); -} - -.vc-command-palette-style-polished .vc-command-palette-item.vc-command-palette-selected.vc-command-palette-chosen .vc-command-palette-item-content { - background: linear-gradient(100deg, rgb(67 181 129 / 38%), rgb(67 181 129 / 16%)); - border-color: rgb(67 181 129 / 60%); - box-shadow: 0 12px 30px rgb(67 181 129 / 32%), 0 0 0 1px rgb(67 181 129 / 60%); -} - -.vc-command-palette-style-polished .vc-command-palette-item.vc-command-palette-danger .vc-command-palette-item-content { - background: linear-gradient(120deg, rgb(237 66 69 / 18%), rgb(237 66 69 / 6%)); - border-color: rgb(237 66 69 / 38%); - box-shadow: 0 10px 26px rgb(237 66 69 / 22%); -} - -.vc-command-palette-style-polished .vc-command-palette-item.vc-command-palette-selected.vc-command-palette-danger .vc-command-palette-item-content { - background: linear-gradient(100deg, rgb(237 66 69 / 32%), rgb(237 66 69 / 12%)); - border-color: rgb(237 66 69 / 62%); - box-shadow: 0 12px 30px rgb(237 66 69 / 32%), 0 0 0 1px rgb(237 66 69 / 62%); -} - -.vc-command-palette-style-polished .vc-command-palette-item.vc-command-palette-danger:is(:hover, :focus-visible) .vc-command-palette-item-content { - background: linear-gradient(120deg, rgb(237 66 69 / 22%), rgb(237 66 69 / 10%)); - border-color: rgb(237 66 69 / 45%); - box-shadow: 0 12px 30px rgb(237 66 69 / 28%); -} - -.vc-command-palette-style-polished .vc-command-palette-item:not(:is(.vc-command-palette-danger, .vc-command-palette-chosen, .vc-command-palette-selected)):hover .vc-command-palette-item-content, -.vc-command-palette-style-polished .vc-command-palette-item:not(:is(.vc-command-palette-danger, .vc-command-palette-chosen, .vc-command-palette-selected)):focus-visible .vc-command-palette-item-content { - border-color: rgb(255 255 255 / 14%); - background: linear-gradient(135deg, rgb(255 255 255 / 8%), rgb(255 255 255 / 3%)); - box-shadow: 0 8px 22px rgb(0 0 0 / 22%); -} - -.vc-command-palette-style-polished .vc-command-palette-item.vc-command-palette-pinned:is(:hover, :focus-visible) .vc-command-palette-item-content { - background: linear-gradient(120deg, rgb(var(--brand-experiment-500-rgb, 88 101 242) / 22%), rgb(var(--brand-experiment-500-rgb, 88 101 242) / 10%)); - border-color: rgb(var(--brand-experiment-500-rgb, 88 101 242) / 45%); - box-shadow: 0 12px 30px rgb(var(--brand-experiment-500-rgb, 88 101 242) / 28%); -} - -.vc-command-palette-style-polished .vc-command-palette-tag-chip { - --tag-chip-color: rgb(88 101 242 / 100%); - --tag-chip-glow: rgb(88 101 242 / 24%); - - border: 1px solid rgb(255 255 255 / 12%); - background: var(--tag-chip-glow, rgb(255 255 255 / 8%)); - color: var(--tag-chip-color); +.vc-command-palette-dropdown-icon-fallback { + font-size: 12px; font-weight: 600; - backdrop-filter: blur(6px); } -.vc-command-palette-style-polished .vc-command-palette-item.vc-command-palette-selected .vc-command-palette-tag-chip, -.vc-command-palette-style-polished .vc-command-palette-item.vc-command-palette-chosen .vc-command-palette-tag-chip { - border-color: rgb(255 255 255 / 28%); - background: linear-gradient(135deg, rgb(255 255 255 / 18%), rgb(255 255 255 / 4%)), var(--tag-chip-glow, rgb(255 255 255 / 12%)); +.vc-command-palette-dropdown-label { + color: var(--cp-text-primary); + font-size: 14px; + font-weight: 500; + line-height: 1.25; + flex: 1; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } -/* classic theme (v1) */ -.vc-command-palette-style-classic.vc-command-palette-root { - background: rgb(32 34 37 / 96%); - border: 1px solid rgb(79 84 92 / 40%); - box-shadow: 0 0 32px rgb(0 0 0 / 45%); - backdrop-filter: blur(18px); -} - -.vc-command-palette-style-classic .vc-command-palette-header { - background: rgb(46 48 54 / 68%); - border-radius: 8px; - border: 1px solid rgb(79 84 92 / 40%); +.vc-command-palette-query-preview-dropdown .vc-command-palette-dropdown-item { + gap: 10px; padding: 10px 12px; } -.vc-command-palette-style-classic .vc-command-palette-title { - color: var(--text-muted, #a5a6ab); +.vc-command-palette-query-preview-dropdown .vc-command-palette-dropdown-label { + font-size: 15px; } -.vc-command-palette-style-classic .vc-command-palette-header-trail { - color: var(--text-muted, #a5a6ab); +.vc-command-palette-dropdown-sublabel { + margin-left: auto; + color: var(--cp-text-secondary); + font-size: 12px; } -.vc-command-palette-style-classic .vc-command-palette-back { - background: rgb(54 57 63); - border-color: rgb(30 33 36); - color: var(--text-normal); -} - -.vc-command-palette-style-classic .vc-command-palette-back:hover, -.vc-command-palette-style-classic .vc-command-palette-back:focus-visible { - background: rgb(64 68 75); - border-color: rgb(90 92 98 / 50%); - transform: translateY(-1px); -} - -.vc-command-palette-style-classic .vc-command-palette-search { - background: rgb(46 48 54 / 75%); - border-color: rgb(79 84 92 / 45%); -} - -.vc-command-palette-style-classic .vc-command-palette-prompt { - color: var(--text-muted, #a5a6ab); -} - -.vc-command-palette-style-classic .vc-command-palette-tag-button { - background: rgb(255 255 255 / 5%); - border-color: rgb(255 255 255 / 8%); - color: var(--text-muted); -} - -.vc-command-palette-style-classic .vc-command-palette-tag-button:hover, -.vc-command-palette-style-classic .vc-command-palette-tag-button:focus-visible { - background: rgb(255 255 255 / 8%); - border-color: rgb(255 255 255 / 16%); - transform: translateY(-1px); -} - -.vc-command-palette-style-classic .vc-command-palette-tag-button.vc-command-palette-active { - background: rgb(var(--brand-experiment-500-rgb, 88 101 242) / 18%); - border-color: rgb(var(--brand-experiment-500-rgb, 88 101 242) / 65%); - color: var(--text-normal); -} - -.vc-command-palette-style-classic .vc-command-palette-tag-count, -.vc-command-palette-style-classic .vc-command-palette-tag-hotkey { - color: var(--text-muted); -} - -.vc-command-palette-style-classic .vc-command-palette-tag-button.vc-command-palette-active .vc-command-palette-tag-count, -.vc-command-palette-style-classic .vc-command-palette-tag-button.vc-command-palette-active .vc-command-palette-tag-hotkey { - color: var(--text-normal); -} - -.vc-command-palette-style-classic .vc-command-palette-tag-indicator { - background: rgb(var(--brand-experiment-500-rgb, 88 101 242) / 100%); - box-shadow: 0 0 0 2px rgb(var(--brand-experiment-500-rgb, 88 101 242) / 28%); -} - -.vc-command-palette-style-classic .vc-command-palette-list { - border: 1px solid rgb(79 84 92 / 35%); - background: rgb(17 18 20 / 75%); +/* List Container */ +.vc-command-palette-list { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; padding: 0; } -.vc-command-palette-style-classic .vc-command-palette-section { - padding: 12px 16px 6px; - color: var(--text-muted, #a5a6ab); +.vc-command-palette-list::-webkit-scrollbar { + width: 6px; } -.vc-command-palette-style-classic .vc-command-palette-item::before { - content: ""; - position: absolute; - inset: 0; - pointer-events: none; +.vc-command-palette-list::-webkit-scrollbar-track { background: transparent; - transition: background 0.18s ease, box-shadow 0.18s ease; } -.vc-command-palette-style-classic .vc-command-palette-item-content { - position: relative; - border-radius: 0; - padding: 14px 16px; - border-left: 3px solid transparent; - transition: color 0.18s ease; - z-index: 1; +.vc-command-palette-list::-webkit-scrollbar-thumb { + background: var(--cp-bg-tertiary); + border-radius: 3px; } -.vc-command-palette-style-classic .vc-command-palette-item + .vc-command-palette-item .vc-command-palette-item-content, -.vc-command-palette-style-classic .vc-command-palette-section + .vc-command-palette-item .vc-command-palette-item-content { - border-top: 1px solid rgb(79 84 92 / 35%); +/* Section Labels */ +.vc-command-palette-section-label { + padding: 16px 24px 8px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--cp-text-secondary); } -.vc-command-palette-style-classic .vc-command-palette-item.vc-command-palette-category .vc-command-palette-item-content { - background: rgb(255 255 255 / 3%); - border: none; -} - -.vc-command-palette-style-classic .vc-command-palette-item.vc-command-palette-category .vc-command-palette-category-indicator { - color: var(--text-muted, #a5a6ab); -} - -.vc-command-palette-style-classic .vc-command-palette-item.vc-command-palette-pinned::before { - background: rgb(var(--brand-experiment-500-rgb, 88 101 242) / 16%); - box-shadow: inset 3px 0 0 rgb(var(--brand-experiment-500-rgb, 88 101 242) / 80%); -} - -.vc-command-palette-style-classic .vc-command-palette-item.vc-command-palette-selected::before { - background: rgb(var(--brand-experiment-500-rgb, 88 101 242) / 18%); - box-shadow: inset 3px 0 0 rgb(var(--brand-experiment-500-rgb, 88 101 242) / 100%); -} - -.vc-command-palette-style-classic .vc-command-palette-item.vc-command-palette-selected .vc-command-palette-item-content { - transform: translate3d(0, -1px, 0); -} - -.vc-command-palette-style-classic .vc-command-palette-item.vc-command-palette-chosen::before { - background: rgb(67 181 129 / 24%); - box-shadow: inset 3px 0 0 rgb(67 181 129 / 90%); -} - -.vc-command-palette-style-classic .vc-command-palette-item.vc-command-palette-danger::before { - background: rgb(237 66 69 / 16%); - box-shadow: inset 3px 0 0 rgb(237 66 69 / 80%); -} - -.vc-command-palette-style-classic .vc-command-palette-item.vc-command-palette-danger:is(:hover, :focus-visible)::before { - background: rgb(237 66 69 / 22%); - box-shadow: inset 3px 0 0 rgb(237 66 69 / 90%), inset 0 1px 0 rgb(255 255 255 / 6%), inset 0 -1px 0 rgb(0 0 0 / 25%); -} - -.vc-command-palette-style-classic .vc-command-palette-item.vc-command-palette-danger:is(:hover, :focus-visible) .vc-command-palette-item-content { - color: var(--text-normal, #dcddde); -} - -.vc-command-palette-style-classic .vc-command-palette-item.vc-command-palette-selected.vc-command-palette-chosen::before { - background: rgb(67 181 129 / 32%); -} - -.vc-command-palette-style-classic .vc-command-palette-item:not(:is(.vc-command-palette-danger, .vc-command-palette-chosen, .vc-command-palette-selected)):hover::before, -.vc-command-palette-style-classic .vc-command-palette-item:not(:is(.vc-command-palette-danger, .vc-command-palette-chosen, .vc-command-palette-selected)):focus-visible::before { - background: rgb(52 60 108 / 96%); - box-shadow: inset 3px 0 0 rgb(var(--brand-experiment-500-rgb, 88 101 242) / 100%), inset 0 1px 0 rgb(255 255 255 / 6%), inset 0 -1px 0 rgb(0 0 0 / 25%); -} - -.vc-command-palette-style-classic .vc-command-palette-item:not(:is(.vc-command-palette-danger, .vc-command-palette-chosen, .vc-command-palette-selected)):hover .vc-command-palette-item-content, -.vc-command-palette-style-classic .vc-command-palette-item:not(:is(.vc-command-palette-danger, .vc-command-palette-chosen, .vc-command-palette-selected)):focus-visible .vc-command-palette-item-content { - color: var(--text-normal); -} - -.vc-command-palette-style-classic .vc-command-palette-item.vc-command-palette-pinned:is(:hover, :focus-visible)::before { - background: rgb(var(--brand-experiment-500-rgb, 88 101 242) / 22%); - box-shadow: inset 3px 0 0 rgb(var(--brand-experiment-500-rgb, 88 101 242) / 90%); -} - -.vc-command-palette-style-classic .vc-command-palette-item.vc-command-palette-pinned:is(:hover, :focus-visible) .vc-command-palette-item-content { - color: var(--text-normal, #dcddde); -} - -.vc-command-palette-style-classic .vc-command-palette-item.vc-command-palette-selected.vc-command-palette-danger .vc-command-palette-item-content { - background: rgb(237 66 69 / 26%); -} - -.vc-command-palette-style-classic .vc-command-palette-tag-chip { - background: rgb(255 255 255 / 6%); - border-color: rgb(255 255 255 / 12%); - color: var(--text-muted); -} - -.vc-command-palette-style-classic .vc-command-palette-item.vc-command-palette-selected .vc-command-palette-tag-chip, -.vc-command-palette-style-classic .vc-command-palette-item.vc-command-palette-chosen .vc-command-palette-tag-chip { - color: var(--text-normal); - border-color: rgb(255 255 255 / 22%); - background: rgb(255 255 255 / 12%); -} - -/* Keybind recorder styles */ -.vc-command-palette-keybind-input { +/* Command Rows */ +.vc-command-palette-row { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 16px; + margin: 0 8px; + background: transparent; + border: none; + border-radius: 10px; + color: var(--cp-text-primary); + cursor: pointer; + transition: background 0.12s ease; + text-align: left; + width: calc(100% - 16px); +} + +.vc-command-palette-row:hover { + background: var(--cp-bg-hover); +} + +.vc-command-palette-row-selected { + background: rgb(255 255 255 / 6%) !important; +} + +.vc-command-palette-row-icon { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + background: var(--cp-bg-secondary); + border-radius: 8px; + flex-shrink: 0; +} + +.vc-command-palette-row-icon svg { + width: 16px; + height: 16px; + color: var(--cp-text-secondary); +} + +.vc-command-palette-row-content { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.vc-command-palette-row-title { + font-size: 15px; + font-weight: 500; + color: var(--cp-text-primary); + line-height: 1.4; +} + +.vc-command-palette-row-subtitle { + font-size: 13px; + color: var(--cp-text-secondary); + line-height: 1.4; +} + +.vc-command-palette-row-meta { + font-size: 14px; + font-weight: 500; + color: var(--cp-text-muted); + flex-shrink: 0; + text-transform: capitalize; +} + +/* Query Rows */ +.vc-command-palette-query-title { + display: flex; + align-items: center; + gap: 8px; +} + +.vc-command-palette-query-prefix { + color: var(--cp-text-primary); +} + +.vc-command-palette-query-field { + color: var(--cp-text-secondary); + background: var(--cp-bg-tertiary); + padding: 2px 8px; + border-radius: 4px; + font-size: 13px; + font-family: monospace; +} + +/* Empty State */ +.vc-command-palette-empty { + padding: 48px 20px; + text-align: center; + color: var(--cp-text-muted); + font-size: 14px; +} + +/* Action Bar */ +.vc-command-palette-action-bar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 20px; + background: var(--cp-bg); + border-top: 1px solid var(--cp-border); + gap: 16px; + min-height: 48px; +} + +.vc-command-palette-picker .vc-command-palette-shell { + max-height: 62vh; +} + +.vc-command-palette-picker .vc-command-palette-input { + padding: 12px 16px 10px; +} + +.vc-command-palette-picker .vc-command-palette-list { + max-height: 54vh; +} + +.vc-command-palette-action-bar-action { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--cp-text-secondary); + flex-shrink: 0; +} + +.vc-command-palette-action-bar-placeholder { + color: var(--cp-text-muted); +} + +.vc-command-palette-action-bar-label { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + display: flex; + align-items: center; +} + +.vc-command-palette-action-bar-actions-button { + display: flex; + align-items: center; + gap: 8px; + padding: 7px 12px; + background: var(--cp-bg-secondary); + border: 1px solid var(--cp-border); + border-radius: 8px; + color: var(--cp-text-secondary); + font-size: 12px; + cursor: pointer; + transition: all 0.15s ease; + line-height: 1; +} + +.vc-command-palette-action-bar-actions-button:hover { + background: var(--cp-bg-hover); + border-color: var(--cp-border-strong); +} + +.vc-command-palette-action-bar-actions-label { + font-weight: 500; +} + +.vc-command-palette-action-bar-key { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 2px 5px; + background: var(--cp-bg-tertiary); + border: 1px solid var(--cp-border-strong); + border-radius: 4px; + font-size: 10px; + font-weight: 600; + color: var(--cp-text-secondary); + font-family: inherit; + min-width: 20px; + text-align: center; +} + +/* Calculator Cards */ +.vc-command-palette-calculator { + padding: 12px 16px; + background: transparent; + border-bottom: 1px solid var(--cp-border); +} + +.vc-command-palette-calculator-title { + font-size: 13px; + font-weight: 600; + color: var(--cp-text-secondary); + margin: 0 0 10px; +} + +.vc-command-palette-calculator-card { + display: flex; + align-items: center; + justify-content: space-between; + min-width: 0; + padding: 28px 32px; + background: var(--cp-bg-tertiary); + border: 1px solid var(--cp-border-strong); + border-radius: 16px; + gap: 16px; +} + +.vc-command-palette-calculator-section { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + flex: 1; + min-width: 0; +} + +.vc-command-palette-calculator-value { + font-size: 40px; + font-weight: 700; + color: var(--cp-text-primary); + text-align: center; + width: 100%; + white-space: nowrap; + overflow: hidden; + line-height: 1.15; +} + +.vc-command-palette-calculator-value-wrapped { + white-space: normal; + overflow-wrap: anywhere; +} + +.vc-command-palette-calculator-arrow { + font-size: 48px; + color: var(--cp-text-primary); + flex-shrink: 0; + padding: 0 24px; + font-weight: 500; + line-height: 1; +} + +.vc-command-palette-calculator-label { + font-size: 14px; + font-weight: 500; + color: var(--cp-text-secondary); + line-height: 1.25; + text-transform: capitalize; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.vc-command-palette-calculator-graph-card { + flex-direction: column; + align-items: stretch; + padding: 20px 22px 18px; +} + +.vc-command-palette-calculator-graph-header { + display: flex; + flex-direction: column; + gap: 12px; +} + +.vc-command-palette-calculator-graph-title { + font-size: 20px; + font-weight: 600; + color: var(--cp-text-primary); + line-height: 1.25; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.vc-command-palette-calculator-graph-meta { + display: flex; + flex-wrap: wrap; + gap: 8px 16px; +} + +.vc-command-palette-calculator-graph-readout { + display: inline-flex; + align-items: center; + gap: 8px; + min-width: 0; + font-size: 13px; + color: var(--cp-text-secondary); +} + +.vc-command-palette-calculator-graph-readout-value { + color: var(--cp-text-primary); + white-space: nowrap; +} + +.vc-command-palette-calculator-graph-swatch { + width: 10px; + height: 10px; + border-radius: 999px; + flex-shrink: 0; +} + +.vc-command-palette-calculator-graph-surface { + width: 100%; +} + +.vc-command-palette-calculator-graph-svg { + display: block; + width: 100%; + height: auto; + max-height: 300px; +} + +.vc-command-palette-calculator-graph-bg { + fill: rgb(255 255 255 / 2%); + stroke: rgb(255 255 255 / 8%); +} + +.vc-command-palette-calculator-graph-axis { + stroke: rgb(255 255 255 / 14%); + stroke-width: 1.5; +} + +.vc-command-palette-calculator-graph-crosshair { + stroke: rgb(255 255 255 / 20%); + stroke-width: 1.5; + stroke-dasharray: 6 6; +} + +.vc-command-palette-calculator-graph-footer { display: flex; - flex-direction: row; align-items: center; justify-content: space-between; gap: 16px; + color: var(--cp-text-secondary); + font-size: 13px; +} + +/* Page Platform */ +.vc-command-palette-page { + flex: 1 1 auto; + min-height: 0; + overflow: hidden auto; + padding: 16px; + display: flex; + flex-direction: column; + gap: 16px; + border-bottom: 1px solid var(--cp-border); +} + +.vc-command-palette-page-title { + font-size: 15px; + font-weight: 600; + color: var(--cp-text-primary); + line-height: 1.2; +} + +.vc-command-palette-page-content { + display: flex; + flex-direction: column; + gap: 12px; +} + +.vc-command-palette-page-field { + display: flex; + flex-direction: column; + gap: 6px; + position: relative; +} + +.vc-command-palette-page-field-label { + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--cp-text-muted); +} + +.vc-command-palette-page-input input { + background: var(--cp-bg-secondary) !important; + border: 1px solid var(--cp-border) !important; + border-radius: 8px !important; + padding: 10px 12px !important; + color: var(--cp-text-primary) !important; + font-size: 14px !important; + min-height: 40px !important; + box-shadow: none !important; +} + +.vc-command-palette-page-input input::placeholder { + color: var(--cp-text-muted) !important; +} + +.vc-command-palette-page-picker { + position: relative; +} + +.vc-command-palette-page-picker .vc-command-palette-dropdown { + position: absolute; + top: calc(100% + 6px); + left: 0; + right: 0; + z-index: 70; +} + +.vc-command-palette-page-error { + padding: 10px 12px; + background: rgb(237 66 69 / 10%); + border: 1px solid rgb(237 66 69 / 30%); + border-radius: 8px; + color: #ed4245; + font-size: 13px; +} + +/* Compact Mode */ +.vc-command-palette-compact .vc-command-palette-shell { + max-height: fit-content; + min-height: 100%; + overflow: hidden; +} + +.vc-command-palette-compact { + min-height: 0 !important; +} + +.vc-command-palette-action-bar-logo { + display: flex; + align-items: center; + color: var(--cp-text-muted); +} + +.vc-command-palette-compact .vc-command-palette-input { + border-bottom: none; +} + +.vc-command-palette-action-bar-compact-btn { + display: flex; + align-items: center; + gap: 8px; + padding: 7px 12px; + background: var(--cp-bg-secondary); + border: 1px solid var(--cp-border); + border-radius: 8px; + color: var(--cp-text-secondary); + font-size: 12px; + cursor: pointer; + transition: all 0.15s ease; + line-height: 1; +} + +.vc-command-palette-action-bar-compact-btn:hover { + background: var(--cp-bg-hover); + border-color: var(--cp-border-strong); +} + +.vc-command-palette-keybind-input { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; padding: 12px 0; border-radius: 8px; - transition: background-color 0.2s ease; } .vc-command-palette-keybind-info { @@ -702,7 +808,7 @@ .vc-command-palette-keybind-controls { display: flex; - flex-direction: row; + flex-direction: column; align-items: center; gap: 8px; } @@ -723,6 +829,11 @@ box-shadow: 0 0 0 1px var(--background-mod-muted); } +.vc-command-palette-keybind-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + .vc-command-palette-keybind-button:hover:not(:disabled) { background-color: var(--background-mod-subtle); border-color: var(--brand-experiment); @@ -730,30 +841,14 @@ box-shadow: 0 2px 8px rgb(0 0 0 / 20%); } -.vc-command-palette-keybind-button.listening:hover:not(:disabled) { - background: linear-gradient(135deg, var(--brand-experiment) 0%, var(--brand-experiment-560) 100%); - border-color: var(--status-danger, #f04747); - box-shadow: none; - transform: translateY(-1px); -} - .vc-command-palette-keybind-button:active:not(:disabled) { transform: translateY(0); box-shadow: 0 1px 4px rgb(0 0 0 / 20%); } -.vc-command-palette-keybind-button:disabled { - opacity: 0.5; - cursor: not-allowed; -} - .vc-command-palette-keybind-button.listening { - background: linear-gradient(135deg, var(--brand-experiment) 0%, var(--brand-experiment-560) 100%); - color: var(--white); - border-color: var(--status-danger, #f04747); - animation: none; - transform: translateY(-1px); - box-shadow: 0 2px 8px rgb(var(--status-danger-rgb, 240 71 71) / 60%); + border-color: var(--text-feedback-critical); + box-shadow: 0 0 0 1px var(--text-feedback-critical); } .vc-command-palette-keybind-conflict { @@ -764,23 +859,206 @@ animation: vc-command-palette-keybind-shake 0.3s ease; } -@keyframes vc-command-palette-keybind-shake { +/* Actions Dropdown Menu */ +.vc-command-palette-actions-dropdown { + position: absolute; + right: 16px; + bottom: 64px; + width: 320px; + max-height: 360px; + background: var(--cp-bg-dropdown); + border: 1px solid var(--cp-border); + border-radius: var(--cp-radius); + box-shadow: 0 8px 32px rgb(0 0 0 / 40%), 0 2px 8px rgb(0 0 0 / 30%); + display: flex; + flex-direction: column; + overflow: hidden; + z-index: 100; + opacity: 0; + animation: vc-cp-actions-fade-in 0.15s ease forwards; + will-change: opacity; + contain: layout paint; + backface-visibility: hidden; +} - /* stylelint-disable-next-line rule-empty-line-before */ - 0%, - 100% { - transform: translateX(0); +.vc-command-palette-actions-dropdown[data-closing="true"] { + opacity: 0; + transition: opacity 0.15s ease; + pointer-events: none; +} + +@keyframes vc-cp-actions-fade-in { + from { + opacity: 0; } - 25% { - transform: translateX(-4px); - } - - 75% { - transform: translateX(4px); + to { + opacity: 1; } } -/* stylelint-enable no-descending-specificity */ +.vc-command-palette-actions-dropdown-header { + padding: 12px 16px; + border-bottom: 1px solid var(--cp-border); +} -/* fuck you css */ \ No newline at end of file +.vc-command-palette-actions-dropdown-title { + font-size: 14px; + font-weight: 600; + color: var(--cp-text-primary); +} + +.vc-command-palette-actions-dropdown-list { + flex: 1; + overflow: hidden auto; + overscroll-behavior: contain; + padding: 8px 12px; + min-height: 0; +} + +.vc-command-palette-actions-dropdown-empty { + padding: 24px 16px; + text-align: center; + color: var(--cp-text-muted); + font-size: 14px; +} + +.vc-command-palette-action-dropdown-item { + border: none; + background: transparent; + color: var(--cp-text-primary); + text-align: left; + display: flex; + align-items: center; + gap: 12px; + width: 100%; + padding: 10px 16px; + min-height: 48px; + cursor: pointer; + border-radius: 10px; + transition: background-color 0.12s ease; +} + +.vc-command-palette-action-dropdown-item-selected, +.vc-command-palette-action-dropdown-item:is(:hover, :focus-visible) { + background: var(--cp-bg-hover); +} + +.vc-command-palette-action-dropdown-icon { + flex-shrink: 0; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 8px; + background: var(--cp-bg-secondary); + color: var(--cp-text-secondary); +} + +.vc-command-palette-action-dropdown-icon svg { + width: 18px; + height: 18px; +} + +.vc-command-palette-action-dropdown-item-selected .vc-command-palette-action-dropdown-icon { + background: var(--cp-bg-tertiary); +} + +.vc-command-palette-action-dropdown-item-selected .vc-command-palette-action-dropdown-icon svg { + color: var(--cp-text-primary); +} + +.vc-command-palette-row-selected .vc-command-palette-row-icon { + background: var(--cp-bg-tertiary); +} + +.vc-command-palette-row-selected .vc-command-palette-row-icon svg { + color: var(--cp-text-primary); +} + +.vc-command-palette-action-dropdown-label { + flex: 1; + font-size: 15px; + line-height: 1.4; + font-weight: 500; + color: var(--cp-text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.vc-command-palette-action-dropdown-shortcuts { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; +} + +.vc-command-palette-action-dropdown-key { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 24px; + padding: 4px 8px; + background: var(--cp-bg-secondary); + border: 1px solid var(--cp-border); + border-radius: 6px; + font-size: 12px; + font-weight: 600; + color: var(--cp-text-secondary); + font-family: inherit; + line-height: 1; +} + +.vc-command-palette-actions-dropdown-search { + padding: 12px 16px; + border-top: 1px solid var(--cp-border); + background: var(--cp-bg-dropdown); +} + +.vc-command-palette-actions-search-input input { + background: var(--cp-bg-secondary) !important; + border: 1px solid var(--cp-border) !important; + border-radius: 8px !important; + color: var(--cp-text-primary) !important; + font-size: 14px !important; + padding: 10px 14px !important; + min-height: 40px !important; + box-shadow: none !important; +} + +.vc-command-palette-picker .vc-command-palette-main-input input { + min-height: 40px !important; + height: 40px !important; + font-size: 18px !important; +} + +.vc-command-palette-actions-search-input input::placeholder { + color: var(--cp-text-muted) !important; +} + +.vc-command-palette-actions-search-input input:focus { + border-color: var(--cp-border-strong) !important; + outline: none !important; +} + +.vc-command-palette-main-input input:focus { + outline: none !important; +} + +.vc-command-palette-page-input input:focus { + border-color: var(--cp-accent) !important; + outline: none !important; +} + +input.vc-command-palette-main-search-input:read-only, +.vc-command-palette-main-search-input input:read-only { + color: var(--cp-text-muted) !important; + cursor: pointer; +} + +input.vc-command-palette-main-search-input:not(:read-only), +.vc-command-palette-main-search-input input:not(:read-only) { + color: var(--cp-text-primary) !important; +} \ No newline at end of file diff --git a/src/equicordplugins/commandPalette/ui.tsx b/src/equicordplugins/commandPalette/ui.tsx index 256d1bf05..1ddf1233e 100644 --- a/src/equicordplugins/commandPalette/ui.tsx +++ b/src/equicordplugins/commandPalette/ui.tsx @@ -4,784 +4,31 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ -import "./style.css"; +import { closeModal, openModal } from "@utils/modal"; -import { IS_LINUX, IS_MAC, IS_WINDOWS } from "@utils/constants"; -import { classNameFactory } from "@utils/css"; -import { closeAllModals, ModalRoot, ModalSize, openModal } from "@utils/modal"; -import { React, TextInput, useCallback, useEffect, useMemo, useRef, useState } from "@webpack/common"; -import type { CSSProperties } from "react"; +import { CommandPaletteModal } from "./ui/CommandPaletteModal"; -import { settings } from "./index"; -import { - type CommandCategory, - type CommandEntry, - type CommandTagMeta, - DEFAULT_CATEGORY_ID, - executeCommand, - getCategoryGroupLabel, - getCategoryPath, - getCategoryWeight, - getCommandSearchText, - getCommandTagIds, - getRecentRank, - getRegistryVersion, - listAllTags, - listChildCategories, - listCommands, - listCommandsByCategory, - listCommandsInTree, - normalizeTag, - refreshAllContextProviders, - subscribePinned, - subscribeRegistry, - togglePinned -} from "./registry"; - -const cl = classNameFactory("vc-command-palette-"); - -const DEFAULT_TAG_COLOR = { - dot: "rgba(var(--brand-experiment-500-rgb, 88 101 242) / 100%)", - glow: "rgba(var(--brand-experiment-500-rgb, 88 101 242) / 35%)" -}; - -const TAG_COLOR_MAP: Record = { - danger: { - dot: "var(--status-danger-background, #ed4245)", - glow: "rgba(237, 66, 69, 0.4)" - }, - warning: { - dot: "var(--status-warning-background, #faa61a)", - glow: "rgba(250, 166, 26, 0.4)" - }, - custom: { - dot: "rgba(186, 104, 200, 1)", - glow: "rgba(186, 104, 200, 0.35)" - }, - core: { - dot: "rgba(78, 201, 176, 1)", - glow: "rgba(78, 201, 176, 0.35)" - }, - navigation: { - dot: "rgba(59, 130, 246, 1)", - glow: "rgba(59, 130, 246, 0.35)" - }, - utility: { - dot: "rgba(255, 140, 0, 1)", - glow: "rgba(255, 140, 0, 0.35)" - }, - developer: { - dot: "rgba(147, 112, 219, 1)", - glow: "rgba(147, 112, 219, 0.35)" - }, - customization: { - dot: "rgba(244, 114, 182, 1)", - glow: "rgba(244, 114, 182, 0.35)" - }, - plugins: { - dot: "rgba(56, 189, 248, 1)", - glow: "rgba(56, 189, 248, 0.35)" - }, - session: { - dot: "rgba(0, 200, 255, 1)", - glow: "rgba(0, 200, 255, 0.35)" - }, - context: { - dot: "rgba(0, 168, 120, 1)", - glow: "rgba(0, 168, 120, 0.35)" - } -}; - -function getTagColors(tagSlug: string) { - return TAG_COLOR_MAP[tagSlug] ?? DEFAULT_TAG_COLOR; -} - -function getTagIndicatorStyle(tagSlug: string): CSSProperties { - const entry = getTagColors(tagSlug); - return { - background: entry.dot, - boxShadow: `0 0 0 2px ${entry.glow}` - }; -} - -function getTagColorStyle(tagSlug: string): CSSProperties { - const entry = getTagColors(tagSlug); - return { - "--tag-chip-color": entry.dot, - "--tag-chip-glow": entry.glow - } as CSSProperties; -} - -type CommandItem = { - type: "command"; - command: CommandEntry; - pinned: boolean; -}; - -type PaletteItem = - | { type: "category"; category: CommandCategory; } - | { type: "section"; label: string; } - | CommandItem; - -export function CommandPalette({ modalProps }) { - const [query, setQuery] = useState(""); - const [categoryStack, setCategoryStack] = useState([]); - const [selectedIndex, setSelectedIndex] = useState(-1); - const [selectedKey, setSelectedKey] = useState(null); - const [registryVersion, setRegistryVersion] = useState(() => getRegistryVersion()); - const [pinnedIds, setPinnedIds] = useState>(new Set()); - const [visible, setVisible] = useState(false); - const [activeTagIds, setActiveTagIds] = useState([]); - const { visualStyle = "classic" } = settings.use(["visualStyle"]); - const styleClass = visualStyle === "polished" ? "style-polished" : "style-classic"; - - const showTags = settings.store.showTags ?? true; - const tagFilterEnabled = showTags && (settings.store.enableTagFilter ?? true); - - const listRef = useRef(null); - const itemRefs = useRef>([]); - - const isSelectableItem = (item?: PaletteItem) => Boolean(item && item.type !== "section"); - - const getFirstSelectableIndex = (list: PaletteItem[]) => { - for (let i = 0; i < list.length; i += 1) { - if (isSelectableItem(list[i])) return i; - } - return -1; - }; - - const getNextSelectableIndex = (start: number, direction: 1 | -1, list: PaletteItem[]) => { - if (list.length === 0) return -1; - if (!list.some(item => isSelectableItem(item))) return -1; - let index = start; - for (let i = 0; i < list.length; i += 1) { - index = (index + direction + list.length) % list.length; - if (isSelectableItem(list[index])) return index; - } - return getFirstSelectableIndex(list); - }; - - useEffect(() => { - const unsubscribe = subscribePinned(ids => { - setPinnedIds(new Set(ids)); - }); - return unsubscribe; - }, []); - - useEffect(() => subscribeRegistry(setRegistryVersion), []); - - useEffect(() => { - const frame = requestAnimationFrame(() => setVisible(true)); - return () => cancelAnimationFrame(frame); - }, []); - - const allTags = useMemo(() => listAllTags(), [registryVersion]); - - useEffect(() => { - const available = new Set(allTags.map(tag => tag.id)); - setActiveTagIds(prev => { - if (available.size === 0) { - return prev.length ? [] : prev; - } - const filtered = prev.filter(id => available.has(id)); - return filtered.length === prev.length ? prev : filtered; - }); - }, [allTags]); - - const hotkeyTags = useMemo(() => allTags.slice(0, 9), [allTags]); - const tagLabelMap = useMemo(() => new Map(allTags.map(tag => [tag.id, tag.label])), [allTags]); - - const activeTagSet = useMemo(() => new Set(activeTagIds), [activeTagIds]); - const activeTagLabels = useMemo( - () => activeTagIds - .map(id => tagLabelMap.get(id) ?? id) - .filter((label, index, arr) => arr.indexOf(label) === index), - [activeTagIds, tagLabelMap] - ); - - const toggleTag = useCallback((tagId: string) => { - setActiveTagIds(prev => { - if (prev.includes(tagId)) { - return prev.filter(id => id !== tagId); - } - return [...prev, tagId]; - }); - }, []); - - const trimmedQuery = query.trim(); - const isSearching = trimmedQuery.length > 0; - const currentCategory = categoryStack[categoryStack.length - 1] ?? null; - - const allCommands = useMemo(() => listCommands(), [registryVersion]); - const commandMap = useMemo(() => new Map(allCommands.map(entry => [entry.id, entry])), [allCommands]); - - const activeTagKey = useMemo(() => activeTagIds.slice().sort().join("|"), [activeTagIds]); - - useEffect(() => { - refreshAllContextProviders(); - }, [activeTagKey, tagFilterEnabled]); - - const matchingCommandIds = useMemo(() => { - if (!tagFilterEnabled || activeTagIds.length === 0) return null; - const required = new Set(activeTagIds); - const matches = new Set(); - for (const entry of allCommands) { - const tags = getCommandTagIds(entry.id); - if (!tags.length) continue; - let ok = true; - for (const tagId of required) { - if (!tags.includes(tagId)) { - ok = false; - break; - } - } - if (ok) matches.add(entry.id); - } - return matches; - }, [allCommands, tagFilterEnabled, activeTagKey]); - - const matchesActiveTags = useCallback((entry: CommandEntry) => { - if (!tagFilterEnabled) return true; - if (!matchingCommandIds) return true; - return matchingCommandIds.has(entry.id); - }, [tagFilterEnabled, matchingCommandIds]); - - const childCategories = useMemo(() => { - const parentId = currentCategory?.id ?? null; - const categories = listChildCategories(parentId); - if (isSearching) return []; - if (matchingCommandIds) { - return categories.filter(category => { - const entries = listCommandsInTree(category.id); - return entries.some(entry => matchingCommandIds.has(entry.id)); - }); - } - return categories; - }, [isSearching, currentCategory, registryVersion, matchingCommandIds]); - - const pinnedCommands = useMemo(() => { - if (pinnedIds.size === 0) return []; - const ordered: CommandEntry[] = []; - for (const id of pinnedIds) { - const entry = commandMap.get(id); - if (entry) ordered.push(entry); - } - return ordered; - }, [pinnedIds, commandMap]); - - const pinnedForView = useMemo(() => { - if (isSearching) return []; - let base = pinnedCommands; - if (currentCategory) { - const inTree = new Set(listCommandsInTree(currentCategory.id).map(entry => entry.id)); - base = base.filter(entry => inTree.has(entry.id)); - } - if (matchingCommandIds) { - base = base.filter(entry => matchingCommandIds.has(entry.id)); - } - return base; - }, [pinnedCommands, currentCategory, isSearching, registryVersion, matchingCommandIds]); - - const visibleCommands = useMemo(() => { - const lower = trimmedQuery.toLowerCase(); - - const scoreCommand = (entry: CommandEntry, isPinned: boolean) => { - const labelLower = entry.label.toLowerCase(); - let score = 0; - - if (labelLower === lower) score += 60; - else if (labelLower.startsWith(lower)) score += 45; - else if (labelLower.includes(lower)) score += 30; - - const labelTokens = labelLower.split(/[^a-z0-9]+/); - if (labelTokens.includes(lower)) score += 20; - - if (entry.keywords && entry.keywords.length) { - const keywordMatches = entry.keywords - .map(keyword => keyword.toLowerCase()) - .filter(Boolean); - if (keywordMatches.includes(lower)) score += 35; - else if (keywordMatches.some(keyword => keyword.includes(lower))) score += 15; - } - - const descriptionLower = entry.description?.toLowerCase() ?? ""; - if (descriptionLower.includes(lower)) score += 10; - - if (entry.tags && entry.tags.length) { - const tagMatches = entry.tags.map(tag => tag.toLowerCase()); - if (tagMatches.includes(lower)) score += 25; - else if (tagMatches.some(tag => tag.includes(lower))) score += 10; - } - - if (score === 0) score = 5; - - score += getCategoryWeight(entry.categoryId); - - if (isPinned) score += 50; - - const recentRank = getRecentRank(entry.id); - if (recentRank >= 0) { - score += Math.max(0, 30 - recentRank * 4); - } - - return score; - }; - - if (lower) { - const source = currentCategory - ? listCommandsInTree(currentCategory.id) - : allCommands; - - let order = 0; - const bestByGroup = new Map(); - - for (const entry of source) { - const isPinned = pinnedIds.has(entry.id); - if (!isPinned && entry.hiddenInSearch) continue; - if (!matchesActiveTags(entry)) continue; - - const searchText = getCommandSearchText(entry.id); - if (!searchText.includes(lower)) continue; - - const score = scoreCommand(entry, isPinned); - const groupKey = entry.searchGroup ?? entry.id; - const existing = bestByGroup.get(groupKey); - if (!existing || score > existing.score || (score === existing.score && order < existing.order)) { - bestByGroup.set(groupKey, { entry, score, order }); - } - order += 1; - } - - return Array.from(bestByGroup.values()) - .sort((a, b) => { - if (b.score !== a.score) return b.score - a.score; - return a.order - b.order; - }) - .map(item => item.entry); - } - - const sortByPinned = (entries: CommandEntry[]) => - [...entries].sort((a, b) => { - const pinnedA = pinnedIds.has(a.id) ? 1 : 0; - const pinnedB = pinnedIds.has(b.id) ? 1 : 0; - if (pinnedA !== pinnedB) return pinnedB - pinnedA; - const dangerA = a.danger ? 1 : 0; - const dangerB = b.danger ? 1 : 0; - if (dangerA !== dangerB) return dangerA - dangerB; - return a.label.localeCompare(b.label); - }); - - let base: CommandEntry[] = []; - if (currentCategory && childCategories.length === 0) { - base = listCommandsByCategory(currentCategory.id); - } else if (!currentCategory && childCategories.length === 0) { - base = listCommandsByCategory(DEFAULT_CATEGORY_ID); - } - - if (matchingCommandIds) { - base = base.filter(entry => matchingCommandIds.has(entry.id)); - } - - return sortByPinned(base); - }, [ - trimmedQuery, - currentCategory, - childCategories, - pinnedIds, - registryVersion, - allCommands, - matchesActiveTags, - matchingCommandIds - ]); - - const items = useMemo(() => { - if (isSearching) { - const grouped = new Map(); - const order: string[] = []; - - for (const entry of visibleCommands) { - const label = getCategoryGroupLabel(entry.categoryId); - if (!grouped.has(label)) { - grouped.set(label, []); - order.push(label); - } - grouped.get(label)!.push({ - type: "command", - command: entry, - pinned: pinnedIds.has(entry.id) - }); - } - - const result: PaletteItem[] = []; - for (const label of order) { - const commands = grouped.get(label); - if (!commands || commands.length === 0) continue; - result.push({ type: "section", label }); - result.push(...commands); - } - - return result; - } - - const pinnedItems: CommandItem[] = pinnedForView.map(command => ({ - type: "command", - command, - pinned: true - })); - - const categoryItems: PaletteItem[] = childCategories.map(category => ({ type: "category", category })); - - const commandItems: CommandItem[] = visibleCommands - .filter(entry => !pinnedIds.has(entry.id)) - .map(entry => ({ type: "command", command: entry, pinned: pinnedIds.has(entry.id) })); - - return [...pinnedItems, ...categoryItems, ...commandItems]; - }, [childCategories, visibleCommands, pinnedForView, pinnedIds, isSearching, showTags]); - - const getItemKey = useCallback((item: PaletteItem): string => { - switch (item.type) { - case "category": - return `category:${item.category.id}`; - case "section": - return `section:${item.label}`; - case "command": - return `command:${item.command.id}`; - default: - return "unknown"; - } - }, []); - - useEffect(() => { - if (items.length === 0) { - if (selectedIndex !== -1) setSelectedIndex(-1); - if (selectedKey !== null) setSelectedKey(null); - return; - } - - if (selectedKey) { - const targetIndex = items.findIndex(item => isSelectableItem(item) && getItemKey(item) === selectedKey); - if (targetIndex !== -1) { - if (targetIndex !== selectedIndex) setSelectedIndex(targetIndex); - return; - } - if (selectedIndex !== -1) setSelectedIndex(-1); - setSelectedKey(null); - return; - } - - if (selectedIndex >= items.length || (selectedIndex >= 0 && !isSelectableItem(items[selectedIndex]))) { - if (selectedIndex !== -1) setSelectedIndex(-1); - } - if (selectedKey !== null) setSelectedKey(null); - }, [items, selectedKey, selectedIndex, getItemKey]); - - useEffect(() => { - if (trimmedQuery.length > 0) { - const first = getFirstSelectableIndex(items); - if (first !== selectedIndex) setSelectedIndex(first); - const key = first >= 0 ? getItemKey(items[first]) : null; - if (key !== selectedKey) setSelectedKey(key); - } - }, [trimmedQuery, items, selectedIndex, selectedKey, getItemKey]); - - useEffect(() => { - if (selectedIndex < 0) return; - const node = itemRefs.current[selectedIndex]; - if (node) node.scrollIntoView({ block: "nearest" }); - }, [selectedIndex, items]); - - const handleSelectCategory = (category: CommandCategory) => { - setCategoryStack(stack => [...stack, category]); - setSelectedIndex(-1); - setSelectedKey(null); - }; - - const handleBack = () => { - setCategoryStack(stack => stack.slice(0, -1)); - setSelectedIndex(-1); - setSelectedKey(null); - }; - - const runCommand = async (entry: CommandEntry) => { - closeAllModals(); - await executeCommand(entry); - }; - - const handleActivateItem = async (item: PaletteItem | undefined) => { - if (!item || item.type === "section") return; - if (item.type === "category") { - handleSelectCategory(item.category); - } else { - await runCommand(item.command); - } - }; - - itemRefs.current = itemRefs.current.slice(0, items.length); - - const breadcrumbLabel = categoryStack.map(cat => cat.label).join(" / "); - const showBreadcrumb = !isSearching && categoryStack.length > 0; - - return ( - -
{ - if (showTags && tagFilterEnabled && e.altKey && !e.ctrlKey && !e.metaKey) { - let digit = -1; - if (e.code?.startsWith("Digit")) { - digit = Number(e.code.slice(5)); - } else if (/^[0-9]$/.test(e.key)) { - digit = Number(e.key); - } - - if (digit >= 1 && digit <= hotkeyTags.length) { - e.preventDefault(); - const target = hotkeyTags[digit - 1]; - if (target) toggleTag(target.id); - return; - } - - if (digit === 0) { - e.preventDefault(); - setActiveTagIds(prev => (prev.length ? [] : prev)); - return; - } - } - if (e.key === "ArrowDown" && items.length > 0) { - e.preventDefault(); - const next = getNextSelectableIndex(selectedIndex, 1, items); - if (next !== -1) { - if (next !== selectedIndex) setSelectedIndex(next); - const key = getItemKey(items[next]); - if (key !== selectedKey) setSelectedKey(key); - } - return; - } - - if (e.key === "ArrowUp" && items.length > 0) { - e.preventDefault(); - const next = getNextSelectableIndex(selectedIndex, -1, items); - if (next !== -1) { - if (next !== selectedIndex) setSelectedIndex(next); - const key = getItemKey(items[next]); - if (key !== selectedKey) setSelectedKey(key); - } - return; - } - - if (e.key === "ArrowLeft" && categoryStack.length > 0) { - e.preventDefault(); - handleBack(); - return; - } - - if (e.key === "ArrowRight") { - const item = items[selectedIndex]; - if (isSelectableItem(item)) { - e.preventDefault(); - await handleActivateItem(item); - } - return; - } - - if (e.key === "Enter") { - const fallback = visibleCommands[0] - ? ({ type: "command", command: visibleCommands[0], pinned: pinnedIds.has(visibleCommands[0].id) } satisfies CommandItem) - : undefined; - const item = items[selectedIndex] ?? fallback; - if (item && isSelectableItem(item)) { - e.preventDefault(); - await handleActivateItem(item); - } - } - }} - > -
-
- Command Palette - {showBreadcrumb && ( -
e.preventDefault()} - > - - {breadcrumbLabel} -
- )} -
-
{ - // prevent focus outline from showing on click - e.preventDefault(); - e.currentTarget.querySelector("input")?.focus({ preventScroll: true }); - }} - > - › - setQuery(value)} - placeholder="Type a command" - /> -
-
- {showTags && tagFilterEnabled && allTags.length > 0 && ( -
e.preventDefault()} - > -
- {allTags.map(tag => { - const isActive = activeTagSet.has(tag.id); - const hotkeyIndex = hotkeyTags.findIndex(h => h.id === tag.id); - const tagSlug = normalizeTag(tag.label ?? tag.id); - const colorStyle = getTagColorStyle(tagSlug || tag.id); - const indicatorStyle = getTagIndicatorStyle(tagSlug || tag.id); - const hotkeyPrefix = IS_MAC ? "⌥" : IS_WINDOWS ? "Alt" : IS_LINUX ? "Alt" : "Alt"; - return ( - - ); - })} -
- {activeTagLabels.length > 0 && ( -
- Filtering by {activeTagLabels.join(", ")} -
- )} -
- )} -
- {items.length === 0 && ( -
No commands found
- )} - {items.map((item, index) => { - const isSelected = index === selectedIndex; - - if (item.type === "section") { - itemRefs.current[index] = null; - return ( -
- {item.label} -
- ); - } - - if (item.type === "category") { - return ( - - ); - } - - const { command, pinned } = item; - const path = getCategoryPath(command.categoryId); - const pathLabel = path - .filter(cat => cat.id !== DEFAULT_CATEGORY_ID) - .map(cat => cat.label) - .join(" / "); - const metaParts: string[] = []; - if (command.shortcut) metaParts.push(command.shortcut); - if (pinned) metaParts.push("Pinned"); - if (pathLabel) metaParts.push(pathLabel); - const metaText = metaParts.join(" · "); - - return ( - - ); - })} -
-
-
- ); -} +let modalSerial = 0; +let activeModalKey: string | null = null; export function openCommandPalette() { - openModal(modalProps => ); + if (activeModalKey) { + closeModal(activeModalKey); + activeModalKey = null; + return; + } + + modalSerial += 1; + const modalKey = `equicord-command-palette-${modalSerial}`; + activeModalKey = openModal( + modalProps => , + { + modalKey, + onCloseCallback: () => { + if (activeModalKey === modalKey) { + activeModalKey = null; + } + } + } + ); } diff --git a/src/equicordplugins/commandPalette/ui/CommandPaletteActionBar.tsx b/src/equicordplugins/commandPalette/ui/CommandPaletteActionBar.tsx new file mode 100644 index 000000000..d6655909b --- /dev/null +++ b/src/equicordplugins/commandPalette/ui/CommandPaletteActionBar.tsx @@ -0,0 +1,61 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { EquicordIcon } from "@equicordplugins/components.dev/components/icons/EquicordIcon"; +import { IS_MAC } from "@utils/constants"; +import { classNameFactory } from "@utils/css"; +import { classes } from "@utils/misc"; + +const cl = classNameFactory("vc-command-palette-"); + +interface CommandPaletteActionBarProps { + selectedLabel?: string; + onOpenActions?(): void; + compact?: boolean; + onExpand?(): void; +} + +export function CommandPaletteActionBar({ selectedLabel, onOpenActions, compact, onExpand }: CommandPaletteActionBarProps) { + const actionsShortcutLabel = IS_MAC ? "⌘L" : "Ctrl+L"; + + if (compact) { + return ( +
+
+ +
+ +
+ ); + } + + return ( +
+
+ {selectedLabel ? ( + {selectedLabel} + ) : ( + No selection + )} +
+ +
+ ); +} diff --git a/src/equicordplugins/commandPalette/ui/CommandPaletteActionsMenu.tsx b/src/equicordplugins/commandPalette/ui/CommandPaletteActionsMenu.tsx new file mode 100644 index 000000000..c90984325 --- /dev/null +++ b/src/equicordplugins/commandPalette/ui/CommandPaletteActionsMenu.tsx @@ -0,0 +1,203 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { CogWheel } from "@components/Icons"; +import { classNameFactory } from "@utils/css"; +import { classes } from "@utils/misc"; +import { TextInput, useCallback, useEffect, useMemo, useRef, useState } from "@webpack/common"; +import type { KeyboardEvent } from "react"; + +import type { CommandActionIntent } from "../registry"; + +const cl = classNameFactory("vc-command-palette-"); + +export interface PaletteActionItem { + id: string; + label: string; + shortcut: string; + icon?: React.ComponentType<{ className?: string; size?: string; }>; + intent: CommandActionIntent; + disabled?: boolean; +} + +interface CommandPaletteActionsMenuProps { + actions: PaletteActionItem[]; + title?: string; + onClose(): void; + onAction(intent: CommandActionIntent): Promise | void; + isClosing?: boolean; +} + +function parseShortcut(shortcut: string): string[] { + if (!shortcut) return []; + const tokens = shortcut + .match(/Esc|Tab|Space|[⌘⌥⇧⌃↵←→↑↓]|[A-Za-z0-9]+/g) ?? []; + + if (!tokens.length) return [shortcut]; + + return tokens.map(token => { + if (token === "Esc" || token === "Tab" || token === "Space") return token; + if (token.length === 1) return token.toUpperCase(); + return token.toUpperCase(); + }); +} + +export function CommandPaletteActionsMenu({ actions, title, onClose, onAction, isClosing }: CommandPaletteActionsMenuProps) { + const [searchQuery, setSearchQuery] = useState(""); + const [selectedIndex, setSelectedIndex] = useState(0); + const containerRef = useRef(null); + const searchInputRef = useRef(null); + const actionRefs = useRef>([]); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + onClose(); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [onClose]); + + const handleAnimationEnd = () => { + if (isClosing) { + onClose(); + } + }; + + const filteredActions = useMemo(() => { + if (!searchQuery.trim()) return actions; + const query = searchQuery.toLowerCase(); + return actions.filter(action => + action.label.toLowerCase().includes(query) + ); + }, [actions, searchQuery]); + + useEffect(() => { + setSelectedIndex(0); + }, [searchQuery]); + + useEffect(() => { + searchInputRef.current?.focus(); + }, []); + + useEffect(() => { + const element = actionRefs.current[selectedIndex]; + if (element) { + element.scrollIntoView({ block: "nearest", behavior: "smooth" }); + } + }, [selectedIndex]); + + const handleActionClick = useCallback((action: PaletteActionItem) => { + if (action.disabled) return; + void onAction(action.intent); + onClose(); + }, [onAction, onClose]); + + const handleKeyDown = useCallback((event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + if (searchQuery) { + setSearchQuery(""); + } else { + onClose(); + } + return; + } + + if (event.key === "ArrowDown") { + event.preventDefault(); + setSelectedIndex(prev => (prev + 1) % filteredActions.length); + return; + } + + if (event.key === "ArrowUp") { + event.preventDefault(); + setSelectedIndex(prev => (prev - 1 + filteredActions.length) % filteredActions.length); + return; + } + + if (event.key === "Enter") { + event.preventDefault(); + const action = filteredActions[selectedIndex]; + if (action) { + handleActionClick(action); + } + return; + } + }, [filteredActions, selectedIndex, handleActionClick, onClose, searchQuery]); + + return ( +
+ {title && ( +
+ {title} +
+ )} + +
+ {filteredActions.length === 0 ? ( +
+ No actions found +
+ ) : ( + filteredActions.map((action, index) => { + const Icon = action.icon ?? CogWheel; + const isSelected = index === selectedIndex; + const shortcutKeys = parseShortcut(action.shortcut); + + return ( + + ); + }) + )} +
+ +
+ +
+
+ ); +} diff --git a/src/equicordplugins/commandPalette/ui/CommandPaletteCalculatorCards.tsx b/src/equicordplugins/commandPalette/ui/CommandPaletteCalculatorCards.tsx new file mode 100644 index 000000000..9eaf47254 --- /dev/null +++ b/src/equicordplugins/commandPalette/ui/CommandPaletteCalculatorCards.tsx @@ -0,0 +1,323 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { classNameFactory } from "@utils/css"; +import { classes } from "@utils/misc"; +import { useEffect, useMemo, useRef, useState } from "@webpack/common"; + +import type { CalculatorGraphSeries, CalculatorResult, CalculatorViewMode } from "../calculator"; + +interface CommandPaletteCalculatorCardsProps { + result: CalculatorResult; + mode: CalculatorViewMode; +} + +interface AutoFitLineProps { + text: string; + className: string; + maxSize: number; + minSize: number; +} + +const cl = classNameFactory("vc-command-palette-"); +const GRAPH_WIDTH = 760; +const GRAPH_HEIGHT = 320; +const GRAPH_PADDING_X = 34; +const GRAPH_PADDING_Y = 24; +const GRAPH_INNER_WIDTH = GRAPH_WIDTH - GRAPH_PADDING_X * 2; + +const SUPERSCRIPT_MAP: Record = { + "0": "⁰", + "1": "¹", + "2": "²", + "3": "³", + "4": "⁴", + "5": "⁵", + "6": "⁶", + "7": "⁷", + "8": "⁸", + "9": "⁹", + "+": "⁺", + "-": "⁻", + "(": "⁽", + ")": "⁾" +}; + +function toSuperscript(value: string): string { + return Array.from(value).map(char => SUPERSCRIPT_MAP[char] ?? char).join(""); +} + +function formatMathDisplayInput(value: string): string { + return value + .replace(/\*\*/g, "^") + .replace(/\bpi\b/g, "π") + .replace(/\btau\b/g, "τ") + .replace(/\binfinity\b/g, "∞") + .replace(/\*/g, "×") + .replace(/\^([+-]?\d+)/g, (_, exponent: string) => toSuperscript(exponent)); +} + +function AutoFitLine({ text, className, maxSize, minSize }: AutoFitLineProps) { + const ref = useRef(null); + const [fontSize, setFontSize] = useState(maxSize); + const [wrapped, setWrapped] = useState(false); + + useEffect(() => { + const node = ref.current; + if (!node) return; + + let frame = 0; + const fit = () => { + cancelAnimationFrame(frame); + frame = requestAnimationFrame(() => { + let next = maxSize; + let nextWrapped = false; + node.classList.remove(cl("calculator-value-wrapped")); + node.style.fontSize = `${next}px`; + + while (next > minSize && node.scrollWidth > node.clientWidth) { + next -= 1; + node.style.fontSize = `${next}px`; + } + + if (node.scrollWidth > node.clientWidth) { + nextWrapped = true; + node.classList.add(cl("calculator-value-wrapped")); + } + + setFontSize(next); + setWrapped(nextWrapped); + }); + }; + + fit(); + + const observer = typeof ResizeObserver !== "undefined" + ? new ResizeObserver(fit) + : null; + observer?.observe(node); + window.addEventListener("resize", fit); + + return () => { + cancelAnimationFrame(frame); + observer?.disconnect(); + window.removeEventListener("resize", fit); + }; + }, [maxSize, minSize, text]); + + return ( +
+ {text} +
+ ); +} + +function formatHoverValue(value: number): string { + if (Number.isNaN(value)) return "NaN"; + if (value === Number.POSITIVE_INFINITY) return "Infinity"; + if (value === Number.NEGATIVE_INFINITY) return "-Infinity"; + const rounded = Number(value.toFixed(4)); + return String(rounded); +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +function toSvgX(value: number, domain: [number, number]): number { + const [min, max] = domain; + if (max === min) return GRAPH_PADDING_X + GRAPH_INNER_WIDTH / 2; + return GRAPH_PADDING_X + ((value - min) / (max - min)) * GRAPH_INNER_WIDTH; +} + +function toSvgY(value: number, range: [number, number]): number { + const [min, max] = range; + const innerHeight = GRAPH_HEIGHT - GRAPH_PADDING_Y * 2; + if (max === min) return GRAPH_PADDING_Y + innerHeight / 2; + return GRAPH_PADDING_Y + innerHeight - ((value - min) / (max - min)) * innerHeight; +} + +function buildPath(series: CalculatorGraphSeries, domain: [number, number], range: [number, number]): string { + let path = ""; + let drawing = false; + + for (const point of series.points) { + if (point.y == null) { + drawing = false; + continue; + } + + const x = toSvgX(point.x, domain); + const y = toSvgY(point.y, range); + path += `${drawing ? "L" : "M"}${x} ${y} `; + drawing = true; + } + + return path.trim(); +} + +function resolveNearestPoint(series: CalculatorGraphSeries, x: number) { + let nearest = series.points[0]; + for (const point of series.points) { + if (Math.abs(point.x - x) < Math.abs(nearest.x - x)) { + nearest = point; + } + } + + return nearest; +} + +function GraphView({ result }: { result: CalculatorResult; }) { + const { graph } = result; + const svgRef = useRef(null); + const [hoverX, setHoverX] = useState(null); + + const hoverSeries = useMemo(() => { + if (!graph || hoverX == null) return []; + return graph.series.map(series => ({ + series, + point: resolveNearestPoint(series, hoverX) + })); + }, [graph, hoverX]); + + if (!graph) return null; + + const axisX = toSvgY(clamp(0, graph.range[0], graph.range[1]), graph.range); + const axisY = toSvgX(clamp(0, graph.domain[0], graph.domain[1]), graph.domain); + const crosshairX = hoverX == null ? null : toSvgX(hoverX, graph.domain); + + return ( +
+

Calculator

+
+
+
+ {formatMathDisplayInput(result.normalizedInput ?? result.displayInput)} +
+
+ {hoverSeries.length > 0 ? hoverSeries.map(({ series, point }) => ( +
+ + {series.label} + + {point.y == null ? "undefined" : `(${formatHoverValue(point.x)}, ${formatHoverValue(point.y)})`} + +
+ )) : ( + graph.series.map(series => ( +
+ + {series.label} +
+ )) + )} +
+
+
+ setHoverX(null)} + onMouseMove={event => { + const bounds = svgRef.current?.getBoundingClientRect(); + if (!bounds) return; + const scaleX = bounds.width / GRAPH_WIDTH; + const localX = (event.clientX - bounds.left) / scaleX; + const innerX = clamp(localX - GRAPH_PADDING_X, 0, GRAPH_INNER_WIDTH); + const clampedRatio = innerX / GRAPH_INNER_WIDTH; + const nextX = graph.domain[0] + (graph.domain[1] - graph.domain[0]) * clampedRatio; + setHoverX(nextX); + }} + > + + + + {crosshairX != null && ( + + )} + {graph.series.map(series => ( + + ))} + {hoverSeries.map(({ series, point }) => point.y == null ? null : ( + + ))} + +
+
+ {result.displayAnswer} + {result.secondaryText ?? "Graph"} +
+
+
+ ); +} + +function ResultView({ result }: { result: CalculatorResult; }) { + const today = new Intl.DateTimeFormat("en-US", { + weekday: "short", + month: "short", + day: "numeric", + year: "numeric" + }).format(new Date()); + const leftLabel = result.tertiaryText + ?? (result.kind === "number" ? "Expression" : result.kind === "unit" ? "Input" : today); + const rightLabel = result.secondaryText ?? "Answer"; + const displayInput = result.kind === "number" + ? formatMathDisplayInput(result.normalizedInput ?? result.displayInput) + : result.displayInput; + + return ( +
+

Calculator

+
+
+ + {leftLabel} +
+
→
+
+ + {rightLabel} +
+
+
+ ); +} + +export function CommandPaletteCalculatorCards({ result, mode }: CommandPaletteCalculatorCardsProps) { + if (mode === "graph" && result.graph) { + return ; + } + + return ; +} diff --git a/src/equicordplugins/commandPalette/ui/CommandPaletteInput.tsx b/src/equicordplugins/commandPalette/ui/CommandPaletteInput.tsx new file mode 100644 index 000000000..0f2fdb40a --- /dev/null +++ b/src/equicordplugins/commandPalette/ui/CommandPaletteInput.tsx @@ -0,0 +1,59 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { classNameFactory } from "@utils/css"; +import { classes } from "@utils/misc"; +import { React, TextInput } from "@webpack/common"; + +const cl = classNameFactory("vc-command-palette-"); + +interface CommandPaletteInputProps { + value: string; + onChange(value: string): void; + placeholder?: string; + autoFocus?: boolean; + inputClassName?: string; + readOnly?: boolean; + inputRef?: React.Ref; + onInputFocus?: React.FocusEventHandler; + onInputBlur?: React.FocusEventHandler; + onInputClick?: React.MouseEventHandler; + children?: React.ReactNode; +} + +export function CommandPaletteInput({ + value, + onChange, + placeholder, + autoFocus = true, + inputClassName, + readOnly = false, + inputRef, + onInputFocus, + onInputBlur, + onInputClick, + children +}: CommandPaletteInputProps) { + return ( +
+
+ +
+ {children} +
+ ); +} diff --git a/src/equicordplugins/commandPalette/ui/CommandPaletteModal.tsx b/src/equicordplugins/commandPalette/ui/CommandPaletteModal.tsx new file mode 100644 index 000000000..d531a8c18 --- /dev/null +++ b/src/equicordplugins/commandPalette/ui/CommandPaletteModal.tsx @@ -0,0 +1,1661 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import "../style.css"; + +import { IS_MAC } from "@utils/constants"; +import { classNameFactory, classNameToSelector } from "@utils/css"; +import { copyWithToast } from "@utils/discord"; +import { Logger } from "@utils/Logger"; +import { classes } from "@utils/misc"; +import { type ModalProps, ModalRoot, ModalSize } from "@utils/modal"; +import { TextInput, Toasts, useEffect, useMemo, useRef, useState } from "@webpack/common"; +import type { KeyboardEvent as ReactKeyboardEvent } from "react"; + +import { buildQueryResolution } from "../actions/executors"; +import { resolveCalculatorQuery } from "../calculator"; +import type { CalculatorViewMode } from "../calculator/types"; +import { settings } from "../index"; +import { getRecentCommandEntries } from "../providers/recentProvider"; +import type { QueryActionCandidate } from "../query/types"; +import { + type CommandActionIntent, + type CommandEntry, + DEFAULT_CATEGORY_ID, + executeCommandAction, + getCategoryPath, + getCategoryWeight, + getCustomCommandById, + getMentionCommandsSnapshot, + getRecentRank, + getRegistryVersion, + isCommandPinned, + listChildCategories, + listCommands, + markCommandAsRecent, + PINNED_CATEGORY_ID, + refreshAllContextProviders, + resolveCommandActionIntentByActionKey, + subscribePinned, + subscribeRegistry, + togglePinned +} from "../registry"; +import { rankItems } from "../search/ranker"; +import { dispatchPaletteActionIntent } from "./actions/dispatchPaletteActionIntent"; +import { resolvePaletteActions } from "./actions/resolvePaletteActions"; +import { CommandPaletteActionBar } from "./CommandPaletteActionBar"; +import { CommandPaletteActionsMenu } from "./CommandPaletteActionsMenu"; +import { CommandPaletteCalculatorCards } from "./CommandPaletteCalculatorCards"; +import { CommandPaletteInput } from "./CommandPaletteInput"; +import { CommandPaletteRow } from "./CommandPaletteRow"; +import { getPalettePageSpec } from "./pages/registry"; +import type { PalettePageRef, PalettePageRuntimeContext, PalettePageValuesState, PaletteSuggestion } from "./pages/types"; +import { PaletteDropdown } from "./primitives/PaletteDropdown"; +import { PaletteField } from "./primitives/PaletteField"; +import { PalettePageShell } from "./primitives/PalettePageShell"; +import { PalettePickerInput } from "./primitives/PalettePickerInput"; +import type { CommandCandidate, PaletteCandidate } from "./types"; + +type NavigationLevel = + | { type: "root"; } + | { type: "category"; categoryId: string; parentLevels: NavigationLevel[]; }; + +interface PalettePageStackItem { + ref: PalettePageRef; + state: PalettePageValuesState; + error: string | null; +} + +const MENTIONS_CATEGORY_ID = "mentions-actions"; +const RECENTS_CATEGORY_ID = "recent-actions"; +let persistedCategoryId: string | null = null; +const SINGLE_SELECT_PROMPT_COMMAND_IDS = new Set([ + "command-palette-open-dm-query", + "command-palette-navigate-to-query", + "extension-holy-notes-delete-notebook-query", + "extension-holy-notes-move-note-query", + "extension-holy-notes-jump-note-query" +]); +const logger = new Logger("CommandPaletteModal"); +const cl = classNameFactory("vc-command-palette-"); +const cn = classNameFactory(); +const promptActiveSelector = classNameToSelector(cl("prompt-active-input")); +const mainInputSelectorClass = classNameToSelector(cl("main-input")); +const pageSelector = classNameToSelector(cl("page")); +const promptChipFont = "500 17px \"gg sans\", \"Noto Sans\", \"Helvetica Neue\", Helvetica, Arial, sans-serif"; +const queryPreviewFont = "400 20px \"gg sans\", \"Noto Sans\", \"Helvetica Neue\", Helvetica, Arial, sans-serif"; +const promptChipWidthCache = new Map(); +const queryPreviewWidthCache = new Map(); +let promptChipCanvasContext: CanvasRenderingContext2D | null = null; +let queryPreviewCanvasContext: CanvasRenderingContext2D | null = null; + +function hasPrimaryModifier(event: ReactKeyboardEvent): boolean { + return IS_MAC ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey; +} + +function getCommandBadge(command: CommandEntry, fallback: "Command" | "Recent" | "Pinned"): string { + const defaultBadge = fallback === "Recent" ? "Command" : fallback; + if (!command.id.startsWith("custom-")) return defaultBadge; + + const customCommand = getCustomCommandById(command.id); + if (!customCommand) return defaultBadge; + + switch (customCommand.action.type) { + case "command": + return "Alias"; + case "url": + return "Quicklink"; + case "macro": + return "Sequence"; + case "settings": + return "Settings"; + default: + return defaultBadge; + } +} + +function asCommandCandidate(command: CommandEntry, pinned: boolean, badge: "Command" | "Recent" | "Pinned"): CommandCandidate { + const path = getCategoryPath(command.categoryId) + .filter(category => category.id !== DEFAULT_CATEGORY_ID) + .map(category => category.label) + .join(" / "); + + return { + type: "command", + id: `command-${command.id}`, + command, + subtitle: path || undefined, + badge: getCommandBadge(command, badge), + pinned, + shortcut: command.shortcut ?? undefined, + icon: command.icon + }; +} + +function isSelectable(candidate: PaletteCandidate | undefined): candidate is CommandCandidate { + return Boolean(candidate && candidate.type === "command"); +} + +function getSelectedLabel(item: PaletteCandidate | undefined): string | undefined { + if (!isSelectable(item)) return undefined; + return item.command.label; +} + +function hasChildren(command: CommandEntry, allCommands: CommandEntry[]): boolean { + const { drilldownCategoryId } = command; + if (drilldownCategoryId) { + if (listChildCategories(drilldownCategoryId).length > 0) return true; + return allCommands.some(entry => entry.categoryId === drilldownCategoryId); + } + + if (!command.categoryId) return false; + const childCategories = listChildCategories(command.categoryId); + if (childCategories.length > 0) return true; + + return allCommands.some(entry => entry.categoryId === command.categoryId && entry.id !== command.id); +} + +function getDrilldownCategoryId(command: CommandEntry): string | null { + if (command.drilldownCategoryId) return command.drilldownCategoryId; + if (!command.categoryId) return null; + return listChildCategories(command.categoryId)[0]?.id ?? null; +} + +function getCategoryCommands(categoryId: string, allCommands: CommandEntry[]): CommandEntry[] { + return allCommands.filter(command => command.categoryId === categoryId); +} + +function pushNavigationLevel(current: NavigationLevel, categoryId: string): NavigationLevel { + if (current.type === "category") { + return { + type: "category", + categoryId, + parentLevels: [...current.parentLevels, current] + }; + } + + return { + type: "category", + categoryId, + parentLevels: [{ type: "root" }] + }; +} + +function scoreSuggestion(id: string, seed: number): number { + let hash = seed; + for (let i = 0; i < id.length; i++) { + hash = ((hash << 5) - hash + id.charCodeAt(i)) | 0; + } + return hash >>> 0; +} + +function getEnterActionKey(event: ReactKeyboardEvent): string { + const keys: string[] = []; + if (event.metaKey) keys.push("meta"); + if (event.altKey) keys.push("alt"); + if (event.ctrlKey) keys.push("ctrl"); + if (event.shiftKey) keys.push("shift"); + keys.push("enter"); + return keys.join("+"); +} + +function buildNavigationLevelForCategory(categoryId: string): NavigationLevel { + const path = getCategoryPath(categoryId); + if (path.length === 0) return { type: "root" }; + + const chain = path.map(category => category.id); + const parentLevels: NavigationLevel[] = [{ type: "root" }]; + for (const parentCategoryId of chain.slice(0, -1)) { + parentLevels.push({ + type: "category", + categoryId: parentCategoryId, + parentLevels: [...parentLevels] + }); + } + + return { + type: "category", + categoryId: chain[chain.length - 1], + parentLevels + }; +} + +function clearPersistedNavigation() { + persistedCategoryId = null; +} + +function measurePromptChipWidth(text: string): number { + const cached = promptChipWidthCache.get(text); + if (cached != null) return cached; + + if (typeof document === "undefined") return 196; + + if (!promptChipCanvasContext) { + const canvas = document.createElement("canvas"); + promptChipCanvasContext = canvas.getContext("2d"); + } + + const context = promptChipCanvasContext; + if (!context) return 196; + context.font = promptChipFont; + const textWidth = context.measureText(text).width; + const chipHorizontalChromePx = 26; + const width = Math.ceil(Math.max(132, Math.min(420, textWidth + chipHorizontalChromePx))); + promptChipWidthCache.set(text, width); + return width; +} + +function measureQueryPreviewWidth(text: string): number { + const cached = queryPreviewWidthCache.get(text); + if (cached !== undefined) return cached; + + if (!queryPreviewCanvasContext) { + const canvas = document.createElement("canvas"); + queryPreviewCanvasContext = canvas.getContext("2d"); + } + + const context = queryPreviewCanvasContext; + if (!context) return 0; + context.font = queryPreviewFont; + const width = Math.ceil(context.measureText(text).width); + queryPreviewWidthCache.set(text, width); + return width; +} + +function createInitialPageState(ref: PalettePageRef): PalettePageValuesState { + const spec = getPalettePageSpec(ref.id); + const initialValues = ref.initialData ?? {}; + const values: Record = {}; + const selectedIds: Record = {}; + + for (const field of spec?.fields ?? []) { + values[field.key] = initialValues[field.key] ?? ""; + selectedIds[field.key] = null; + } + + return { + values, + selectedIds + }; +} + +export function CommandPaletteModal({ modalProps, instanceKey }: { modalProps: ModalProps; instanceKey: number; }) { + const { + compactStartEnabled = true, + closeAfterExecute = true + } = settings.use(); + const [query, setQuery] = useState(""); + const [expanded, setExpanded] = useState(false); + const [selectedIndex, setSelectedIndex] = useState(-1); + const [selectedKey, setSelectedKey] = useState(null); + const [keyboardSelectedKey, setKeyboardSelectedKey] = useState(null); + const [registryVersion, setRegistryVersion] = useState(() => getRegistryVersion()); + const [pinnedIds, setPinnedIds] = useState>(new Set()); + const [navigationLevel, setNavigationLevel] = useState(() => { + if (compactStartEnabled || !persistedCategoryId) return { type: "root" }; + return buildNavigationLevelForCategory(persistedCategoryId); + }); + const [activePromptCommand, setActivePromptCommand] = useState(null); + const [promptInputValue, setPromptInputValue] = useState(""); + const [selectedPromptCandidateId, setSelectedPromptCandidateId] = useState(null); + const [focusPromptInput, setFocusPromptInput] = useState(false); + const [showPromptDropdown, setShowPromptDropdown] = useState(false); + const [selectionSource, setSelectionSource] = useState<"keyboard" | "pointer">("keyboard"); + const [pageStack, setPageStack] = useState([]); + const [isActionsMenuOpen, setIsActionsMenuOpen] = useState(false); + const [isActionsMenuClosing, setIsActionsMenuClosing] = useState(false); + const [calculatorViewMode, setCalculatorViewMode] = useState("result"); + const activePromptCommandIdRef = useRef(null); + const promptContainerRef = useRef(null); + const mainInputRef = useRef(null); + const promptInputRef = useRef(null); + const suggestionSeedRef = useRef((Math.random() * 0xffffffff) >>> 0); + const listRef = useRef(null); + const closeReasonRef = useRef<"programmatic" | "explicit-root" | null>(null); + const keyboardNavigationAtRef = useRef(0); + const lastTrimmedQueryRef = useRef(""); + + const itemRefs = useRef>([]); + + useEffect(() => subscribeRegistry(setRegistryVersion), []); + + useEffect(() => subscribePinned(ids => setPinnedIds(new Set(ids))), []); + + useEffect(() => { + setQuery(""); + setExpanded(false); + setSelectedIndex(-1); + setSelectedKey(null); + setKeyboardSelectedKey(null); + setActivePromptCommand(null); + setPromptInputValue(""); + setSelectedPromptCandidateId(null); + setFocusPromptInput(false); + setShowPromptDropdown(false); + setSelectionSource("keyboard"); + setPageStack([]); + setIsActionsMenuOpen(false); + setActivePageFieldKey(null); + activePromptCommandIdRef.current = null; + closeReasonRef.current = null; + keyboardNavigationAtRef.current = 0; + setNavigationLevel(() => { + if (compactStartEnabled || !persistedCategoryId) return { type: "root" }; + return buildNavigationLevelForCategory(persistedCategoryId); + }); + }, [compactStartEnabled, instanceKey]); + + useEffect(() => { + return () => { + if (closeReasonRef.current === "explicit-root") { + clearPersistedNavigation(); + return; + } + + if (closeReasonRef.current === "programmatic") { + clearPersistedNavigation(); + return; + } + + if (navigationLevel.type === "category") { + persistedCategoryId = navigationLevel.categoryId; + return; + } + + clearPersistedNavigation(); + }; + }, [navigationLevel]); + + useEffect(() => { + setIsActionsMenuOpen(false); + }, [navigationLevel]); + + const trimmedQuery = query.trim(); + const compact = compactStartEnabled && trimmedQuery.length === 0 && !expanded && navigationLevel.type === "root" && pageStack.length === 0; + + const allCommands = useMemo(() => listCommands(), [registryVersion]); + + const currentCommands = useMemo(() => { + if (navigationLevel.type === "root") return allCommands; + if (navigationLevel.categoryId === PINNED_CATEGORY_ID) { + return Array.from(pinnedIds) + .map(id => allCommands.find(command => command.id === id) ?? null) + .filter((command): command is CommandEntry => Boolean(command && !command.hiddenInSearch)) + .sort((left, right) => left.label.localeCompare(right.label)); + } + if (navigationLevel.categoryId === RECENTS_CATEGORY_ID) { + return getRecentCommandEntries(30); + } + if (navigationLevel.categoryId === MENTIONS_CATEGORY_ID) { + const snapshot = getMentionCommandsSnapshot(); + if (snapshot.length > 0) return snapshot; + } + return getCategoryCommands(navigationLevel.categoryId, allCommands); + }, [allCommands, navigationLevel, pinnedIds]); + + const includeHiddenInCurrentLevel = navigationLevel.type === "category"; + const searchableCommands = useMemo( + () => currentCommands.filter(command => includeHiddenInCurrentLevel || !command.hiddenInSearch), + [currentCommands, includeHiddenInCurrentLevel] + ); + + const rankedCommandCandidates = useMemo(() => { + if (!trimmedQuery) { + return searchableCommands.map(command => asCommandCandidate(command, pinnedIds.has(command.id), "Command")); + } + + const ranked = rankItems( + trimmedQuery, + searchableCommands.map(entry => ({ + id: entry.id, + label: entry.label, + description: entry.description, + keywords: entry.keywords, + pinned: pinnedIds.has(entry.id), + recentRank: getRecentRank(entry.id), + categoryWeight: getCategoryWeight(entry.categoryId) + })), + { semantic: true } + ); + + return ranked + .filter(item => item.score > 0) + .slice(0, 40) + .map(item => { + const command = searchableCommands.find(entry => entry.id === item.item.id); + if (!command) return null; + return asCommandCandidate(command, pinnedIds.has(command.id), "Command"); + }) + .filter((entry): entry is CommandCandidate => Boolean(entry)); + }, [trimmedQuery, searchableCommands, pinnedIds]); + + const recentCandidates = useMemo(() => { + return getRecentCommandEntries(5).map(command => asCommandCandidate(command, pinnedIds.has(command.id), "Recent")); + }, [registryVersion, pinnedIds]); + const pinnedCandidates = useMemo(() => { + if (navigationLevel.type !== "root") return []; + if (trimmedQuery.length > 0) return []; + + const pinnedItems: CommandCandidate[] = []; + for (const id of pinnedIds) { + const command = allCommands.find(entry => entry.id === id); + if (!command || command.hiddenInSearch) continue; + pinnedItems.push(asCommandCandidate(command, true, "Pinned")); + } + + return pinnedItems.sort((left, right) => left.command.label.localeCompare(right.command.label)); + }, [allCommands, navigationLevel.type, pinnedIds, trimmedQuery.length]); + + const suggestedCandidates = useMemo(() => { + if (navigationLevel.type !== "root") return []; + if (trimmedQuery.length > 0) return []; + + const recentIds = new Set(recentCandidates.map(item => item.command.id)); + const pool = searchableCommands + .filter(command => !recentIds.has(command.id)) + .map(command => asCommandCandidate(command, pinnedIds.has(command.id), "Command")); + + return pool + .sort((left, right) => scoreSuggestion(left.command.id, suggestionSeedRef.current) - scoreSuggestion(right.command.id, suggestionSeedRef.current)) + .slice(0, 8); + }, [navigationLevel.type, trimmedQuery, recentCandidates, searchableCommands, pinnedIds]); + + const items = useMemo(() => { + const expandedItems: PaletteCandidate[] = []; + + if (navigationLevel.type === "category") { + const path = getCategoryPath(navigationLevel.categoryId); + const breadcrumb = path.map(category => category.label).join(" → "); + expandedItems.push({ type: "section", id: "section-breadcrumb", label: breadcrumb }); + } + + if (!compact && navigationLevel.type === "root" && trimmedQuery.length === 0) { + if (pinnedCandidates.length > 0) { + expandedItems.push({ type: "section", id: "section-pinned", label: "Pinned" }); + expandedItems.push(...pinnedCandidates); + } + + if (recentCandidates.length > 0) { + expandedItems.push({ type: "section", id: "section-recent", label: "Recent" }); + expandedItems.push(...recentCandidates); + } + + if (suggestedCandidates.length > 0) { + expandedItems.push({ type: "section", id: "section-suggested", label: "Suggested" }); + expandedItems.push(...suggestedCandidates); + } + } + + if (rankedCommandCandidates.length > 0) { + if (trimmedQuery.length > 0) { + expandedItems.push({ type: "section", id: "section-results", label: "Results" }); + } + if (navigationLevel.type === "root" && trimmedQuery.length === 0) { + const seenCommandIds = new Set(); + for (const item of expandedItems) { + if (item.type === "command") { + seenCommandIds.add(item.command.id); + } + } + expandedItems.push(...rankedCommandCandidates.filter(item => !seenCommandIds.has(item.command.id))); + } else { + expandedItems.push(...rankedCommandCandidates); + } + } + + return expandedItems; + }, [compact, navigationLevel, pinnedCandidates, rankedCommandCandidates, recentCandidates, suggestedCandidates, trimmedQuery]); + + const emptyStateText = navigationLevel.type === "category" && navigationLevel.categoryId === MENTIONS_CATEGORY_ID + ? "All caught up." + : "No results."; + const hasCommandItems = items.some(item => item.type === "command"); + + const selectedItem = selectedIndex >= 0 ? items[selectedIndex] : undefined; + const currentSelectedCommand = isSelectable(selectedItem) ? selectedItem.command : null; + const previewPromptCommand = !activePromptCommand && currentSelectedCommand?.queryTemplate + ? currentSelectedCommand + : null; + const promptCommand = activePromptCommand ?? previewPromptCommand; + const hasMainInputText = query.length > 0; + const hasPromptInProgress = Boolean(activePromptCommand || promptInputValue.length > 0 || selectedPromptCandidateId); + const shouldRenderPromptPreview = Boolean(promptCommand); + const calculatorResult = useMemo(() => { + if (!trimmedQuery) return null; + if (activePromptCommand) return null; + return resolveCalculatorQuery(trimmedQuery); + }, [activePromptCommand, trimmedQuery]); + const calculatorCanGraph = Boolean(calculatorResult?.graph?.series.length); + + useEffect(() => { + if (!calculatorResult?.graph) { + setCalculatorViewMode("result"); + return; + } + + setCalculatorViewMode(calculatorResult.graph.defaultViewMode); + }, [calculatorResult]); + + const queryCandidates = useMemo(() => { + if (!activePromptCommand?.queryTemplate) return []; + + const fullQuery = `${activePromptCommand.queryTemplate}${promptInputValue}`.trim(); + const resolution = buildQueryResolution(fullQuery); + if (resolution.type !== "candidates") return []; + return resolution.candidates; + }, [activePromptCommand, promptInputValue]); + const selectedPromptCandidate = useMemo(() => { + if (!selectedPromptCandidateId) return null; + return queryCandidates.find(candidate => candidate.id === selectedPromptCandidateId) ?? null; + }, [queryCandidates, selectedPromptCandidateId]); + const activePromptIsSingleSelect = Boolean(activePromptCommand?.id && SINGLE_SELECT_PROMPT_COMMAND_IDS.has(activePromptCommand.id)); + + const selectedLabel = getSelectedLabel(selectedItem); + const activePage = pageStack.length > 0 ? pageStack[pageStack.length - 1] : null; + const activePageSpec = activePage ? getPalettePageSpec(activePage.ref.id) ?? null : null; + const activePageState = activePage?.state ?? null; + const isPageOpen = Boolean(activePageSpec && activePageState); + + const canDrillDown = !isPageOpen && isSelectable(selectedItem) && hasChildren(selectedItem.command, allCommands); + const canGoBack = isPageOpen || navigationLevel.type !== "root"; + const selectedCommand = !isPageOpen && isSelectable(selectedItem) ? selectedItem.command : null; + const selectedDrilldownCategoryId = selectedCommand ? getDrilldownCategoryId(selectedCommand) : null; + const promptDropdownSuggestions = useMemo(() => queryCandidates.map(candidate => ({ + id: candidate.id, + label: candidate.label, + iconUrl: candidate.iconUrl, + kind: candidate.suggestionKind ?? "generic" + })), [queryCandidates]); + const shouldShowPromptDropdown = Boolean(activePromptCommand && showPromptDropdown && queryCandidates.length > 0); + const promptSelectedSuggestionIndex = useMemo(() => { + if (!selectedPromptCandidateId) return -1; + return promptDropdownSuggestions.findIndex(candidate => candidate.id === selectedPromptCandidateId); + }, [promptDropdownSuggestions, selectedPromptCandidateId]); + const [activePageFieldKey, setActivePageFieldKey] = useState(null); + const activePageSuggestions = useMemo(() => { + if (!activePageSpec || !activePageState || !activePageFieldKey) return []; + if (!activePageSpec.resolveSuggestions) return []; + const value = activePageState.values[activePageFieldKey] ?? ""; + return activePageSpec.resolveSuggestions(activePageFieldKey, value, activePageState.values, activePageState.selectedIds); + }, [activePageSpec, activePageState, activePageFieldKey]); + + const shouldShowPromptCommandLabel = Boolean(promptCommand && !hasMainInputText); + const showPromptCommandPreview = Boolean(promptCommand && !hasMainInputText && !hasPromptInProgress); + const mainInputPlaceholder = showPromptCommandPreview + ? promptCommand?.label + : undefined; + const promptChipPlaceholderText = promptCommand?.queryPlaceholder ?? "Action"; + const promptChipWidthPx = useMemo(() => measurePromptChipWidth(promptChipPlaceholderText), [promptChipPlaceholderText]); + const promptChipStyle = { width: `${promptChipWidthPx}px` }; + const queryPreviewPromptOffsetPx = useMemo(() => { + if (!hasMainInputText || shouldShowPromptCommandLabel) return 0; + const typedWidth = measureQueryPreviewWidth(query); + return Math.min(typedWidth + 16, 520); + }, [hasMainInputText, query, shouldShowPromptCommandLabel]); + const queryPreviewPromptStyle = queryPreviewPromptOffsetPx > 0 + ? { marginLeft: `${queryPreviewPromptOffsetPx}px` } + : undefined; + + useEffect(() => { + if (!selectedPromptCandidateId) return; + if (queryCandidates.some(candidate => candidate.id === selectedPromptCandidateId)) return; + setSelectedPromptCandidateId(null); + }, [queryCandidates, selectedPromptCandidateId]); + + useEffect(() => { + if (compact) { + setSelectedIndex(-1); + setSelectedKey(null); + return; + } + + if (!hasCommandItems) { + setSelectedIndex(-1); + setSelectedKey(null); + return; + } + + const firstSelectable = items.findIndex(item => isSelectable(item)); + if (firstSelectable < 0) { + setSelectedIndex(-1); + setSelectedKey(null); + return; + } + + if (selectedKey) { + const found = items.findIndex(item => isSelectable(item) && item.id === selectedKey); + if (found >= 0) { + setSelectedIndex(found); + return; + } + } + + setSelectedIndex(firstSelectable); + setSelectedKey(items[firstSelectable].id); + }, [hasCommandItems, items, selectedKey, compact]); + + useEffect(() => { + if (selectedIndex < 0) return; + if (selectionSource !== "keyboard") return; + const container = listRef.current; + const node = itemRefs.current[selectedIndex]; + if (!container || !node) return; + + const containerRect = container.getBoundingClientRect(); + const nodeRect = node.getBoundingClientRect(); + + if (nodeRect.bottom > containerRect.bottom) { + container.scrollTop += nodeRect.bottom - containerRect.bottom; + return; + } + + if (nodeRect.top < containerRect.top) { + container.scrollTop -= containerRect.top - nodeRect.top; + } + }, [selectedIndex, selectionSource]); + + useEffect(() => { + const previous = lastTrimmedQueryRef.current; + if (previous === trimmedQuery) return; + lastTrimmedQueryRef.current = trimmedQuery; + + if (compact || isPageOpen) return; + + setSelectedIndex(-1); + setSelectedKey(null); + setKeyboardSelectedKey(null); + + if (listRef.current) { + listRef.current.scrollTop = 0; + } + }, [compact, isPageOpen, trimmedQuery]); + + useEffect(() => { + if (!focusPromptInput) return; + + requestAnimationFrame(() => { + promptInputRef.current?.focus(); + }); + setFocusPromptInput(false); + }, [focusPromptInput]); + + const focusSearchInput = () => { + mainInputRef.current?.focus(); + }; + + const clearPromptState = () => { + setActivePromptCommand(null); + setPromptInputValue(""); + setSelectedPromptCandidateId(null); + setShowPromptDropdown(false); + activePromptCommandIdRef.current = null; + }; + + const activatePromptCommand = (command: CommandEntry, shouldFocus: boolean) => { + if (activePromptCommand?.id !== command.id) { + setPromptInputValue(""); + setSelectedPromptCandidateId(null); + } + + setActivePromptCommand(command); + activePromptCommandIdRef.current = command.id; + if (SINGLE_SELECT_PROMPT_COMMAND_IDS.has(command.id) && selectedPromptCandidateId) { + setShowPromptDropdown(false); + } else { + setShowPromptDropdown(true); + } + if (shouldFocus) { + setFocusPromptInput(true); + } + }; + + const executePromptCandidate = async (run: () => Promise | boolean | void, sourceCommandId?: string | null) => { + const commandId = sourceCommandId ?? activePromptCommandIdRef.current ?? activePromptCommand?.id ?? null; + let success = false; + let shouldClose = true; + + try { + const result = await run(); + if (result === false) { + shouldClose = false; + return; + } + success = true; + } catch (error) { + Toasts.show({ + message: "Unable to complete prompt action.", + type: Toasts.Type.FAILURE, + id: Toasts.genId(), + options: { position: Toasts.Position.BOTTOM } + }); + logger.error("Prompt action failed", error); + } finally { + if (!success) return; + if (commandId) { + markCommandAsRecent(commandId); + } + if (!shouldClose) return; + closePalette("programmatic"); + } + }; + + const copyCalculatorResult = async (mode: "formatted" | "raw" | "qa") => { + if (!calculatorResult) return; + + const text = mode === "formatted" + ? calculatorResult.displayAnswer + : mode === "raw" + ? calculatorResult.rawAnswer + : `${calculatorResult.displayInput} = ${calculatorResult.displayAnswer}`; + + await copyWithToast(text, "Copied to clipboard."); + }; + + const pushPage = (ref: PalettePageRef) => { + setPageStack(current => [ + ...current, + { + ref, + state: createInitialPageState(ref), + error: null + } + ]); + clearPromptState(); + setActivePageFieldKey(null); + setSelectedKey(null); + setKeyboardSelectedKey(null); + setQuery(""); + setExpanded(false); + }; + + const setActivePageValue = (fieldKey: string, value: string) => { + setPageStack(current => { + if (current.length === 0) return current; + const next = [...current]; + const top = next[next.length - 1]; + next[next.length - 1] = { + ...top, + state: { + ...top.state, + values: { + ...top.state.values, + [fieldKey]: value + }, + selectedIds: { + ...top.state.selectedIds, + [fieldKey]: null + } + }, + error: null + }; + return next; + }); + }; + + const setActivePageSelectedId = (fieldKey: string, id: string | null) => { + setPageStack(current => { + if (current.length === 0) return current; + const next = [...current]; + const top = next[next.length - 1]; + next[next.length - 1] = { + ...top, + state: { + ...top.state, + selectedIds: { + ...top.state.selectedIds, + [fieldKey]: id + } + }, + error: null + }; + return next; + }); + }; + + const setActivePageError = (error: string | null) => { + setPageStack(current => { + if (current.length === 0) return current; + const next = [...current]; + const top = next[next.length - 1]; + next[next.length - 1] = { + ...top, + error + }; + return next; + }); + }; + + const buildActivePageContext = (): PalettePageRuntimeContext | null => { + if (!activePageState) return null; + return { + values: activePageState.values, + selectedIds: activePageState.selectedIds, + setValue: setActivePageValue, + setSelectedId: setActivePageSelectedId, + showSuccess(message) { + Toasts.show({ + message, + type: Toasts.Type.SUCCESS, + id: Toasts.genId(), + options: { position: Toasts.Position.BOTTOM } + }); + }, + showFailure(message) { + Toasts.show({ + message, + type: Toasts.Type.FAILURE, + id: Toasts.genId(), + options: { position: Toasts.Position.BOTTOM } + }); + } + }; + }; + + const submitActivePage = async () => { + if (!activePageSpec) return; + const context = buildActivePageContext(); + if (!context) return; + + const validationError = activePageSpec.validate?.(context) ?? null; + if (validationError) { + setActivePageError(validationError); + return; + } + + try { + await activePageSpec.submit(context); + closePalette("programmatic"); + } catch (error) { + const message = error instanceof Error && error.message + ? error.message + : "Unable to complete page action."; + setActivePageError(message); + } + }; + + const openDrilldownCategory = (categoryId: string): boolean => { + refreshAllContextProviders(); + setNavigationLevel(current => pushNavigationLevel(current, categoryId)); + clearPromptState(); + setSelectedKey(null); + setKeyboardSelectedKey(null); + setQuery(""); + setExpanded(false); + return true; + }; + + const openDrilldown = (command: CommandEntry): boolean => { + if (command.drilldownCategoryId) { + return openDrilldownCategory(command.drilldownCategoryId); + } + + const { categoryId } = command; + if (!categoryId) return false; + + const childCategories = listChildCategories(categoryId); + if (childCategories.length > 0) { + return openDrilldownCategory(childCategories[0].id); + } + + return false; + }; + + const closePalette = (reason: "programmatic" | "explicit-root") => { + closeReasonRef.current = reason; + modalProps.onClose?.(); + }; + + const executeItem = async (item: PaletteCandidate | undefined, actionKey: string = "primary") => { + if (!isSelectable(item)) return; + + if (actionKey === "primary" && item.command.queryTemplate) { + activatePromptCommand(item.command, true); + return; + } + + if (actionKey === "primary" && item.command.page) { + markCommandAsRecent(item.command.id); + pushPage(item.command.page); + return; + } + + if (actionKey === "primary" && openDrilldown(item.command)) { + return; + } + + const executed = await executeCommandAction(item.command, actionKey); + if (!executed) return; + + if (item.command.closeAfterExecute ?? closeAfterExecute) { + closePalette("programmatic"); + } + }; + + const handleTogglePin = async (commandId: string) => { + const result = await togglePinned(commandId); + if (result === null) { + Toasts.show({ + message: "Unable to toggle pin for this command.", + type: Toasts.Type.FAILURE, + id: Toasts.genId(), + options: { position: Toasts.Position.BOTTOM } + }); + return; + } + + const command = selectedCommand; + const label = command?.id === commandId ? command.label : "Command"; + + Toasts.show({ + message: `${label} ${result ? "pinned" : "unpinned"}.`, + type: Toasts.Type.SUCCESS, + id: Toasts.genId(), + options: { position: Toasts.Position.BOTTOM } + }); + }; + + const drillDown = () => { + if (!isSelectable(selectedItem)) return; + openDrilldown(selectedItem.command); + }; + + const goBack = () => { + if (isPageOpen) { + setPageStack(current => current.slice(0, -1)); + setActivePageFieldKey(null); + return; + } + + if (navigationLevel.type === "root") return; + + const { parentLevels } = navigationLevel; + if (parentLevels.length === 0) { + setNavigationLevel({ type: "root" }); + } else { + setNavigationLevel(parentLevels[parentLevels.length - 1]); + } + + clearPromptState(); + setSelectedKey(null); + setKeyboardSelectedKey(null); + setQuery(""); + setExpanded(false); + }; + + const updateSelection = (index: number, item: PaletteCandidate) => { + if (!isSelectable(item)) return; + + keyboardNavigationAtRef.current = Date.now(); + setSelectionSource("keyboard"); + setSelectedIndex(index); + setSelectedKey(item.id); + setKeyboardSelectedKey(item.id); + + if (activePromptCommand) { + clearPromptState(); + } + }; + + const updateSelectionFromPointer = (index: number, item: PaletteCandidate, force = false) => { + if (!isSelectable(item)) return; + if (!force && Date.now() - keyboardNavigationAtRef.current < 200) return; + if (!force && activePromptCommand) return; + + setSelectionSource("pointer"); + setSelectedIndex(index); + setSelectedKey(item.id); + setKeyboardSelectedKey(item.id); + }; + + const ensureSelectionForActions = () => { + if (isPageOpen) return true; + if (isSelectable(selectedItem)) return true; + + const firstSelectable = items.findIndex(item => isSelectable(item)); + if (firstSelectable < 0) return false; + + const firstItem = items[firstSelectable]; + if (!isSelectable(firstItem)) return false; + + setSelectionSource("keyboard"); + setSelectedIndex(firstSelectable); + setSelectedKey(firstItem.id); + setKeyboardSelectedKey(firstItem.id); + return true; + }; + + const moveSelection = (direction: 1 | -1) => { + if (!hasCommandItems) return; + + let start = selectedIndex; + if (start < 0 || !isSelectable(items[start])) { + start = items.findIndex(item => isSelectable(item)); + } + if (start < 0) return; + + let index = start; + for (; ;) { + const next = index + direction; + if (next < 0 || next >= items.length) break; + + index = next; + const candidate = items[index]; + if (!isSelectable(candidate)) continue; + + updateSelection(index, candidate); + return; + } + }; + + const goRoot = () => { + setNavigationLevel({ type: "root" }); + clearPromptState(); + setSelectedKey(null); + setQuery(""); + setExpanded(false); + }; + + const onKeyDown = async (event: ReactKeyboardEvent) => { + const target = event.target as HTMLElement | null; + const isPromptInputTarget = Boolean( + target?.closest(promptActiveSelector) + || (target && promptContainerRef.current?.contains(target)) + ); + const isMainInputTarget = Boolean(target?.closest(mainInputSelectorClass)) && !isPromptInputTarget; + const isPageTarget = Boolean(target?.closest(pageSelector)); + + if (event.key === "Escape" && hasPrimaryModifier(event) && !event.altKey && !event.shiftKey) { + event.preventDefault(); + event.stopPropagation(); + clearPersistedNavigation(); + goRoot(); + return; + } + + if (event.key.toLowerCase() === "l" && hasPrimaryModifier(event) && !event.altKey && !event.shiftKey) { + event.preventDefault(); + event.stopPropagation(); + if (isActionsMenuOpen || isActionsMenuClosing) { + setIsActionsMenuClosing(true); + } else { + if (!ensureSelectionForActions()) return; + setIsActionsMenuOpen(true); + setIsActionsMenuClosing(false); + } + return; + } + + if (event.key.toLowerCase() === "p" && hasPrimaryModifier(event) && !event.altKey) { + event.preventDefault(); + event.stopPropagation(); + + if (event.shiftKey) { + if (selectedCommand) { + await handleTogglePin(selectedCommand.id); + } + return; + } + + openDrilldownCategory(PINNED_CATEGORY_ID); + return; + } + + if (event.key.toLowerCase() === "g" && hasPrimaryModifier(event) && !event.altKey && !event.shiftKey && calculatorCanGraph) { + event.preventDefault(); + event.stopPropagation(); + setCalculatorViewMode(current => current === "graph" ? "result" : "graph"); + return; + } + + if (isActionsMenuOpen && !isActionsMenuClosing) { + return; + } + + if (isPageOpen && isPageTarget) { + if (event.key === "Enter" && hasPrimaryModifier(event) && !event.altKey && !event.shiftKey) { + event.preventDefault(); + await submitActivePage(); + return; + } + + if (event.key === "Enter") { + return; + } + + if (event.key !== "Escape") { + return; + } + } + + if (!isPromptInputTarget && event.key === "Enter" && !event.metaKey && !event.altKey && !event.ctrlKey && !event.shiftKey && activePromptCommand && selectedPromptCandidate) { + event.preventDefault(); + await executePromptCandidate(selectedPromptCandidate.run, activePromptCommand.id ?? activePromptCommandIdRef.current); + return; + } + + if (!isPromptInputTarget && calculatorResult && event.key === "Enter") { + event.preventDefault(); + if (hasPrimaryModifier(event) && event.shiftKey) { + await copyCalculatorResult("qa"); + return; + } + + if (hasPrimaryModifier(event)) { + await copyCalculatorResult("raw"); + return; + } + + if (!event.altKey && !event.ctrlKey) { + await copyCalculatorResult("formatted"); + return; + } + } + + if (isPromptInputTarget && activePromptCommand) { + if (!activePromptIsSingleSelect && selectedPromptCandidate && event.key === "Backspace" && promptInputValue.length === 0) { + event.preventDefault(); + setSelectedPromptCandidateId(null); + return; + } + + if (event.key === "Tab" || event.key === "ArrowLeft") { + event.preventDefault(); + focusSearchInput(); + return; + } + + if (event.key === "ArrowDown") { + event.preventDefault(); + if (promptDropdownSuggestions.length > 0) { + if (!showPromptDropdown) { + setShowPromptDropdown(true); + } + const baseIndex = showPromptDropdown && promptSelectedSuggestionIndex >= 0 + ? promptSelectedSuggestionIndex + : -1; + const nextIndex = baseIndex < promptDropdownSuggestions.length - 1 + ? baseIndex + 1 + : promptDropdownSuggestions.length - 1; + const candidate = promptDropdownSuggestions[nextIndex]; + if (candidate) { + setSelectedPromptCandidateId(candidate.id); + } + return; + } + setShowPromptDropdown(true); + return; + } + + if (event.key === "ArrowUp") { + event.preventDefault(); + if (promptDropdownSuggestions.length > 0) { + if (!showPromptDropdown) { + setShowPromptDropdown(true); + } + const baseIndex = showPromptDropdown && promptSelectedSuggestionIndex >= 0 + ? promptSelectedSuggestionIndex + : 0; + const nextIndex = baseIndex > 0 + ? baseIndex - 1 + : 0; + const candidate = promptDropdownSuggestions[nextIndex]; + if (candidate) { + setSelectedPromptCandidateId(candidate.id); + } + return; + } + setShowPromptDropdown(true); + return; + } + + if (event.key === "Enter") { + event.preventDefault(); + if (showPromptDropdown && selectedPromptCandidate) { + await executePromptCandidate(selectedPromptCandidate.run, activePromptCommand?.id ?? activePromptCommandIdRef.current); + return; + } + const candidate = selectedPromptCandidate ?? queryCandidates[0]; + if (!candidate) return; + + await executePromptCandidate(candidate.run, activePromptCommand?.id ?? activePromptCommandIdRef.current); + return; + } + + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + if (showPromptDropdown) { + setShowPromptDropdown(false); + return; + } + clearPromptState(); + return; + } + + return; + } + + if (isMainInputTarget && promptCommand && !isPageOpen && (event.key === "Tab" || event.key === "ArrowRight")) { + event.preventDefault(); + activatePromptCommand(promptCommand, true); + return; + } + + if (isMainInputTarget && activePromptCommand && (event.key === "ArrowDown" || event.key === "ArrowUp")) { + event.preventDefault(); + setFocusPromptInput(true); + return; + } + + if (isMainInputTarget && (event.key === "ArrowLeft" || event.key === "ArrowRight")) { + return; + } + + if (isMainInputTarget && event.key === "ArrowDown") { + event.preventDefault(); + if (compact) { + setExpanded(true); + return; + } + moveSelection(1); + return; + } + + if (isMainInputTarget && event.key === "ArrowUp") { + event.preventDefault(); + if (compact) return; + if (selectedIndex <= 0) { + focusSearchInput(); + return; + } + moveSelection(-1); + return; + } + + if (event.key === "ArrowRight") { + event.preventDefault(); + if (canDrillDown) drillDown(); + return; + } + + if (event.key === "ArrowLeft") { + event.preventDefault(); + if (canGoBack) goBack(); + return; + } + + if (event.key === "ArrowDown") { + event.preventDefault(); + if (compact) { + setExpanded(true); + return; + } + + moveSelection(1); + return; + } + + if (event.key === "ArrowUp") { + event.preventDefault(); + if (compact) return; + + if (selectedIndex <= 0 && trimmedQuery.length === 0 && navigationLevel.type === "root") { + setExpanded(false); + return; + } + + moveSelection(-1); + return; + } + + if (event.key === "Enter") { + event.preventDefault(); + const actionKey = event.metaKey || event.altKey || event.ctrlKey || event.shiftKey + ? getEnterActionKey(event) + : "primary"; + if (actionKey === "primary") { + await executeItem(selectedItem, actionKey); + return; + } + + if (!selectedCommand) return; + + const actionContext = { + command: selectedCommand, + drilldownCategoryId: selectedDrilldownCategoryId, + isPageOpen: false, + hasCalculatorResult: Boolean(calculatorResult), + canGoBack + }; + const intent = resolveCommandActionIntentByActionKey(selectedCommand, actionKey, actionContext); + if (!intent) return; + + await dispatchPaletteActionIntent({ + intent, + executePrimary: () => executeItem(selectedItem, "primary"), + executeSecondary: async resolvedActionKey => { + const executed = await executeCommandAction(selectedCommand, resolvedActionKey, actionContext); + if (!executed) return; + if (selectedCommand.closeAfterExecute ?? closeAfterExecute) { + closePalette("programmatic"); + } + }, + togglePin: handleTogglePin, + openPage: pushPage, + openDrilldown: openDrilldownCategory, + submitActivePage, + goBack, + setCalculatorViewMode, + copyCalculatorResult + }); + return; + } + + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + + if (activePromptCommand) { + clearPromptState(); + return; + } + + if (canGoBack) { + goBack(); + return; + } + + if (!compact && trimmedQuery.length > 0) { + setQuery(""); + setExpanded(false); + return; + } + + if (!compact) { + setExpanded(false); + return; + } + + closePalette("explicit-root"); + } + }; + + const paletteActions = useMemo(() => { + return resolvePaletteActions({ + activePage: activePageSpec + ? { id: activePageSpec.id, submitLabel: activePageSpec.submitLabel } + : null, + hasCalculatorResult: Boolean(calculatorResult), + calculatorCanGraph, + calculatorViewMode, + selectedCommand, + selectedCommandPinned: selectedCommand ? isCommandPinned(selectedCommand.id) : false, + canGoBack, + canDrillDown, + drilldownCategoryId: selectedDrilldownCategoryId + }); + }, [activePageSpec, calculatorCanGraph, calculatorResult, calculatorViewMode, selectedCommand, canGoBack, canDrillDown, selectedDrilldownCategoryId, pinnedIds]); + + const handleActionIntent = async (intent: CommandActionIntent) => { + await dispatchPaletteActionIntent({ + intent, + executePrimary: () => executeItem(selectedItem, "primary"), + executeSecondary: actionKey => executeItem(selectedItem, actionKey), + togglePin: handleTogglePin, + openPage: pushPage, + openDrilldown: openDrilldownCategory, + submitActivePage, + goBack, + setCalculatorViewMode, + copyCalculatorResult + }); + }; + + const activePageView = useMemo(() => { + if (!activePageSpec || !activePageState) return null; + const context = buildActivePageContext(); + if (!context) return null; + + return ( + + {activePageSpec.renderPage?.(context)} + {activePageSpec.fields.map(field => { + const custom = activePageSpec.renderField?.(field, context); + if (custom) return
{custom}
; + + const value = context.values[field.key] ?? ""; + if (field.type === "picker") { + const suggestions = activePageFieldKey === field.key + ? activePageSuggestions.slice(0, field.suggestionLimit ?? activePageSuggestions.length) + : []; + + return ( + + { + setActivePageFieldKey(field.key); + context.setValue(field.key, next); + }} + onPick={suggestion => { + setActivePageFieldKey(field.key); + context.setValue(field.key, suggestion.label); + context.setSelectedId(field.key, suggestion.id); + }} + /> + + ); + } + + return ( + + context.setValue(field.key, next)} + onFocus={() => setActivePageFieldKey(field.key)} + placeholder={field.placeholder} + /> + + ); + })} +
+ ); + }, [activePage, activePageFieldKey, activePageSpec, activePageState, activePageSuggestions]); + + return ( + +
+ {!isPageOpen && ( + { + if (activePromptCommand && value.length > 0) { + clearPromptState(); + } + + const next = value.trim(); + setQuery(value); + + if (next.length > 0) { + setExpanded(true); + } else { + if (navigationLevel.type !== "root") { + goBack(); + return; + } + setExpanded(false); + } + }} + placeholder={mainInputPlaceholder} + readOnly={false} + > + {shouldRenderPromptPreview && promptCommand && ( +
+ {shouldShowPromptCommandLabel && ( + {promptCommand.label} + )} +
+ {activePromptCommand ? ( +
setShowPromptDropdown(true)} + > + { + setPromptInputValue(value); + setShowPromptDropdown(true); + if (selectedPromptCandidateId) { + setSelectedPromptCandidateId(null); + } + }} + placeholder={selectedPromptCandidate ? "" : (promptCommand.queryPlaceholder ?? "Action")} + onFocus={() => setShowPromptDropdown(true)} + onBlur={() => { + window.setTimeout(() => { + const active = document.activeElement; + if (promptContainerRef.current?.contains(active)) return; + setShowPromptDropdown(false); + if (selectedPromptCandidateId || promptInputValue.trim().length > 0) return; + clearPromptState(); + }, 0); + }} + /> +
+ ) : ( + + )} + {shouldShowPromptDropdown && ( + { + const candidate = promptDropdownSuggestions[index]; + if (!candidate) return; + setSelectedPromptCandidateId(candidate.id); + }} + onPick={candidate => { + setSelectedPromptCandidateId(candidate.id); + if (activePromptIsSingleSelect) { + setPromptInputValue(candidate.label); + } else { + setPromptInputValue(""); + } + if (activePromptIsSingleSelect) { + setShowPromptDropdown(false); + } + setFocusPromptInput(true); + }} + /> + )} +
+
+ )} +
+ )} + + {!compact && !isPageOpen && calculatorResult && ( + + )} + + {!compact && !isPageOpen && ( +
+ {!hasCommandItems &&
{emptyStateText}
} + {items.map((item, index) => { + if (item.type === "section") { + itemRefs.current[index] = null; + return undefined} onHover={() => undefined} />; + } + + if (item.type !== "command") { + itemRefs.current[index] = null; + return null; + } + + return ( +
{ + itemRefs.current[index] = el?.querySelector("button") ?? null; + }}> + { + if (activePromptCommand && activePromptCommand.id !== item.command.id) { + clearPromptState(); + } + updateSelectionFromPointer(index, item, true); + }} + onDoubleClick={() => { + void executeItem(item, "primary"); + }} + onHover={() => { + if (item.command.queryTemplate) return; + updateSelectionFromPointer(index, item); + }} + /> +
+ ); + })} +
+ )} + + {!compact && isPageOpen && activePageView} + + {!compact && (isActionsMenuOpen || isActionsMenuClosing) && ( + { + setIsActionsMenuOpen(false); + setIsActionsMenuClosing(false); + }} + /> + )} + + {( + { + if (!ensureSelectionForActions()) return; + setIsActionsMenuOpen(true); + setIsActionsMenuClosing(false); + }} + compact={compact} + onExpand={() => setExpanded(true)} + /> + )} +
+
+ ); +} diff --git a/src/equicordplugins/commandPalette/ui/CommandPaletteRow.tsx b/src/equicordplugins/commandPalette/ui/CommandPaletteRow.tsx new file mode 100644 index 000000000..d4c94b852 --- /dev/null +++ b/src/equicordplugins/commandPalette/ui/CommandPaletteRow.tsx @@ -0,0 +1,155 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { CogWheel, ColorPaletteIcon, CopyIcon, FolderIcon, LinkIcon, MagnifyingGlassIcon, MainSettingsIcon, Microphone, NotesIcon, OpenExternalIcon, PluginIcon, RestartIcon, SafetyIcon, UpdaterIcon, WarningIcon } from "@components/Icons"; +import { classNameFactory } from "@utils/css"; +import { classes } from "@utils/misc"; +import type { JSX } from "react"; + +import type { PaletteCandidate } from "./types"; + +const cl = classNameFactory("vc-command-palette-"); + +type IconProps = JSX.IntrinsicElements["svg"]; + +function BellDismiss(props: IconProps) { + return ( + + + + + ); +} + +interface CommandPaletteRowProps { + item: PaletteCandidate; + selected: boolean; + onClick(): void; + onDoubleClick?(): void; + onHover(): void; +} + +export function CommandPaletteRow({ item, selected, onClick, onDoubleClick, onHover }: CommandPaletteRowProps) { + const resolveQueryIcon = () => { + if (item.type !== "query") return CogWheel; + if (item.query.icon) return item.query.icon; + + const text = `${item.query.label} ${item.query.description ?? ""}`.toLowerCase(); + if (text.includes("send message")) return NotesIcon; + if (text.includes("open dm")) return NotesIcon; + if (text.includes("go to")) return MagnifyingGlassIcon; + if (text.includes("open settings")) return MainSettingsIcon; + if (text.includes("toggle plugin")) return PluginIcon; + if (text.includes("open url")) return OpenExternalIcon; + if (text.includes("invalid") || text.includes("no matching")) return WarningIcon; + return CogWheel; + }; + + const resolveCommandIcon = () => { + if (item.type !== "command") return CogWheel; + if (item.command.icon) return item.command.icon; + if (item.icon) return item.icon; + + const category = item.command.categoryId?.toLowerCase() ?? ""; + const metadata = `${item.command.id} ${item.command.label} ${(item.command.keywords ?? []).join(" ")}`.toLowerCase(); + + if (category.includes("plugin") || metadata.includes("plugin")) return PluginIcon; + if (category.includes("discord-settings") || metadata.includes("settings")) return MainSettingsIcon; + if (metadata.includes("update") || metadata.includes("changelog")) return UpdaterIcon; + if (metadata.includes("reload") || metadata.includes("restart")) return RestartIcon; + if ((metadata.includes("notification") || metadata.includes("equicord")) + && metadata.includes("mute") + && !metadata.includes("voice") + && !metadata.includes("deafen") + && !metadata.includes("microphone") + && !metadata.includes("mic")) return BellDismiss; + if (metadata.includes("mute") + && (metadata.includes("channel") || metadata.includes("guild") || metadata.includes("server"))) + return BellDismiss; + if (metadata.includes("voice") || metadata.includes("mute") || metadata.includes("deafen")) return Microphone; + if (metadata.includes("copy")) return CopyIcon; + if (metadata.includes("link")) return LinkIcon; + if (metadata.includes("browser") || metadata.includes("external") || metadata.includes("url")) return OpenExternalIcon; + if (metadata.includes("theme") || metadata.includes("appearance") || metadata.includes("css") || metadata.includes("transparency")) return ColorPaletteIcon; + if (metadata.includes("privacy") || metadata.includes("safety")) return SafetyIcon; + if (metadata.includes("dm") || metadata.includes("message") || metadata.includes("chat")) return NotesIcon; + if (metadata.includes("guild") || metadata.includes("server") || metadata.includes("channel")) return FolderIcon; + if (metadata.includes("open") || metadata.includes("go to") || metadata.includes("navigate")) return MagnifyingGlassIcon; + + return CogWheel; + }; + + if (item.type === "section") { + return
{item.label}
; + } + + if (item.type === "query") { + const Icon = resolveQueryIcon(); + const hasInputPreview = Boolean(item.query.inputPreview?.length); + return ( + + ); + } + + const Icon = resolveCommandIcon(); + const hasDescription = item.command.description || item.subtitle; + + return ( + + ); +} diff --git a/src/equicordplugins/commandPalette/ui/actions/dispatchPaletteActionIntent.ts b/src/equicordplugins/commandPalette/ui/actions/dispatchPaletteActionIntent.ts new file mode 100644 index 000000000..d2f0433dd --- /dev/null +++ b/src/equicordplugins/commandPalette/ui/actions/dispatchPaletteActionIntent.ts @@ -0,0 +1,65 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import type { CalculatorViewMode } from "../../calculator/types"; +import type { CommandActionIntent } from "../../registry"; +import type { PalettePageRef } from "../pages/types"; + +interface DispatchPaletteActionIntentInput { + intent: CommandActionIntent; + executePrimary(): Promise; + executeSecondary(actionKey: string): Promise; + togglePin(commandId: string): Promise; + openPage(page: PalettePageRef): void; + openDrilldown(categoryId: string): void; + submitActivePage(): Promise; + goBack(): void; + setCalculatorViewMode(mode: CalculatorViewMode): void; + copyCalculatorResult(mode: "formatted" | "raw" | "qa"): Promise; +} + +export async function dispatchPaletteActionIntent({ + intent, + executePrimary, + executeSecondary, + togglePin, + openPage, + openDrilldown, + submitActivePage, + goBack, + setCalculatorViewMode, + copyCalculatorResult +}: DispatchPaletteActionIntentInput): Promise { + switch (intent.type) { + case "execute-primary": + await executePrimary(); + return; + case "execute-secondary": + await executeSecondary(intent.actionKey); + return; + case "toggle-pin": + await togglePin(intent.commandId); + return; + case "open-page": + openPage(intent.page); + return; + case "open-drilldown": + openDrilldown(intent.categoryId); + return; + case "submit-active-page": + await submitActivePage(); + return; + case "go-back": + goBack(); + return; + case "toggle-calculator-view": + setCalculatorViewMode(intent.mode); + return; + case "copy-calculator": + await copyCalculatorResult(intent.mode); + return; + } +} diff --git a/src/equicordplugins/commandPalette/ui/actions/resolvePaletteActions.ts b/src/equicordplugins/commandPalette/ui/actions/resolvePaletteActions.ts new file mode 100644 index 000000000..076735827 --- /dev/null +++ b/src/equicordplugins/commandPalette/ui/actions/resolvePaletteActions.ts @@ -0,0 +1,160 @@ +/* + * 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 { CalculatorViewMode } from "../../calculator/types"; +import type { + CommandActionContext, + CommandActionDefinition, + CommandEntry +} from "../../registry"; +import type { PaletteActionItem } from "../CommandPaletteActionsMenu"; + +interface ResolvePaletteActionsInput { + activePage: { id: string; submitLabel: string; } | null; + hasCalculatorResult: boolean; + calculatorCanGraph: boolean; + calculatorViewMode: CalculatorViewMode; + selectedCommand: CommandEntry | null; + selectedCommandPinned: boolean; + canGoBack: boolean; + canDrillDown: boolean; + drilldownCategoryId: string | null; +} + +function formatPrimaryShortcutLabel(shortcut: string): string { + if (IS_MAC) return shortcut; + + return shortcut.replaceAll("⌘", "Ctrl+"); +} + +function mapCommandActions( + actionDefs: CommandActionDefinition[], + context: CommandActionContext +): PaletteActionItem[] { + const items: PaletteActionItem[] = []; + + for (const action of actionDefs) { + if (action.isVisible && !action.isVisible(context)) continue; + + items.push({ + id: action.id, + label: action.label, + shortcut: action.shortcut, + icon: action.icon, + intent: action.intent, + disabled: action.isEnabled ? !action.isEnabled(context) : false + }); + } + + return items; +} + +export function resolvePaletteActions({ + activePage, + hasCalculatorResult, + calculatorCanGraph, + calculatorViewMode, + selectedCommand, + selectedCommandPinned, + canGoBack, + canDrillDown, + drilldownCategoryId +}: ResolvePaletteActionsInput): PaletteActionItem[] { + const actions: PaletteActionItem[] = []; + + if (activePage) { + actions.push({ + id: `page-submit-${activePage.id}`, + label: activePage.submitLabel, + shortcut: formatPrimaryShortcutLabel("⌘↵"), + intent: { type: "submit-active-page" } + }); + actions.push({ + id: "back", + label: "Go Back", + shortcut: "Esc", + intent: { type: "go-back" } + }); + return actions; + } + + if (hasCalculatorResult) { + if (calculatorCanGraph) { + actions.push({ + id: calculatorViewMode === "graph" ? "calculator-show-result" : "calculator-show-graph", + label: calculatorViewMode === "graph" ? "Show Result" : "Show Graph", + shortcut: formatPrimaryShortcutLabel("⌘G"), + intent: { type: "toggle-calculator-view", mode: calculatorViewMode === "graph" ? "result" : "graph" } + }); + } + actions.push({ + id: "copy-answer", + label: "Copy Answer", + shortcut: "↵", + intent: { type: "copy-calculator", mode: "formatted" } + }); + actions.push({ + id: "copy-raw", + label: "Copy Raw", + shortcut: formatPrimaryShortcutLabel("⌘↵"), + intent: { type: "copy-calculator", mode: "raw" } + }); + actions.push({ + id: "copy-qa", + label: "Copy Q+A", + shortcut: formatPrimaryShortcutLabel("⌘⇧↵"), + intent: { type: "copy-calculator", mode: "qa" } + }); + } else if (selectedCommand) { + const context: CommandActionContext = { + command: selectedCommand, + drilldownCategoryId, + isPageOpen: false, + hasCalculatorResult, + canGoBack + }; + + if (selectedCommand.actions) { + actions.push(...mapCommandActions(selectedCommand.actions(context), context)); + } else { + actions.push({ + id: "execute", + label: "Execute", + shortcut: "↵", + intent: { type: "execute-primary" } + }); + + if (canDrillDown && drilldownCategoryId) { + actions.push({ + id: "open", + label: "Open", + shortcut: "→", + intent: { type: "open-drilldown", categoryId: drilldownCategoryId } + }); + } + } + + actions.push({ + id: selectedCommandPinned ? "unpin-command" : "pin-command", + label: selectedCommandPinned ? "Unpin Command" : "Pin Command", + shortcut: formatPrimaryShortcutLabel("⌘⇧P"), + intent: { type: "toggle-pin", commandId: selectedCommand.id } + }); + } + + if (canGoBack) { + actions.push({ + id: "back", + label: "Go Back", + shortcut: "←", + intent: { type: "go-back" } + }); + } + + return actions; +} diff --git a/src/equicordplugins/commandPalette/ui/iconFromUrl.tsx b/src/equicordplugins/commandPalette/ui/iconFromUrl.tsx new file mode 100644 index 000000000..efb2b99d3 --- /dev/null +++ b/src/equicordplugins/commandPalette/ui/iconFromUrl.tsx @@ -0,0 +1,31 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import type { ComponentType } from "react"; + +export function makeIconFromUrl(iconUrl?: string): ComponentType<{ className?: string; size?: string; }> | undefined { + if (!iconUrl) return undefined; + + return function IconFromUrl({ className, size }) { + const parsed = Number.parseInt(size ?? "18", 10); + const base = Number.isFinite(parsed) ? parsed : 18; + const dimension = Math.max(22, base + 8); + + return ( + + ); + }; +} diff --git a/src/equicordplugins/commandPalette/ui/pages/README.md b/src/equicordplugins/commandPalette/ui/pages/README.md new file mode 100644 index 000000000..284a8e4f1 --- /dev/null +++ b/src/equicordplugins/commandPalette/ui/pages/README.md @@ -0,0 +1,13 @@ +# Command Page Authoring + +Add a new page command in three steps. + +1. Register a command with `createCommandPageCommand({ ..., page: { id: "your-page-id" } })`. +2. Add a `PalettePageSpec` in `ui/pages/specs/`. +3. Add the spec to `ui/pages/registry.ts`. + +The modal uses the page registry and page stack automatically, so normal pages do not require `CommandPaletteModal.tsx` edits. + +Action routing follows the typed `actions(ctx)` model in `CommandEntry`. + +For command-specific secondary actions, define typed intents on the command and avoid modal branching. diff --git a/src/equicordplugins/commandPalette/ui/pages/createCommandPageCommand.ts b/src/equicordplugins/commandPalette/ui/pages/createCommandPageCommand.ts new file mode 100644 index 000000000..fd97e8d6e --- /dev/null +++ b/src/equicordplugins/commandPalette/ui/pages/createCommandPageCommand.ts @@ -0,0 +1,20 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import type { CommandEntry } from "../../registry"; +import type { PalettePageRef } from "./types"; + +type CreateCommandPageCommandInput = Omit & { + page: PalettePageRef; +}; + +export function createCommandPageCommand(input: CreateCommandPageCommandInput): CommandEntry { + return { + ...input, + page: input.page, + handler: () => undefined + }; +} diff --git a/src/equicordplugins/commandPalette/ui/pages/registry.ts b/src/equicordplugins/commandPalette/ui/pages/registry.ts new file mode 100644 index 000000000..392b3684e --- /dev/null +++ b/src/equicordplugins/commandPalette/ui/pages/registry.ts @@ -0,0 +1,20 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import scheduledCreatePageSpec from "./specs/scheduledCreatePage"; +import sendDmPageSpec from "./specs/sendDmPage"; +import statusTimerPageSpec from "./specs/statusTimerPage"; +import type { PalettePageId, PalettePageSpec } from "./types"; + +const pageSpecs = new Map([ + [sendDmPageSpec.id, sendDmPageSpec], + [scheduledCreatePageSpec.id, scheduledCreatePageSpec], + [statusTimerPageSpec.id, statusTimerPageSpec] +]); + +export function getPalettePageSpec(id: PalettePageId): PalettePageSpec | undefined { + return pageSpecs.get(id); +} diff --git a/src/equicordplugins/commandPalette/ui/pages/specs/scheduledCreatePage.ts b/src/equicordplugins/commandPalette/ui/pages/specs/scheduledCreatePage.ts new file mode 100644 index 000000000..9212e87ce --- /dev/null +++ b/src/equicordplugins/commandPalette/ui/pages/specs/scheduledCreatePage.ts @@ -0,0 +1,243 @@ +/* + * 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 { addScheduledMessage } from "@equicordplugins/scheduledMessages/utils"; +import { ChannelStore, SelectedChannelStore } from "@webpack/common"; + +import { resolveAllChannels } from "../../../query/resolvers"; +import type { PalettePageSpec, PaletteSuggestion } from "../types"; + +const LIMIT = 12; +const IN_WORDS = ["in", "en", "dans", "em", "fra", "через"]; +const TODAY_WORDS = ["today", "hoy", "aujourdhui", "heute", "oggi", "hoje", "сегодня"]; +const TOMORROW_WORDS = ["tomorrow", "manana", "demain", "morgen", "domani", "amanha", "завтра"]; +const AT_WORDS = ["at", "a", "alle", "às", "в"]; +const MINUTE_UNITS = ["m", "min", "mins", "minute", "minutes", "minuto", "minutos", "minute", "minutes", "minute", "minuten", "м", "мин", "минута", "минут"]; +const HOUR_UNITS = ["h", "hr", "hrs", "hour", "hours", "hora", "horas", "heure", "heures", "stunde", "stunden", "ч", "час", "часа", "часов"]; +const DAY_UNITS = ["d", "day", "days", "dia", "dias", "jour", "jours", "tag", "tage", "giorno", "giorni", "д", "день", "дня", "дней"]; + +function normalizeLocaleToken(input: string): string { + return input + .trim() + .toLowerCase() + .normalize("NFD") + .replace(/\p{Diacritic}/gu, "") + .replace(/[.'’]/g, "") + .replace(/\s+/g, " "); +} + +function startsWithWord(input: string, words: string[]): string | null { + for (const word of words) { + if (input === word) return word; + if (input.startsWith(`${word} `)) return word; + } + + return null; +} + +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 = normalizeLocaleToken(normalized); + const now = Date.now(); + + const relativePrefix = startsWithWord(lower, IN_WORDS); + const relative = relativePrefix + ? lower.slice(relativePrefix.length).trim().match(/^(\d+)\s*([^\s]+)$/) + : null; + if (relative) { + const amount = Number.parseInt(relative[1], 10); + if (amount < 1) return null; + const unit = normalizeLocaleToken(relative[2]); + const multiplier = MINUTE_UNITS.includes(unit) + ? 60_000 + : HOUR_UNITS.includes(unit) + ? 3_600_000 + : DAY_UNITS.includes(unit) + ? 86_400_000 + : null; + if (!multiplier) return null; + return now + amount * multiplier; + } + + const tomorrowWord = startsWithWord(lower, TOMORROW_WORDS); + if (tomorrowWord) { + let remainder = lower.slice(tomorrowWord.length).trim(); + const atWord = startsWithWord(remainder, AT_WORDS); + if (atWord) remainder = remainder.slice(atWord.length).trim(); + if (!remainder) return null; + + const base = new Date(); + base.setDate(base.getDate() + 1); + return parseClockToken(remainder, base); + } + + const todayWord = startsWithWord(lower, TODAY_WORDS); + if (todayWord) { + let remainder = lower.slice(todayWord.length).trim(); + const atWord = startsWithWord(remainder, AT_WORDS); + if (atWord) remainder = remainder.slice(atWord.length).trim(); + if (!remainder) return null; + + const timestamp = parseClockToken(remainder, 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 absolute = normalized.match(/^(\d{4}-\d{2}-\d{2})(?:[ t](\d{1,2}:\d{2}(?:\s*(?:am|pm))?))?$/i); + if (absolute) { + if (absolute[2]) { + const [year, month, day] = absolute[1].split("-").map(part => Number.parseInt(part, 10)); + const base = new Date(year, month - 1, day); + return parseClockToken(absolute[2], base); + } + + const dateOnly = new Date(`${absolute[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 null; + return parsed; +} + +function resolveChannelId(channelInput: string, selectedChannelId: string | null): string | null { + if (selectedChannelId && ChannelStore.getChannel(selectedChannelId)) { + return selectedChannelId; + } + + const typed = channelInput.trim(); + if (typed) { + const match = resolveAllChannels(typed, { limit: 1 })[0]; + if (match) return match.id; + return null; + } + + const currentChannelId = SelectedChannelStore.getChannelId(); + if (!currentChannelId) return null; + return ChannelStore.getChannel(currentChannelId) ? currentChannelId : null; +} + +async function ensureScheduledMessagesPluginEnabled() { + const plugin = plugins.ScheduledMessages; + if (!plugin) return false; + if (isPluginEnabled(plugin.name)) return true; + + const success = await toggleEnabled(plugin.name); + return Boolean(success && isPluginEnabled(plugin.name)); +} + +const scheduledCreatePageSpec: PalettePageSpec = { + id: "scheduled-create", + title: "Create Scheduled Message", + submitLabel: "Create Scheduled Message", + fields: [ + { key: "channel", label: "Channel", type: "picker", placeholder: "Current channel, DM, or group DM", suggestionLimit: LIMIT }, + { key: "time", label: "Time", type: "text", placeholder: "in 10m, tomorrow 5pm, 2026-02-14 18:00" }, + { key: "message", label: "Message", type: "text", placeholder: "Message content" } + ], + resolveSuggestions(fieldKey, query) { + if (fieldKey !== "channel") return []; + + const trimmed = query.trim(); + const resolved = resolveAllChannels(trimmed, { + includeAllWhenEmpty: trimmed.length === 0, + limit: LIMIT + }).map(entry => { + const channel = ChannelStore.getChannel(entry.id); + const kind: PaletteSuggestion["kind"] = channel + ? (typeof channel.isDM === "function" && channel.isDM()) + ? "user" + : (typeof channel.isGroupDM === "function" && channel.isGroupDM()) + ? "generic" + : "channel" + : "channel"; + return { + id: entry.id, + label: entry.display, + iconUrl: entry.iconUrl, + kind + }; + }); + + return resolved satisfies PaletteSuggestion[]; + }, + validate(context) { + const message = context.values.message?.trim() ?? ""; + if (!message) return "Message content is required."; + + const scheduledTime = parseScheduledTimeInput(context.values.time ?? ""); + if (!scheduledTime) return "Enter a valid future time."; + + const channelId = resolveChannelId(context.values.channel ?? "", context.selectedIds.channel ?? null); + if (!channelId) return "Select a valid channel."; + + return null; + }, + async submit(context) { + const message = context.values.message?.trim() ?? ""; + if (!message) { + throw new Error("Message content is required."); + } + + const scheduledTime = parseScheduledTimeInput(context.values.time ?? ""); + if (!scheduledTime) { + throw new Error("Enter a valid future time."); + } + + const channelId = resolveChannelId(context.values.channel ?? "", context.selectedIds.channel ?? null); + if (!channelId) { + throw new Error("Select a valid channel."); + } + + if (!await ensureScheduledMessagesPluginEnabled()) { + throw new Error("ScheduledMessages plugin is unavailable."); + } + + const result = await addScheduledMessage(channelId, message, scheduledTime); + if (!result.success) { + throw new Error(result.error ?? "Failed to schedule message."); + } + + context.showSuccess("Message scheduled."); + } +}; + +export default scheduledCreatePageSpec; diff --git a/src/equicordplugins/commandPalette/ui/pages/specs/sendDmPage.ts b/src/equicordplugins/commandPalette/ui/pages/specs/sendDmPage.ts new file mode 100644 index 000000000..a64271d3b --- /dev/null +++ b/src/equicordplugins/commandPalette/ui/pages/specs/sendDmPage.ts @@ -0,0 +1,75 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { UserStore } from "@webpack/common"; + +import { sendMessageToUser } from "../../../actions/sendMessageAction"; +import { resolveRecentDmUsers, resolveUsers } from "../../../query/resolvers"; +import type { PalettePageSpec, PaletteSuggestion } from "../types"; + +const LIMIT = 8; + +function resolveRecipientId(recipientInput: string, selectedRecipientId: string | null): string | null { + if (selectedRecipientId && UserStore.getUser(selectedRecipientId)) { + return selectedRecipientId; + } + + const trimmed = recipientInput.trim(); + if (!trimmed) return null; + + const match = resolveUsers(trimmed)[0]; + return match?.user.id ?? null; +} + +const sendDmPageSpec: PalettePageSpec = { + id: "send-dm", + title: "Send DM", + submitLabel: "Send DM", + fields: [ + { key: "recipient", label: "Recipient", type: "picker", placeholder: "Username or display name", suggestionLimit: LIMIT }, + { key: "message", label: "Message", type: "text", placeholder: "Message content" } + ], + resolveSuggestions(fieldKey, query) { + if (fieldKey !== "recipient") return []; + + const trimmed = query.trim(); + const resolved = trimmed.length === 0 + ? resolveRecentDmUsers(LIMIT).map(entry => ({ id: entry.user.id, label: entry.display, iconUrl: entry.iconUrl, kind: "user" as const })) + : resolveUsers(trimmed).slice(0, LIMIT).map(entry => ({ id: entry.user.id, label: entry.display, iconUrl: entry.iconUrl, kind: "user" as const })); + + return resolved satisfies PaletteSuggestion[]; + }, + validate(context) { + const message = context.values.message?.trim() ?? ""; + if (!message) return "Message content is required."; + + const recipientId = resolveRecipientId(context.values.recipient ?? "", context.selectedIds.recipient ?? null); + if (!recipientId) return "Select a valid recipient."; + + return null; + }, + async submit(context) { + const recipientId = resolveRecipientId(context.values.recipient ?? "", context.selectedIds.recipient ?? null); + if (!recipientId) { + throw new Error("Select a valid recipient."); + } + + const message = context.values.message?.trim() ?? ""; + if (!message) { + throw new Error("Message content is required."); + } + + await sendMessageToUser({ + userId: recipientId, + content: message + }); + + const recipient = UserStore.getUser(recipientId); + context.showSuccess(`Message sent to ${recipient?.globalName ?? recipient?.username ?? "recipient"}.`); + } +}; + +export default sendDmPageSpec; diff --git a/src/equicordplugins/commandPalette/ui/pages/specs/statusTimerPage.ts b/src/equicordplugins/commandPalette/ui/pages/specs/statusTimerPage.ts new file mode 100644 index 000000000..cbf1061eb --- /dev/null +++ b/src/equicordplugins/commandPalette/ui/pages/specs/statusTimerPage.ts @@ -0,0 +1,79 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { findExportedComponentLazy } from "@webpack"; + +import { cancelScheduledStatusReset, setStatusDndForDuration } from "../../../registry"; +import type { PalettePageSpec, PaletteSuggestion } from "../types"; + +const TimerIcon = findExportedComponentLazy("TimerIcon"); +const DURATION_CHOICES = [ + { id: "15", label: "15 minutes", icon: TimerIcon }, + { id: "30", label: "30 minutes", icon: TimerIcon }, + { id: "45", label: "45 minutes", icon: TimerIcon }, + { id: "60", label: "1 hour", icon: TimerIcon }, + { id: "120", label: "2 hours", icon: TimerIcon }, + { id: "cancel", label: "Cancel active timer", icon: TimerIcon } +] satisfies PaletteSuggestion[]; + +function resolveDurationId(input: string, selectedId: string | null): string | null { + if (selectedId && DURATION_CHOICES.some(choice => choice.id === selectedId)) { + return selectedId; + } + + const normalized = input.trim().toLowerCase(); + if (!normalized) return null; + + const choice = DURATION_CHOICES.find(entry => entry.label.toLowerCase() === normalized || entry.id === normalized); + return choice?.id ?? null; +} + +const statusTimerPageSpec: PalettePageSpec = { + id: "status-timer", + title: "Set DND Timer", + submitLabel: "Apply", + fields: [ + { + key: "duration", + label: "Duration", + type: "picker", + placeholder: "Choose a duration", + suggestionLimit: DURATION_CHOICES.length + } + ], + resolveSuggestions(fieldKey, query) { + if (fieldKey !== "duration") return []; + const trimmed = query.trim().toLowerCase(); + if (!trimmed) return DURATION_CHOICES; + return DURATION_CHOICES.filter(choice => { + const label = choice.label.toLowerCase(); + return label.includes(trimmed) || choice.id.includes(trimmed); + }); + }, + validate(context) { + const durationId = resolveDurationId(context.values.duration ?? "", context.selectedIds.duration ?? null); + if (!durationId) return "Choose a duration."; + return null; + }, + async submit(context) { + const durationId = resolveDurationId(context.values.duration ?? "", context.selectedIds.duration ?? null); + if (!durationId) { + throw new Error("Choose a duration."); + } + + if (durationId === "cancel") { + cancelScheduledStatusReset(); + return; + } + + const minutes = Number.parseInt(durationId, 10); + if (!setStatusDndForDuration(minutes)) { + throw new Error("Unable to set DND timer."); + } + } +}; + +export default statusTimerPageSpec; diff --git a/src/equicordplugins/commandPalette/ui/pages/types.ts b/src/equicordplugins/commandPalette/ui/pages/types.ts new file mode 100644 index 000000000..a8fb00cfa --- /dev/null +++ b/src/equicordplugins/commandPalette/ui/pages/types.ts @@ -0,0 +1,57 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import type { ReactNode } from "react"; + +export type PalettePageId = "send-dm" | "scheduled-create" | "status-timer" | (string & {}); + +export interface PalettePageRef { + id: PalettePageId; + initialData?: Record; +} + +export interface PaletteSuggestion { + id: string; + label: string; + sublabel?: string; + iconUrl?: string; + icon?: React.ComponentType<{ className?: string; size?: string; }>; + kind?: "user" | "channel" | "guild" | "generic"; +} + +export interface PaletteFieldSpec { + key: string; + label: string; + type: "text" | "picker"; + placeholder?: string; + suggestionLimit?: number; +} + +export interface PalettePageValuesState { + values: Record; + selectedIds: Record; +} + +export interface PalettePageRuntimeContext { + values: Record; + selectedIds: Record; + setValue(fieldKey: string, value: string): void; + setSelectedId(fieldKey: string, id: string | null): void; + showSuccess(message: string): void; + showFailure(message: string): void; +} + +export interface PalettePageSpec { + id: PalettePageId; + title: string; + submitLabel: string; + fields: PaletteFieldSpec[]; + resolveSuggestions?(fieldKey: string, query: string, values: Record, selectedIds: Record): PaletteSuggestion[]; + validate?(context: PalettePageRuntimeContext): string | null; + submit(context: PalettePageRuntimeContext): Promise; + renderPage?(context: PalettePageRuntimeContext): ReactNode; + renderField?(field: PaletteFieldSpec, context: PalettePageRuntimeContext): ReactNode; +} diff --git a/src/equicordplugins/commandPalette/ui/primitives/PaletteDropdown.tsx b/src/equicordplugins/commandPalette/ui/primitives/PaletteDropdown.tsx new file mode 100644 index 000000000..a65acc617 --- /dev/null +++ b/src/equicordplugins/commandPalette/ui/primitives/PaletteDropdown.tsx @@ -0,0 +1,87 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { classNameFactory } from "@utils/css"; +import { classes } from "@utils/misc"; +import { useEffect, useRef } from "@webpack/common"; + +import type { PaletteSuggestion } from "../pages/types"; + +const cl = classNameFactory("vc-command-palette-"); + +interface PaletteDropdownProps { + suggestions: PaletteSuggestion[]; + highlightedIndex: number; + className?: string; + showIcons?: boolean; + onPick(suggestion: PaletteSuggestion): void; + onHover(index: number): void; +} + +export function PaletteDropdown({ suggestions, highlightedIndex, className, showIcons = true, onPick, onHover }: PaletteDropdownProps) { + const listRef = useRef(null); + + useEffect(() => { + if (highlightedIndex < 0) return; + const list = listRef.current; + if (!list) return; + + const selected = list.querySelector(`[data-index="${highlightedIndex}"]`); + if (!selected) return; + selected.scrollIntoView({ block: "nearest" }); + }, [highlightedIndex, suggestions.length]); + + if (suggestions.length === 0) return null; + + return ( +
+
+ {suggestions.map((suggestion, index) => { + const selected = index === highlightedIndex; + return ( + + ); + })} +
+
+ ); +} diff --git a/src/equicordplugins/commandPalette/ui/primitives/PaletteField.tsx b/src/equicordplugins/commandPalette/ui/primitives/PaletteField.tsx new file mode 100644 index 000000000..da0f93b54 --- /dev/null +++ b/src/equicordplugins/commandPalette/ui/primitives/PaletteField.tsx @@ -0,0 +1,24 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { classNameFactory } from "@utils/css"; +import type { ReactNode } from "react"; + +const cl = classNameFactory("vc-command-palette-"); + +interface PaletteFieldProps { + label: string; + children: ReactNode; +} + +export function PaletteField({ label, children }: PaletteFieldProps) { + return ( +
+ + {children} +
+ ); +} diff --git a/src/equicordplugins/commandPalette/ui/primitives/PalettePageShell.tsx b/src/equicordplugins/commandPalette/ui/primitives/PalettePageShell.tsx new file mode 100644 index 000000000..66aeb1648 --- /dev/null +++ b/src/equicordplugins/commandPalette/ui/primitives/PalettePageShell.tsx @@ -0,0 +1,32 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { classNameFactory } from "@utils/css"; +import type { ReactNode } from "react"; + +const cl = classNameFactory("vc-command-palette-"); + +interface PalettePageShellProps { + title?: string; + children: ReactNode; + error?: string | null; +} + +export function PalettePageShell({ title, children, error }: PalettePageShellProps) { + return ( +
+ {title && ( +
{title}
+ )} +
+ {children} +
+ {error && ( +
{error}
+ )} +
+ ); +} diff --git a/src/equicordplugins/commandPalette/ui/primitives/PalettePickerInput.tsx b/src/equicordplugins/commandPalette/ui/primitives/PalettePickerInput.tsx new file mode 100644 index 000000000..e79c0dfeb --- /dev/null +++ b/src/equicordplugins/commandPalette/ui/primitives/PalettePickerInput.tsx @@ -0,0 +1,93 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { classNameFactory } from "@utils/css"; +import { classes } from "@utils/misc"; +import { TextInput } from "@webpack/common"; +import type { KeyboardEvent } from "react"; + +import type { PaletteSuggestion } from "../pages/types"; +import { PaletteDropdown } from "./PaletteDropdown"; +import { usePaletteDropdown } from "./usePaletteDropdown"; + +const cl = classNameFactory("vc-command-palette-"); + +interface PalettePickerInputProps { + value: string; + suggestions: PaletteSuggestion[]; + placeholder?: string; + className?: string; + onChange(value: string): void; + onPick(suggestion: PaletteSuggestion): void; +} + +export function PalettePickerInput({ + value, + suggestions, + placeholder, + className, + onChange, + onPick +}: PalettePickerInputProps) { + const dropdown = usePaletteDropdown(suggestions); + + const handlePick = (suggestion: PaletteSuggestion) => { + dropdown.setIsOpen(false); + dropdown.reset(); + onPick(suggestion); + }; + + const onKeyDown = (event: KeyboardEvent) => { + if ((event.key === "ArrowDown" || event.key === "ArrowUp") && suggestions.length > 0) { + event.preventDefault(); + dropdown.setIsOpen(true); + dropdown.moveHighlight(event.key === "ArrowDown" ? 1 : -1); + return; + } + + if (event.key === "Enter" && !event.metaKey && !event.ctrlKey && !event.altKey && !event.shiftKey) { + const candidate = suggestions[dropdown.highlightIndex] ?? suggestions[0]; + if (!candidate) return; + event.preventDefault(); + handlePick(candidate); + return; + } + + if (event.key === "Escape") { + dropdown.setIsOpen(false); + dropdown.reset(); + } + }; + + return ( +
+ { + onChange(next); + dropdown.setIsOpen(true); + }} + onFocus={() => dropdown.setIsOpen(true)} + onBlur={() => { + window.setTimeout(() => { + dropdown.setIsOpen(false); + }, 0); + }} + onKeyDown={onKeyDown} + /> + {dropdown.isOpen && suggestions.length > 0 && ( + + )} +
+ ); +} diff --git a/src/equicordplugins/commandPalette/ui/primitives/usePaletteDropdown.ts b/src/equicordplugins/commandPalette/ui/primitives/usePaletteDropdown.ts new file mode 100644 index 000000000..a94bf9276 --- /dev/null +++ b/src/equicordplugins/commandPalette/ui/primitives/usePaletteDropdown.ts @@ -0,0 +1,47 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { useEffect, useState } from "@webpack/common"; + +export function usePaletteDropdown(items: T[]) { + const [isOpen, setIsOpen] = useState(false); + const [highlightIndex, setHighlightIndex] = useState(-1); + + useEffect(() => { + if (items.length === 0) { + setHighlightIndex(-1); + return; + } + + if (highlightIndex >= items.length) { + setHighlightIndex(0); + } + }, [highlightIndex, items.length]); + + const moveHighlight = (direction: 1 | -1) => { + if (items.length === 0) return; + setHighlightIndex(current => { + if (current < 0) return direction > 0 ? 0 : 0; + const next = current + direction; + if (next < 0) return 0; + if (next >= items.length) return items.length - 1; + return next; + }); + }; + + const reset = () => { + setHighlightIndex(-1); + }; + + return { + isOpen, + highlightIndex, + setIsOpen, + setHighlightIndex, + moveHighlight, + reset + }; +} diff --git a/src/equicordplugins/commandPalette/ui/types.ts b/src/equicordplugins/commandPalette/ui/types.ts new file mode 100644 index 000000000..eb8f62aba --- /dev/null +++ b/src/equicordplugins/commandPalette/ui/types.ts @@ -0,0 +1,33 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import type { QueryActionCandidate } from "../query/types"; +import type { CommandEntry } from "../registry"; + +export type CommandCandidate = { + type: "command"; + id: string; + command: CommandEntry; + subtitle?: string; + badge: string; + pinned: boolean; + shortcut?: string; + icon?: React.ComponentType<{ className?: string; size?: string; }>; +}; + +export type QueryCandidate = { + type: "query"; + id: string; + query: QueryActionCandidate; +}; + +export type SectionCandidate = { + type: "section"; + id: string; + label: string; +}; + +export type PaletteCandidate = CommandCandidate | QueryCandidate | SectionCandidate; diff --git a/src/equicordplugins/components.dev/index.tsx b/src/equicordplugins/components.dev/index.tsx index a8269b4bc..b740b2403 100644 --- a/src/equicordplugins/components.dev/index.tsx +++ b/src/equicordplugins/components.dev/index.tsx @@ -33,11 +33,8 @@ export default definePlugin({ Component: ComponentsTab, Icon: ComponentsIcon }); - - SettingsPlugin.settingsSectionMap.push(["EquicordDiscordComponents", "equicord_components"]); }, stop() { removeFromArray(SettingsPlugin.customEntries, e => e.key === "equicord_components"); - removeFromArray(SettingsPlugin.settingsSectionMap, entry => entry[1] === "equicord_components"); }, }); diff --git a/src/equicordplugins/components.dev/styles.css b/src/equicordplugins/components.dev/styles.css index d0033e15f..a9d47b6f7 100644 --- a/src/equicordplugins/components.dev/styles.css +++ b/src/equicordplugins/components.dev/styles.css @@ -10,7 +10,7 @@ gap: 8px 16px; } -.vc-compfinder-tabbar>div { +.vc-compfinder-tabbar > div { flex-wrap: wrap !important; gap: 8px 16px; } @@ -63,7 +63,7 @@ background: rgb(0 0 0 / 15%); border: 1px solid rgb(255 255 255 / 6%); border-radius: 4px; - color: var(--text-normal); + color: var(--text-default); cursor: pointer; } @@ -226,7 +226,7 @@ .vc-compfinder-table td { padding: 8px 12px; - color: var(--text-normal); + color: var(--text-default); vertical-align: middle; } @@ -261,7 +261,7 @@ .vc-compfinder-table-field { font-weight: 600; font-size: 0.75rem; - color: var(--text-normal); + color: var(--text-default); font-family: var(--font-code); white-space: nowrap; } @@ -311,4 +311,4 @@ .vc-compfinder-table-desc { color: var(--text-muted); line-height: 1.4; -} +} \ No newline at end of file diff --git a/src/equicordplugins/customSounds/styles.css b/src/equicordplugins/customSounds/styles.css index a28e3a64f..e3c9f2ef1 100644 --- a/src/equicordplugins/customSounds/styles.css +++ b/src/equicordplugins/customSounds/styles.css @@ -26,7 +26,7 @@ background: var(--input-background-default, var(--input-background)); border: none; border-radius: 3px; - color: var(--text-normal); + color: var(--text-default); } .vc-custom-sounds-buttons { @@ -45,7 +45,7 @@ background: var(--input-background-default, var(--input-background)); border: none; border-radius: 3px; - color: var(--text-normal); + color: var(--text-default); } .vc-custom-sounds-sounds-list { diff --git a/src/equicordplugins/declutter/index.tsx b/src/equicordplugins/declutter/index.tsx index 0eb0d2a88..e816d9159 100644 --- a/src/equicordplugins/declutter/index.tsx +++ b/src/equicordplugins/declutter/index.tsx @@ -6,7 +6,7 @@ import "./style.css"; -import { definePluginSettings, migratePluginSetting, migratePluginSettings } from "@api/Settings"; +import { definePluginSettings, migrateOldSettingToNewPlugin, migratePluginSetting, migratePluginSettings } from "@api/Settings"; import { Divider } from "@components/Divider"; import { HeadingSecondary } from "@components/Heading"; import { Notice } from "@components/Notice"; @@ -15,6 +15,7 @@ import { Devs, EquicordDevs } from "@utils/index"; import definePlugin, { OptionType } from "@utils/types"; migratePluginSettings("Declutter", "BetterUserArea", "Anammox"); +migrateOldSettingToNewPlugin("Declutter", "removeClanTag", "GuildTagSettings", "hideTags"); const migrationsAnammox = [ ["dms", "removeShopAboveDM"], @@ -154,7 +155,7 @@ export default definePlugin({ }, { // Profile banner animation effect - find: "bannerAdjustment,noBorderRadius", + find: "bannerAdjustment,isHovering", replacement: { match: /\i=\i=>\{(?=.{0,50}\.useReducedMotion\))/, replace: "$&return null;" @@ -172,7 +173,7 @@ export default definePlugin({ }, { // Always show username - find: ".DISPLAY_NAME_STYLES_COACHMARK),", + find: ".DISPLAY_NAME_STYLES_COACHMARK)", replacement: { match: /hoverText:(\i),forceHover:\i,children:/g, replace: "hoverText:$1,forceHover:!0,children:" diff --git a/src/equicordplugins/dragify/dragState.ts b/src/equicordplugins/dragify/dragState.ts new file mode 100644 index 000000000..a32dcfbcc --- /dev/null +++ b/src/equicordplugins/dragify/dragState.ts @@ -0,0 +1,134 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import type { DropEntity } from "./utils"; + +type DragSessionState = { + activeEntity: DropEntity | null; + activeUserId: string | null; + activeGuildId: string | null; + dragSourceIsInput: boolean; + active: boolean; + lastDropAt: number; + lastHandledDrop: { at: number; key: string; }; + lastDragEventAt: number; + watchdogTimer: number | null; + guildCleanupTimer: number | null; +}; + +const dropDedupeWindowMs = 150; + +const state: DragSessionState = { + activeEntity: null, + activeUserId: null, + activeGuildId: null, + dragSourceIsInput: false, + active: false, + lastDropAt: 0, + lastHandledDrop: { at: 0, key: "" }, + lastDragEventAt: 0, + watchdogTimer: null, + guildCleanupTimer: null, +}; + +export function beginDrag(entity: DropEntity) { + cancelGuildCleanup(); + state.activeEntity = entity; + state.activeUserId = entity.kind === "user" ? entity.id : null; + state.activeGuildId = entity.kind === "guild" ? entity.id : null; + state.active = true; + touchDrag(); +} + +export function clearDragState() { + state.activeEntity = null; + state.activeUserId = null; + state.activeGuildId = null; + state.dragSourceIsInput = false; + state.active = false; +} + +export function markInputDragSource() { + clearDragState(); + state.dragSourceIsInput = true; +} + +export function isInputDragSource() { + return state.dragSourceIsInput; +} + +export function touchDrag() { + state.lastDragEventAt = Date.now(); +} + +export function getLastDropAt() { + return state.lastDropAt; +} + +export function markDrop() { + state.lastDropAt = Date.now(); +} + +export function shouldIgnoreDrop(key: string) { + const now = Date.now(); + if (state.lastHandledDrop.key === key && now - state.lastHandledDrop.at < dropDedupeWindowMs) return true; + state.lastHandledDrop = { key, at: now }; + return false; +} + +export function hasActiveDrag() { + return state.active || state.activeEntity !== null || state.activeUserId !== null || state.activeGuildId !== null; +} + +export function isGuildDragActive() { + return state.activeGuildId !== null || state.activeEntity?.kind === "guild"; +} + +export function isUserDragActive() { + return state.activeUserId !== null || state.activeEntity?.kind === "user"; +} + +export function getActiveEntity() { + return state.activeEntity; +} + +export function startDragWatchdog(onExpire: () => void) { + if (state.watchdogTimer !== null) return; + state.watchdogTimer = window.setInterval(() => { + if (!state.active) return; + if (Date.now() - state.lastDragEventAt < 1200) return; + clearDragState(); + onExpire(); + }, 500); +} + +export function stopDragWatchdog() { + if (state.watchdogTimer !== null) { + clearInterval(state.watchdogTimer); + state.watchdogTimer = null; + } +} + +export function scheduleGuildCleanup(onExpire: () => void) { + cancelGuildCleanup(); + state.guildCleanupTimer = window.setTimeout(() => { + if (Date.now() - state.lastDragEventAt < 200) return; + clearDragState(); + onExpire(); + }, 300); +} + +export function cancelGuildCleanup() { + if (state.guildCleanupTimer === null) return; + clearTimeout(state.guildCleanupTimer); + state.guildCleanupTimer = null; +} + +export function stopDragState() { + cancelGuildCleanup(); + stopDragWatchdog(); + clearDragState(); +} diff --git a/src/equicordplugins/dragify/ghost.tsx b/src/equicordplugins/dragify/ghost.tsx index 38e6fd039..32506c279 100644 --- a/src/equicordplugins/dragify/ghost.tsx +++ b/src/equicordplugins/dragify/ghost.tsx @@ -7,7 +7,6 @@ import ErrorBoundary from "@components/ErrorBoundary"; import { classNameFactory } from "@utils/css"; import { classes } from "@utils/misc"; -import { findComponentByCodeLazy } from "@webpack"; import { createRoot, React, useEffect, useState, useStateFromStores, VoiceStateStore } from "@webpack/common"; export type GhostState = { @@ -24,15 +23,6 @@ export type GhostState = { exiting: boolean; }; -const ScreenArrowIcon = findComponentByCodeLazy("3V5Zm16") as React.ComponentType<{ - className?: string; - width?: number; - height?: number; - size?: number; - color?: string; - colorClass?: string; -}>; - const cl = classNameFactory("vc-dragify-"); let ghostRoot: ReturnType | null = null; @@ -184,12 +174,7 @@ const DragGhost = () => { : ) : null} {state.kind === "user" && inVoice && isStreaming - ? + ? : null}
{state.subtitle &&
{state.subtitle}
} @@ -218,6 +203,24 @@ function VoiceStateIcon({ className, size = 14 }: { className?: string; size?: n ); } +function StreamIcon({ className, size = 14 }: { className?: string; size?: number; }) { + return ( + + + + ); +} + function VoiceMutedIcon({ className, size = 14 }: { className?: string; size?: number; }) { return ( ; isMessageInputEvent(event: DragEvent): boolean; - isAttachmentElement(target: Element | null): boolean; - isMessageInputElement(el: Element | null): boolean; - extractUserIdFromTarget(target: Element | null): string | null; - extractUserIdFromEvent(event: DragEvent): string | null; onUserDragStart(event: DragEvent, user?: { id: string; userId?: string; user?: { id: string; }; }): void; - extractAvatarUserIdFromTarget(target: HTMLElement | null): string | null; - extractChannelIdFromTarget(target: HTMLElement | null): { id: string; guildId?: string; } | null; - onChannelDragStart(event: DragEvent, channel?: Pick | { id: string; guildId?: string; }): void; - extractGuildIdFromTarget(target: HTMLElement): string | null; + onChannelDragStart(event: DragEvent, channel?: ChannelTarget): void; + onDmDragStart(event: DragEvent, channel?: Channel | null): void; + onGuildDragStart(event: DragEvent, guildId: string): void; + beginDrag(event: DragEvent, entity: DropEntity, options?: { effectAllowed?: DataTransfer["effectAllowed"]; }): void; showGhost(entity: DropEntity, event?: DragEvent): void; + getDmRecipientId(channel?: Channel | null): string | null; }; let pluginInstance: DragifyRuntime | null = null; -let activeGuildDragId: string | null = null; -let activeUserDragId: string | null = null; -let activeDragEntity: DropEntity | null = null; -let lastDropAt = 0; -let lastHandledDrop: { at: number; key: string; } = { at: 0, key: "" }; -let lastDragEventAt = 0; -let guildGhostCleanupTimer: number | null = null; -let dragifyActive = false; -let dragStateWatchdog: number | null = null; -let dragSourceIsInput = false; -const dropDedupeWindowMs = 150; let transparentDragImage: HTMLCanvasElement | null = null; function getTransparentDragImage(): HTMLCanvasElement | null { @@ -64,11 +54,35 @@ function suppressDefaultDragPreview(event: DragEvent) { event.dataTransfer.setDragImage(image, 0, 0); } -function shouldIgnoreDrop(key: string): boolean { - const now = Date.now(); - if (lastHandledDrop.key === key && now - lastHandledDrop.at < dropDedupeWindowMs) return true; - lastHandledDrop = { key, at: now }; - return false; +function setDragifyDataTransfer(dataTransfer: DataTransfer | null, entity: DropEntity) { + if (!dataTransfer) return; + + if (dataTransfer.clearData) { + dataTransfer.clearData("text/plain"); + dataTransfer.clearData("text/uri-list"); + dataTransfer.clearData("text/html"); + } + + const payload = serializeDragEntity(entity); + dataTransfer.setData("application/json", payload); + dataTransfer.setData("application/dragify", payload); + dataTransfer.setData("text/plain", ""); +} + +function hasDragifyTransfer(dataTransfer?: DataTransfer | null) { + return dataTransfer?.types?.includes("application/dragify"); +} + +function buildDropKey(entity: DropEntity, channelId: string) { + return `${entity.kind}:${entity.id}:${channelId}`; +} + +function inspectDragTarget(event: DragEvent, runtime: Pick): ResolvedDragTarget { + return inspectDragEvent(event, { + ChannelStore, + UserStore, + getDmRecipientId: runtime.getDmRecipientId, + }); } const settings = definePluginSettings({ @@ -132,31 +146,6 @@ const settings = definePluginSettings({ }, }); -function clearDragState() { - activeUserDragId = null; - activeGuildDragId = null; - activeDragEntity = null; - dragifyActive = false; - dragSourceIsInput = false; -} - -function setDragifyDataTransfer(dataTransfer: DataTransfer | null, payload: string) { - if (!dataTransfer) return; - if (dataTransfer.clearData) { - dataTransfer.clearData("text/plain"); - dataTransfer.clearData("text/uri-list"); - dataTransfer.clearData("text/html"); - } - dataTransfer.setData("application/json", payload); - dataTransfer.setData("application/dragify", payload); - dataTransfer.setData("text/plain", ""); -} - -function hasDragifyTransfer(dataTransfer?: DataTransfer | null) { - return dataTransfer?.types?.includes("application/dragify"); -} -const inviteCache = new Map(); - export default definePlugin({ name: "Dragify", description: "Drop users, channels, or servers into chat to insert mentions or invites.", @@ -166,9 +155,9 @@ export default definePlugin({ patches: [ // Voice user rows (voice channel sidebar list) { - find: "location:\"VoiceUser\"", + find: '"system:click_outside","user:escape"', replacement: { - match: /(?<=previewIsOpen:.{0,100})"data-dnd-name":(\i)\.name,/, + match: /(?<=GuildChannelUserContextMenu.{0,150})"data-dnd-name":\i/, replace: "$&\"data-dragify-user\":!0,\"data-user-id\":arguments[0].user?.id,draggable:!0,onDragStart:e=>$self.onUserDragStart(e,arguments[0].user)," } }, @@ -182,7 +171,7 @@ export default definePlugin({ }, // Voice channel rows (guild sidebar fallback - li wrapper) { - match: /(?<=getModeClass\(\),.{0,50})"data-dnd-name":(\i)\.name,children:\[/, + match: /(?<=getModeClass\(\),.{0,50})"data-dnd-name":.{0,25},children:\[/, replace: "draggable:!0,onDragStart:e=>$self.onChannelDragStart(e,{id:this?.props?.channel?.id,guild_id:this?.props?.channel?.guild_id}),$&" } ] @@ -191,8 +180,8 @@ export default definePlugin({ { find: "handleClickChat", replacement: { - match: /(?<=getModeClass\(\),.{0,50})"data-dnd-name":(\i)\.name,/, - replace: "$&draggable:!0,onDragStart:t=>$self.onChannelDragStart(t,{id:this?.props?.channel?.id,guild_id:this?.props?.channel?.guild_id})," + match: /(?<=getModeClass\(\),.{0,50})"data-dnd-name":.{0,25},children:\[/, + replace: "draggable:!0,onDragStart:e=>$self.onChannelDragStart(e,{id:this?.props?.channel?.id,guild_id:this?.props?.channel?.guild_id}),$&" } }, // Thread rows in channel list (sidebar thread items) @@ -209,8 +198,8 @@ export default definePlugin({ { find: "shouldShowThreadsPopout", replacement: { - match: /(?<=getClassName\(\).{0,50})"data-dnd-name":(\i)\.name,/, - replace: "$&draggable:!0,onDragStart:e=>$self.onChannelDragStart(e,{id:this?.props?.channel?.id,guild_id:this?.props?.channel?.guild_id})," + match: /(?<=getClassName\(\).{0,50})"data-dnd-name":.{0,25},onMouseEnter:/, + replace: "draggable:!0,onDragStart:e=>$self.onChannelDragStart(e,{id:this?.props?.channel?.id,guild_id:this?.props?.channel?.guild_id}),$&" } }, // Thread rows (active threads popout) @@ -256,70 +245,41 @@ export default definePlugin({ { find: "[aria-owns=folder-items-", replacement: { - match: /"data-dnd-name":(\i)\.name,(?="data-drop-hovering".{0,100}formatToPlainString\()/, + match: /"data-dnd-name":(\i)\.name,(?="data-drop-hovering".{0,300}"aria-selected":\i\}\)\}\))/, replace: "$&draggable:!0,onDragStart:e=>$self.onGuildDragStart(e,$1.id)," } }, ], async onDrop(event: DragEvent, channel?: Channel | null) { - if (dragSourceIsInput) return; + if (isInputDragSource()) return; const { dataTransfer } = event; if (!dataTransfer || dataTransfer.files?.length) return; - const dragifyData = dataTransfer.getData("application/dragify"); - const hasActiveEntity = activeDragEntity || activeUserDragId || activeGuildDragId; - if (dragifyData || hasActiveEntity) { - lastDropAt = Date.now(); - event.preventDefault(); - event.stopPropagation(); - } - const resolvedChannel = channel ?? (SelectedChannelStore?.getChannelId?.() ? ChannelStore.getChannel(SelectedChannelStore.getChannelId()) : null); if (!resolvedChannel) return; - if (dragifyData) { - const parsed = tryParseJson<{ kind?: string; id?: string; guildId?: string; }>(dragifyData); - if (parsed?.kind && parsed.id) { - let fromDragify: DropEntity | null = null; - switch (parsed.kind) { - case "user": - fromDragify = { kind: "user", id: parsed.id }; - break; - case "channel": - fromDragify = { kind: "channel", id: parsed.id, guildId: parsed.guildId }; - break; - case "guild": - fromDragify = { kind: "guild", id: parsed.id }; - break; - } - if (fromDragify) { - const key = `${fromDragify.kind}:${fromDragify.id}:${resolvedChannel.id}`; - if (shouldIgnoreDrop(key)) return; - await this.handleDropEntity(fromDragify, resolvedChannel); - return; - } + if (hasDragifyTransfer(dataTransfer)) { + const entity = parseDragifyPayload(dataTransfer.getData("application/dragify")); + if (!entity) { + clearDragState(); + return; } - } - - const payloads = await collectPayloadStrings(dataTransfer); - let entity = parseFromStrings(payloads, { ChannelStore, GuildStore, UserStore }); - if (!entity && activeDragEntity) { - entity = activeDragEntity; - } else if (!entity && activeUserDragId) { - entity = { kind: "user", id: activeUserDragId }; - } else if (!entity && activeGuildDragId) { - entity = { kind: "guild", id: activeGuildDragId }; - } - if (!entity) { + if (shouldIgnoreDrop(buildDropKey(entity, resolvedChannel.id))) return; + await this.handleDropEntity(entity, resolvedChannel); return; } - const key = `${entity.kind}:${entity.id}:${resolvedChannel.id}`; - if (shouldIgnoreDrop(key)) return; + const payloads = await collectPayloadStrings(dataTransfer); + const entity = parseFromStrings(payloads, { ChannelStore, GuildStore, UserStore }); + if (!entity) { + if (hasActiveDrag()) clearDragState(); + return; + } + if (shouldIgnoreDrop(buildDropKey(entity, resolvedChannel.id))) return; await this.handleDropEntity(entity, resolvedChannel); }, @@ -330,15 +290,15 @@ export default definePlugin({ clearDragState(); return; } - let inserted = false; - if (entity.kind === "user") { - inserted = this.insertText(channel.id, text, { removeUnknownUser: true }); - } else { - inserted = this.insertText(channel.id, text); - } + + const inserted = entity.kind === "user" + ? this.insertText(channel.id, text, { removeUnknownUser: true }) + : this.insertText(channel.id, text); + if (!inserted) throw new Error("Unable to insert drag content"); clearDragState(); } catch (error) { + clearDragState(); logger.error("Failed handling drop", error); showToast("Dragify failed to handle drop.", Toasts.Type.FAILURE); } @@ -351,7 +311,7 @@ export default definePlugin({ case "channel": return this.formatChannel(entity.id, entity.guildId); case "guild": - return await this.createInvite(entity.id, currentChannel); + return await createInvite(entity.id, currentChannel, settings.store); default: return null; } @@ -360,327 +320,107 @@ export default definePlugin({ formatChannel(channelId: string, guildId?: string): string | null { const channel = ChannelStore.getChannel(channelId); if (!channel) return null; - if (channel.isGroupDM() || channel.isMultiUserDM()) { - return settings.store.userOutput === "id" - ? channelId - : `https://discord.com/channels/@me/${channelId}`; + if (isGroupMessageChannel(channel)) { + return `https://discord.com/channels/@me/${channelId}`; } if (settings.store.channelOutput === "link") { - const effectiveGuildId = guildId ?? channel?.guild_id ?? "@me"; + const effectiveGuildId = guildId ?? channel.guild_id ?? "@me"; return `https://discord.com/channels/${effectiveGuildId}/${channelId}`; } return `<#${channelId}>`; }, - async createInvite(guildId: string, currentChannel: Channel): Promise { - const inviteChannel = this.findInviteChannel(guildId, currentChannel); - const fallbackChannelId = inviteChannel ? null : await this.fetchInviteChannelId(guildId); - - if (settings.store.reuseExistingInvites) { - const cached = inviteCache.get(guildId); - if (cached && cached.maxUses === null && !this.isInviteExpired(cached)) return `https://discord.gg/${cached.code}`; - const reused = await this.fetchReusableInvite(guildId, inviteChannel ?? null); - if (reused) return `https://discord.gg/${reused}`; - } - - const inviteChannelId = inviteChannel?.id ?? fallbackChannelId; - if (!inviteChannelId) { - showToast("No channel available for invites.", Toasts.Type.FAILURE); - return null; - } - if (inviteChannel && inviteChannel.guild_id !== guildId) { - showToast("No channel available for invites.", Toasts.Type.FAILURE); - return null; - } - - try { - const maxAge = settings.store.inviteExpireAfter ?? 0; - const maxUses = settings.store.inviteMaxUses ?? 0; - const { body } = await RestAPI.post({ - url: `/channels/${inviteChannelId}/invites`, - body: { - max_age: maxAge, - max_uses: maxUses, - temporary: settings.store.inviteTemporaryMembership, - unique: true, - }, - }); - const code = typeof body === "object" && body ? (body as { code?: string; }).code : null; - if (!code) throw new Error("Invite response missing code"); - inviteCache.set(guildId, { - code, - expiresAt: maxAge > 0 ? Date.now() + maxAge * 1000 : null, - maxUses: maxUses === 0 ? null : maxUses, - uses: 0, - }); - showToast("Invite created.", Toasts.Type.SUCCESS); - return `https://discord.gg/${code}`; - } catch (error) { - logger.error("Failed to create invite", error); - showToast("Unable to create invite.", Toasts.Type.FAILURE); // uh oh! - return null; - } - }, - - async fetchInviteChannelId(guildId: string): Promise { - try { - const { body } = await RestAPI.get({ url: `/guilds/${guildId}/channels` }); - if (!Array.isArray(body)) return null; - - const candidates = body - .filter(ch => ch && ch?.id) - .filter(ch => { - const { type } = ch; - return type === ChannelType.GUILD_TEXT - || type === ChannelType.GUILD_ANNOUNCEMENT - || type === ChannelType.GUILD_FORUM - || type === ChannelType.GUILD_MEDIA; - }) - .sort((a, b) => (a.position ?? 0) - (b.position ?? 0)); - - return candidates[0]?.id ?? null; - } catch { - return null; - } - }, - - async fetchReusableInvite(guildId: string, inviteChannel: Channel | null): Promise { - const cached = inviteCache.get(guildId); - if (cached && cached.maxUses === null && !this.isInviteExpired(cached)) return cached.code; - - try { - const channelId = inviteChannel?.id ?? null; - const url = channelId ? `/channels/${channelId}/invites` : `/guilds/${guildId}/invites`; - - const { body } = await RestAPI.get({ url }); - if (!Array.isArray(body)) return null; - - const now = Date.now(); - const invite = body.find(inv => { - const expiresAt = inv.expires_at ? Date.parse(inv.expires_at) : null; - const maxUsesRaw = inv.max_uses ?? null; - const maxUses = maxUsesRaw === 0 ? null : maxUsesRaw; - const uses = inv.uses ?? null; - const notExpired = expiresAt === null || expiresAt > now; - const usesLeft = maxUses === null || uses === null || uses < maxUses; - return notExpired && usesLeft && typeof inv.code === "string"; - }); - - if (invite?.code) { - inviteCache.set(guildId, { - code: invite.code, - expiresAt: invite.expires_at ? Date.parse(invite.expires_at) : null, - maxUses: invite.max_uses === 0 ? null : invite.max_uses ?? null, - uses: invite.uses ?? null, - }); - return invite.code; - } - } catch { - // If we cannot list invites (permissions/403), fall back silently. - return null; - } - - return null; - }, - - isInviteExpired(invite: { expiresAt: number | null; maxUses: number | null; uses: number | null; }): boolean { - const now = Date.now(); - const expired = invite.expiresAt !== null && invite.expiresAt <= now; - const exhausted = invite.maxUses !== null && invite.uses !== null && invite.uses >= invite.maxUses; - return expired || exhausted; - }, - - findInviteChannel(guildId: string, currentChannel: Channel): Channel | null { - if (currentChannel.guild_id === guildId && this.canCreateInvite(currentChannel)) return currentChannel; - - const guild = GuildStore.getGuild(guildId); - const preferredIds = [ - guild?.systemChannelId, - guild?.rulesChannelId, - guild?.publicUpdatesChannelId, - ].filter(Boolean) as string[]; - for (const id of preferredIds) { - const channel = ChannelStore.getChannel(id); - if (this.canCreateInvite(channel)) return channel; - } - - const selectableStore = (GuildChannelStore.getSelectableChannels?.(guildId) ?? []) - .map(e => e.channel) - .filter(Boolean) as Channel[]; - const selectableCollection = (() => { - const collection = GuildChannelStore.getChannels(guildId); - if (!collection?.SELECTABLE) return []; - - const result: Channel[] = []; - for (const entry of Object.values(collection.SELECTABLE)) { - const channel = entry && entry?.channel ? entry.channel : entry; - if (channel) result.push(channel); - } - return result; - })(); - - const byId = new Map(); - for (const ch of [...selectableStore, ...selectableCollection]) { - if (!ch || ch.guild_id !== guildId) continue; - if (!byId.has(ch.id)) byId.set(ch.id, ch); - } - const candidates = [...byId.values()] - .sort((a, b) => { - const pa = typeof a.position === "number" ? a.position : 0; - const pb = typeof b.position === "number" ? b.position : 0; - if (pa === pb) return a.id.localeCompare(b.id); - return pa - pb; - }); - - for (const channel of candidates) { - if (this.canCreateInvite(channel)) return channel; - } - - return null; - }, - - canCreateInvite(channel?: Channel | null): channel is Channel { - if (!channel || !channel.guild_id) return false; - if (channel.isDM() || channel.isGroupDM() || channel.isMultiUserDM()) return false; - if (channel.isCategory()) return false; - if (channel.isThread()) return false; - return PermissionStore.can(PermissionsBits.CREATE_INSTANT_INVITE, channel); - }, - insertText(channelId: string, text: string, options?: { removeUnknownUser?: boolean; }): boolean { - let insertedIntoInput = false; - let insertedIntoDraft = false; - try { - insertTextIntoChatInputBox(text); - insertedIntoInput = true; + const nextText = options?.removeUnknownUser + ? text.replace(/@unknown[- ]user/gi, "").trim() + : text; + if (!nextText) return false; + insertTextIntoChatInputBox(nextText); + return true; } catch (error) { logger.error("Failed to insert text into input", error); + return false; } - - try { - let existing = DraftStore.getDraft(channelId, DraftType.ChannelMessage) ?? ""; - if (options?.removeUnknownUser) existing = existing.replace(/@unknown[- ]user/gi, "").trim(); - - const needsSpace = existing.length > 0 && !existing.endsWith(" "); - const nextValue = needsSpace ? `${existing} ${text}` : `${existing}${text}`; - - !existing ? DraftActions.saveDraft(channelId, nextValue, DraftType.ChannelMessage) : DraftActions.changeDraft(channelId, nextValue, DraftType.ChannelMessage); - insertedIntoDraft = true; - } catch (error) { - logger.error("Failed to update draft", error); - } - - return insertedIntoInput || insertedIntoDraft; }, - onChannelDragStart(event: DragEvent, channel?: Pick | { id: string; guildId?: string; }) { - if (activeUserDragId) return; - const existingDragify = event.dataTransfer?.getData("application/dragify") ?? ""; - if (existingDragify) { - const parsed = tryParseJson<{ kind?: string; }>(existingDragify); - if (parsed?.kind === "user") return; - } - const targetEl = event.target as HTMLElement | null; - if (targetEl?.closest?.("[data-dragify-user]")) return; + beginDrag(event: DragEvent, entity: DropEntity, options?: { effectAllowed?: DataTransfer["effectAllowed"]; }) { + if (!event.dataTransfer) return; + suppressDefaultDragPreview(event); + if (options?.effectAllowed) event.dataTransfer.effectAllowed = options.effectAllowed; + beginSessionDrag(entity); + this.showGhost(entity, event); + setDragifyDataTransfer(event.dataTransfer, entity); + if (entity.kind === "user") event.dataTransfer.setData("data-user-id", entity.id); + }, - const targetResolved = this.extractChannelIdFromTarget(event.target as HTMLElement | null); - const resolved = - channel && "id" in channel && channel.id - ? channel - : targetResolved; + onChannelDragStart(event: DragEvent, channel?: ChannelTarget) { + if (isUserDragActive()) return; + + const existing = parseDragifyPayload(event.dataTransfer?.getData("application/dragify") ?? ""); + if (existing?.kind === "user") return; + + const inspection = inspectDragTarget(event, this); + if (inspection.hasDragifyUser) return; + + const resolved = channel ?? inspection.channel; if (!resolved?.id) return; - const channelObj = ChannelStore.getChannel(resolved.id); - if (!channelObj) return; - const resolvedGuildId = resolved ? ("guild_id" in resolved ? resolved.guild_id : resolved.guildId) : undefined; - const guildId = resolvedGuildId ?? targetResolved?.guildId ?? channelObj?.guild_id; - const isDirectMessage = channelObj.isDM(); - if (isDirectMessage) { - const recipientId = this.getDmRecipientId(channelObj); - if (recipientId) { - this.onUserDragStart(event, { id: recipientId }); - return; - } - } - suppressDefaultDragPreview(event); - this.showGhost({ kind: "channel", id: channelObj.id, guildId }, event); - const payload = JSON.stringify({ kind: "channel", id: channelObj.id, guildId }); - activeDragEntity = { kind: "channel", id: channelObj.id, guildId }; - dragifyActive = true; - lastDragEventAt = Date.now(); - setDragifyDataTransfer(event.dataTransfer ?? null, payload); - }, - - onUserDragStart(event: DragEvent, user?: { id: string; userId?: string; user?: { id: string; }; }) { - if (this.isAttachmentElement(event.target as HTMLElement | null)) return; - const searchTarget = event.target as HTMLElement | null; - if (searchTarget?.closest) { - const chatMessage = searchTarget.closest("[data-author-id]") as HTMLElement | null; - if (chatMessage) { - const authorId = chatMessage.getAttribute("data-author-id"); - const parsed = authorId ? extractSnowflakeFromString(authorId) : null; - if (parsed) { - user = { id: parsed }; - } - } - } - const currentTarget = (event as unknown as { currentTarget?: EventTarget | null; }).currentTarget as HTMLElement | null; - const target = (event.target as HTMLElement | null) ?? null; - const userIdFromParam = user?.id ?? user?.userId ?? user?.user?.id ?? null; - const userIdFromDom = - this.extractUserIdFromTarget(currentTarget) - ?? this.extractUserIdFromTarget(target) - ?? null; - const userIdFromEvent = this.extractUserIdFromEvent(event); - const rawUserId = - userIdFromParam - ?? userIdFromDom - ?? userIdFromEvent - ?? event.dataTransfer?.getData("data-user-id") - ?? null; - const userId = rawUserId ? (extractSnowflakeFromString(rawUserId) ?? rawUserId) : null; - if (!userId) return; - const payload = JSON.stringify({ kind: "user", id: userId }); - activeUserDragId = userId; - activeDragEntity = { kind: "user", id: userId }; - dragifyActive = true; - lastDragEventAt = Date.now(); - suppressDefaultDragPreview(event); - this.showGhost({ kind: "user", id: userId }, event); - setDragifyDataTransfer(event.dataTransfer ?? null, payload); - event.dataTransfer?.setData("data-user-id", userId); - }, - - onDmDragStart(event: DragEvent, channel?: Channel | null) { - let resolvedChannel = channel ?? null; - if (!resolvedChannel) { - const targetChannel = this.extractChannelIdFromTarget(event.target as HTMLElement | null); - if (targetChannel?.id) resolvedChannel = ChannelStore.getChannel(targetChannel.id) ?? null; - } + const resolvedChannel = ChannelStore.getChannel(resolved.id); if (!resolvedChannel) return; - const shouldDragChannel = resolvedChannel.isGroupDM() && resolvedChannel.isMultiUserDM(); - if (!shouldDragChannel) { + + if (resolvedChannel.isDM() && !isGroupMessageChannel(resolvedChannel)) { const recipientId = this.getDmRecipientId(resolvedChannel); if (recipientId) { this.onUserDragStart(event, { id: recipientId }); - return; } + return; } - this.onChannelDragStart(event, resolvedChannel); + this.beginDrag(event, { + kind: "channel", + id: resolvedChannel.id, + guildId: resolved.guildId ?? resolvedChannel.guild_id ?? undefined, + }); + }, + + onUserDragStart(event: DragEvent, user?: { id: string; userId?: string; user?: { id: string; }; }) { + const inspection = inspectDragTarget(event, this); + if (inspection.hasAttachment) return; + + const rawUserId = + inspection.authorId + ?? user?.id + ?? user?.userId + ?? user?.user?.id + ?? inspection.userId + ?? null; + const userId = rawUserId?.trim(); + if (!userId) return; + + this.beginDrag(event, { kind: "user", id: userId }); + }, + + onDmDragStart(event: DragEvent, channel?: Channel | null) { + const inspection = inspectDragTarget(event, this); + const resolvedChannel = channel ?? (inspection.channel?.id ? ChannelStore.getChannel(inspection.channel.id) ?? null : null); + if (!resolvedChannel) return; + + if (isGroupMessageChannel(resolvedChannel)) { + this.onChannelDragStart(event, { + id: resolvedChannel.id, + guildId: resolvedChannel.guild_id ?? undefined, + }); + return; + } + + const recipientId = this.getDmRecipientId(resolvedChannel); + if (!recipientId) return; + this.onUserDragStart(event, { id: recipientId }); }, onGuildDragStart(event: DragEvent, guildId: string) { - suppressDefaultDragPreview(event); - this.showGhost({ kind: "guild", id: guildId }, event); - const payload = JSON.stringify({ kind: "guild", id: guildId }); - if (event.dataTransfer) event.dataTransfer.effectAllowed = "copyMove"; - activeGuildDragId = guildId; - activeDragEntity = { kind: "guild", id: guildId }; - dragifyActive = true; - lastDragEventAt = Date.now(); - setDragifyDataTransfer(event.dataTransfer ?? null, payload); + this.beginDrag(event, { kind: "guild", id: guildId }, { effectAllowed: "copyMove" }); }, start() { @@ -692,17 +432,7 @@ export default definePlugin({ window.addEventListener("drag", this.globalDragMove, true); window.addEventListener("dragover", this.globalDragMove, true); window.addEventListener("dragend", this.globalDragEnd, true); - if (dragStateWatchdog === null) { - dragStateWatchdog = window.setInterval(() => { - if (!dragifyActive) return; - if (Date.now() - lastDragEventAt < 1200) return; - activeUserDragId = null; - activeGuildDragId = null; - activeDragEntity = null; - dragifyActive = false; - hideDragGhost(); - }, 500); - } + startDragWatchdog(hideDragGhost); }, stop() { @@ -712,16 +442,8 @@ export default definePlugin({ window.removeEventListener("drag", this.globalDragMove, true); window.removeEventListener("dragover", this.globalDragMove, true); window.removeEventListener("dragend", this.globalDragEnd, true); - clearDragState(); - inviteCache.clear(); - if (guildGhostCleanupTimer !== null) { - clearTimeout(guildGhostCleanupTimer); - guildGhostCleanupTimer = null; - } - if (dragStateWatchdog !== null) { - clearInterval(dragStateWatchdog); - dragStateWatchdog = null; - } + stopDragState(); + clearInviteCache(); this.unmountGhost(); pluginInstance = null; }, @@ -729,35 +451,48 @@ export default definePlugin({ globalDragOver: (event: DragEvent) => { const inst = pluginInstance; if (!inst || !inst.isMessageInputEvent(event)) return; - if (dragSourceIsInput) return; - if (event.dataTransfer?.files?.length) return; - const hasActiveGuildDrag = activeGuildDragId !== null; - const hasActiveEntity = activeDragEntity || activeUserDragId; - const shouldHandle = hasActiveGuildDrag || hasActiveEntity || dragifyActive || hasDragifyTransfer(event.dataTransfer); - if (!shouldHandle) return; + if (isInputDragSource()) return; + const { dataTransfer } = event; + if (!dataTransfer || dataTransfer.files?.length) return; + if (!hasActiveDrag() && !hasDragifyTransfer(dataTransfer)) { + const payloads = extractStrings(dataTransfer); + if (!parseFromStrings(payloads, { ChannelStore, GuildStore, UserStore })) return; + } + event.preventDefault(); event.stopPropagation(); - if (event.dataTransfer) event.dataTransfer.dropEffect = "copy"; + dataTransfer.dropEffect = "copy"; }, globalDrop: async (event: DragEvent) => { const inst = pluginInstance; if (!inst || !inst.isMessageInputEvent(event)) return; - if (dragSourceIsInput) return; - if (event.dataTransfer?.files?.length) { + if (isInputDragSource()) return; + const { dataTransfer } = event; + if (!dataTransfer) return; + + if (dataTransfer.files?.length) { clearDragState(); + hideDragGhost(); return; } + + const hasDragifyPayload = hasDragifyTransfer(dataTransfer); + const hasActiveSessionDrag = hasActiveDrag(); + if (!hasDragifyPayload && !hasActiveSessionDrag) { + const payloads = await collectPayloadStrings(dataTransfer); + if (!parseFromStrings(payloads, { ChannelStore, GuildStore, UserStore })) return; + } + const channelId = SelectedChannelStore.getChannelId(); const channel = ChannelStore.getChannel(channelId); - if (!channel) return; + if (!channel) { + clearDragState(); + hideDragGhost(); + return; + } - const types = event.dataTransfer?.types ?? []; - const hasDragify = types.includes("application/dragify"); - const hasAnyActiveDrag = activeGuildDragId !== null || activeUserDragId !== null || activeDragEntity !== null || dragifyActive; - if (!hasDragify && !hasAnyActiveDrag) return; - - lastDropAt = Date.now(); + markDrop(); event.preventDefault(); event.stopPropagation(); await inst.onDrop(event, channel); @@ -768,342 +503,69 @@ export default definePlugin({ const inst = pluginInstance; if (!inst || !event.dataTransfer) return; - const target = event.target as HTMLElement | null; - if (!target) return; - const hasDragify = event.dataTransfer.types?.includes("application/dragify") ?? false; - const path = event.composedPath?.() ?? []; - if (inst.isAttachmentElement(target)) return; - if (inst.isMessageInputElement(target)) { - clearDragState(); - dragSourceIsInput = true; + const inspection = inspectDragTarget(event, inst); + if (inspection.hasAttachment) return; + if (inspection.hasMessageInput) { + markInputDragSource(); return; } - for (const entry of path) { - const el = entry as Element | null; - if (!el) continue; - if (inst.isAttachmentElement(el)) return; - if (inst.isMessageInputElement(el)) { - clearDragState(); - dragSourceIsInput = true; + if (hasDragifyTransfer(event.dataTransfer)) return; + + const markedUserId = inspection.authorId ?? inspection.userId; + if (inspection.hasDragifyUser && markedUserId) { + inst.onUserDragStart(event, { id: markedUserId }); + return; + } + + if (inspection.hasTextNode && inspection.authorId) { + inst.onUserDragStart(event, { id: inspection.authorId }); + return; + } + + if (inspection.authorId && inspection.avatarUserId) { + inst.onUserDragStart(event, { id: inspection.authorId }); + return; + } + + if (inspection.channel && !inspection.hasUserMarker) { + const channel = ChannelStore.getChannel(inspection.channel.id); + if (channel && (channel.isDM() || isGroupMessageChannel(channel))) { + inst.onDmDragStart(event, channel); return; } - } - if (!hasDragify) { - for (const entry of path) { - const el = entry as HTMLElement | null; - if (!el) continue; - const userTarget = el.closest?.("[data-dragify-user]"); - if (!userTarget) continue; - const userId = inst.extractUserIdFromTarget(userTarget); - if (userId) { - inst.onUserDragStart(event, { id: userId }); - } - return; - } - } - - const authorContainer = target.closest?.("[data-author-id]"); - const authorId = authorContainer?.getAttribute?.("data-author-id") ?? null; - if (authorId && target.getAttribute?.("data-text") && !hasDragify) { - inst.onUserDragStart(event, { id: authorId }); + inst.onChannelDragStart(event, inspection.channel); return; } - if (!hasDragify) { - const dragifyTarget = target.closest?.("[data-dragify-user]"); - if (dragifyTarget) { - const userIdFromTarget = inst.extractUserIdFromEvent(event) ?? inst.extractUserIdFromTarget(dragifyTarget); - if (userIdFromTarget) { - inst.onUserDragStart(event, { id: userIdFromTarget }); - return; - } - } - } - - if (!hasDragify) { - const avatarUserId = inst.extractAvatarUserIdFromTarget(target); - if (avatarUserId) { - const messageItem = target.closest?.("[data-author-id]") as HTMLElement | null; - const authorId = messageItem?.getAttribute("data-author-id") ?? null; - if (authorId) { - inst.onUserDragStart(event, { id: authorId }); - return; - } - const channelFromTarget = inst.extractChannelIdFromTarget(target); - if (channelFromTarget) { - inst.onChannelDragStart(event, channelFromTarget); - return; - } - } - } - - const channelFromTarget = inst.extractChannelIdFromTarget(target); - if (channelFromTarget && !hasDragify) { - if (target.closest?.("[data-user-id]")) return; - inst.onChannelDragStart(event, channelFromTarget); - return; - } - - const guildId = inst.extractGuildIdFromTarget(target); - if (!guildId) return; - - if (!hasDragify) { - event.dataTransfer.effectAllowed = "copyMove"; - suppressDefaultDragPreview(event); - activeGuildDragId = guildId; - setDragifyDataTransfer(event.dataTransfer, JSON.stringify({ kind: "guild", id: guildId })); - inst.showGhost({ kind: "guild", id: guildId }, event); + if (inspection.guildId) { + inst.onGuildDragStart(event, inspection.guildId); } }, + globalDragEnd: (_event: DragEvent) => { setTimeout(() => { - if (Date.now() - lastDropAt < 100) return; + if (Date.now() - getLastDropAt() < 100) return; clearDragState(); hideDragGhost(); }, 0); }, + globalDragMove: (event: DragEvent | MouseEvent) => { if (!isGhostVisible()) return; - if (event?.clientX == null || event?.clientY == null) return; + if (event.clientX == null || event.clientY == null) return; + if (event instanceof DragEvent) { - lastDragEventAt = Date.now(); - if (activeGuildDragId !== null || activeDragEntity?.kind === "guild") { - if (guildGhostCleanupTimer !== null) clearTimeout(guildGhostCleanupTimer); - guildGhostCleanupTimer = window.setTimeout(() => { - if (Date.now() - lastDragEventAt < 200) return; - activeGuildDragId = null; - if (activeDragEntity?.kind === "guild") activeDragEntity = null; - hideDragGhost(); - }, 300); - } + touchDrag(); + if (isGuildDragActive()) scheduleGuildCleanup(hideDragGhost); } + scheduleDragGhostPosition(event.clientX + 16, event.clientY + 20); }, + isMessageInputEvent(event: DragEvent): boolean { - const target = event.target as Element | null; - if (this.isMessageInputElement(target)) return true; - if (settings.store.allowChatBodyDrop && this.isChatBodyElement(target)) return true; - - const path = event.composedPath?.() ?? []; - for (const entry of path) { - const el = entry as Element | null; - if (this.isMessageInputElement(el)) return true; - if (settings.store.allowChatBodyDrop && this.isChatBodyElement(el)) return true; - } - - if (typeof document !== "undefined" && event?.clientX != null && event?.clientY != null) { - const elements = document.elementsFromPoint?.(event.clientX, event.clientY); - if (elements && elements.length) { - for (const el of elements) { - if (this.isMessageInputElement(el)) return true; - if (settings.store.allowChatBodyDrop && this.isChatBodyElement(el)) return true; - } - } else { - const atPoint = document.elementFromPoint(event.clientX, event.clientY); - if (this.isMessageInputElement(atPoint)) return true; - if (settings.store.allowChatBodyDrop && this.isChatBodyElement(atPoint)) return true; - } - } - - return false; - }, - - isMessageInputElement(el: Element | null): boolean { - if (!el) return false; - const selector = "[data-slate-editor],[role=\"textbox\"],[contenteditable=\"true\"],[aria-label^=\"Message \"]"; - return Boolean((el as HTMLElement).closest?.(selector)); - }, - - isChatBodyElement(el: Element | null): boolean { - if (!el) return false; - const selector = "[role=\"log\"],[data-list-id^=\"chat-messages\"]"; - return Boolean((el as HTMLElement).closest?.(selector)); - }, - - isAttachmentElement(target: Element | null): boolean { - let el: Element | null = target; - while (el) { - if (!(el instanceof HTMLElement)) { - el = el.parentElement ?? null; - continue; - } - const href = el.getAttribute("href") ?? ""; - const src = el.getAttribute("src") ?? ""; - const style = el.style?.backgroundImage ?? ""; - const dataAttachment = - el.getAttribute("data-attachment-id") - ?? el.getAttribute("data-attachment-type") - ?? el.getAttribute("data-attachment-item-id") - ?? ""; - - if (dataAttachment) return true; - - const combined = `${href} ${src} ${style}`; - if (/(?:cdn|media)\.discordapp\.(?:com|net)\/attachments\//i.test(combined)) return true; - if (/discord\.com\/attachments\//i.test(combined)) return true; - if (/cdn\.discordapp\.com\/ephemeral-attachments\//i.test(combined)) return true; - - el = el.parentElement; - } - return false; - }, - - extractGuildIdFromTarget(target: HTMLElement): string | null { - let el: HTMLElement | null = target; - while (el) { - const listId = el.getAttribute("data-list-id"); - const rawId = el.getAttribute("data-list-item-id") ?? ""; - const isGuildContext = (listId && /guild/i.test(listId)) || /guild/i.test(rawId); - - if (!isGuildContext) { - el = el.parentElement; - continue; - } - - const parts = rawId.split("___"); - const candidate = parts[parts.length - 1] ?? rawId; - if (/^\d{17,20}$/.test(candidate)) return candidate; - - const direct = - extractSnowflakeFromString(rawId) ?? - extractSnowflakeFromString(listId ?? "") ?? - extractSnowflakeFromString(el.getAttribute("data-guild-id") ?? ""); - if (direct) return direct; - - el = el.parentElement; - } - return null; - }, - - extractChannelIdFromTarget(target: HTMLElement | null): { id: string; guildId?: string; } | null { - let el = target; - while (el) { - const listId = el.getAttribute("data-list-id") ?? ""; - const rawId = el.getAttribute("data-list-item-id") ?? ""; - const channelIdAttr = el.getAttribute("data-channel-id") ?? el.getAttribute("data-item-id") ?? ""; - const threadIdAttr = el.getAttribute("data-thread-id") ?? ""; - const href = el.getAttribute("href") ?? ""; - const isChannelContext = /(channel|thread|private|forum)/i.test(listId) || /(channel|thread|private|forum)/i.test(rawId); - - const pathMatch = extractChannelPath(href); - if (pathMatch?.channelId) { - const guildId = pathMatch.guildId === "@me" ? undefined : pathMatch.guildId; - return { id: pathMatch.channelId, guildId }; - } - - const fullMatch = extractChannelFromUrl(href); - if (fullMatch?.channelId) { - const guildId = fullMatch.guildId === "@me" ? undefined : fullMatch.guildId; - return { id: fullMatch.channelId, guildId }; - } - - const candidate = isChannelContext - ? (extractSnowflakeFromString(threadIdAttr) - ?? extractSnowflakeFromString(channelIdAttr) - ?? extractSnowflakeFromString(rawId) - ?? extractSnowflakeFromString(listId)) - : null; - if (candidate) { - const guildId = extractSnowflakeFromString(el.getAttribute("data-guild-id") ?? "") ?? undefined; - return { id: candidate, guildId }; - } - - el = el.parentElement; - } - return null; - }, - - extractUserIdFromTarget(target: Element | null): string | null { - let el: Element | null = target; - while (el) { - if (!(el instanceof HTMLElement)) { - el = el.parentElement ?? null; - continue; - } - const dataUserId = el.getAttribute("data-user-id") ?? el.getAttribute("data-userid") ?? ""; - const dataAuthorId = el.getAttribute("data-author-id") ?? ""; - const listId = el.getAttribute("data-list-item-id") ?? el.getAttribute("data-item-id") ?? ""; - const href = el.getAttribute("href") ?? ""; - const src = el.getAttribute("src") ?? ""; - const aria = el.getAttribute("aria-label") ?? ""; - - const explicit = extractSnowflakeFromString(dataUserId) - ?? extractSnowflakeFromString(dataAuthorId); - if (explicit) return explicit; - - const listCandidate = extractSnowflakeFromString(listId); - if (listCandidate) { - if (UserStore.getUser(listCandidate)) return listCandidate; - const channel = ChannelStore.getChannel(listCandidate); - if (channel && channel.isDM() && !channel.isGroupDM() && !channel.isMultiUserDM()) { - const recipientId = this.getDmRecipientId(channel); - if (recipientId) return recipientId; - } - } - - const profile = extractUserFromProfile(href); - if (profile) return profile; - - const avatar = extractUserFromAvatar(src); - if (avatar) return avatar; - - const styleAttr = el.getAttribute("style") ?? ""; - const bgImage = el.style?.backgroundImage ?? ""; - const styleAvatar = extractUserFromAvatar(styleAttr + " " + bgImage); - if (styleAvatar) return styleAvatar; - - const ariaMatch = extractSnowflakeFromString(aria); - if (ariaMatch && UserStore.getUser(ariaMatch)) return ariaMatch; - - el = el.parentElement; - } - return null; - }, - - extractAvatarUserIdFromTarget(target: HTMLElement | null): string | null { - let el: Element | null = target; - while (el) { - if (!(el instanceof HTMLElement)) { - el = el.parentElement ?? null; - continue; - } - const src = el.getAttribute("src") ?? ""; - const styleAttr = el.getAttribute("style") ?? ""; - const bgImage = el.style?.backgroundImage ?? ""; - const candidate = extractUserFromAvatar(`${src} ${styleAttr} ${bgImage}`); - if (candidate) return candidate; - el = el.parentElement; - } - return null; - }, - - extractUserIdFromEvent(event: DragEvent): string | null { - const path = event.composedPath?.() ?? []; - for (const entry of path) { - const candidate = entry instanceof HTMLElement ? this.extractUserIdFromTarget(entry) : null; - if (candidate) return candidate; - } - - const target = event.target as HTMLElement | null; - if (target) { - const direct = target.closest?.("[data-author-id],[data-user-id]") as HTMLElement | null; - if (direct) { - return this.extractUserIdFromTarget(direct); - } - } - - if (typeof document !== "undefined" && event?.clientX != null && event?.clientY != null) { - const elements = document.elementsFromPoint?.(event.clientX, event.clientY); - if (elements && elements.length) { - for (const el of elements) { - const candidate = el instanceof HTMLElement ? this.extractUserIdFromTarget(el) : null; - if (candidate) return candidate; - } - } - } - - return null; + const inspection = inspectDragTarget(event, this); + return inspection.hasMessageInput || (settings.store.allowChatBodyDrop && inspection.hasChatBody); }, mountGhost() { @@ -1117,7 +579,8 @@ export default definePlugin({ showGhost(entity: DropEntity, event?: DragEvent) { const ghost = this.buildGhost(entity); if (!ghost) return; - const position = event && event?.clientX != null && event?.clientY != null + + const position = event?.clientX != null && event.clientY != null ? { x: event.clientX + 16, y: event.clientY + 20 } : undefined; showDragGhost({ ...ghost, entityId: entity.id }, position); @@ -1127,6 +590,7 @@ export default definePlugin({ if (entity.kind === "user") { const user = UserStore.getUser(entity.id); if (!user) return null; + const title = user.globalName ?? user.username; const subtitle = user.username ? `@${user.username}` : "User"; const iconUrl = user.getAvatarURL(void 0, 80, true) ?? undefined; @@ -1136,13 +600,15 @@ export default definePlugin({ if (entity.kind === "channel") { const channel = ChannelStore.getChannel(entity.id); if (!channel) return null; + const isThread = channel.isThread(); const isGroupDm = channel.type === ChannelType.GROUP_DM; const channelName = this.getGroupDmDisplayName(channel) || channel.name; const title = `${isThread || isGroupDm ? "" : "#"}${channelName}`; - const guild = channel?.guild_id ? GuildStore.getGuild(channel.guild_id) : null; + const guild = channel.guild_id ? GuildStore.getGuild(channel.guild_id) : null; const subtitle = guild?.name ?? (entity.guildId ? "Server" : "Direct Messages"); let iconUrl: string | undefined; + if (guild?.icon) { iconUrl = IconUtils.getGuildIconURL({ id: guild.id, icon: guild.icon, size: 64 }) ?? undefined; } else if (channel.type === ChannelType.GROUP_DM) { @@ -1153,11 +619,13 @@ export default definePlugin({ }); iconUrl = channelIcon ?? undefined; } + if (!iconUrl && (channel.isDM() || channel.type === ChannelType.GROUP_DM)) { const recipientId = this.getDmRecipientId(channel); const recipient = recipientId ? UserStore.getUser(recipientId) : null; iconUrl = recipient ? recipient.getAvatarURL(void 0, 80, true) : undefined; } + let badge = "channel"; if (isThread) { badge = "thread"; @@ -1184,6 +652,7 @@ export default definePlugin({ badge = "channel"; } } + return { kind: "channel", title, subtitle, iconUrl, badge, entityId: entity.id, exiting: false }; } @@ -1207,9 +676,11 @@ export default definePlugin({ getGroupDmDisplayName(channel?: Channel | null): string | null { if (!channel || channel.type !== ChannelType.GROUP_DM) return null; + const selfId = UserStore.getCurrentUser()?.id ?? null; const recipients = (channel.recipients ?? channel.rawRecipients ?? []).filter(Boolean); if (recipients.length === 0) return null; + const names = recipients .filter(id => id !== selfId) .map(id => { diff --git a/src/equicordplugins/dragify/invite.ts b/src/equicordplugins/dragify/invite.ts new file mode 100644 index 000000000..66bfce9ac --- /dev/null +++ b/src/equicordplugins/dragify/invite.ts @@ -0,0 +1,189 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { Logger } from "@utils/Logger"; +import type { Channel } from "@vencord/discord-types"; +import { ChannelType } from "@vencord/discord-types/enums"; +import { ChannelStore, GuildChannelStore, GuildStore, PermissionsBits, PermissionStore, RestAPI, showToast, Toasts } from "@webpack/common"; + +const logger = new Logger("Dragify"); + +export type InviteSettings = { + inviteExpireAfter?: number; + inviteMaxUses?: number; + inviteTemporaryMembership: boolean; + reuseExistingInvites: boolean; +}; + +export type InviteCacheEntry = { + code: string; + expiresAt: number | null; + maxUses: number | null; + uses: number | null; +}; + +const inviteCache = new Map(); + +export function clearInviteCache() { + inviteCache.clear(); +} + +function normalizeInviteCacheEntry(invite: { + code: string; + expires_at?: string | null; + max_uses?: number | null; + uses?: number | null; +}): InviteCacheEntry { + return { + code: invite.code, + expiresAt: invite.expires_at ? Date.parse(invite.expires_at) : null, + maxUses: invite.max_uses === 0 ? null : invite.max_uses ?? null, + uses: invite.uses ?? null, + }; +} + +function isInviteExpired(invite: InviteCacheEntry) { + const now = Date.now(); + const expired = invite.expiresAt !== null && invite.expiresAt <= now; + const exhausted = invite.maxUses !== null && invite.uses !== null && invite.uses >= invite.maxUses; + return expired || exhausted; +} + +function canCreateInvite(channel?: Channel | null): channel is Channel { + if (!channel || !channel.guild_id) return false; + if (channel.isDM() || channel.isGroupDM() || channel.isMultiUserDM()) return false; + if (channel.isCategory()) return false; + if (channel.isThread()) return false; + return PermissionStore.can(PermissionsBits.CREATE_INSTANT_INVITE, channel); +} + +function appendInviteCandidate(candidates: Map, channel?: Channel | null) { + if (!channel || !channel.guild_id || candidates.has(channel.id)) return; + candidates.set(channel.id, channel); +} + +function resolveInviteChannel(guildId: string, currentChannel: Channel): Channel | null { + if (currentChannel.guild_id === guildId && canCreateInvite(currentChannel)) return currentChannel; + + const guild = GuildStore.getGuild(guildId); + const preferredIds = [ + guild?.systemChannelId, + guild?.rulesChannelId, + guild?.publicUpdatesChannelId, + ].filter(Boolean) as string[]; + + for (const id of preferredIds) { + const channel = ChannelStore.getChannel(id); + if (canCreateInvite(channel)) return channel; + } + + const candidatesById = new Map(); + for (const entry of GuildChannelStore.getSelectableChannels?.(guildId) ?? []) { + appendInviteCandidate(candidatesById, entry?.channel); + } + + for (const entry of Object.values(GuildChannelStore.getChannels(guildId)?.SELECTABLE ?? {})) { + appendInviteCandidate(candidatesById, entry && "channel" in entry ? entry.channel : entry); + } + + return [...candidatesById.values()] + .filter(channel => channel.guild_id === guildId) + .sort((a, b) => { + const pa = typeof a.position === "number" ? a.position : 0; + const pb = typeof b.position === "number" ? b.position : 0; + if (pa === pb) return a.id.localeCompare(b.id); + return pa - pb; + }) + .find(canCreateInvite) ?? null; +} + +async function fetchReusableInvite(guildId: string, inviteChannelId: string) { + const cached = inviteCache.get(guildId); + if (cached && cached.maxUses === null && !isInviteExpired(cached)) return { ok: true as const, code: cached.code }; + + try { + const { body } = await RestAPI.get({ url: `/channels/${inviteChannelId}/invites` }); + if (!Array.isArray(body)) return { ok: false as const, reason: "failed" as const }; + + const now = Date.now(); + const invite = body.find(inv => { + const expiresAt = inv.expires_at ? Date.parse(inv.expires_at) : null; + const maxUses = inv.max_uses === 0 ? null : inv.max_uses ?? null; + const uses = inv.uses ?? null; + const notExpired = expiresAt === null || expiresAt > now; + const usesLeft = maxUses === null || uses === null || uses < maxUses; + return notExpired && usesLeft && typeof inv.code === "string"; + }); + + if (!invite?.code) return { ok: false as const, reason: "missing" as const }; + + inviteCache.set(guildId, normalizeInviteCacheEntry(invite)); + return { ok: true as const, code: invite.code }; + } catch (error) { + logger.error("Failed to reuse invite", error); + return { ok: false as const, reason: "failed" as const }; + } +} + +function getInviteUrl(code: string) { + return `https://discord.gg/${code}`; +} + +export async function createInvite(guildId: string, currentChannel: Channel, settings: InviteSettings): Promise { + const inviteChannel = resolveInviteChannel(guildId, currentChannel); + if (!inviteChannel) { + showToast("No channel available for invites.", Toasts.Type.FAILURE); + return null; + } + + if (settings.reuseExistingInvites) { + const reused = await fetchReusableInvite(guildId, inviteChannel.id); + if (reused.ok) return getInviteUrl(reused.code); + + showToast( + reused.reason === "missing" + ? "No reusable invite available." + : "Unable to reuse invite.", + Toasts.Type.FAILURE, + ); + return null; + } + + try { + const maxAge = settings.inviteExpireAfter ?? 0; + const maxUses = settings.inviteMaxUses ?? 0; + const { body } = await RestAPI.post({ + url: `/channels/${inviteChannel.id}/invites`, + body: { + max_age: maxAge, + max_uses: maxUses, + temporary: settings.inviteTemporaryMembership, + unique: true, + }, + }); + + const code = typeof body === "object" && body ? (body as { code?: string; }).code : null; + if (!code) throw new Error("Invite response missing code"); + + inviteCache.set(guildId, { + code, + expiresAt: maxAge > 0 ? Date.now() + maxAge * 1000 : null, + maxUses: maxUses === 0 ? null : maxUses, + uses: 0, + }); + showToast("Invite created.", Toasts.Type.SUCCESS); + return getInviteUrl(code); + } catch (error) { + logger.error("Failed to create invite", error); + showToast("Unable to create invite.", Toasts.Type.FAILURE); + return null; + } +} + +export function isGroupMessageChannel(channel?: Channel | null) { + if (!channel) return false; + return channel.type === ChannelType.GROUP_DM || channel.isGroupDM() || channel.isMultiUserDM(); +} diff --git a/src/equicordplugins/dragify/targets.ts b/src/equicordplugins/dragify/targets.ts new file mode 100644 index 000000000..c44596b56 --- /dev/null +++ b/src/equicordplugins/dragify/targets.ts @@ -0,0 +1,224 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import type { Channel, User } from "@vencord/discord-types"; + +import { extractChannelFromUrl, extractChannelPath, extractSnowflakeFromString, extractUserFromAvatar, extractUserFromProfile } from "./utils"; + +export type ChannelTarget = { id: string; guildId?: string; }; + +export type ResolvedDragTarget = { + hasAttachment: boolean; + hasMessageInput: boolean; + hasChatBody: boolean; + hasDragifyUser: boolean; + hasUserMarker: boolean; + hasTextNode: boolean; + userId: string | null; + avatarUserId: string | null; + authorId: string | null; + channel: ChannelTarget | null; + guildId: string | null; +}; + +type InspectionContext = { + ChannelStore: { getChannel(id: string): Channel | null | undefined; }; + UserStore: { getUser(id: string): User | null | undefined; }; + getDmRecipientId(channel?: Channel | null): string | null; +}; + +const messageInputSelector = "[data-slate-editor],[role=\"textbox\"],[contenteditable=\"true\"],[aria-label^=\"Message \"]"; +const chatBodySelector = "[role=\"log\"],[data-list-id^=\"chat-messages\"]"; + +function collectEventElements(event: DragEvent): HTMLElement[] { + const seen = new Set(); + const elements: HTMLElement[] = []; + + const add = (value: unknown) => { + if (!(value instanceof HTMLElement) || seen.has(value)) return; + seen.add(value); + elements.push(value); + }; + + add(event.target); + for (const entry of event.composedPath?.() ?? []) add(entry); + + if (typeof document !== "undefined" && event.clientX != null && event.clientY != null) { + for (const element of document.elementsFromPoint?.(event.clientX, event.clientY) ?? []) add(element); + } + + return elements; +} + +function collectAncestors(elements: HTMLElement[]): HTMLElement[] { + const seen = new Set(); + const ancestors: HTMLElement[] = []; + + for (const element of elements) { + let current: HTMLElement | null = element; + while (current) { + if (!seen.has(current)) { + seen.add(current); + ancestors.push(current); + } + current = current.parentElement; + } + } + + return ancestors; +} + +function resolveChannelTarget(element: HTMLElement): ChannelTarget | null { + const listId = element.getAttribute("data-list-id") ?? ""; + const rawId = element.getAttribute("data-list-item-id") ?? ""; + const channelIdAttr = element.getAttribute("data-channel-id") ?? element.getAttribute("data-item-id") ?? ""; + const threadIdAttr = element.getAttribute("data-thread-id") ?? ""; + const href = element.getAttribute("href") ?? ""; + const isChannelContext = /(channel|thread|private|forum)/i.test(listId) || /(channel|thread|private|forum)/i.test(rawId); + + const pathMatch = extractChannelPath(href); + if (pathMatch?.channelId) { + return { + id: pathMatch.channelId, + guildId: pathMatch.guildId === "@me" ? undefined : pathMatch.guildId, + }; + } + + const fullMatch = extractChannelFromUrl(href); + if (fullMatch?.channelId) { + return { + id: fullMatch.channelId, + guildId: fullMatch.guildId === "@me" ? undefined : fullMatch.guildId, + }; + } + + const candidate = isChannelContext + ? (extractSnowflakeFromString(threadIdAttr) + ?? extractSnowflakeFromString(channelIdAttr) + ?? extractSnowflakeFromString(rawId) + ?? extractSnowflakeFromString(listId)) + : null; + if (!candidate) return null; + + return { + id: candidate, + guildId: extractSnowflakeFromString(element.getAttribute("data-guild-id") ?? "") ?? undefined, + }; +} + +function resolveGuildId(element: HTMLElement): string | null { + const listId = element.getAttribute("data-list-id"); + const rawId = element.getAttribute("data-list-item-id") ?? ""; + const isGuildContext = (listId && /guild/i.test(listId)) || /guild/i.test(rawId); + if (!isGuildContext) return null; + + const parts = rawId.split("___"); + const candidate = parts[parts.length - 1] ?? rawId; + if (/^\d{17,20}$/.test(candidate)) return candidate; + + return extractSnowflakeFromString(rawId) + ?? extractSnowflakeFromString(listId ?? "") + ?? extractSnowflakeFromString(element.getAttribute("data-guild-id") ?? ""); +} + +function resolveUserId(element: HTMLElement, context: InspectionContext): string | null { + const dataUserId = element.getAttribute("data-user-id") ?? element.getAttribute("data-userid") ?? ""; + const dataAuthorId = element.getAttribute("data-author-id") ?? ""; + const listId = element.getAttribute("data-list-item-id") ?? element.getAttribute("data-item-id") ?? ""; + const href = element.getAttribute("href") ?? ""; + const src = element.getAttribute("src") ?? ""; + const aria = element.getAttribute("aria-label") ?? ""; + + const explicit = extractSnowflakeFromString(dataUserId) ?? extractSnowflakeFromString(dataAuthorId); + if (explicit) return explicit; + + const listCandidate = extractSnowflakeFromString(listId); + if (listCandidate) { + if (context.UserStore.getUser(listCandidate)) return listCandidate; + + const channel = context.ChannelStore.getChannel(listCandidate); + if (channel && channel.isDM() && !channel.isGroupDM() && !channel.isMultiUserDM()) { + const recipientId = context.getDmRecipientId(channel); + if (recipientId) return recipientId; + } + } + + const profile = extractUserFromProfile(href); + if (profile) return profile; + + const avatar = extractUserFromAvatar(src); + if (avatar) return avatar; + + const styleAttr = element.getAttribute("style") ?? ""; + const bgImage = element.style?.backgroundImage ?? ""; + const styleAvatar = extractUserFromAvatar(`${styleAttr} ${bgImage}`); + if (styleAvatar) return styleAvatar; + + const ariaMatch = extractSnowflakeFromString(aria); + if (ariaMatch && context.UserStore.getUser(ariaMatch)) return ariaMatch; + + return null; +} + +function resolveAvatarUserId(element: HTMLElement) { + const src = element.getAttribute("src") ?? ""; + const styleAttr = element.getAttribute("style") ?? ""; + const bgImage = element.style?.backgroundImage ?? ""; + return extractUserFromAvatar(`${src} ${styleAttr} ${bgImage}`); +} + +export function inspectDragEvent(event: DragEvent, context: InspectionContext): ResolvedDragTarget { + return inspectElements(collectEventElements(event), context); +} + +export function inspectElement(target: HTMLElement | null, context: InspectionContext): ResolvedDragTarget { + return inspectElements(target ? [target] : [], context); +} + +function inspectElements(elements: HTMLElement[], context: InspectionContext): ResolvedDragTarget { + const resolved: ResolvedDragTarget = { + hasAttachment: false, + hasMessageInput: false, + hasChatBody: false, + hasDragifyUser: false, + hasUserMarker: false, + hasTextNode: elements.some(element => element.getAttribute("data-text") != null), + userId: null, + avatarUserId: null, + authorId: null, + channel: null, + guildId: null, + }; + + for (const element of collectAncestors(elements)) { + resolved.hasMessageInput ||= element.matches(messageInputSelector); + resolved.hasChatBody ||= element.matches(chatBodySelector); + resolved.hasDragifyUser ||= element.getAttribute("data-dragify-user") != null; + resolved.hasUserMarker ||= element.getAttribute("data-user-id") != null || element.getAttribute("data-userid") != null; + resolved.authorId ??= extractSnowflakeFromString(element.getAttribute("data-author-id") ?? ""); + + const href = element.getAttribute("href") ?? ""; + const src = element.getAttribute("src") ?? ""; + const style = element.style?.backgroundImage ?? ""; + const dataAttachment = + element.getAttribute("data-attachment-id") + ?? element.getAttribute("data-attachment-type") + ?? element.getAttribute("data-attachment-item-id") + ?? ""; + const combined = `${href} ${src} ${style}`; + resolved.hasAttachment ||= Boolean(dataAttachment) + || /(?:cdn|media)\.discordapp\.(?:com|net)\/attachments\//i.test(combined) + || /discord\.com\/attachments\//i.test(combined) + || /cdn\.discordapp\.com\/ephemeral-attachments\//i.test(combined); + + resolved.userId ??= resolveUserId(element, context); + resolved.avatarUserId ??= resolveAvatarUserId(element); + resolved.channel ??= resolveChannelTarget(element); + resolved.guildId ??= resolveGuildId(element); + } + + return resolved; +} diff --git a/src/equicordplugins/dragify/utils.ts b/src/equicordplugins/dragify/utils.ts index 131f0db28..4c26ac714 100644 --- a/src/equicordplugins/dragify/utils.ts +++ b/src/equicordplugins/dragify/utils.ts @@ -20,7 +20,7 @@ export type DropEntity = | { kind: "channel"; id: string; guildId?: string; } | { kind: "guild"; id: string; }; -interface DragifyPayload { +export interface DragifyPayload { id?: string; userId?: string; channelId?: string; @@ -92,55 +92,98 @@ export async function collectPayloadStrings(dataTransfer: DataTransfer): Promise return Array.from(new Set([...sync, ...asyncValues])); } +export function serializeDragEntity(entity: DropEntity) { + return JSON.stringify(entity); +} + +export function parseDragifyPayload(value: string): DropEntity | null { + const parsed = tryParseJson(value); + if (!parsed?.kind || !parsed.id) return null; + + switch (parsed.kind) { + case "user": + return { kind: "user", id: parsed.id }; + case "channel": + return { kind: "channel", id: parsed.id, guildId: parsed.guildId }; + case "guild": + return { kind: "guild", id: parsed.id }; + default: + return null; + } +} + +function parseJsonPayload(payload: DragifyPayload): DropEntity | null { + const id = payload.id ?? payload.userId ?? payload.channelId ?? payload.guildId; + if (!id) return null; + + const type = (payload.type ?? payload.kind ?? payload.itemType ?? "").toLowerCase(); + if (type.includes("user")) return { kind: "user", id }; + if (type.includes("channel")) return { kind: "channel", id, guildId: payload.guildId }; + if (type.includes("guild") || type.includes("server")) return { kind: "guild", id }; + return null; +} + +function parseUserString(value: string): DropEntity | null { + const userFromMention = userMentionRegex.exec(value); + if (userFromMention) return { kind: "user", id: userFromMention[1] }; + + const userFromProfile = userProfileUrlRegex.exec(value); + if (userFromProfile) return { kind: "user", id: userFromProfile[1] }; + + const guildUserAvatar = guildUserAvatarRegex.exec(value); + if (guildUserAvatar) return { kind: "user", id: guildUserAvatar[1] }; + + const userFromAvatar = userAvatarRegex.exec(value); + return userFromAvatar ? { kind: "user", id: userFromAvatar[1] } : null; +} + +function parseChannelString(value: string): DropEntity | null { + const channelFromMention = channelMentionRegex.exec(value); + if (channelFromMention) return { kind: "channel", id: channelFromMention[1] }; + + const channelFromUrl = channelUrlRegex.exec(value); + if (!channelFromUrl) return null; + + const guildId = channelFromUrl[1] === "@me" ? "@me" : channelFromUrl[2]; + return { kind: "channel", id: channelFromUrl[3], guildId: guildId ?? undefined }; +} + +function parseGuildString(value: string): DropEntity | null { + const guildFromIcon = guildIconRegex.exec(value); + return guildFromIcon ? { kind: "guild", id: guildFromIcon[1] } : null; +} + export function parseFromStrings(payloads: string[], stores: StoreSet): DropEntity | null { if (payloads.length === 0) return null; - for (const value of payloads) { + const values = payloads + .map(value => value.trim()) + .filter(Boolean); + + for (const value of values) { const parsed = tryParseJson(value); - if (parsed) { - const id = parsed.id ?? parsed.userId ?? parsed.channelId ?? parsed.guildId; - const type = (parsed.type ?? parsed.kind ?? parsed.itemType ?? "").toLowerCase(); - if (id) { - if (type.includes("user")) return { kind: "user", id }; - if (type.includes("channel")) return { kind: "channel", id, guildId: parsed.guildId }; - if (type.includes("guild") || type.includes("server")) return { kind: "guild", id }; - } - } + if (!parsed) continue; + + const jsonEntity = parseJsonPayload(parsed); + if (jsonEntity) return jsonEntity; } - for (const value of payloads) { - const trimmed = value.trim(); - if (!trimmed) continue; - const userFromMention = userMentionRegex.exec(trimmed); - if (userFromMention) return { kind: "user", id: userFromMention[1] }; - const userFromProfile = userProfileUrlRegex.exec(trimmed); - if (userFromProfile) return { kind: "user", id: userFromProfile[1] }; - const guildUserAvatar = guildUserAvatarRegex.exec(trimmed); - if (guildUserAvatar) return { kind: "user", id: guildUserAvatar[1] }; - const userFromAvatar = userAvatarRegex.exec(trimmed); - if (userFromAvatar) return { kind: "user", id: userFromAvatar[1] }; + for (const value of values) { + const userEntity = parseUserString(value); + if (userEntity) return userEntity; } - for (const value of payloads) { - const trimmed = value.trim(); - if (!trimmed) continue; - const channelFromMention = channelMentionRegex.exec(trimmed); - if (channelFromMention) return { kind: "channel", id: channelFromMention[1] }; - const channelFromUrl = channelUrlRegex.exec(trimmed); - if (channelFromUrl) { - const guildId = channelFromUrl[1] === "@me" ? "@me" : channelFromUrl[2]; - return { kind: "channel", id: channelFromUrl[3], guildId: guildId ?? undefined }; - } + for (const value of values) { + const channelEntity = parseChannelString(value); + if (channelEntity) return channelEntity; } - for (const value of payloads) { - const trimmed = value.trim(); - if (!trimmed) continue; - const guildFromIcon = guildIconRegex.exec(trimmed); - if (guildFromIcon) return { kind: "guild", id: guildFromIcon[1] }; + for (const value of values) { + const guildEntity = parseGuildString(value); + if (guildEntity) return guildEntity; } - const candidates = extractSnowflakes(payloads); + const candidates = extractSnowflakes(values); for (const candidate of candidates) { if (stores.ChannelStore.getChannel(candidate)) return { kind: "channel", id: candidate }; if (stores.GuildStore.getGuild(candidate)) return { kind: "guild", id: candidate }; diff --git a/src/equicordplugins/equibopStreamFixes.equibop/index.tsx b/src/equicordplugins/equibopStreamFixes.equibop/index.tsx new file mode 100644 index 000000000..fb9a99b3f --- /dev/null +++ b/src/equicordplugins/equibopStreamFixes.equibop/index.tsx @@ -0,0 +1,284 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { isPluginEnabled } from "@api/PluginManager"; +import { definePluginSettings } from "@api/Settings"; +import fakeNitro from "@plugins/fakeNitro"; +import { EquicordDevs } from "@utils/constants"; +import { localStorage } from "@utils/localStorage"; +import definePlugin, { OptionType } from "@utils/types"; + +interface StreamQualityOpts { + bitrateMin?: number; + bitrateMax?: number; + bitrateTarget?: number; + capture?: { + width?: number; + height?: number; + framerate?: number; + pixelCount?: number; + }; + encode?: { + width?: number; + height?: number; + framerate?: number; + pixelCount?: number; + }; +} + +const settings = definePluginSettings({ + unlockQualityOptions: { + type: OptionType.BOOLEAN, + description: "Unlock stream quality options regardless of Nitro status", + default: true, + restartNeeded: true, + }, + removeResolutionCap: { + type: OptionType.BOOLEAN, + description: "Allow resolutions above 720p at 60fps", + default: true, + restartNeeded: true, + }, + forceEncoderSettings: { + type: OptionType.BOOLEAN, + description: "Force encoder to use configured resolution/fps", + default: true, + restartNeeded: true, + }, + preventDownscale: { + type: OptionType.BOOLEAN, + description: "Prevent Discord from downscaling stream resolution", + default: true, + restartNeeded: true, + }, + keyframeInterval: { + type: OptionType.NUMBER, + description: "Keyframe interval in ms (0 = encoder default, 5000 = every 5s)", + default: 5000, + restartNeeded: true, + }, + minBitrate: { + type: OptionType.NUMBER, + description: "Minimum encoder bitrate in kbps", + default: 500, + restartNeeded: true, + }, + raiseBitrateCaps: { + type: OptionType.BOOLEAN, + description: "Raise default desktop bitrate caps (600kbps target → 10Mbps, 3.5Mbps max → 40Mbps)", + default: true, + restartNeeded: true, + }, + preventFramerateReduction: { + type: OptionType.BOOLEAN, + description: "Prevent Discord from reducing stream framerate when not speaking", + default: true, + restartNeeded: true, + }, + bitsPerPixelPct: { + type: OptionType.NUMBER, + description: "Bits per pixel percentage for target bitrate (8 = 0.08 bpp, 12 = 0.12bpp)", + default: 8, + restartNeeded: false, + }, +}); + +export default definePlugin({ + name: "EquibopStreamFixes", + description: "Tries to fix stream quality on Equibop by patching Discord's encoder and quality restrictions.", + authors: [EquicordDevs.creations], + settings, + + patches: [ + { + find: "this.getDefaultGoliveQuality()", + replacement: [ + // override default stream quality with configured bitrate/resolution/fps + { + match: /(this\.goliveMaxQuality)=(this\.getDefaultGoliveQuality\(\))/, + replace: "$1=$self.patchStreamQuality($2)", + }, + // inject configured quality when stream settings change mid-stream + { + match: /setGoliveQuality\((\i)\)\{/, + replace: "setGoliveQuality($1){$1=$self.patchGoliveArgs($1);", + }, + // override encoder min/max bitrate limits + { + match: + /(\i)\.encodingVideoMinBitRate=\i\.bitrateMin,\i\.encodingVideoMaxBitRate=\i\.bitrateMax/, + replace: "$1.encodingVideoMinBitRate=$self.getMinBitrate(),$1.encodingVideoMaxBitRate=$self.getBitrateMax()", + }, + ], + }, + // unlock stream quality options for all users regardless of nitro + { + find: "canUseCustomStickersEverywhere:", + replacement: [ + { + match: /canStreamQuality:\i,/, + replace: "canStreamQuality:()=>true,", + }, + { + match: /canUseHighVideoUploadQuality:\i,/, + replace: "canUseHighVideoUploadQuality:()=>true,", + }, + ], + predicate: () => settings.store.unlockQualityOptions && !isPluginEnabled(fakeNitro.name), + noWarn: true, + }, + // remove guild premium tier restriction from stream fps options + { + find: "#{intl::STREAM_FPS_OPTION}", + replacement: { + match: /guildPremiumTier:\i\.\i\.TIER_\d,?/g, + replace: "", + }, + predicate: () => settings.store.unlockQualityOptions && !isPluginEnabled(fakeNitro.name), + noWarn: true, + }, + // allow resolutions above 720p at 60fps + { + find: ".RESOLUTION_720&&", + replacement: { + match: /\i===\i\.\i\.RESOLUTION_720&&\i!==\i\.\i\.FPS_60/, + replace: "false", + }, + predicate: () => settings.store.removeResolutionCap, + }, + // force encoder width/height/fps to configured values + { + find: "}setDesktopEncodingOptions(", + replacement: [ + { + match: /setDesktopEncodingOptions\((\i),(\i),(\i)\)\{/, + replace: "$&$1=$self.getWidth();$2=$self.getHeight();$3=$self.getFps();", + predicate: () => settings.store.forceEncoderSettings, + }, + // set keyframe interval for periodic quality refresh + { + match: /keyframeInterval=0/, + replace: "keyframeInterval=$self.getKeyframeInterval()", + }, + ], + }, + // raise default desktop bitrate caps at the source + { + find: "desktopBitrate:{", + replacement: { + match: /desktopBitrate:\{min:5e5,max:35e5,target:6e5\}/, + replace: "desktopBitrate:{min:5e5,max:4e7,target:1e7}", + }, + predicate: () => settings.store.raiseBitrateCaps, + }, + // prevent framerate reduction when not speaking + { + find: "Reduced framerate after", + replacement: { + match: /this\.framerateReductionTimeout=setTimeout/, + replace: "this.framerateReductionTimeout=void 0&&setTimeout", + }, + predicate: () => settings.store.preventFramerateReduction, + }, + ], + + getStreamConfig() { + let quality: { frameRate?: string; resolution?: string; } | undefined; + try { + const state = JSON.parse(localStorage.getItem("EquibopState") ?? "{}"); + quality = state.screenshareQuality; + } catch { } + const framerate = Number(quality?.frameRate ?? 30); + const height = Number(quality?.resolution ?? 720); + const width = Math.round(height * (16 / 9)); + const pixelCount = width * height; + + const pixelRate = pixelCount * framerate; + const bpp = (settings.store.bitsPerPixelPct ?? 8) / 100; + const bitrateTarget = Math.min(20000000, Math.max(1500000, Math.round(pixelRate * bpp)),); + const bitrateMax = Math.min(40000000, Math.max(bitrateTarget * 2, 8000000)); + + return { framerate, height, width, pixelCount, bitrateTarget, bitrateMax }; + }, + + getWidth() { + return this.getStreamConfig().width; + }, + getHeight() { + return this.getStreamConfig().height; + }, + getFps() { + return this.getStreamConfig().framerate; + }, + getBitrateMax() { + return this.getStreamConfig().bitrateMax; + }, + coerceResolution(res: { height?: number; width?: number; }) { + if (!res) return res; + const config = this.getStreamConfig(); + return { + ...res, + height: Math.max(res.height ?? 0, config.height), + width: Math.max(res.width ?? 0, config.width), + }; + }, + coerceFps(fps: number | undefined) { + const config = this.getStreamConfig(); + return typeof fps === "number" + ? Math.max(fps, config.framerate) + : config.framerate; + }, + getMinBitrate() { + return (settings.store.minBitrate ?? 500) * 1000; + }, + getKeyframeInterval() { + return settings.store.keyframeInterval ?? 5000; + }, + patchGoliveArgs(opts: StreamQualityOpts) { + const config = this.getStreamConfig(); + return { + ...opts, + bitrateTarget: config.bitrateTarget, + capture: { + ...opts.capture, + width: config.width, + height: config.height, + framerate: config.framerate, + }, + encode: { + ...opts.encode, + width: config.width, + height: config.height, + framerate: config.framerate, + pixelCount: config.pixelCount, + }, + }; + }, + patchStreamQuality(opts: StreamQualityOpts) { + const config = this.getStreamConfig(); + + Object.assign(opts, { + bitrateMin: this.getMinBitrate(), + bitrateMax: config.bitrateMax, + bitrateTarget: config.bitrateTarget, + }); + if (opts?.encode) { + Object.assign(opts.encode, { + framerate: config.framerate, + width: config.width, + height: config.height, + pixelCount: config.pixelCount, + }); + } + Object.assign((opts.capture ??= {}), { + framerate: config.framerate, + width: config.width, + height: config.height, + pixelCount: config.pixelCount, + }); + return opts; + }, +}); diff --git a/src/equicordplugins/equicordHelper/index.tsx b/src/equicordplugins/equicordHelper/index.tsx index 3c6bb883f..d7d082bb9 100644 --- a/src/equicordplugins/equicordHelper/index.tsx +++ b/src/equicordplugins/equicordHelper/index.tsx @@ -6,8 +6,9 @@ import { ApplicationCommandInputType, sendBotMessage } from "@api/Commands"; import { HeaderBarButton } from "@api/HeaderBar"; +import { addMessagePreSendListener, removeMessagePreSendListener } from "@api/MessageEvents"; import { isPluginEnabled } from "@api/PluginManager"; -import { definePluginSettings } from "@api/Settings"; +import { definePluginSettings, migratePluginToSettings } from "@api/Settings"; import customRPC from "@plugins/customRPC"; import { Devs, EquicordDevs, GUILD_ID, SUPPORT_CHANNEL_ID, SUPPORT_CHANNEL_IDS, VC_SUPPORT_CHANNEL_IDS } from "@utils/constants"; import { isAnyPluginDev } from "@utils/misc"; @@ -20,6 +21,10 @@ import { ComponentType } from "react"; import { PluginButtons } from "./pluginButtons"; import { PluginCards } from "./pluginCards"; +migratePluginToSettings(true, "EquicordHelper", "NoBulletPoints", "noBulletPoints"); +migratePluginToSettings(true, "EquicordHelper", "NoModalAnimation", "noModalAnimation"); +migratePluginToSettings(true, "EquicordHelper", "GuildTagSettings", "disableAdoptTagPrompt"); + let clicked = false; const SafetyHubStore = findStoreLazy("SafetyHubStore"); @@ -58,6 +63,11 @@ function StandingButton() { ); } +const listener = async (channelId, msg) => { + if (!settings.store.noBulletPoints) return; + msg.content = textProcessing(msg.content); +}; + const settings = definePluginSettings({ noMirroredCamera: { type: OptionType.BOOLEAN, @@ -77,12 +87,6 @@ const settings = definePluginSettings({ restartNeeded: true, default: false, }, - noDefaultHangStatus: { - type: OptionType.BOOLEAN, - description: "Disable the default hang status when joining voice channels", - restartNeeded: true, - default: false, - }, refreshSlashCommands: { type: OptionType.BOOLEAN, description: "Refreshes Slash Commands to show newly added commands without restarting your client.", @@ -106,12 +110,40 @@ const settings = definePluginSettings({ restartNeeded: true, default: false }, + noBulletPoints: { + type: OptionType.BOOLEAN, + description: "Stops you from typing markdown bullet points (stinky)", + restartNeeded: true, + default: false + }, + noModalAnimation: { + type: OptionType.BOOLEAN, + description: "Remove the 300ms long animation when opening or closing modals", + restartNeeded: true, + default: false + }, + disableAdoptTagPrompt: { + type: OptionType.BOOLEAN, + description: "Disable the prompt to adopt tags", + default: true, + restartNeeded: true + }, }); export default definePlugin({ name: "EquicordHelper", description: "Used to provide support, fix discord caused crashes, and other misc features.", - authors: [Devs.thororen, EquicordDevs.nyx, EquicordDevs.Naibuu, EquicordDevs.keircn, EquicordDevs.SerStars, EquicordDevs.mart, EquicordDevs.omaw], + authors: [ + Devs.thororen, + EquicordDevs.nyx, + EquicordDevs.Naibuu, + EquicordDevs.keircn, + EquicordDevs.SerStars, + EquicordDevs.mart, + EquicordDevs.omaw, + Devs.Samwich, + Devs.AutumnVN + ], required: true, settings, headerBarButton: { @@ -179,18 +211,9 @@ export default definePlugin({ replace: "$& && false" } }, - // No Default Hang Status - { - find: ".CHILLING)", - predicate: () => settings.store.noDefaultHangStatus, - replacement: { - match: /{enableHangStatus:(\i),/, - replace: "{_enableHangStatus:$1=false," - } - }, // Force Role Icon { - find: "Message Username", + find: "#{intl::GUILD_COMMUNICATION_DISABLED_ICON_TOOLTIP_BODY}", predicate: () => settings.store.forceRoleIcon, replacement: { match: /(?<=\}\):null\].{0,150}\?2:)0(?=\})/, @@ -199,13 +222,57 @@ export default definePlugin({ }, // Restore File Download Button { - predicate: () => settings.store.restoreFileDownloadButton, find: '"VISUAL_PLACEHOLDER":', + predicate: () => settings.store.restoreFileDownloadButton, replacement: { match: /(\.downloadUrl,showDownload:)\i/, replace: "$1!0" } }, + // Removes Modal Animation + { + find: "DURATION_IN:", + predicate: () => settings.store.noModalAnimation, + replacement: { + match: /300,/, + replace: "0,", + } + }, + // Removes Modal Animation + { + find: 'backdropFilter:"blur(0px)"', + predicate: () => settings.store.noModalAnimation, + replacement: { + match: /\?0:200/, + replace: "?0:0", + } + }, + // Removes Modal Animation + { + find: '="ABOVE"', + predicate: () => settings.store.noModalAnimation, + replacement: { + match: /\?\?300/, + replace: "??0", + } + }, + // Removes Modal Animation + { + find: "renderLurkerModeUpsellPopout,position:", + predicate: () => settings.store.noModalAnimation, + replacement: { + match: /200:300/g, + replace: "0:0", + }, + }, + { + find: "GuildTagAvailableCoachmark", + replacement: { + match: /return.{0,100}shouldShow/g, + replace: "return null;$&" + }, + predicate: () => settings.store.disableAdoptTagPrompt + } ], renderMessageAccessory(props) { return ( @@ -255,5 +322,19 @@ export default definePlugin({ } } } - ] + ], + start() { + if (settings.store.noBulletPoints) { + addMessagePreSendListener(listener); + } + }, + stop() { + if (settings.store.noBulletPoints) { + removeMessagePreSendListener(listener); + } + } }); + +function textProcessing(text: string): string { + return text.replace(/(^|\n)(\s*)([*+-])\s+/g, "$1$2\\$3 "); +} diff --git a/src/equicordplugins/favouriteAnything/index.tsx b/src/equicordplugins/favouriteAnything/index.tsx index 9797c9b42..b9d094962 100644 --- a/src/equicordplugins/favouriteAnything/index.tsx +++ b/src/equicordplugins/favouriteAnything/index.tsx @@ -26,11 +26,12 @@ export default definePlugin({ name: "FavouriteAnything", description: "Favourite any image, video, or file attachment", authors: [Devs.nin0dev, EquicordDevs.davri], + tags: ["favorite"], managedStyle, patches: [ // EMBEDS { - find: "#{intl::SUPPRESS_ALL_EMBEDS}", + find: "this.renderInlineMediaEmbed", replacement: [ { // Wrap the embed component's render method in a custom context to avoid having to drill props diff --git a/src/equicordplugins/favouriteAnything/stores.ts b/src/equicordplugins/favouriteAnything/stores.ts index 53b59f45a..a4939776a 100644 --- a/src/equicordplugins/favouriteAnything/stores.ts +++ b/src/equicordplugins/favouriteAnything/stores.ts @@ -10,18 +10,18 @@ import { Constants, Flux, FluxDispatcher, RestAPI } from "@webpack/common"; import { RefreshedUrlsResponse } from "./types"; import { BatchedRequestQueue, isAllowedHost } from "./utils"; +export interface SignedUrlsStoreType { + get(url: string): string | null; + addSigned(url: string): void; +} + /** Used for storing and automatically refreshing signed CDN/Media proxy urls ({@link https://docs.discord.food/reference#signed-attachment-urls}). */ export const SignedUrlsStore = proxyLazyWebpack(() => { - interface Store { - get(url: string): string | null; - addSigned(url: string): void; - } + class SignedUrlsStoreClass extends Flux.Store implements SignedUrlsStoreType { + public static readonly _expirationThreshold = 60 * 60 * 1000; - class SignedUrlsStore extends Flux.Store implements Store { - private static readonly _expirationThreshold = 60 * 60 * 1000; - - private _urls = new Map(); - private _queue = new BatchedRequestQueue(batch => this._handleBatch(batch), { + public _urls = new Map(); + public _queue = new BatchedRequestQueue(batch => this._handleBatch(batch), { maxCount: 50, timeout: 50 }); @@ -50,27 +50,27 @@ export const SignedUrlsStore = proxyLazyWebpack(() => { else this._update([[`${this._clean(parsed)}`, url]]); } - private _refresh(url: URL): void { + public _refresh(url: URL): void { this._queue.add(`${this._clean(url)}`); } - private _clean(url: URL): URL { + public _clean(url: URL): URL { const clean = new URL(url); clean.search = ""; clean.hash = ""; return clean; } - private _isValid(url: URL | null): url is URL { + public _isValid(url: URL | null): url is URL { return !!(url && isAllowedHost(url.hostname)); } - private _willExpire(url: URL): boolean { + public _willExpire(url: URL): boolean { const expiryTimestamp = parseInt(url.searchParams.get("ex")!, 16) * 1000; - return isNaN(expiryTimestamp) || expiryTimestamp - SignedUrlsStore._expirationThreshold < Date.now(); + return isNaN(expiryTimestamp) || expiryTimestamp - SignedUrlsStoreClass._expirationThreshold < Date.now(); } - private _update(urls: [string, string][]): void { + public _update(urls: [string, string][]): void { let hasChanged: boolean = false; for (const [url, value] of urls) { @@ -83,7 +83,7 @@ export const SignedUrlsStore = proxyLazyWebpack(() => { if (hasChanged) this.emitChange(); } - private async _handleBatch(batch: string[]): Promise { + public async _handleBatch(batch: string[]): Promise { await RestAPI.post({ url: Constants.Endpoints.ATTACHMENTS_REFRESH_URLS, body: { attachment_urls: batch }, @@ -94,5 +94,5 @@ export const SignedUrlsStore = proxyLazyWebpack(() => { } } - return new SignedUrlsStore(FluxDispatcher) as Store; + return new SignedUrlsStoreClass(FluxDispatcher) as SignedUrlsStoreType; }); diff --git a/src/equicordplugins/favouriteAnything/utils.ts b/src/equicordplugins/favouriteAnything/utils.ts index b90ea9653..b03aa347c 100644 --- a/src/equicordplugins/favouriteAnything/utils.ts +++ b/src/equicordplugins/favouriteAnything/utils.ts @@ -70,23 +70,27 @@ function defineItems>(def: Ite // Stringify returns a simple string representation used for thumbnail text and expression picker search. export const defs = defineItems({ [CustomItemFormat.ATTACHMENT]: defineItem({ - encode: ({ id, filename, size, url, content_type = "" }: MessageAttachment) => [ + encode: ({ id, filename, size, url, content_type = "", title, description }: MessageAttachment) => [ id, filename, size, new URL(url).pathname, - content_type + content_type, + title ?? null, + description ?? null ], - decode: ([id, filename, size, path, content_type]) => ({ + decode: ([id, filename, size, path, content_type, title, description]) => ({ id: id ?? "0", filename: filename ?? "UNKNOWN", size: +size! || 0, url: `${new URL(path!, `https://${window.GLOBAL_ENV.CDN_HOST}`)}`, proxy_url: `${new URL(path!, `https://${window.GLOBAL_ENV.MEDIA_PROXY_ENDPOINT}`)}`, content_type: content_type ?? "application/octet-stream", - spoiler: filename?.startsWith("SPOILER_") ?? false + spoiler: filename?.startsWith("SPOILER_") ?? false, + title: title ?? undefined, + description: description ?? undefined }), - stringify: ({ filename }) => filename + stringify: ({ title, filename }) => title?.trim() || filename }) // This could be expanded in the future with other item types (e.g. voice messages) }); @@ -149,23 +153,15 @@ async function fetchAttachment(attachment: MessageAttachment): Promise { } export async function sendAttachment(attachment: MessageAttachment, channel: Channel) { - const file = await fetchAttachment(attachment) - .catch(() => - Toasts.show({ - message: `Couldn't fetch ${attachment.filename}`, - id: Toasts.genId(), - type: Toasts.Type.FAILURE - }) - ); + const { filename, title, description } = attachment; + const file = await fetchAttachment(attachment).catch(() => + Toasts.show({ message: `Couldn't fetch ${filename}`, id: Toasts.genId(), type: Toasts.Type.FAILURE }) + ); if (!file) return; // Using promptToUpload instead of addFiles directly since it has file size checks with error popups await UploadHandler.promptToUpload([file], channel, DraftType.ChannelMessage).catch(() => - Toasts.show({ - message: `Couldn't upload ${attachment.filename}`, - id: Toasts.genId(), - type: Toasts.Type.FAILURE - }) + Toasts.show({ message: `Couldn't upload ${filename}`, id: Toasts.genId(), type: Toasts.Type.FAILURE }) ); const uploads = [...UploadAttachmentStore.getUploads(channel.id, DraftType.ChannelMessage)]; @@ -173,8 +169,13 @@ export async function sendAttachment(attachment: MessageAttachment, channel: Cha if (uploadIdx === -1) return; const reply = PendingReplyStore.getPendingReply(channel.id); + const [upload] = uploads.splice(uploadIdx); UploadManager.setUploads({ uploads, channelId: channel.id, draftType: DraftType.ChannelMessage }); + // Empty titles and descriptions are allowed + if (title != null) upload.filename = title; + if (description != null) upload.description = description; + FluxDispatcher.dispatch({ type: "DELETE_PENDING_REPLY", channelId: channel.id }); void sendMessage(channel.id, {}, false, { diff --git a/src/equicordplugins/fileUpload/constants.ts b/src/equicordplugins/fileUpload/constants.ts new file mode 100644 index 000000000..5934a3be9 --- /dev/null +++ b/src/equicordplugins/fileUpload/constants.ts @@ -0,0 +1,15 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +export const CORS_PROXY = "https://cors.keiran0.workers.dev"; + +export function normalizeCorsProxyUrl(url?: string): string { + return (url?.trim() || CORS_PROXY).replace(/\/+$/, ""); +} + +export function toProxiedUrl(url: string, corsProxyUrl = CORS_PROXY): string { + return `${normalizeCorsProxyUrl(corsProxyUrl)}?url=${encodeURIComponent(url)}`; +} diff --git a/src/equicordplugins/fileUpload/index.tsx b/src/equicordplugins/fileUpload/index.tsx index 1c91c126d..e06d504a8 100644 --- a/src/equicordplugins/fileUpload/index.tsx +++ b/src/equicordplugins/fileUpload/index.tsx @@ -4,15 +4,140 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ +import "./styles.css"; + import { findGroupChildrenByChildId, NavContextMenuPatchCallback } from "@api/ContextMenu"; -import { EquicordDevs } from "@utils/constants"; +import ErrorBoundary from "@components/ErrorBoundary"; +import { OpenExternalIcon } from "@components/Icons"; +import { Devs, EquicordDevs } from "@utils/constants"; +import { classNameFactory } from "@utils/css"; import definePlugin from "@utils/types"; -import { Menu } from "@webpack/common"; +import { findByPropsLazy } from "@webpack"; +import { DraftType, FluxDispatcher, Menu, PermissionsBits, PermissionStore, React, useEffect, UserStore, useState } from "@webpack/common"; import { settings } from "./settings"; import { serviceLabels, ServiceType } from "./types"; import { getMediaUrl } from "./utils/getMediaUrl"; -import { uploadFile } from "./utils/upload"; +import { cancelCurrentUpload, getUploadState, isConfigured, subscribeUploadState, uploadFile, uploadPickedFile, uploadProvidedFiles } from "./utils/upload"; +const cl = classNameFactory("vc-file-upload-"); +const { getUserMaxFileSize } = findByPropsLazy("getUserMaxFileSize"); +let uploadAddFilesInterceptor: ((event: unknown) => void) | null = null; + +type UploadAddFilesEvent = { + type: string; + files?: unknown; + uploads?: unknown; + items?: unknown; + draftType?: unknown; + maxFileSize?: unknown; + fileSizeLimit?: unknown; + limits?: { + fileSize?: unknown; + }; +}; + +function shouldInterceptUploadFiles(files: readonly File[], payload: UploadAddFilesEvent): boolean { + if (!settings.store.interceptDiscordUploadOnlyOverLimit) return true; + + const directLimit = [payload.maxFileSize, payload.fileSizeLimit, payload.limits?.fileSize].find(limit => Number.isFinite(limit)) as number | undefined; + const fallbackLimit = getUserMaxFileSize(UserStore.getCurrentUser()); + const discordLimit = Math.max(0, directLimit ?? fallbackLimit); + + return files.some(file => file.size > discordLimit); +} +function extractFilesFromValue(value: unknown): File[] { + if (value instanceof File) return [value]; + + if (!Array.isArray(value)) return []; + + return value.flatMap(entry => { + if (entry instanceof File) return [entry]; + + if (!entry || typeof entry !== "object") return []; + + const uploadFile = "file" in entry ? entry.file : null; + if (uploadFile instanceof File) return [uploadFile]; + + const item = "item" in entry && entry.item && typeof entry.item === "object" ? entry.item : null; + if (!item || !("file" in item)) return []; + + return item.file instanceof File ? [item.file] : []; + }); +} + +function interceptUploadAddFiles(event: unknown): void { + if (!event || typeof event !== "object" || !("type" in event)) return; + + const payload = event as UploadAddFilesEvent; + if (payload.type !== "UPLOAD_ATTACHMENT_ADD_FILES") return; + + if (payload.draftType !== DraftType.ChannelMessage) return; + + if (!Boolean((settings.store as { interceptDiscordUpload?: boolean; }).interceptDiscordUpload) || !isConfigured()) return; + + const files = [ + ...extractFilesFromValue(payload.files), + ...extractFilesFromValue(payload.uploads), + ...extractFilesFromValue(payload.items) + ]; + const uniqueFiles = Array.from(new Set(files)); + + if (!uniqueFiles.length) return; + if (!shouldInterceptUploadFiles(uniqueFiles, payload)) return; + + payload.files = []; + payload.uploads = []; + payload.items = []; + void uploadProvidedFiles(uniqueFiles); +} + +const ProgressBarInner = () => { + const [state, setState] = useState(getUploadState); + + useEffect(() => subscribeUploadState(() => setState(getUploadState())), []); + + if (state.phase === "idle") return null; + + const percentage = Math.max(0, Math.min(100, state.percent)); + + return ( +
+
+
+ {state.status || "Uploading..."} +
+
+ + {state.attempt > 0 && state.totalAttempts > 0 ? `${state.attempt}/${state.totalAttempts}` : ""} + + {state.canCancel && ( + + )} +
+
+
+
+
+
+ {state.fileName || ""}{state.currentServiceLabel ? ` • ${state.currentServiceLabel}` : ""} +
+
+ ); +}; + +const ProgressBar = ErrorBoundary.wrap(ProgressBarInner, { noop: true }); const messageContextMenuPatch: NavContextMenuPatchCallback = (children, props) => { if (!props) return; @@ -65,13 +190,80 @@ const imageContextMenuPatch: NavContextMenuPatchCallback = (children, props) => ); }; +const ExternalIcon = () => ; + +const channelAttachMenuPatch: NavContextMenuPatchCallback = (children, props) => { + const channel = props?.channel; + if (!channel) return; + if (channel.guild_id && !PermissionStore.can(PermissionsBits.SEND_MESSAGES, channel)) return; + if (children.some(child => child?.props?.id === "file-upload-manual")) return; + + children.splice(1, 0, + uploadPickedFile()} + /> + ); +}; + export default definePlugin({ name: "FileUpload", description: "Upload images and videos to file hosting services like Zipline and Nest", - authors: [EquicordDevs.creations, EquicordDevs.keircn], + authors: [EquicordDevs.creations, EquicordDevs.keircn, Devs.ScattrdBlade], settings, + patches: [ + { + find: ".CREATE_FORUM_POST||", + replacement: { + match: /(textValue:.{0,50}channelId:\i\.id\}\))(?:,\i(,))?/, + replace: "$1,$self.renderUploadProgress()$2" + } + }, + // forces an early return on the file size limit nitro upsell modal + { + find: "#{intl::tRuxk9::raw}", + replacement: { + match: /(?<=MAX_FILE_SIZE_250_MB.{0,250})Array\.from\(\i\)\.some/, + replace: "$self.shouldBypassDiscordUploadSizeCheck()?false:$&" + } + }, + ], contextMenus: { "message": messageContextMenuPatch, - "image-context": imageContextMenuPatch + "image-context": imageContextMenuPatch, + "channel-attach": channelAttachMenuPatch + }, + start() { + if (uploadAddFilesInterceptor) { + return; + } + + uploadAddFilesInterceptor = event => interceptUploadAddFiles(event); + FluxDispatcher.addInterceptor(uploadAddFilesInterceptor); + }, + stop() { + if (!uploadAddFilesInterceptor) { + return; + } + + const index = FluxDispatcher._interceptors.indexOf(uploadAddFilesInterceptor); + if (index > -1) { + FluxDispatcher._interceptors.splice(index, 1); + } + + uploadAddFilesInterceptor = null; + }, + shouldBypassDiscordUploadSizeCheck(): boolean { + return Boolean((settings.store as { interceptDiscordUpload?: boolean; }).interceptDiscordUpload) && isConfigured(); + }, + renderUploadProgress() { + return ; } }); diff --git a/src/equicordplugins/fileUpload/native.ts b/src/equicordplugins/fileUpload/native.ts index cafd2f8cf..e20121dc5 100644 --- a/src/equicordplugins/fileUpload/native.ts +++ b/src/equicordplugins/fileUpload/native.ts @@ -204,6 +204,178 @@ export async function uploadToLitterbox( } } +export async function uploadToGofile( + _: IpcMainInvokeEvent, + fileBuffer: ArrayBuffer, + filename: string, + token?: string +): Promise { + try { + const formData = new FormData(); + if (token?.trim()) { + formData.append("token", token.trim()); + } + formData.append("file", new Blob([fileBuffer]), filename); + + const response = await fetch("https://upload.gofile.io/uploadfile", { + method: "POST", + body: formData + }); + + if (!response.ok) { + const errorText = await response.text(); + return { success: false, error: `Upload failed: ${response.status} ${errorText}` }; + } + + const data = await response.json() as { + status?: string; + error?: string; + data?: { downloadPage?: string; code?: string; }; + }; + + if (data.status !== "ok") { + return { success: false, error: data.error || "Upload failed" }; + } + + const url = data.data?.downloadPage || (data.data?.code ? `https://gofile.io/d/${data.data.code}` : ""); + if (!url) { + return { success: false, error: "No URL returned from upload" }; + } + + return { success: true, url }; + } catch (e) { + return { success: false, error: e instanceof Error ? e.message : "Unknown error" }; + } +} + +export async function uploadToTmpfiles( + _: IpcMainInvokeEvent, + fileBuffer: ArrayBuffer, + filename: string +): Promise { + try { + const formData = new FormData(); + formData.append("file", new Blob([fileBuffer]), filename); + + const response = await fetch("https://tmpfiles.org/api/v1/upload", { + method: "POST", + body: formData + }); + + if (!response.ok) { + const errorText = await response.text(); + return { success: false, error: `Upload failed: ${response.status} ${errorText}` }; + } + + const data = await response.json() as { status?: string; data?: { url?: string; }; }; + const rawUrl = data.data?.url || ""; + if (!rawUrl || data.status !== "success") { + return { success: false, error: "No URL returned from upload" }; + } + + const url = rawUrl.includes("tmpfiles.org/") && !rawUrl.includes("/dl/") + ? rawUrl.replace(/tmpfiles\.org\/(\d+)/, "tmpfiles.org/dl/$1") + : rawUrl; + + return { success: true, url }; + } catch (e) { + return { success: false, error: e instanceof Error ? e.message : "Unknown error" }; + } +} + +export async function uploadToBuzzheavier( + _: IpcMainInvokeEvent, + fileBuffer: ArrayBuffer, + filename: string +): Promise { + try { + const response = await fetch(`https://w.buzzheavier.com/${encodeURIComponent(filename)}`, { + method: "PUT", + body: new Blob([fileBuffer]) + }); + + const text = await response.text(); + if (!response.ok) { + return { success: false, error: `Upload failed: ${response.status} ${text}` }; + } + + try { + const data = JSON.parse(text) as { code?: number; data?: { id?: string; }; }; + if (data.code === 201 && data.data?.id) { + return { success: true, url: `https://buzzheavier.com/${data.data.id}` }; + } + } catch { + } + + const url = text.trim(); + if (!url) { + return { success: false, error: "No URL returned from upload" }; + } + + return { success: true, url }; + } catch (e) { + return { success: false, error: e instanceof Error ? e.message : "Unknown error" }; + } +} + +export async function uploadToTempSh( + _: IpcMainInvokeEvent, + fileBuffer: ArrayBuffer, + filename: string +): Promise { + try { + const formData = new FormData(); + formData.append("file", new Blob([fileBuffer]), filename); + + const response = await fetch("https://temp.sh/upload", { + method: "POST", + body: formData + }); + + if (!response.ok) { + const errorText = await response.text(); + return { success: false, error: `Upload failed: ${response.status} ${errorText}` }; + } + + const url = (await response.text()).trim(); + if (!url) { + return { success: false, error: "No URL returned from upload" }; + } + + return { success: true, url }; + } catch (e) { + return { success: false, error: e instanceof Error ? e.message : "Unknown error" }; + } +} + +export async function uploadToFilebin( + _: IpcMainInvokeEvent, + fileBuffer: ArrayBuffer, + filename: string +): Promise { + try { + const binId = `${Date.now().toString(16)}${Math.random().toString(16).slice(2, 10)}`; + const uploadUrl = `https://filebin.net/${binId}/${encodeURIComponent(filename)}`; + + const formData = new FormData(); + formData.append("file", new Blob([fileBuffer]), filename); + + const response = await fetch(uploadUrl, { + method: "POST", + body: formData + }); + + if (!response.ok) { + const errorText = await response.text(); + return { success: false, error: `Upload failed: ${response.status} ${errorText}` }; + } + + return { success: true, url: `https://filebin.net/${binId}/${encodeURIComponent(filename)}` }; + } catch (e) { + return { success: false, error: e instanceof Error ? e.message : "Unknown error" }; + } +} + export async function fetchFile( _: IpcMainInvokeEvent, diff --git a/src/equicordplugins/fileUpload/settings.tsx b/src/equicordplugins/fileUpload/settings.tsx index 371fa6243..b1aceb041 100644 --- a/src/equicordplugins/fileUpload/settings.tsx +++ b/src/equicordplugins/fileUpload/settings.tsx @@ -12,6 +12,7 @@ import { useForceUpdater } from "@utils/react"; import { OptionType } from "@utils/types"; import { React, Select, showToast, TextArea, TextInput, Toasts } from "@webpack/common"; +import { CORS_PROXY } from "./constants"; import { ServiceType } from "./types"; import { parseShareXConfig } from "./utils/sharex"; @@ -23,6 +24,11 @@ const serviceOptions = [ { label: "Catbox.moe", value: ServiceType.CATBOX }, ...(IS_DISCORD_DESKTOP ? [{ label: "0x0.st", value: ServiceType.ZEROX0 }] : []), { label: "Litterbox", value: ServiceType.LITTERBOX }, + { label: "GoFile", value: ServiceType.GOFILE }, + { label: "tmpfiles.org", value: ServiceType.TMPFILES }, + { label: "buzzheavier.com", value: ServiceType.BUZZHEAVIER }, + { label: "temp.sh", value: ServiceType.TEMPSH }, + { label: "filebin.net", value: ServiceType.FILEBIN }, { label: "ShareX Custom Uploader", value: ServiceType.SHAREX } ]; @@ -143,12 +149,60 @@ export const settings = definePluginSettings({ default: "", hidden: true }, + disableFallbacks: { + type: OptionType.BOOLEAN, + description: "Disable fallback upload services", + default: false, + hidden: true + }, + autoSend: { + type: OptionType.BOOLEAN, + description: "Insert uploaded URL in chat input", + default: false, + hidden: true + }, + autoFormat: { + type: OptionType.BOOLEAN, + description: "Wrap inserted URL in angle brackets", + default: false, + hidden: true + }, + interceptDiscordUpload: { + type: OptionType.BOOLEAN, + description: "Intercept Discord uploads and use FileUpload instead.", + default: false, + hidden: true + }, + interceptDiscordUploadOnlyOverLimit: { + type: OptionType.BOOLEAN, + description: "Only intercept uploads that exceed Discord file size limit.", + default: true, + hidden: true + }, + gofileToken: { + type: OptionType.STRING, + description: "Optional GoFile API token", + default: "", + hidden: true + }, + uploadTimeoutMs: { + type: OptionType.NUMBER, + description: "Upload timeout in milliseconds", + default: 300000, + hidden: true + }, stripQueryParams: { type: OptionType.BOOLEAN, description: "Strip query params from uploaded URLs", default: false, hidden: true }, + corsProxyUrl: { + type: OptionType.STRING, + description: "CORS proxy URL used for browser uploads", + default: CORS_PROXY, + hidden: true + }, apngToGif: { type: OptionType.BOOLEAN, description: "Convert APNG uploads to GIF", @@ -198,6 +252,7 @@ export function SettingsComponent() { const isZipline = store.serviceType === ServiceType.ZIPLINE; const isCatbox = store.serviceType === ServiceType.CATBOX; const isLitterbox = store.serviceType === ServiceType.LITTERBOX; + const isGofile = store.serviceType === ServiceType.GOFILE; const isShareX = store.serviceType === ServiceType.SHAREX; const validateShareXConfig = () => { @@ -388,6 +443,16 @@ export function SettingsComponent() { )} + {isGofile && ( + store.gofileToken = v} + placeholder="Optional GoFile token" + /> + )} + {isShareX && ( <> + store.corsProxyUrl = v} + placeholder="https://your-cors-proxy.example.com" + /> + + + codeberg.org/key/corsproxy + + store.autoCopy = v} /> + + + store.disableFallbacks = v} + /> + + + + store.autoSend = v} + /> + + + + store.autoFormat = v} + /> + + + + (store as { interceptDiscordUpload?: boolean; }).interceptDiscordUpload = v} + /> + + + + store.interceptDiscordUploadOnlyOverLimit = v} + /> + + + + handleSortTypeChange(SortingOptions.NAME)} - className="collections-sort-input" - /> - Name - -
-
- -
-
- -
-
- -
- Order -
- -
-
- -
-
- - ); - } - }, - importGifs: { - type: OptionType.COMPONENT, - description: "Import Collections", - component: () => - , - }, - exportGifs: { - type: OptionType.COMPONENT, - description: "Export Collections", - component: () => - - }, - resetCollections: { - type: OptionType.COMPONENT, - description: "Reset Collections", - component: () => - - } -}); +let refreshingUrls = false; +let oldTrendingCat: Category[] | null = null; export default definePlugin({ name: "GifCollections", - description: "Allows you to create collections of gifs", - authors: [Devs.Aria, EquicordDevs.creations], + description: "Allows you to create collections of gifs.", + authors: [EquicordDevs.creations], + settings, + contextMenus: { + "message": addCollectionContextMenuPatch, + }, + patches: [ { find: "renderCategoryExtras", replacement: [ { match: /(render\(\){)(.{1,50}getItemGrid)/, - replace: "$1;$self.insertCollections(this);$2" + replace: "$1;$self.insertCollections(this);$2", }, { match: /("span",\{className:\i\.\i,children:)(\i)/, - replace: "$1$self.hidePrefix($2)," + replace: "$1$self.hidePrefix($2),", }, - ] + ], }, { find: "renderEmptyFavorite", replacement: { match: /render\(\){.{1,500}onClick:this\.handleClick,/, - replace: "$&onContextMenu: (e) => $self.collectionContextMenu(e, this)," - } + replace: "$&onContextMenu: (e) => $self.collectionContextMenu(e, this),", + }, }, { find: "renderHeaderContent()", - replacement: [ - { - match: /(renderContent\(\){)(.{1,50}resultItems)/, - replace: "$1$self.renderContent(this);$2" - }, - ] + replacement: { + match: /(renderContent\(\){)(.{1,50}resultItems)/, + replace: "$1$self.renderContent(this);$2", + }, }, { find: "type:\"GIF_PICKER_QUERY\"", replacement: { match: /(function \i\(.{1,10}\){)(.{1,100}.GIFS_SEARCH,query:)/, - replace: "$1if($self.shouldStopFetch(arguments[0])) return;$2" - } + replace: "$1if($self.shouldStopFetch(arguments[0])) return;$2", + }, }, ], - settings, - contextMenus: { - "message": addCollectionContextMenuPatch - }, + start() { refreshCacheCollection(); GIF_COLLECTION_PREFIX = settings.store.collectionPrefix; GIF_ITEM_PREFIX = settings.store.itemPrefix; }, - get collections() { - refreshCacheCollection(); - return this.sortedCollections(); - }, - sortedCollections() { - return cache_collections.sort((a, b) => { - const sortType = settings.store.collectionsSortType; - const sortOrder = settings.store.collectionsSortOrder === "asc" ? 1 : -1; + + sortedCollections(): Collection[] { + const sorted = [...cache_collections]; + const sortType = settings.store.collectionsSortType; + const sortOrder = settings.store.collectionsSortOrder === "asc" ? 1 : -1; + + return sorted.sort((a, b) => { switch (sortType) { case SortingOptions.NAME: return a.name.localeCompare(b.name) * sortOrder; @@ -373,448 +93,84 @@ export default definePlugin({ } }); }, - renderContent(instance) { - if (instance.props.query.startsWith(GIF_COLLECTION_PREFIX)) { - const collection = this.collections.find(c => c.name === instance.props.query); - if (collection) { - instance.props.resultItems = collection.gifs.map(g => ({ - id: g.id, - format: getFormat(g.src), - src: g.src, - url: g.url, - width: g.width, - height: g.height - })).reverse(); - const expiredGifs = collection.gifs.filter(g => g.src && g.url && (isCdnUrlExpired(g.src) || isCdnUrlExpired(g.url))); - if (expiredGifs.length > 0) { - const allUrls = [...new Set( - expiredGifs.flatMap(g => [g.src, g.url].filter((u): u is string => !!u && isCdnUrlExpired(u))) - )]; - const refreshInBatches = async () => { - const fullMap: Record = {}; - for (let i = 0; i < allUrls.length; i += 50) { - const batch = allUrls.slice(i, i + 50); - const result = await batchRefreshAttachmentUrls(batch); - Object.assign(fullMap, result); - } - return fullMap; - }; - refreshInBatches().then(async refreshMap => { - if (!Object.keys(refreshMap).length) return; - let anyUpdated = false; - for (const gif of expiredGifs) { - const newSrc = refreshMap[gif.src] ?? gif.src; - const newUrl = refreshMap[gif.url] ?? gif.url; - if (newSrc !== gif.src || newUrl !== gif.url) { - await updateGif(gif.id, { ...gif, src: newSrc, url: newUrl }); - anyUpdated = true; - } - } - if (!anyUpdated) return; - const { query } = instance.props; - FluxDispatcher.dispatch({ type: "GIF_PICKER_QUERY", query: `${query} ` }); - await new Promise(r => setTimeout(r, 2500)); - FluxDispatcher.dispatch({ type: "GIF_PICKER_QUERY", query }); - }); + renderContent(instance: GifPickerInstance) { + if (!instance.props.query.startsWith(GIF_COLLECTION_PREFIX)) return; + + const collection = cache_collections.find(c => c.name === instance.props.query); + if (!collection) return; + + instance.props.resultItems = collection.gifs.map(g => ({ + id: g.id, + format: getFormat(g.src), + src: g.src, + url: g.url, + width: g.width, + height: g.height, + })).reverse(); + + const expiredGifs = collection.gifs.filter(g => g.src && g.url && (isCdnUrlExpired(g.src) || isCdnUrlExpired(g.url))); + if (expiredGifs.length === 0) return; + + const allUrls = [...new Set( + expiredGifs.flatMap(g => [g.src, g.url].filter((u): u is string => !!u && isCdnUrlExpired(u))) + )]; + + if (!refreshingUrls) this.refreshExpiredUrls(allUrls, expiredGifs, instance.props.query); + }, + + async refreshExpiredUrls(urls: string[], expiredGifs: Gif[], query: string) { + refreshingUrls = true; + try { + const fullMap: Record = {}; + for (let i = 0; i < urls.length; i += 50) { + const result = await batchRefreshAttachmentUrls(urls.slice(i, i + 50)); + Object.assign(fullMap, result); + } + + if (!Object.keys(fullMap).length) return; + + let anyUpdated = false; + for (const gif of expiredGifs) { + const newSrc = fullMap[gif.src] ?? gif.src; + const newUrl = fullMap[gif.url] ?? gif.url; + if (newSrc !== gif.src || newUrl !== gif.url) { + await updateGif(gif.id, { ...gif, src: newSrc, url: newUrl }); + anyUpdated = true; } } + + if (!anyUpdated) return; + + FluxDispatcher.dispatch({ type: "GIF_PICKER_QUERY", query: "" }); + FluxDispatcher.dispatch({ type: "GIF_PICKER_QUERY", query }); + } finally { + refreshingUrls = false; } }, - hidePrefix(name) { - return name.includes(":") ? name.replace(/.+?:/, "") : name; - }, - insertCollections(instance) { - const shouldRemoveAll = settings.store.onlyShowCollections; + + hidePrefix: stripPrefix, + + insertCollections(instance: GifPickerInstance) { try { if (instance.props.trendingCategories.length && instance.props.trendingCategories[0].type === "Trending") { - this.oldTrendingCat = instance.props.trendingCategories; + oldTrendingCat = instance.props.trendingCategories; } - if (shouldRemoveAll) { - instance.props.trendingCategories = this.sortedCollections().concat(instance.props.favorites); - } else if (this.oldTrendingCat != null) { - instance.props.trendingCategories = this.sortedCollections().concat(this.oldTrendingCat); + if (settings.store.onlyShowCollections) { + instance.props.trendingCategories = [...this.sortedCollections(), ...instance.props.favorites]; + } else if (oldTrendingCat != null) { + instance.props.trendingCategories = [...this.sortedCollections(), ...oldTrendingCat]; } } catch (err) { - console.error(err); + logger.error("Failed to insert collections", err); } }, - shouldStopFetch(query) { - return query.startsWith(GIF_COLLECTION_PREFIX) && this.collections.find(c => c.name === query) != null; + + shouldStopFetch(query: string) { + return query.startsWith(GIF_COLLECTION_PREFIX) && cache_collections.some(c => c.name === query); }, - collectionContextMenu(e, instance) { - const { item } = instance.props; - if (item?.name?.startsWith(GIF_COLLECTION_PREFIX)) { - return ContextMenuApi.openContextMenu(e, () => - - ); - } - if (item?.id?.startsWith(GIF_ITEM_PREFIX)) { - return ContextMenuApi.openContextMenu(e, () => - - ); - } - const { src, url, height, width } = item; - if (src && url && height != null && width != null && !item.id?.startsWith(GIF_ITEM_PREFIX)) { - return ContextMenuApi.openContextMenu(e, () => - FluxDispatcher.dispatch({ type: "CONTEXT_MENU_CLOSE" })} - aria-label="Gif Collections" - > - {MenuThingy({ gif: { ...item, id: uuidv4(GIF_ITEM_PREFIX) } })} - - ); - } - return null; + + collectionContextMenu(e: React.MouseEvent, instance: GifPickerInstance) { + return buildGifPickerContextMenu(e, instance.props.item, GIF_COLLECTION_PREFIX, GIF_ITEM_PREFIX, instance); }, }); - -const RemoveItemContextMenu = ({ type, nameOrId, instance }) => ( - FluxDispatcher.dispatch({ type: "CONTEXT_MENU_CLOSE" })} - aria-label={type === "collection" ? "Delete Collection" : "Remove"} - > - {type === "collection" && ( - <> - { - const collection = cache_collections.find(c => c.name === nameOrId); - if (!collection) return; - openModal(modalProps => ( - - - Collection Information - - -
- - Name - {collection.name.replace(/.+?:/, "")} - - - Gifs - {collection.gifs.length} - - - Created At - {collection.createdAt ? new Date(collection.createdAt).toLocaleString() : "Unknown"} - - - Last Updated - {collection.lastUpdated ? new Date(collection.lastUpdated).toLocaleString() : "Unknown"} - -
-
- - - -
- )); - }} - /> - - openModal(modalProps => ( - - ))} - /> - - )} - {type === "gif" && ( - <> - { - const gifInfo = getGifById(nameOrId); - if (!gifInfo) return; - openModal(modalProps => ( - - - Information - - -
- - Added At - {gifInfo.addedAt ? new Date(gifInfo.addedAt).toLocaleString() : "Unknown"} - - - Width - {gifInfo.width} - - - Height - {gifInfo.height} - -
-
- - - -
- )); - }} - /> - - { - const gifInfo = getGifById(nameOrId); - if (!gifInfo) return; - copyToClipboard(gifInfo.url); - showToast("URL copied to clipboard", Toasts.Type.SUCCESS); - }} - /> - { - openModal(modalProps => ( - - - Move To Collection - - - - Select a collection to move the item to - -
- {cache_collections - .filter(col => col.name !== getItemCollectionNameFromId(nameOrId)) - .map(col => ( - - ))} -
-
- - - -
- )); - }} - /> - - - )} - { - if (settings.store.stopWarnings) { - const collectionName = getItemCollectionNameFromId(nameOrId); - if (type === "collection") { - deleteCollection(nameOrId); - instance.forceUpdate(); - } else { - await removeFromCollection(nameOrId); - FluxDispatcher.dispatch({ - type: "GIF_PICKER_QUERY", - query: `${collectionName} ` - }); - FluxDispatcher.dispatch({ - type: "GIF_PICKER_QUERY", - query: `${collectionName}` - }); - } - return; - } - Alerts.show({ - title: "Are you sure?", - body: `Do you really want to ${type === "collection" ? "delete this collection" : "remove this item"}?`, - confirmText: type === "collection" ? "Delete" : "Remove", - confirmColor: Button.Colors.RED, - cancelText: "Nevermind", - onConfirm: async () => { - const collectionName = type === "collection" ? nameOrId : getItemCollectionNameFromId(nameOrId); - if (type === "collection") { - deleteCollection(nameOrId); - instance.forceUpdate(); - } else { - await removeFromCollection(nameOrId); - FluxDispatcher.dispatch({ - type: "GIF_PICKER_QUERY", - query: `${collectionName} ` - }); - FluxDispatcher.dispatch({ - type: "GIF_PICKER_QUERY", - query: `${collectionName}` - }); - } - } - }); - }} - /> -
-); - -const MenuThingy = ({ gif }) => { - const collections = cache_collections; - const menuItems: React.ReactNode[] = []; - - if (settings.store.showCopyImageLink) { - menuItems.push( - { - copyToClipboard(gif.url); - showToast("Image link copied to clipboard", Toasts.Type.SUCCESS); - }} - /> - ); - } - - menuItems.push( - - {collections.map(col => ( - addToCollection(col.name, gif)} - /> - ))} - {collections.length > 0 && } - openModal(modalProps => ( - - ))} - /> - - ); - - return <>{menuItems}; -}; - -function CreateCollectionModal({ gif, onClose, modalProps }) { - const [name, setName] = useState(""); - const onSubmit = useCallback(e => { - e.preventDefault(); - if (!name.length) return; - createCollection(name, [gif]); - onClose(); - }, [name]); - - return ( - -
- - Create Collection - - - Collection Name - setName(e)} /> - -
- - - -
-
-
- ); -} - -function RenameCollectionModal({ name, onClose, modalProps }) { - const prefix = settings.store.collectionPrefix; - const strippedName = name.startsWith(prefix) ? name.slice(prefix.length) : name; - const [newName, setNewName] = useState(strippedName); - - const onSubmit = useCallback(async e => { - e.preventDefault(); - if (!newName.length || newName.length >= 25) return; - await renameCollection(name, newName); - onClose(); - }, [newName, name, onClose]); - - return ( - -
- - Rename Collection - - - New Collection Name - = 25 ? "input-warning" : ""}`} onChange={e => setNewName(e)} /> - {newName.length >= 25 && Name can't be longer than 24 characters} - -
- - - -
-
-
- ); -} diff --git a/src/equicordplugins/gifCollections/settings.tsx b/src/equicordplugins/gifCollections/settings.tsx new file mode 100644 index 000000000..6752eb975 --- /dev/null +++ b/src/equicordplugins/gifCollections/settings.tsx @@ -0,0 +1,171 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { DataStore } from "@api/index"; +import { definePluginSettings } from "@api/Settings"; +import { Divider } from "@components/Divider"; +import { Heading } from "@components/Heading"; +import { Paragraph } from "@components/Paragraph"; +import { OptionType } from "@utils/types"; +import { Alerts, Button, useState } from "@webpack/common"; + +import { DATA_COLLECTION_NAME, getCollections, refreshCacheCollection } from "./utils/collectionManager"; +import { cl } from "./utils/misc"; +import { downloadCollections, uploadGifCollections } from "./utils/settingsUtils"; + +export const SortingOptions = { + NAME: 1, + CREATION_DATE: 2, + MODIFIED_DATE: 3, +} as const; + +export const settings = definePluginSettings({ + itemPrefix: { + description: "The prefix for gif items.", + type: OptionType.STRING, + default: "gc-item:", + restartNeeded: true, + }, + collectionPrefix: { + description: "The prefix for collections.", + type: OptionType.STRING, + default: "gc:", + restartNeeded: true, + }, + onlyShowCollections: { + description: "Only show collections in the GIF picker.", + type: OptionType.BOOLEAN, + default: false, + restartNeeded: true, + }, + stopWarnings: { + description: "Skip deletion confirmation dialogs.", + type: OptionType.BOOLEAN, + default: false, + }, + showCopyImageLink: { + description: "Show copy image link option in context menus.", + type: OptionType.BOOLEAN, + default: false, + }, + preventDuplicates: { + description: "Prevent adding the same GIF to a collection multiple times.", + type: OptionType.BOOLEAN, + default: false, + }, + defaultEmptyCollectionImage: { + description: "The image shown when a collection has no images.", + type: OptionType.STRING, + default: "https://c.tenor.com/YEG33HsLEaIAAAAC/parksandrec-oops.gif", + }, + collectionsSortType: { + description: "The type of sorting for collections.", + type: OptionType.NUMBER, + default: SortingOptions.NAME, + hidden: true, + }, + collectionsSortOrder: { + description: "The order of sorting for collections.", + type: OptionType.STRING, + default: "asc", + hidden: true, + }, + collectionsSort: { + type: OptionType.COMPONENT, + description: "Decide how to sort collections.", + component: SortSettingsComponent, + }, + importGifs: { + type: OptionType.COMPONENT, + description: "Import collections.", + component: () => + , + }, + exportGifs: { + type: OptionType.COMPONENT, + description: "Export collections.", + component: () => + , + }, + resetCollections: { + type: OptionType.COMPONENT, + description: "Reset collections.", + component: () => + , + }, +}); + +function RadioOption({ name, value, checked, onChange, label }: { name: string; value: string | number; checked: boolean; onChange: () => void; label: string; }) { + return ( +
+ +
+ ); +} + +function SortSettingsComponent() { + const [sortType, setSortType] = useState(settings.store.collectionsSortType ?? SortingOptions.NAME); + const [sortOrder, setSortOrder] = useState(settings.store.collectionsSortOrder ?? "asc"); + + const handleSortType = (value: number) => { + setSortType(value); + settings.store.collectionsSortType = value; + }; + + const handleSortOrder = (value: string) => { + setSortOrder(value); + settings.store.collectionsSortOrder = value; + }; + + return ( +
+ Sort Collections + + Choose a sorting criteria for your collections + +
+ Sort By + handleSortType(SortingOptions.NAME)} label="Name" /> + handleSortType(SortingOptions.CREATION_DATE)} label="Creation Date" /> + handleSortType(SortingOptions.MODIFIED_DATE)} label="Modified Date" /> +
+ +
+ Order + handleSortOrder("asc")} label="Ascending" /> + handleSortOrder("desc")} label="Descending" /> +
+
+ ); +} diff --git a/src/equicordplugins/gifCollections/style.css b/src/equicordplugins/gifCollections/style.css index a729e604d..313036e52 100644 --- a/src/equicordplugins/gifCollections/style.css +++ b/src/equicordplugins/gifCollections/style.css @@ -1,41 +1,41 @@ -.collections-sort-container { +.vc-gif-collections-sort-container { padding: 12px; background-color: var(--background-secondary-alt); border-radius: 8px; margin-bottom: 16px; } -.collections-sort-title { +.vc-gif-collections-sort-title { font-size: 16px; font-weight: 600; color: var(--text-strong); margin-bottom: 8px; } -.collections-sort-divider { +.vc-gif-collections-sort-divider { margin: 10px 0; - border-top: 1px solid var(--input-border-default, var(--input-border)); + border-top: 1px solid var(--border-subtle); } -.collections-sort-description, -.collections-sort-section-title { +.vc-gif-collections-sort-description, +.vc-gif-collections-sort-section-title { font-size: 14px; font-weight: 500; color: var(--text-default); margin-bottom: 8px; } -.collections-sort-section { +.vc-gif-collections-sort-section { margin-top: 12px; } -.collections-sort-option { +.vc-gif-collections-sort-option { display: flex; align-items: center; margin-bottom: 8px; } -.collections-sort-label { +.vc-gif-collections-sort-label { font-size: 14px; font-weight: 400; color: var(--text-default); @@ -44,43 +44,41 @@ align-items: center; } -.collections-sort-input { +.vc-gif-collections-sort-input { margin-right: 10px; cursor: pointer; accent-color: var(--brand-experiment); } -.collections-sort-label:hover { +.vc-gif-collections-sort-label:hover { color: var(--text-strong); } -.custom-modal { - background-color: var(--background-primary); +.vc-gif-collections-modal { + background-color: var(--modal-background) !important; border-radius: 8px; - box-shadow: 0 8px 16px rgb(0 0 0 / 24%), 0 16px 32px rgb(0 0 0 / 24%); } -.custom-modal-header { +.vc-gif-collections-modal-header { padding: 16px; background-color: var(--background-secondary-alt); - border-bottom: 1px solid var(--background-base-low); + border-bottom: 1px solid var(--border-subtle); } -.custom-modal-title { +.vc-gif-collections-modal-title { font-size: 16px; font-weight: 600; color: var(--text-strong); } -.custom-modal-content { +.vc-gif-collections-modal-content { padding: 16px; display: flex; flex-direction: column; gap: 16px; } -.gif-info, -.collection-info { +.vc-gif-collections-info-row { display: flex; justify-content: space-between; align-items: center; @@ -88,17 +86,17 @@ padding: 8px 0; } -.collection-info:last-child { +.vc-gif-collections-info-row:last-child { border-bottom: none; } -.collection-info-title { +.vc-gif-collections-info-title { font-size: 14px; font-weight: 500; color: var(--text-default); } -.collection-info-text { +.vc-gif-collections-info-text { font-size: 14px; color: var(--text-muted); max-width: 70%; @@ -107,82 +105,54 @@ text-overflow: ellipsis; } -.custom-modal-footer { +.vc-gif-collections-modal-footer { padding: 16px; background-color: var(--background-secondary-alt); display: flex; justify-content: flex-end; } -.custom-modal-footer button { - margin-left: auto; -} - -.custom-modal-button { - background-color: var(--brand-experiment); - color: white; - border: none; - padding: 8px 16px; - border-radius: 4px; - font-size: 14px; - cursor: pointer; - transition: background-color 0.2s; -} - -.custom-modal-button:hover { - background-color: var(--brand-experiment-560); -} - - -.rename-collection-text { +.vc-gif-collections-rename-text { font-size: 14px; font-weight: 500; color: var(--text-default); margin: 16px 0 2px; } -.rename-collection-input { +.vc-gif-collections-rename-input { outline: 0 solid transparent; transition: outline 0.2s; border-radius: 4px; } -.input-warning { +.vc-gif-collections-input-warning { outline: 1px solid var(--text-feedback-critical); } -.warning-text { +.vc-gif-collections-warning-text { margin-top: 16px; font-size: 14px; color: var(--text-feedback-critical); } -.collection-buttons { +.vc-gif-collections-buttons { display: grid; grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); gap: 8px; max-height: 200px; } -/* e */ -.search-bar { - width: 100%; - margin-bottom: 12px; - padding: 8px; - border: 1px solid var(--border-faint); - border-radius: 4px; - background-color: var(--background-secondary-alt); - color: var(--text-default); - outline: none; -} - -.collection-button { +.vc-gif-collections-button { text-align: center; padding: 8px; - background-color: var(--background-base-low); + background-color: var(--background-secondary); color: var(--text-default); border: none; border-radius: 4px; cursor: pointer; - transition: background-color 0.2s; -} \ No newline at end of file + transition: background-color 0.15s; +} + +.vc-gif-collections-button:hover { + background-color: var(--background-modifier-hover); +} diff --git a/src/equicordplugins/gifCollections/types.ts b/src/equicordplugins/gifCollections/types.ts index 8b1e8ef27..5d94f6d2e 100644 --- a/src/equicordplugins/gifCollections/types.ts +++ b/src/equicordplugins/gifCollections/types.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ -export enum Format { NONE = 0, IMAGE = 1, VIDEO = 2 } +export enum Format { IMAGE = 1, VIDEO = 2 } export interface Category { type: "Trending" | "Category"; @@ -28,11 +28,26 @@ export interface Gif { attachmentId?: string; } -export interface Props { - favorites: { [src: string]: any; }; - trendingCategories: Category[]; -} - type WithRequired = T & { [P in K]-?: T[P] }; export type Collection = WithRequired; + +export interface GifPickerInstance { + props: { + query: string; + resultItems: { id: string; format: Format; src: string; url: string; width: number; height: number; }[]; + trendingCategories: Category[]; + favorites: Category[]; + item: { name?: string; id?: string; src?: string; url?: string; height?: number; width?: number; }; + }; + forceUpdate: () => void; +} + +export interface GifItem { + name?: string; + id?: string; + src?: string; + url?: string; + height?: number; + width?: number; +} diff --git a/src/equicordplugins/gifCollections/utils/cleanUrl.ts b/src/equicordplugins/gifCollections/utils/cleanUrl.ts index 415598487..edba2eac2 100644 --- a/src/equicordplugins/gifCollections/utils/cleanUrl.ts +++ b/src/equicordplugins/gifCollections/utils/cleanUrl.ts @@ -4,8 +4,12 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ -export const cleanUrl = (url: string) => { - const urlObject = new URL(url); - urlObject.search = ""; - return urlObject.href; -}; +export function cleanUrl(url: string): string { + try { + const urlObject = new URL(url); + urlObject.search = ""; + return urlObject.href; + } catch { + return url; + } +} diff --git a/src/equicordplugins/gifCollections/utils/collectionManager.ts b/src/equicordplugins/gifCollections/utils/collectionManager.ts index d7617dab8..eafcc839b 100644 --- a/src/equicordplugins/gifCollections/utils/collectionManager.ts +++ b/src/equicordplugins/gifCollections/utils/collectionManager.ts @@ -5,192 +5,142 @@ */ import { DataStore } from "@api/index"; -import { settings } from "@equicordplugins/gifCollections/index"; -import { Collection, Gif } from "@equicordplugins/gifCollections/types"; import { Toasts } from "@webpack/common"; +import { settings } from "../settings"; +import { Collection, Gif } from "../types"; import { getFormat } from "./getFormat"; +import { logger } from "./misc"; export const DATA_COLLECTION_NAME = "gif-collections-collections"; -// this is here bec async + react class component dont play nice and stutters happen. IF theres a better way of doing it pls let me know export let cache_collections: Collection[] = []; export const getCollections = async (): Promise => (await DataStore.get(DATA_COLLECTION_NAME)) ?? []; -export const getCollection = async (name: string): Promise => { - const collections = await getCollections(); - return collections.find(c => c.name === name); -}; - -export const getCachedCollection = (name: string): Collection | undefined => cache_collections.find(c => c.name === name); - -export const createCollection = async (name: string, gifs: Gif[]): Promise => { - const collections = await getCollections(); - const duplicateCollection = collections.find(c => c.name === `${settings.store.collectionPrefix}${name}`); - if (duplicateCollection) - return Toasts.show({ - message: "That collection already exists", - type: Toasts.Type.FAILURE, - id: Toasts.genId(), - options: { - duration: 3000, - position: Toasts.Position.BOTTOM - } - }); - - // gifs shouldnt be empty because to create a collection you need to right click an image / gif and then create it yk. but cant hurt to have a null-conditional check RIGHT? - const latestGifSrc = gifs[gifs.length - 1]?.src ?? settings.store.defaultEmptyCollectionImage; - const collection: Collection = { - name: `${settings.store.collectionPrefix}${name}`, - src: latestGifSrc, - format: getFormat(latestGifSrc), - type: "Category", - gifs, - createdAt: Date.now(), - lastUpdated: Date.now() - }; - - await DataStore.set(DATA_COLLECTION_NAME, [...collections, collection]); - return await refreshCacheCollection(); -}; - -export const addToCollection = async (name: string, gif: Gif): Promise => { - const collections = await getCollections(); - const collectionIndex = collections.findIndex(c => c.name === name); - if (collectionIndex === -1) return console.warn("collection not found"); - - if (settings.store.preventDuplicates) { - const isDuplicate = collections[collectionIndex].gifs.some(g => g.url === gif.url); - if (isDuplicate) { - return Toasts.show({ - message: "This GIF is already in the collection", - type: Toasts.Type.FAILURE, - id: Toasts.genId(), - options: { - duration: 3000, - position: Toasts.Position.BOTTOM - } - }); - } - } - - collections[collectionIndex].gifs.push(gif); - collections[collectionIndex].src = gif.src; - collections[collectionIndex].format = getFormat(gif.src); - collections[collectionIndex].lastUpdated = Date.now(); - - gif.addedAt = Date.now(); - +async function saveCollections(collections: Collection[]) { + cache_collections = collections; await DataStore.set(DATA_COLLECTION_NAME, collections); - return await refreshCacheCollection(); -}; - -export const renameCollection = async (oldName: string, newName: string): Promise => { - const collections = await getCollections(); - const collectionIndex = collections.findIndex(c => c.name === oldName); - if (collectionIndex === -1) return console.warn("collection not found"); - - collections[collectionIndex].name = `${settings.store.collectionPrefix}${newName}`; - collections[collectionIndex].lastUpdated = Date.now(); - - await DataStore.set(DATA_COLLECTION_NAME, collections); - return await refreshCacheCollection(); -}; - -export const removeFromCollection = async (id: string): Promise => { - const collections = await getCollections(); - const collectionIndex = collections.findIndex(c => c.gifs.some(g => g.id === id)); - if (collectionIndex === -1) return console.warn("collection not found"); - - collections[collectionIndex].gifs = collections[collectionIndex].gifs.filter(g => g.id !== id); - - const collection = collections[collectionIndex]; - const latestGifSrc = collection.gifs.length ? collection.gifs[collection.gifs.length - 1].src : settings.store.defaultEmptyCollectionImage; - collections[collectionIndex].src = latestGifSrc; - collections[collectionIndex].format = getFormat(latestGifSrc); - collections[collectionIndex].lastUpdated = Date.now(); - - await DataStore.set(DATA_COLLECTION_NAME, collections); - return await refreshCacheCollection(); -}; - -export const deleteCollection = async (name: string): Promise => { - const collections = await getCollections(); - const col = collections.filter(c => c.name !== name); - await DataStore.set(DATA_COLLECTION_NAME, col); - await refreshCacheCollection(); -}; +} export const refreshCacheCollection = async (): Promise => { cache_collections = await getCollections(); }; -export const fixPrefix = async (newPrefix: string): Promise => { - const normalizedPrefix = newPrefix.replace(/:+$/, "") + ":"; +export async function createCollection(name: string, gifs: Gif[]): Promise { + const collections = [...cache_collections]; + const fullName = `${settings.store.collectionPrefix}${name}`; - const collections = await getCollections(); + if (collections.some(c => c.name === fullName)) { + Toasts.show({ message: "That collection already exists", type: Toasts.Type.FAILURE, id: Toasts.genId(), options: { duration: 3000, position: Toasts.Position.BOTTOM } }); + return; + } - collections.forEach(c => { - const nameParts = c.name.split(":"); - c.name = `${normalizedPrefix}${nameParts[nameParts.length - 1]}`; + const latestGifSrc = gifs[gifs.length - 1]?.src ?? settings.store.defaultEmptyCollectionImage; + collections.push({ + name: fullName, + src: latestGifSrc, + format: getFormat(latestGifSrc), + type: "Category", + gifs, + createdAt: Date.now(), + lastUpdated: Date.now(), }); - await DataStore.set(DATA_COLLECTION_NAME, collections); - await refreshCacheCollection(); -}; + await saveCollections(collections); +} -export const getItemCollectionNameFromId = (id: string): string | undefined => { - const collections = cache_collections; +export async function addToCollection(name: string, gif: Gif): Promise { + const collections = [...cache_collections]; + const collection = collections.find(c => c.name === name); + if (!collection) return void logger.warn("Collection not found"); + + if (settings.store.preventDuplicates && collection.gifs.some(g => g.url === gif.url)) { + Toasts.show({ message: "This GIF is already in the collection", type: Toasts.Type.FAILURE, id: Toasts.genId(), options: { duration: 3000, position: Toasts.Position.BOTTOM } }); + return; + } + + collection.gifs = [...collection.gifs, { ...gif, addedAt: Date.now() }]; + collection.src = gif.src; + collection.format = getFormat(gif.src); + collection.lastUpdated = Date.now(); + + await saveCollections(collections); +} + +export async function renameCollection(oldName: string, newName: string): Promise { + const collections = [...cache_collections]; + const collection = collections.find(c => c.name === oldName); + if (!collection) return void logger.warn("Collection not found"); + + collection.name = `${settings.store.collectionPrefix}${newName}`; + collection.lastUpdated = Date.now(); + + await saveCollections(collections); +} + +export async function removeFromCollection(id: string): Promise { + const collections = [...cache_collections]; const collection = collections.find(c => c.gifs.some(g => g.id === id)); - return collection?.name; -}; + if (!collection) return void logger.warn("Collection not found"); -export const getGifById = (id: string): Gif | undefined => { - const collections = cache_collections; - const gif = collections.flatMap(c => c.gifs).find(g => g.id === id); - return gif; -}; + collection.gifs = collection.gifs.filter(g => g.id !== id); + const latestGifSrc = collection.gifs.length ? collection.gifs[collection.gifs.length - 1].src : settings.store.defaultEmptyCollectionImage; + collection.src = latestGifSrc; + collection.format = getFormat(latestGifSrc); + collection.lastUpdated = Date.now(); -export const moveGifToCollection = async (gifId: string, fromCollection: string, toCollection: string): Promise => { - const collections = await getCollections(); - const fromCollectionIndex = collections.findIndex(c => c.name === fromCollection); - const toCollectionIndex = collections.findIndex(c => c.name === toCollection); - if (fromCollectionIndex === -1 || toCollectionIndex === -1) return console.warn("collection not found"); + await saveCollections(collections); +} - const gifIndex = collections[fromCollectionIndex].gifs.findIndex(g => g.id === gifId); - if (gifIndex === -1) return console.warn("gif not found"); +export async function deleteCollection(name: string): Promise { + await saveCollections(cache_collections.filter(c => c.name !== name)); +} - const gif = collections[fromCollectionIndex].gifs[gifIndex]; - gif.addedAt = Date.now(); - collections[fromCollectionIndex].gifs.splice(gifIndex, 1); - collections[toCollectionIndex].gifs.push(gif); +export async function moveGifToCollection(gifId: string, fromName: string, toName: string): Promise { + const collections = [...cache_collections]; + const from = collections.find(c => c.name === fromName); + const to = collections.find(c => c.name === toName); + if (!from || !to) return void logger.warn("Collection not found"); - const fromCollectionLatestGifSrc = collections[fromCollectionIndex].gifs.length ? collections[fromCollectionIndex].gifs[collections[fromCollectionIndex].gifs.length - 1].src : settings.store.defaultEmptyCollectionImage; - collections[fromCollectionIndex].src = fromCollectionLatestGifSrc; - collections[fromCollectionIndex].format = getFormat(fromCollectionLatestGifSrc); - collections[fromCollectionIndex].lastUpdated = Date.now(); + const gifIndex = from.gifs.findIndex(g => g.id === gifId); + if (gifIndex === -1) return void logger.warn("Gif not found"); - const toCollectionLatestGifSrc = collections[toCollectionIndex].gifs.length ? collections[toCollectionIndex].gifs[collections[toCollectionIndex].gifs.length - 1].src : settings.store.defaultEmptyCollectionImage; - collections[toCollectionIndex].src = toCollectionLatestGifSrc; - collections[toCollectionIndex].format = getFormat(toCollectionLatestGifSrc); - collections[toCollectionIndex].lastUpdated = Date.now(); + const gif = { ...from.gifs[gifIndex], addedAt: Date.now() }; + from.gifs = from.gifs.filter((_, i) => i !== gifIndex); + to.gifs = [...to.gifs, gif]; - await DataStore.set(DATA_COLLECTION_NAME, collections); - return await refreshCacheCollection(); -}; + const updateCollectionMeta = (col: Collection) => { + const latest = col.gifs.length ? col.gifs[col.gifs.length - 1].src : settings.store.defaultEmptyCollectionImage; + col.src = latest; + col.format = getFormat(latest); + col.lastUpdated = Date.now(); + }; -export const updateGif = async (gifId: string, updatedGif: Gif): Promise => { - const collections = await getCollections(); - const collectionIndex = collections.findIndex(c => c.gifs.some(g => g.id === gifId)); - if (collectionIndex === -1) return console.warn("collection not found"); + updateCollectionMeta(from); + updateCollectionMeta(to); - const gifIndex = collections[collectionIndex].gifs.findIndex(g => g.id === gifId); - if (gifIndex === -1) return console.warn("gif not found"); + await saveCollections(collections); +} - collections[collectionIndex].gifs[gifIndex] = updatedGif; - collections[collectionIndex].lastUpdated = Date.now(); +export async function updateGif(gifId: string, updatedGif: Gif): Promise { + const collections = [...cache_collections]; + const collection = collections.find(c => c.gifs.some(g => g.id === gifId)); + if (!collection) return void logger.warn("Collection not found"); - await DataStore.set(DATA_COLLECTION_NAME, collections); - return await refreshCacheCollection(); -}; + const gifIndex = collection.gifs.findIndex(g => g.id === gifId); + if (gifIndex === -1) return void logger.warn("Gif not found"); + + collection.gifs = collection.gifs.map((g, i) => i === gifIndex ? updatedGif : g); + collection.lastUpdated = Date.now(); + + await saveCollections(collections); +} + +export function getItemCollectionNameFromId(id: string): string | undefined { + return cache_collections.find(c => c.gifs.some(g => g.id === id))?.name; +} + +export function getGifById(id: string): Gif | undefined { + return cache_collections.flatMap(c => c.gifs).find(g => g.id === id); +} diff --git a/src/equicordplugins/gifCollections/utils/getFormat.ts b/src/equicordplugins/gifCollections/utils/getFormat.ts index 3ab90b8ec..238a2d781 100644 --- a/src/equicordplugins/gifCollections/utils/getFormat.ts +++ b/src/equicordplugins/gifCollections/utils/getFormat.ts @@ -4,8 +4,7 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ -import { Format } from "@equicordplugins/gifCollections/types"; - +import { Format } from "../types"; import { getUrlExtension } from "./getUrlExtension"; const videoExtensions = ["mp4", "ogg", "webm", "avi", "wmv", "flv", "mov", "mkv", "m4v"]; diff --git a/src/equicordplugins/gifCollections/utils/getGif.ts b/src/equicordplugins/gifCollections/utils/getGif.ts index caf41cc68..f1f3a2a08 100644 --- a/src/equicordplugins/gifCollections/utils/getGif.ts +++ b/src/equicordplugins/gifCollections/utils/getGif.ts @@ -4,61 +4,58 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ -import { settings } from "@equicordplugins/gifCollections/index"; -import { Gif } from "@equicordplugins/gifCollections/types"; import { Message } from "@vencord/discord-types"; import { MessageStore, SnowflakeUtils } from "@webpack/common"; +import { settings } from "../settings"; +import { Gif } from "../types"; import { cleanUrl } from "./cleanUrl"; import { isAudio } from "./isAudio"; import { uuidv4 } from "./uuidv4"; -export function getGifByTarget(url: string, target?: HTMLDivElement | null): Gif | null { +function isValidSnowflake(snowflake: string): boolean { + return !Number.isNaN(SnowflakeUtils.extractTimestamp(snowflake)); +} + +function getGifByTarget(url: string, target?: HTMLElement | null): Gif | null { const liElement = target?.closest("li"); - if (!target || !liElement || !liElement.id) return null; + if (!target || !liElement?.id) return null; const [channelId, messageId] = liElement.id.split("-").slice(2); - // the isValidSnowFlake part may not be nessesery cuse either way (valid or not) message will be undefined if it doenst find a message /shrug - if (!channelId || !messageId || !isValidSnowFlake(channelId) || !isValidSnowFlake(messageId)) return null; + if (!channelId || !messageId || !isValidSnowflake(channelId) || !isValidSnowflake(messageId)) return null; const message = MessageStore.getMessage(channelId, messageId); - if (!message || !message.embeds.length && !message.attachments.length) return null; + if (!message || (!message.embeds.length && !message.attachments.length)) return null; return getGifByMessageAndUrl(url, message); } -export function getGifByMessageAndTarget(target: HTMLDivElement, message: Message) { - const url = target.closest('[class*="imageWrapper"]')?.querySelector("video")?.src ?? target.closest('[class*="imageWrapper"]')?.querySelector("img")?.src; - +function getGifByMessageAndTarget(target: HTMLElement, message: Message): Gif | null { + const wrapper = target.closest("[class*=\"imageWrapper\"]"); + const url = wrapper?.querySelector("video")?.src ?? wrapper?.querySelector("img")?.src; if (!url) return null; return getGifByMessageAndUrl(url, message); } -export function getGifByMessageAndUrl(url: string, message: Message): Gif | null { - if (!message.embeds.length && !message.attachments.length || isAudio(url)) - return null; +function getGifByMessageAndUrl(url: string, message: Message): Gif | null { + if ((!message.embeds.length && !message.attachments.length) || isAudio(url)) return null; const cleanedUrl = cleanUrl(url); - // find embed with matching url or image/thumbnail url const embed = message.embeds.find(e => { - const hasMatchingUrl = e.url && cleanUrl(e.url) === cleanedUrl; - const hasMatchingImage = e.image && cleanUrl(e.image.url) === cleanedUrl; - const hasMatchingImageProxy = e.image?.proxyURL === cleanedUrl; - const hasMatchingVideoProxy = e.video?.proxyURL === cleanedUrl; - const hasMatchingThumbnailProxy = e.thumbnail?.proxyURL === cleanedUrl; - - return ( - hasMatchingUrl || - hasMatchingImage || - hasMatchingImageProxy || - hasMatchingVideoProxy || - hasMatchingThumbnailProxy - ); + const urls = [ + e.url && cleanUrl(e.url), + e.image && cleanUrl(e.image.url), + e.image?.proxyURL, + e.video?.proxyURL, + e.thumbnail?.proxyURL, + ]; + return urls.some(u => u === cleanedUrl); }); + if (embed) { - if (embed.image) + if (embed.image) { return { id: uuidv4(settings.store.itemPrefix), height: embed.image.height, @@ -66,62 +63,52 @@ export function getGifByMessageAndUrl(url: string, message: Message): Gif | null src: embed.image.proxyURL!, url: embed.image.url, }; - // Tennor - if (embed.video && embed.video.proxyURL) return { - id: uuidv4(settings.store.itemPrefix), - height: embed.video.height, - width: embed.video.width, - src: embed.video.proxyURL, - url: embed.provider?.name === "Tenor" ? embed.url ?? embed.video.url : embed.video.url, - }; - - // Youtube thumbnails and other stuff idk - if (embed.thumbnail && embed.thumbnail.proxyURL) return { - id: uuidv4(settings.store.itemPrefix), - height: embed.thumbnail.height, - width: embed.thumbnail.width, - src: embed.thumbnail.proxyURL, - url: embed.thumbnail.url, - }; + } + if (embed.video?.proxyURL) { + return { + id: uuidv4(settings.store.itemPrefix), + height: embed.video.height, + width: embed.video.width, + src: embed.video.proxyURL, + url: embed.provider?.name === "Tenor" ? embed.url ?? embed.video.url : embed.video.url, + }; + } + if (embed.thumbnail?.proxyURL) { + return { + id: uuidv4(settings.store.itemPrefix), + height: embed.thumbnail.height, + width: embed.thumbnail.width, + src: embed.thumbnail.proxyURL, + url: embed.thumbnail.url, + }; + } } const attachment = message.attachments.find(a => cleanUrl(a.url) === cleanedUrl || a.proxy_url === cleanedUrl); - if (attachment) return { - id: uuidv4(settings.store.itemPrefix), - height: attachment.height ?? 50, - width: attachment.width ?? 50, - src: attachment.proxy_url, - url: attachment.url, - channelId: message.channel_id, - messageId: message.id, - attachmentId: attachment.id - }; + if (attachment) { + return { + id: uuidv4(settings.store.itemPrefix), + height: attachment.height ?? 50, + width: attachment.width ?? 50, + src: attachment.proxy_url, + url: attachment.url, + channelId: message.channel_id, + messageId: message.id, + attachmentId: attachment.id, + }; + } return null; } -export const getGif = (message: Message | null, url: string | null, target: HTMLDivElement | null) => { - if (message && url) { - const gif = getGifByMessageAndUrl(url, message); - if (!gif) return null; - - return gif; - } - if (message && target && !url) { - const gif = getGifByMessageAndTarget(target, message); - if (!gif) return null; - - return gif; - } +export function getGif(message: Message | null, url: string | null, target: HTMLElement | null): Gif | null { + if (message && url) return getGifByMessageAndUrl(url, message); + if (message && target && !url) return getGifByMessageAndTarget(target, message); if (url && target && !message) { - // youtube thumbnail url is message link for some reason eh - const gif = getGifByTarget(url.startsWith("https://discord.com/") ? target.parentElement?.querySelector("img")?.src ?? url : url, target); - if (!gif) return null; - - return gif; + const resolvedUrl = url.startsWith("https://discord.com/") + ? target.parentElement?.querySelector("img")?.src ?? url + : url; + return getGifByTarget(resolvedUrl, target); } -}; - -function isValidSnowFlake(snowflake: string) { - return !Number.isNaN(SnowflakeUtils.extractTimestamp(snowflake)); + return null; } diff --git a/src/equicordplugins/gifCollections/utils/getUrlExtension.ts b/src/equicordplugins/gifCollections/utils/getUrlExtension.ts index 05ca07165..5f33fec5a 100644 --- a/src/equicordplugins/gifCollections/utils/getUrlExtension.ts +++ b/src/equicordplugins/gifCollections/utils/getUrlExtension.ts @@ -5,7 +5,6 @@ */ export function getUrlExtension(url: string) { - // tennor stuff is like //media.tenor/blah/blah if (!url.startsWith("https:")) url = "https:" + url; return new URL(url).pathname.split(".").pop(); } diff --git a/src/equicordplugins/gifCollections/utils/misc.ts b/src/equicordplugins/gifCollections/utils/misc.ts new file mode 100644 index 000000000..685ed08b0 --- /dev/null +++ b/src/equicordplugins/gifCollections/utils/misc.ts @@ -0,0 +1,15 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { classNameFactory } from "@utils/css"; +import { Logger } from "@utils/Logger"; + +export const cl = classNameFactory("vc-gif-collections-"); +export const logger = new Logger("GifCollections"); + +export function stripPrefix(name: string): string { + return name.includes(":") ? name.replace(/.+?:/, "") : name; +} diff --git a/src/equicordplugins/gifCollections/utils/refreshUrl.ts b/src/equicordplugins/gifCollections/utils/refreshUrl.ts index c07922ca5..76d25cdbf 100644 --- a/src/equicordplugins/gifCollections/utils/refreshUrl.ts +++ b/src/equicordplugins/gifCollections/utils/refreshUrl.ts @@ -6,12 +6,15 @@ import { RestAPI } from "@webpack/common"; +import { logger } from "./misc"; + export function isCdnUrlExpired(url: string): boolean { try { const ex = new URL(url).searchParams.get("ex"); if (!ex) return false; return parseInt(ex, 16) * 1000 < Date.now(); - } catch { + } catch (e) { + logger.warn("Failed to parse CDN URL expiry", e); return false; } } @@ -28,7 +31,8 @@ export async function batchRefreshAttachmentUrls(urls: string[]): Promise { - URL.revokeObjectURL(a.href); - document.body.removeChild(a); - }); + if (IS_DISCORD_DESKTOP) { + DiscordNative.fileManager.saveWithDialog(data, "gif-collections.json"); } else { - DiscordNative.fileManager.saveWithDialog(data, filename); + saveFile(new File([data], "gif-collections.json", { type: "application/json" })); } - } export async function exportCollections() { @@ -40,53 +28,23 @@ export async function exportCollections() { } export async function importCollections(data: string) { - try { - var parsed = JSON.parse(data); - } catch (err) { - console.log(data); - throw new Error("Failed to parse JSON: " + String(err)); - } + const parsed = JSON.parse(data); if ("collections" in parsed) { await DataStore.set(DATA_COLLECTION_NAME, parsed.collections); await refreshCacheCollection(); - } else - throw new Error("Invalid Collections"); + } else { + throw new Error("Invalid collections format"); + } } export async function uploadGifCollections(showToast = true): Promise { - if (IS_WEB || IS_EQUIBOP || IS_VESKTOP) { - const input = document.createElement("input"); - input.type = "file"; - input.style.display = "none"; - input.accept = "application/json"; - input.onchange = async () => { - const file = input.files?.[0]; - if (!file) return; - - const reader = new FileReader(); - reader.onload = async () => { - try { - await importCollections(reader.result as string); - if (showToast) toastSuccess(); - } catch (err) { - console.error(err); - // new Logger("SettingsSync").error(err); - if (showToast) toastFailure(err); - } - }; - reader.readAsText(file); - }; - - document.body.appendChild(input); - input.click(); - setImmediate(() => document.body.removeChild(input)); - } else { + if (IS_DISCORD_DESKTOP) { const [file] = await DiscordNative.fileManager.openFiles({ filters: [ { name: "Gif Collections", extensions: ["json"] }, - { name: "all", extensions: ["*"] } - ] + { name: "all", extensions: ["*"] }, + ], }); if (file) { @@ -94,22 +52,32 @@ export async function uploadGifCollections(showToast = true): Promise { await importCollections(new TextDecoder().decode(file.data)); if (showToast) toastSuccess(); } catch (err) { - console.error(err); - // new Logger("SettingsSync").error(err); + logger.error("Failed to import collections", err); if (showToast) toastFailure(err); } } + } else { + const file = await chooseFile("application/json"); + if (!file) return; + + try { + await importCollections(await file.text()); + if (showToast) toastSuccess(); + } catch (err) { + logger.error("Failed to import collections", err); + if (showToast) toastFailure(err); + } } } const toastSuccess = () => Toasts.show({ type: Toasts.Type.SUCCESS, - message: "Settings successfully imported.", - id: Toasts.genId() + message: "Collections imported successfully.", + id: Toasts.genId(), }); -const toastFailure = (err: any) => Toasts.show({ +const toastFailure = (err: unknown) => Toasts.show({ type: Toasts.Type.FAILURE, - message: `Failed to import settings: ${String(err)}`, - id: Toasts.genId() + message: `Failed to import collections: ${String(err)}`, + id: Toasts.genId(), }); diff --git a/src/equicordplugins/gifCollections/utils/uuidv4.ts b/src/equicordplugins/gifCollections/utils/uuidv4.ts index 5ffe08916..0dae663d3 100644 --- a/src/equicordplugins/gifCollections/utils/uuidv4.ts +++ b/src/equicordplugins/gifCollections/utils/uuidv4.ts @@ -5,15 +5,5 @@ */ export function uuidv4(prefix: string) { - let d = new Date().getTime(); - d += performance.now(); - return `${prefix}xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx`.replace(/[xy]/g, c => { - const r = (d + Math.random() * 16) % 16 | 0; - d = Math.floor(d / 16); - if (c === "x") { - return r.toString(16); - } else { - return ((r & 0x3) | 0x8).toString(16); - } - }); + return `${prefix}${crypto.randomUUID()}`; } diff --git a/src/equicordplugins/gifRoulette/index.tsx b/src/equicordplugins/gifRoulette/index.tsx deleted file mode 100644 index fdb527f04..000000000 --- a/src/equicordplugins/gifRoulette/index.tsx +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Vencord, a Discord client mod - * Copyright (c) 2024 Vendicated and contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -import { definePluginSettings } from "@api/Settings"; -import { Devs } from "@utils/constants"; -import definePlugin, { OptionType } from "@utils/types"; -import { UserSettingsActionCreators } from "@webpack/common"; - -function getMessage(opts, other) { - const frecencyStore = UserSettingsActionCreators.FrecencyUserSettingsActionCreators.getCurrentValue(); - - const gifsArray = Object.keys(frecencyStore.favoriteGifs.gifs); - - const chosenGifUrl = gifsArray[Math.floor(Math.random() * gifsArray.length)]; - - let ownerPing = ""; - - if (other.guild != null) { - if (other.guild.ownerId != null && settings.store.pingOwnerChance && Math.random() <= 0.1) { - ownerPing = `<@${other.guild.ownerId}>`; - } - } - - return `${chosenGifUrl} ${ownerPing}`; -} - -const settings = definePluginSettings({ - pingOwnerChance: { - type: OptionType.BOOLEAN, - description: "If there should be a 1 in 10 change to ping the owner of the guild (oh no)", - default: true - } -}); - -export default definePlugin({ - name: "GifRoulette", - description: "Adds a command to send a random gif from your favourites, and a one in ten chance to ping the owner of the server", - authors: [Devs.Samwich], - settings, - commands: [ - { - name: "gifroulette", - description: "Tempt fate and send a gif", - execute: (opts, other) => ({ - content: getMessage(opts, other) - }), - } - ] -}); diff --git a/src/equicordplugins/githubRepos/components/ProfilePopoutComponent.tsx b/src/equicordplugins/githubRepos/components/ProfilePopoutComponent.tsx new file mode 100644 index 000000000..617ac8d3d --- /dev/null +++ b/src/equicordplugins/githubRepos/components/ProfilePopoutComponent.tsx @@ -0,0 +1,139 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { Paragraph } from "@components/Paragraph"; +import { Span } from "@components/Span"; +import { fetchReposByUserId, fetchReposByUsername, fetchUserInfo, GitHubUserInfo } from "@equicordplugins/githubRepos/githubApi"; +import { GitHubRepo } from "@equicordplugins/githubRepos/types"; +import { classes } from "@utils/misc"; +import { openModal } from "@utils/modal"; +import { findCssClassesLazy } from "@webpack"; +import { Clickable, React, useEffect, UserProfileStore, useState } from "@webpack/common"; + +import { ReposModal } from "./ReposModal"; + +const DMSideBarClasses = findCssClassesLazy("widgetPreviews"); +const ProfileCardClasses = findCssClassesLazy("cardsList", "firstCardContainer", "card", "container"); +const ProfileCardContainerClasses = findCssClassesLazy("innerContainer", "icons", "icon", "displayCount", "displayCountText", "displayCountTextColor", "breadcrumb"); +const ProfileCardOverlayClasses = findCssClassesLazy("overlay", "isPrivate", "outer"); + +const LANGUAGE_MAP: Record = { + "c++": "cplusplus", + "c#": "csharp", + "f#": "fsharp", + "q#": "qsharp", + "objective-c": "objectivec", + "visual basic": "visualbasic", + "shell": "bash", + "batchfile": "bash", + "vim script": "vim", + "dockerfile": "docker", + "gdscript": "godot", + "html": "html5", +}; + +function getLanguageIconUrl(language: string | null): string { + if (!language) return "https://cdn.jsdelivr.net/gh/devicons/devicon@develop/icons/github/github-original.svg"; + + const normalized = LANGUAGE_MAP[language.toLowerCase()] ?? language.toLowerCase().replace(/\s+/g, ""); + return `https://cdn.jsdelivr.net/gh/devicons/devicon@develop/icons/${normalized}/${normalized}-original.svg`; +} + +export function ProfilePopoutComponent({ id, isSideBar = false }: { id: string, isSideBar?: boolean; }) { + const [repos, setRepos] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [userInfo, setUserInfo] = useState(null); + + const openReposModal = () => { + if (!userInfo) return; + const sortedRepos = [...repos].sort((a, b) => b.stargazers_count - a.stargazers_count); + openModal(props => ( + + )); + }; + + useEffect(() => { + const fetchData = async () => { + try { + const profile = UserProfileStore.getUserProfile(id); + if (!profile) { setLoading(false); return; } + + const connections = profile.connectedAccounts; + if (!connections?.length) { setLoading(false); return; } + + const githubConnection = connections.find(conn => conn.type === "github"); + if (!githubConnection) { setLoading(false); return; } + + const username = githubConnection.name; + const userInfoData = await fetchUserInfo(username); + if (userInfoData) setUserInfo(userInfoData); + + const githubId = githubConnection.id; + + const reposById = await fetchReposByUserId(githubId); + if (reposById) { setRepos(reposById); setLoading(false); return; } + + const reposByUsername = await fetchReposByUsername(username); + setRepos(reposByUsername); + setLoading(false); + } catch (error) { + setError(error instanceof Error ? error.message : "Failed to fetch repositories"); + setLoading(false); + } + }; + + fetchData(); + }, [id]); + + if (loading || error || !repos.length) return null; + + const topRepos = repos.slice(0, 4); + + const reposSection = ( +
+
    +
  • + +
    + + GitHub Repositories + + {!!repos.length && ( +
    + {topRepos.slice(0, 4).map((repo, idx) => { + const showCount = idx === 3 && repos.length > 4; + + return ( +
    + {repo.language + {showCount && ( +
    + + +{repos.length - 4} + +
    + )} +
    + ); + })} +
    + )} +
    +
    +
  • +
+
+ ); + + return isSideBar + ?
{reposSection}
+ : reposSection; +} diff --git a/src/equicordplugins/githubRepos/components/GitHubReposComponent.tsx b/src/equicordplugins/githubRepos/components/ProfileTabComponent.tsx similarity index 71% rename from src/equicordplugins/githubRepos/components/GitHubReposComponent.tsx rename to src/equicordplugins/githubRepos/components/ProfileTabComponent.tsx index ea9e1e775..3008f94a0 100644 --- a/src/equicordplugins/githubRepos/components/GitHubReposComponent.tsx +++ b/src/equicordplugins/githubRepos/components/ProfileTabComponent.tsx @@ -5,7 +5,6 @@ */ import { BaseText } from "@components/BaseText"; -import { Button, TextButton } from "@components/Button"; import { fetchReposByUserId, fetchReposByUsername, fetchUserInfo, GitHubUserInfo } from "@equicordplugins/githubRepos/githubApi"; import { GitHubRepo } from "@equicordplugins/githubRepos/types"; import { openModal } from "@utils/modal"; @@ -15,12 +14,11 @@ import { cl, settings } from ".."; import { RepoCard } from "./RepoCard"; import { ReposModal } from "./ReposModal"; -export function GitHubReposComponent({ id, theme, variant = "popout" }: { id: string, theme: string; variant?: "popout" | "tab"; }) { +export function ProfileTabComponent({ id }: { id: string, theme: string; }) { const [repos, setRepos] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [userInfo, setUserInfo] = useState(null); - const [returnJustButton, setReturnJustButton] = useState(false); const openReposModal = () => { if (!userInfo) return; @@ -64,8 +62,6 @@ export function GitHubReposComponent({ id, theme, variant = "popout" }: { id: st const githubId = githubConnection.id; - if (!settings.store.showInMiniProfile && variant !== "tab") setReturnJustButton(true); - // Try to fetch by ID first, fall back to username const reposById = await fetchReposByUserId(githubId); if (reposById) { @@ -99,56 +95,26 @@ export function GitHubReposComponent({ id, theme, variant = "popout" }: { id: st if (!repos.length) return null; - if (returnJustButton) { - return ( - - ); - } - - const topRepos = variant === "tab" ? repos : repos.slice(0, 4); - return ( -
+
GitHub Repositories {userInfo && ( - {variant === "tab" - ? ` (${repos.length})` - : ` (Showing only top ${topRepos.length}/${userInfo.totalRepos})` - } + {` (${repos.length})`} )}
- {topRepos.map(repo => ( + {repos.map(repo => ( )) }
- {variant !== "tab" && ( -
- - Show More - -
- )}
); } diff --git a/src/equicordplugins/githubRepos/components/RepoCard.tsx b/src/equicordplugins/githubRepos/components/RepoCard.tsx index f4a4c0c50..3512b1516 100644 --- a/src/equicordplugins/githubRepos/components/RepoCard.tsx +++ b/src/equicordplugins/githubRepos/components/RepoCard.tsx @@ -12,7 +12,7 @@ import { React, Tooltip } from "@webpack/common"; import { cl } from ".."; import { Star } from "./Star"; -export function RepoCard({ repo, showStars, showLanguage, variant }: RepoCardProps & { variant?: "popout" | "tab"; }) { +export function RepoCard({ repo, showStars, showLanguage }: RepoCardProps) { const handleClick = () => window.open(repo.html_url, "_blank"); const renderStars = () => { @@ -62,8 +62,6 @@ export function RepoCard({ repo, showStars, showLanguage, variant }: RepoCardPro ); }; - const cardProps = variant === "tab" ? { onClick: handleClick } : {}; - return ( <> {repo.description ? ( @@ -72,7 +70,7 @@ export function RepoCard({ repo, showStars, showLanguage, variant }: RepoCardPro
@@ -85,7 +83,7 @@ export function RepoCard({ repo, showStars, showLanguage, variant }: RepoCardPro )} ) : ( -
+
{repo.name} diff --git a/src/equicordplugins/githubRepos/index.tsx b/src/equicordplugins/githubRepos/index.tsx index c621e4991..75f566a82 100644 --- a/src/equicordplugins/githubRepos/index.tsx +++ b/src/equicordplugins/githubRepos/index.tsx @@ -7,7 +7,6 @@ import "./styles.css"; import { definePluginSettings } from "@api/Settings"; -import { BaseText } from "@components/BaseText"; import ErrorBoundary from "@components/ErrorBoundary"; import { EquicordDevs } from "@utils/constants"; import { classNameFactory } from "@utils/css"; @@ -16,7 +15,10 @@ import { User } from "@vencord/discord-types"; import { findByCodeLazy } from "@webpack"; import { React } from "@webpack/common"; -import { GitHubReposComponent } from "./components/GitHubReposComponent"; +import { ProfilePopoutComponent } from "./components/ProfilePopoutComponent"; +import { ProfileTabComponent } from "./components/ProfileTabComponent"; + +const getProfileThemeProps = findByCodeLazy(".getPreviewThemeColors", "primaryColor:"); export const cl = classNameFactory("vc-github-repos-"); @@ -31,52 +33,8 @@ export const settings = definePluginSettings({ description: "Show repository language", default: true }, - showInMiniProfile: { - type: OptionType.BOOLEAN, - description: "Show full ui in the mini profile instead of just a button", - default: true - }, - showRepositoryTab: { - type: OptionType.BOOLEAN, - description: "Show repositories tab in profile modal (hides button in connections when enabled)", - default: true - }, }); -const getProfileThemeProps = findByCodeLazy(".getPreviewThemeColors", "primaryColor:"); - -const ProfilePopoutComponent = ErrorBoundary.wrap( - (props: { user: User; displayProfile?: any; }) => { - return ( - - ); - }, - { - noop: true, - fallback: () => - Error, Failed to render GithubRepos - - } -); - -const ProfileRepositoriesTab = ErrorBoundary.wrap( - (props: { user: User; displayProfile?: any; }) => { - return ( - - ); - }, - { noop: true } -); - export default definePlugin({ name: "GitHubRepos", description: "Displays a user's public GitHub repositories in their profile", @@ -84,30 +42,12 @@ export default definePlugin({ settings, patches: [ - // User Popout - { - find: /onOpenUserProfileModal:\i\}\),\i/, - replacement: { - match: /userId:\i\.id,guild:\i.{0,15}\}\).{0,100}(?=\])/, - replace: "$&,$self.ProfilePopoutComponent(arguments[0])" - } - }, - // User Profile Modal v2 - { - find: ".MODAL_V2,onClose:", - replacement: { - match: /displayProfile:(\i).*?connections:\i.{0,25}\i.\i\}\)\}\)/, - replace: "$&,$self.ProfilePopoutComponent({ user: arguments[0].user, displayProfile: $1 }),", - predicate: () => !settings.store.showRepositoryTab, - } - }, // User Profile Modal v2 tab bar { find: "#{intl::USER_PROFILE_ACTIVITY}", replacement: { match: /\.MUTUAL_GUILDS\}\)\)(?=,(\i))/, replace: '$&,$1.push({text:"GitHub",section:"GITHUB"})', - predicate: () => settings.store.showRepositoryTab, } }, // User Profile Modal v2 tab content @@ -115,10 +55,25 @@ export default definePlugin({ find: ".WIDGETS?", replacement: { match: /(\i)===\i\.\i\.WISHLIST/, - replace: '$1==="GITHUB"?$self.ProfileRepositoriesTab(arguments[0]):$&' + replace: '$1==="GITHUB"?$self.renderProfileRepositoriesTab(arguments[0]):$&', } } ], - ProfilePopoutComponent, - ProfileRepositoriesTab + renderProfileCollection: (props: { user: User; displayProfile?: any; }) => { + return ( + + ); + }, + renderProfileRepositoriesTab: ErrorBoundary.wrap((props: { user: User; displayProfile?: any; }) => { + return ( + + ); + }, { noop: true }), }); diff --git a/src/equicordplugins/githubRepos/styles.css b/src/equicordplugins/githubRepos/styles.css index fdfe19b34..4d1791d1a 100644 --- a/src/equicordplugins/githubRepos/styles.css +++ b/src/equicordplugins/githubRepos/styles.css @@ -25,7 +25,11 @@ justify-content: center; margin-top: 2px; padding-top: 12px; - border-top: 1px solid var(--profile-body-divider-color, var(--input-border-default, var(--input-border))); + border-top: 1px solid + var( + --profile-body-divider-color, + var(--input-border-default, var(--input-border)) + ); } .vc-github-repos-button, @@ -37,7 +41,11 @@ .vc-github-repos-card { padding: 8px; border-radius: var(--radius-xs); - border: 1px solid var(--profile-body-divider-color, var(--input-border-default, var(--input-border))); + border: 1px solid + var( + --profile-body-divider-color, + var(--input-border-default, var(--input-border)) + ); } .vc-github-repos-header { @@ -227,19 +235,19 @@ } .vc-github-repos-header-repo { - width: 20% + width: 20%; } .vc-github-repos-header-description { - width: 50% + width: 50%; } .vc-github-repos-header-language { - width: 15% + width: 15%; } .vc-github-repos-header-stars { - width: 10% + width: 10%; } /* Tab variant styles */ @@ -291,4 +299,4 @@ .vc-github-repos-container.vc-github-repos-tab .vc-github-repos-star-icon, .vc-github-repos-container.vc-github-repos-tab .vc-github-repos-stars { margin-left: 2px; -} \ No newline at end of file +} diff --git a/src/equicordplugins/guildTagSettings/index.ts b/src/equicordplugins/guildTagSettings/index.ts deleted file mode 100644 index bbf6f5c0c..000000000 --- a/src/equicordplugins/guildTagSettings/index.ts +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Vencord, a modification for Discord's desktop app - * Copyright (c) 2022 Vendicated and Megumin - * - * 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 . -*/ - -import { definePluginSettings } from "@api/Settings"; -import { disableStyle, enableStyle } from "@api/Styles"; -import { Devs } from "@utils/constants"; -import definePlugin, { OptionType } from "@utils/types"; - -import clanBadges from "./styles.css?managed"; - -const settings = definePluginSettings({ - hideTags: { - type: OptionType.BOOLEAN, - description: "Hide tags", - default: false, - onChange: value => { - if (value) enableStyle(clanBadges); - else disableStyle(clanBadges); - } - }, - disableAdoptTagPrompt: { - type: OptionType.BOOLEAN, - description: "Disable the prompt to adopt tags", - default: true, - restartNeeded: true - } -}); - -export default definePlugin({ - name: "GuildTagSettings", - description: "Adds some settings for guild tags, such as hiding them or disabling the prompt to adopt them.", - authors: [Devs.thororen], - settings, - patches: [ - { - find: "GuildTagAvailableCoachmark", - replacement: { - match: /return.{0,100}shouldShow/g, - replace: "return null;$&" - }, - predicate: () => settings.store.disableAdoptTagPrompt - } - ], - start() { - if (settings.store.hideTags) enableStyle(clanBadges); - }, - stop() { - if (settings.store.hideTags) disableStyle(clanBadges); - } -}); diff --git a/src/equicordplugins/guildTagSettings/styles.css b/src/equicordplugins/guildTagSettings/styles.css deleted file mode 100644 index 315575fb1..000000000 --- a/src/equicordplugins/guildTagSettings/styles.css +++ /dev/null @@ -1,3 +0,0 @@ -[class*="chipletContainerInner"]:has([src *="/clan-badges/"]) { - display: none; -} \ No newline at end of file diff --git a/src/equicordplugins/homeTyping/index.tsx b/src/equicordplugins/homeTyping/index.tsx index a52bc2957..83fa1c0b9 100644 --- a/src/equicordplugins/homeTyping/index.tsx +++ b/src/equicordplugins/homeTyping/index.tsx @@ -5,12 +5,14 @@ */ import { Devs } from "@utils/constants"; +import { classNameFactory } from "@utils/css"; import definePlugin from "@utils/types"; import { findComponentByCodeLazy, findStoreLazy } from "@webpack"; import { TypingStore, UserStore, useStateFromStores } from "@webpack/common"; -const ThreeDots = findComponentByCodeLazy("Math.min(1,Math.max(", "dotRadius:"); +const cl = classNameFactory("vc-home-typing-"); +const ThreeDots = findComponentByCodeLazy("Math.min(1,Math.max(", "dotRadius:"); const PrivateChannelSortStore = findStoreLazy("PrivateChannelSortStore") as { getPrivateChannelIds: () => string[]; }; export default definePlugin({ @@ -18,7 +20,7 @@ export default definePlugin({ description: "Changes the home button to a typing indicator if someone in your dms is typing", authors: [Devs.Samwich], TypingIcon() { - return ; + return ; }, isTyping() { return useStateFromStores([TypingStore], () => diff --git a/src/equicordplugins/iRememberYou/index.tsx b/src/equicordplugins/iRememberYou/index.tsx index 8b4614331..dc9f62b57 100644 --- a/src/equicordplugins/iRememberYou/index.tsx +++ b/src/equicordplugins/iRememberYou/index.tsx @@ -31,7 +31,6 @@ export default definePlugin({ Component: () => , Icon: EyeIcon }); - SettingsPlugin.settingsSectionMap.push(["EquicordIRememberYou", "equicord_i_remember_you"]); const data = (this.dataManager = await new Data().withStart()); @@ -46,7 +45,6 @@ export default definePlugin({ stop() { removeFromArray(SettingsPlugin.customEntries, e => e.key === "equicord_i_remember_you"); - removeFromArray(SettingsPlugin.settingsSectionMap, entry => entry[1] === "equicord_i_remember_you"); const dataManager = this.dataManager as Data; removeMessagePreSendListener(dataManager._onMessagePreSend_preSend); diff --git a/src/equicordplugins/iconViewer/components/IconsTab.tsx b/src/equicordplugins/iconViewer/components/IconsTab.tsx index 2996e33ef..ca06e9797 100644 --- a/src/equicordplugins/iconViewer/components/IconsTab.tsx +++ b/src/equicordplugins/iconViewer/components/IconsTab.tsx @@ -16,7 +16,7 @@ import { Icon } from "@vencord/discord-types"; import { Clickable, TextInput, useCallback, useEffect, useMemo, useState } from "@webpack/common"; import { IconsDef } from "../types"; -import { getIconsModule } from "../utils"; +import { IconsModule } from "../utils"; import { openIconModal } from "./Modals"; let cachedIcons: IconsDef | null = null; @@ -24,9 +24,8 @@ let cachedIcons: IconsDef | null = null; function getIcons(): IconsDef { if (cachedIcons) return cachedIcons; - const iconsModule = getIconsModule(); cachedIcons = Object.fromEntries( - Object.entries(iconsModule).filter(([name, fn]) => + Object.entries(IconsModule).filter(([name, fn]) => typeof fn === "function" && name.endsWith("Icon") ) ); diff --git a/src/equicordplugins/iconViewer/index.tsx b/src/equicordplugins/iconViewer/index.tsx index f2135cbf2..1f364adf5 100644 --- a/src/equicordplugins/iconViewer/index.tsx +++ b/src/equicordplugins/iconViewer/index.tsx @@ -35,11 +35,8 @@ export default definePlugin({ Component: IconsTab, Icon: MagnifyingGlassIcon }); - - SettingsPlugin.settingsSectionMap.push(["EquicordDiscordIcons", "equicord_icon_viewer"]); }, stop() { removeFromArray(SettingsPlugin.customEntries, e => e.key === "equicord_icon_viewer"); - removeFromArray(SettingsPlugin.settingsSectionMap, entry => entry[1] === "equicord_icon_viewer"); }, }); diff --git a/src/equicordplugins/iconViewer/utils.ts b/src/equicordplugins/iconViewer/utils.ts index 972a77e34..09f6e6a94 100644 --- a/src/equicordplugins/iconViewer/utils.ts +++ b/src/equicordplugins/iconViewer/utils.ts @@ -12,7 +12,7 @@ import { CssColorData, IconSize } from "./types"; let colorKeys: string[] = []; const Colors = findByPropsLazy("colors", "layout"); -const IconsModule = findByPropsLazy("AngleBracketsIcon", "StaffBadgeIcon") as Record; +export const IconsModule = findByPropsLazy("AngleBracketsIcon", "StaffBadgeIcon") as Record; export const iconSizesInPx: Record = findByPropsLazy("md", "lg", "xxs"); export const iconSizes: IconSize[] = ["xxs", "xs", "sm", "md", "lg"]; @@ -37,10 +37,6 @@ export const cssColors = new Proxy({} as Record, { } }); -export function getIconsModule(): Record { - return IconsModule; -} - waitFor(["colors", "layout"], m => { colorKeys = Object.keys(m.colors); }); diff --git a/src/equicordplugins/imgToGif/index.tsx b/src/equicordplugins/imgToGif/index.tsx deleted file mode 100644 index fc4d81d52..000000000 --- a/src/equicordplugins/imgToGif/index.tsx +++ /dev/null @@ -1,157 +0,0 @@ -/* - * Vencord, a modification for Discord's desktop app - * Copyright (c) 2022 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 . -*/ - -import { ApplicationCommandInputType, ApplicationCommandOptionType, sendBotMessage } from "@api/Commands"; -import { EquicordDevs } from "@utils/constants"; -import definePlugin from "@utils/types"; -import { CommandArgument, CommandContext } from "@vencord/discord-types"; -import { DraftType, UploadAttachmentStore, UploadHandler, UploadManager } from "@webpack/common"; -import { applyPalette, GIFEncoder, quantize } from "gifenc"; - -const FRAMES = 1; - -function loadImage(source: File | string) { - const isFile = source instanceof File; - const url = isFile ? URL.createObjectURL(source) : source; - - return new Promise((resolve, reject) => { - const img = new Image(); - img.onload = () => { - if (isFile) URL.revokeObjectURL(url); - resolve(img); - }; - img.onerror = (event, _source, _lineno, _colno, err) => reject(err || event); - img.crossOrigin = "Anonymous"; - img.src = url; - }); -} - -async function resolveImage(options: CommandArgument[], ctx: CommandContext): Promise<{ image: File | null; width: number | null; height: number | null; }> { - let image: File | null = null; - let width: number | null = null; - let height: number | null = null; - - for (const opt of options) { - switch (opt.name) { - case "image": - const upload = UploadAttachmentStore.getUpload(ctx.channel.id, opt.name, DraftType.SlashCommand); - if (upload) { - if (!upload.isImage) { - UploadManager.clearAll(ctx.channel.id, DraftType.SlashCommand); - throw "Upload is not an image"; - } - image = upload.item.file; - } - break; - case "width": - width = Number(opt.value); - break; - case "height": - height = Number(opt.value); - break; - } - } - - UploadManager.clearAll(ctx.channel.id, DraftType.SlashCommand); - return { image, width, height }; -} - -export default definePlugin({ - name: "ImgToGif", - description: "Adds a /imgtogif slash command to create a gif from any image", - authors: [EquicordDevs.zyqunix], - commands: [ - { - inputType: ApplicationCommandInputType.BUILT_IN, - name: "imgtogif", - description: "Allows you to turn an image to a gif", - options: [ - { - name: "image", - description: "Image attachment to use", - type: ApplicationCommandOptionType.ATTACHMENT - }, - { - name: "width", - description: "Width of the gif", - type: ApplicationCommandOptionType.INTEGER - }, - { - name: "height", - description: "Height of the gif", - type: ApplicationCommandOptionType.INTEGER - } - ], - execute: async (opts, cmdCtx) => { - try { - const { image, width, height } = await resolveImage(opts, cmdCtx); - if (!image) throw "No Image specified!"; - - const avatar = await loadImage(image); - - let gifWidth: number; - let gifHeight: number; - - if (width && height) { - gifWidth = width; - gifHeight = height; - } else if (width) { - gifWidth = width; - gifHeight = Math.round((avatar.height / avatar.width) * width); - } else if (height) { - gifHeight = height; - gifWidth = Math.round((avatar.width / avatar.height) * height); - } else { - gifWidth = avatar.width; - gifHeight = avatar.height; - } - - const gif = GIFEncoder(); - const canvas = document.createElement("canvas"); - canvas.width = gifWidth; - canvas.height = gifHeight; - const ctx = canvas.getContext("2d")!; - - UploadManager.clearAll(cmdCtx.channel.id, DraftType.SlashCommand); - - for (let i = 0; i < FRAMES; i++) { - ctx.clearRect(0, 0, canvas.width, canvas.height); - ctx.drawImage(avatar, 0, 0, avatar.width, avatar.height, 0, 0, canvas.width, canvas.height); - - const { data } = ctx.getImageData(0, 0, canvas.width, canvas.height); - const palette = quantize(data, 256); - const index = applyPalette(data, palette); - - gif.writeFrame(index, canvas.width, canvas.height, { - transparent: true, - palette, - }); - } - - gif.finish(); - const originalName = image.name ? image.name.replace(/\.[^/.]+$/, "") : "converted"; - const file = new File([new Uint8Array(gif.bytesView())], `${originalName}.gif`, { type: "image/gif" }); - setTimeout(() => UploadHandler.promptToUpload([file], cmdCtx.channel, DraftType.ChannelMessage), 10); - } catch (err) { - UploadManager.clearAll(cmdCtx.channel.id, DraftType.SlashCommand); - sendBotMessage(cmdCtx.channel.id, { content: String(err) }); - } - }, - }, - ] -}); diff --git a/src/equicordplugins/jellyfinRichPresence/index.tsx b/src/equicordplugins/jellyfinRichPresence/index.tsx deleted file mode 100644 index 2b4ac9ec6..000000000 --- a/src/equicordplugins/jellyfinRichPresence/index.tsx +++ /dev/null @@ -1,419 +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 LastFMRichPresence -import { definePluginSettings } from "@api/Settings"; -import { HeadingSecondary } from "@components/Heading"; -import { Paragraph } from "@components/Paragraph"; -import { Devs, 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 ActivityButton { - label: string; - url: string; -} - -interface Activity { - state: string; - details?: string; - timestamps?: { - start?: number; - }; - assets?: ActivityAssets; - buttons?: Array; - name: string; - application_id: string; - metadata?: { - button_urls?: Array; - }; - type: number; - flags: number; -} - -interface MediaData { - name: string; - type: string; - artist?: string; - album?: string; - seriesName?: string; - seasonNumber?: number; - episodeNumber?: number; - year?: number; - url?: string; - imageUrl?: string; - duration?: number; - position?: number; - isPaused?: boolean; -} - -const settings = definePluginSettings({ - serverUrl: { - description: "Jellyfin server URL (e.g., https://jellyfin.example.com)", - type: OptionType.STRING, - }, - apiKey: { - description: "Jellyfin API key obtained from your Jellyfin administration dashboard", - type: OptionType.STRING, - }, - userId: { - description: "Jellyfin user ID obtained from your user profile URL", - type: OptionType.STRING, - }, - nameDisplay: { - description: "Choose how the application name should appear in Rich Presence", - type: OptionType.SELECT, - options: [ - { label: "Series/Movie Name", value: "default", default: true }, - { label: "Series - Episode/Track/Movie Name", value: "full" }, - { label: "Custom", value: "custom" }, - ], - }, - customName: { - description: "Custom Rich Presence name (only used if 'Custom' is selected).\nOptions: {name}, {series}, {season}, {episode}, {artist}, {album}, {year}", - type: OptionType.STRING, - }, - coverType: { - description: "Choose which cover to display when watching a TV show", - type: OptionType.SELECT, - options: [ - { label: "Series Cover", value: "series", default: true }, - { label: "Episode Cover", value: "episode" }, - ], - }, - episodeFormat: { - description: "Episode number format", - type: OptionType.SELECT, - options: [ - { label: "S01E01", value: "long", default: true }, - { label: "1x01", value: "short" }, - { label: "Season 1 Episode 1", value: "fulltext" }, - ], - }, - showEpisodeName: { - description: "Show episode name after season/episode info", - type: OptionType.BOOLEAN, - default: false, - }, - overrideRichPresenceType: { - description: "Override the rich presence type", - type: OptionType.SELECT, - options: [ - { - label: "Off", - value: false, - default: true, - }, - { - label: "Listening", - value: 2, - }, - { - label: "Playing", - value: 0, - }, - { - label: "Streaming", - value: 1, - }, - { - label: "Watching", - value: 3 - }, - ], - }, - showPausedState: { - description: "Show Rich Presence when media is paused", - type: OptionType.BOOLEAN, - default: true, - }, - privacyMode: { - description: "Privacy Mode (Hide media details like Episode/Song Name)", - type: OptionType.BOOLEAN, - default: false, - }, -}); - -const applicationId = "1381368130164625469"; - -const logger = new Logger("JellyfinRichPresence"); - -let updateInterval: NodeJS.Timeout | undefined; - -async function getApplicationAsset(key: string): Promise { - return (await ApplicationAssetUtils.fetchAssetIds(applicationId, [key]))[0]; -} - -function setActivity(activity: Activity | null) { - FluxDispatcher.dispatch({ - type: "LOCAL_ACTIVITY_UPDATE", - activity, - socketId: "Jellyfin", - }); -} - -export default definePlugin({ - name: "JellyfinRichPresence", - description: "Rich presence for Jellyfin media server", - authors: [EquicordDevs.vmohammad, Devs.SerStars, EquicordDevs.ZcraftElite], - - settingsAboutComponent: () => ( - <> - How to get an API key - - Auth token can be found by following these steps: -
    -
  1. 1. Log into your Jellyfin instance
  2. -
  3. 2. Open your browser's Developer Tools (usually F12 or right-click then Inspect)
  4. -
  5. 3. Go to the Network tab in Developer Tools
  6. -
  7. 4. Look for requests to your Jellyfin server
  8. -
  9. - 5. In the request headers, find X-MediaBrowser-Token or Authorization -
    - - Easiest way: press Ctrl+F in the Developer Tools and search for X-MediaBrowser-Token - -
  10. -
-
- You'll also need your User ID, which can be found in the url of your user profile page. -
- - ), - - settings, - - start() { - this.updatePresence(); - updateInterval = setInterval(() => { this.updatePresence(); }, 10000); - }, - - stop() { - clearInterval(updateInterval); - updateInterval = undefined; - }, - - async fetchMediaData(): Promise { - if (!settings.store.serverUrl || !settings.store.apiKey || !settings.store.userId) { - logger.warn("Jellyfin server URL, API key, or user ID is not set in settings."); - showToast("JellyfinRPC is not configured.", "failure", { - duration: 15000, - }); - return null; - } - - try { - const baseUrl = settings.store.serverUrl.replace(/\/$/, ""); - const url = `${baseUrl}/Sessions?api_key=${settings.store.apiKey}`; - - const res = await fetch(url); - if (!res.ok) throw `${res.status} ${res.statusText}`; - - const sessions = await res.json(); - const userSession = sessions.find((session: any) => - session.UserId === settings.store.userId && session.NowPlayingItem - ); - - if (!userSession || !userSession.NowPlayingItem) return null; - - const item = userSession.NowPlayingItem; - const playState = userSession.PlayState; - - if (playState?.IsPaused && !settings.store.showPausedState) return null; - - const imageUrl = item.ImageTags?.Primary - ? `${baseUrl}/Items/${item.Type === "Episode" && - item.SeriesId && - settings.store.coverType === "series" - ? item.SeriesId - : item.Id - }/Images/Primary` - : undefined; - - return { - name: item.Name || "Unknown", - type: item.Type, - artist: item.Artists?.[0] || item.AlbumArtist, - album: item.Album, - seriesName: item.SeriesName, - seasonNumber: item.ParentIndexNumber, - episodeNumber: item.IndexNumber, - year: item.ProductionYear, - url: `${baseUrl}/web/#!/details?id=${item.Id}`, - imageUrl, - duration: item.RunTimeTicks ? Math.floor(item.RunTimeTicks / 10000000) : undefined, - position: playState?.PositionTicks ? Math.floor(playState.PositionTicks / 10000000) : undefined, - isPaused: !!playState?.IsPaused, - }; - } catch (e) { - logger.error("Failed to query Jellyfin API", e); - return null; - } - }, - - async updatePresence() { - setActivity(await this.getActivity()); - }, - - async getActivity(): Promise { - let richPresenceType; - let appName: string; - const nameSetting = settings.store.nameDisplay || "default"; - - const mediaData = await this.fetchMediaData(); - if (!mediaData) return null; - - if (settings.store.overrideRichPresenceType) { - richPresenceType = settings.store.overrideRichPresenceType; - } else { - switch (mediaData.type) { - case "Audio": - richPresenceType = 2; - break; - default: - richPresenceType = 3; - break; - } - } - - const templateReplace = (template: string) => { - return template - .replace(/\{name\}/g, mediaData.name || "") - .replace(/\{series\}/g, mediaData.seriesName || "") - .replace(/\{season\}/g, mediaData.seasonNumber?.toString() || "") - .replace(/\{episode\}/g, mediaData.episodeNumber?.toString() || "") - .replace(/\{artist\}/g, mediaData.artist || "") - .replace(/\{album\}/g, mediaData.album || "") - .replace(/\{year\}/g, mediaData.year?.toString() || ""); - }; - - switch (nameSetting) { - case "full": - if (mediaData.type === "Episode" && mediaData.seriesName) { - appName = settings.store.privacyMode - ? `${mediaData.seriesName} - [Episode Hidden]` - : `${mediaData.seriesName} - ${mediaData.name}`; - } else if (mediaData.type === "Audio") { - appName = settings.store.privacyMode - ? "[Track Hidden]" - : `${mediaData.artist || "Unknown Artist"} - ${mediaData.name}`; - } else { - appName = settings.store.privacyMode - ? "[Movie Hidden]" - : mediaData.name || "Jellyfin"; - } - break; - case "custom": - appName = templateReplace(settings.store.customName || "{name} on Jellyfin"); - if (settings.store.privacyMode) { - appName = appName - .replace(mediaData.name || "", "[Title Hidden]") - .replace(mediaData.seriesName || "", "[Series Hidden]") - .replace(mediaData.artist || "", "[Artist Hidden]") - .replace(mediaData.album || "", "[Album Hidden]"); - } - break; - case "default": - default: - if (mediaData.type === "Episode" && mediaData.seriesName) { - appName = mediaData.seriesName; - } else { - appName = settings.store.privacyMode - ? "[Media Hidden]" - : mediaData.name || "Jellyfin"; - } - break; - } - - const assets: ActivityAssets = { - large_image: (mediaData.imageUrl - ? await getApplicationAsset(mediaData.imageUrl) - : undefined), - large_text: mediaData.seriesName || mediaData.album || undefined, - }; - - if (settings.store.privacyMode) { - assets.large_image = undefined; - } - - const buttons: ActivityButton[] = []; - - const getDetails = () => { - if (mediaData.type === "Episode" && mediaData.seriesName) { - return settings.store.privacyMode ? "Watching a TV Show" : mediaData.seriesName; - } - return settings.store.privacyMode ? "Watching Something" : mediaData.name; - }; - - const getState = () => { - if (mediaData.isPaused) { - return "Paused"; - } - if (mediaData.type === "Episode" && mediaData.seriesName) { - let episodeFormat = ""; - const season = mediaData.seasonNumber; - const episode = mediaData.episodeNumber; - const format = settings.store.episodeFormat || "long"; - - if (season != null && episode != null) { - switch (format) { - case "long": - episodeFormat = `S${season.toString().padStart(2, "0")}E${episode.toString().padStart(2, "0")}`; - break; - case "short": - episodeFormat = `${season}x${episode.toString().padStart(2, "0")}`; - break; - case "fulltext": - episodeFormat = `Season ${season} Episode ${episode}`; - break; - } - } else if (season != null) { - episodeFormat = format === "fulltext" ? `Season ${season}` : `S${season.toString().padStart(2, "0")}`; - } else if (episode != null) { - episodeFormat = format === "fulltext" ? `Episode ${episode}` : `E${episode.toString().padStart(2, "0")}`; - } - - if (settings.store.showEpisodeName && mediaData.name && !settings.store.privacyMode) { - return `${episodeFormat} - ${mediaData.name}`; - } - return episodeFormat; - } - if (settings.store.privacyMode) { - return mediaData.type === "Audio" ? "Listening to music" : (mediaData.year ? "(????)" : undefined); - } - return mediaData.artist || (mediaData.year ? `(${mediaData.year})` : undefined); - }; - - const timestamps = (!mediaData.isPaused && mediaData.position != null && mediaData.duration != null) ? { - start: Date.now() - (mediaData.position * 1000), - end: Date.now() + ((mediaData.duration - mediaData.position) * 1000) - } : undefined; - - return { - application_id: applicationId, - name: appName, - details: getDetails(), - state: getState() || "something", - assets, - timestamps, - - buttons: buttons.length ? buttons.map(v => v.label) : undefined, - metadata: { - button_urls: buttons.map(v => v.url), - }, - type: richPresenceType, - flags: 1 - }; - } -}); diff --git a/src/equicordplugins/listenBrainzRPC/index.tsx b/src/equicordplugins/listenBrainzRPC/index.tsx deleted file mode 100644 index b69230856..000000000 --- a/src/equicordplugins/listenBrainzRPC/index.tsx +++ /dev/null @@ -1,376 +0,0 @@ -/* - * Vencord, a Discord client mod - * Copyright (c) 2025 Vendicated and contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -import { definePluginSettings } from "@api/Settings"; -import { HeadingSecondary } from "@components/Heading"; -import { Link } from "@components/Link"; -import { Paragraph } from "@components/Paragraph"; -import { EquicordDevs } from "@utils/constants"; -import { Logger } from "@utils/Logger"; -import definePlugin, { OptionType } from "@utils/types"; -import { Activity, ActivityAssets, ActivityButton } from "@vencord/discord-types"; -import { ActivityFlags, ActivityType } from "@vencord/discord-types/enums"; -import { findByPropsLazy } from "@webpack"; -import { ApplicationAssetUtils, FluxDispatcher } from "@webpack/common"; - -interface TrackData { - name: string; - album: string; - artist: string; - durationMs?: number; - recordingMBID?: string; - url: string; - imageUrl?: string; -} - -const enum NameFormat { - StatusName = "status-name", - ArtistFirst = "artist-first", - SongFirst = "song-first", - ArtistOnly = "artist", - SongOnly = "song", - AlbumName = "album", -} - -const applicationId = "1090155131007406132"; -const placeholderId = "2a96cbd8b46e442fc41c2b86b821562f"; - -const logger = new Logger("ListenBrainzRPC"); - -const PresenceStore = findByPropsLazy("getLocalPresence"); - -let updateInterval: NodeJS.Timeout | undefined; - -async function getApplicationAsset(key: string): Promise { - return (await ApplicationAssetUtils.fetchAssetIds(applicationId, [key]))[0]; -} - -function setActivity(activity: Activity | null) { - FluxDispatcher.dispatch({ - type: "LOCAL_ACTIVITY_UPDATE", - activity, - socketId: "ListenBrainz", - }); -} - -const settings = definePluginSettings({ - username: { - description: "ListenBrainz username", - type: OptionType.STRING, - }, - mbContact: { - description: "ListenBrainz contact", - type: OptionType.STRING, - }, - shareUsername: { - description: - "show link to ListenBrainz profile (may only be visible to other users)", - type: OptionType.BOOLEAN, - default: false, - }, - shareSong: { - description: - "show link to song on ListenBrainz (may only be visible to other users)", - type: OptionType.BOOLEAN, - default: true, - }, - hideWithSpotify: { - description: "hide ListenBrainz presence if Spotify is running", - type: OptionType.BOOLEAN, - default: true, - }, - hideWithActivity: { - description: "hide ListenBrainz presence if you have any other presence", - type: OptionType.BOOLEAN, - default: false, - }, - useTimeBar: { - description: - "use track duration to display a time bar (must be using Listening status)", - type: OptionType.BOOLEAN, - default: true, - }, - statusName: { - description: "custom status text", - type: OptionType.STRING, - default: "some music", - }, - nameFormat: { - description: "Show name of song and artist in status name", - type: OptionType.SELECT, - options: [ - { - label: "Use custom status name", - value: NameFormat.StatusName, - default: true, - }, - { - label: "Use format 'artist - song'", - value: NameFormat.ArtistFirst, - }, - { - label: "Use format 'song - artist'", - value: NameFormat.SongFirst, - }, - { - label: "Use artist name only", - value: NameFormat.ArtistOnly, - }, - { - label: "Use song name only", - value: NameFormat.SongOnly, - }, - { - label: - "Use album name (falls back to custom status text if song has no album)", - value: NameFormat.AlbumName, - }, - ], - }, - useListeningStatus: { - description: 'show "Listening to" status instead of "Playing"', - type: OptionType.BOOLEAN, - default: true, - }, - missingArt: { - description: "When album or album art is missing", - type: OptionType.SELECT, - options: [ - { - label: "Use large ListenBrainz logo", - value: "listenbrainzLogo", - default: true, - }, - { - label: "Use generic placeholder", - value: "placeholder", - }, - ], - }, - useLogo: { - description: "Show ListenBrainz logo on album art", - type: OptionType.BOOLEAN, - default: true, - }, -}); - -var currentRecordingMBID = ""; -var currentStart = 0; - -export default definePlugin({ - name: "ListenBrainzRPC", - description: "Little plugin for ListenBrainz rich presence", - authors: [EquicordDevs.qouesm], - - settingsAboutComponent: () => ( - <> - About MusicBrainz API - - The MusicBrainz API does not require an API key, but it does require a{" "} - - {" "} - meaningful user-agent string - {" "} - . For most, an email address should suffice. - - - ), - - settings, - - start() { - this.updatePresence(); - updateInterval = setInterval(() => { - this.updatePresence(); - }, 16000); - }, - - stop() { - clearInterval(updateInterval); - updateInterval = undefined; - }, - - async fetchTrackData(): Promise { - if (!settings.store.username) return null; - - try { - const lbRes = await fetch( - `https://api.listenbrainz.org/1/user/${settings.store.username}/playing-now`, - ); - if (!lbRes.ok) throw `${lbRes.status} ${lbRes.statusText}`; - - const lbJson = await lbRes.json(); - if (lbJson.error) { - logger.error( - "Error from ListenBrainz API", - `${lbJson.error}: ${lbJson.message}`, - ); - return null; - } - - const listen = lbJson.payload?.listens?.[0]; - if (!listen?.playing_now) return null; - - const trackMetadata = listen.track_metadata; - const albumName = trackMetadata.release_name || "Unknown"; - const artistName = trackMetadata.artist_name || "Unknown"; - - const mbRes = await fetch(`https://musicbrainz.org/ws/2/release/?query=release:${encodeURIComponent(albumName)}%20AND%20artist:${encodeURIComponent(artistName)}&fmt=json`,); - - if (!mbRes.ok) throw `${mbRes.status} ${mbRes.statusText}`; - - const mbJson = await mbRes.json(); - const releases = mbJson.releases || []; - - const releaseGroup = releases[0]["release-group"].id; - - const caaRes = await fetch( - `https://coverartarchive.org/release-group/${releaseGroup}`, - ); - if (!caaRes.ok) throw `${caaRes.status} ${caaRes.statusText}`; - const caaJson = await caaRes.json(); - - const url: string = caaJson.release; - - const { images } = caaJson; - let imageUrl: string = ""; - for (const image of images) { - imageUrl = image.thumbnails.large || ""; - if (!imageUrl) continue; - break; - } - - return { - name: trackMetadata.track_name || "Unknown", - album: albumName, - artist: artistName, - durationMs: trackMetadata.additional_info.duration_ms, - recordingMBID: trackMetadata.additional_info.recording_mbid, - url: url, - imageUrl: imageUrl, - }; - } catch (e) { - logger.error("Failed to query ListenBrainz API", e); - // will clear the rich presence if API fails - return null; - } - }, - - async updatePresence() { - setActivity(await this.getActivity()); - }, - - getLargeImage(track: TrackData): string | undefined { - if (track.imageUrl && !track.imageUrl.includes(placeholderId)) - return track.imageUrl; - - if (settings.store.missingArt === "placeholder") return "placeholder"; - }, - - async getActivity(): Promise { - if (settings.store.hideWithActivity) { - if ( - PresenceStore.getActivities().some( - a => a.application_id !== applicationId, - ) - ) { - return null; - } - } - - if (settings.store.hideWithSpotify) { - if ( - PresenceStore.getActivities().some( - a => - a.type === ActivityType.LISTENING && - a.application_id !== applicationId, - ) - ) { - // there is already music status because of Spotify or richerCider (probably more) - return null; - } - } - - const trackData = await this.fetchTrackData(); - if (!trackData) return null; - - const largeImage = this.getLargeImage(trackData); - const assets: ActivityAssets = largeImage - ? { - large_image: await getApplicationAsset(largeImage), - large_text: trackData.album || undefined, - small_image: settings.store.useLogo - ? await getApplicationAsset("listenbrainz") - : undefined, - small_text: "ListenBrainz", - } - : { - large_image: await getApplicationAsset("listenbrainz"), - large_text: trackData.album || undefined, - }; - - const buttons: ActivityButton[] = []; - - if (settings.store.shareUsername) - buttons.push({ - label: "ListenBrainz Profile", - url: `https://www.listenbrainz.org/user/${settings.store.username}`, - }); - - if (settings.store.shareSong) - buttons.push({ - label: "View Song", - url: trackData.url, - }); - - const statusName = (() => { - switch (settings.store.nameFormat) { - case NameFormat.ArtistFirst: - return trackData.artist + " - " + trackData.name; - case NameFormat.SongFirst: - return trackData.name + " - " + trackData.artist; - case NameFormat.ArtistOnly: - return trackData.artist; - case NameFormat.SongOnly: - return trackData.name; - case NameFormat.AlbumName: - return trackData.album || settings.store.statusName; - default: - return settings.store.statusName; - } - })(); - - if (trackData.recordingMBID && trackData.recordingMBID !== currentRecordingMBID) { - currentRecordingMBID = trackData.recordingMBID; - currentStart = Date.now(); - } - - return { - application_id: applicationId, - name: statusName, - - details: trackData.name, - state: trackData.artist, - assets, - - timestamps: { - start: settings.store.useTimeBar ? currentStart : undefined, - end: settings.store.useTimeBar - ? currentStart + (trackData.durationMs ?? 0) - : undefined, - }, - buttons: buttons.length ? buttons.map(v => v.label) : undefined, - metadata: { - button_urls: buttons.map(v => v.url), - }, - - type: settings.store.useListeningStatus - ? ActivityType.LISTENING - : ActivityType.PLAYING, - flags: ActivityFlags.INSTANCE, - }; - }, -}); diff --git a/src/equicordplugins/loginWithQR/index.tsx b/src/equicordplugins/loginWithQR/index.tsx index d0c36ccc6..21f1c7c9d 100644 --- a/src/equicordplugins/loginWithQR/index.tsx +++ b/src/equicordplugins/loginWithQR/index.tsx @@ -66,13 +66,11 @@ export default definePlugin({ Component: openQrModal, Icon: QrCodeIcon }); - SettingsPlugin.settingsSectionMap.push(["EquicordLoginWithQR", "equicord_login_with_qr"]); preload(); }, stop() { removeFromArray(SettingsPlugin.customEntries, e => e.key === "equicord_login_with_qr"); - removeFromArray(SettingsPlugin.settingsSectionMap, entry => entry[1] === "equicord_login_with_qr"); unload(); }, }); diff --git a/src/equicordplugins/messageLoggerEnhanced/LoggedMessageManager.ts b/src/equicordplugins/messageLoggerEnhanced/LoggedMessageManager.ts index 5408349e9..7f51572af 100644 --- a/src/equicordplugins/messageLoggerEnhanced/LoggedMessageManager.ts +++ b/src/equicordplugins/messageLoggerEnhanced/LoggedMessageManager.ts @@ -17,7 +17,7 @@ */ import { Flogger, settings } from "."; -import { addMessageIDB, db, DBMessageStatus, deleteMessagesBulkIDB, getOlderThanTimestampForGuildsIDB, getOldestMessagesIDB } from "./db"; +import { addMessageIDB, countMessagesIDB, DBMessageStatus, deleteMessagesBulkIDB, getOlderThanTimestampForGuildsIDB, getOldestMessagesIDB } from "./db"; import { LoggedMessage, LoggedMessageJSON } from "./types"; import { cleanupMessage } from "./utils"; import { cacheMessageImages } from "./utils/saveImage"; @@ -47,7 +47,7 @@ export const addMessage = async (message: LoggedMessage | LoggedMessageJSON, sta } if (settings.store.messageLimit > 0) { - const currentMessageCount = await db.count("messages"); + const currentMessageCount = await countMessagesIDB(); if (currentMessageCount > settings.store.messageLimit) { const messagesToDelete = currentMessageCount - settings.store.messageLimit; if (messagesToDelete <= 0 || messagesToDelete >= settings.store.messageLimit) return; diff --git a/src/equicordplugins/messageLoggerEnhanced/db.ts b/src/equicordplugins/messageLoggerEnhanced/db.ts index 8d80e0213..31c34ef45 100644 --- a/src/equicordplugins/messageLoggerEnhanced/db.ts +++ b/src/equicordplugins/messageLoggerEnhanced/db.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ +import { Logger } from "@utils/Logger"; import { ChannelStore, Toasts } from "@webpack/common"; import { DBSchema, IDBPDatabase, openDB } from "idb"; @@ -37,17 +38,63 @@ export interface MLIDB extends DBSchema { by_timestamp_and_message_id: [string, string]; }; }; - } -export let db: IDBPDatabase; +const logger = new Logger("MessageLoggerEnhanced"); +let db: IDBPDatabase | null = null; +let dbPromise: Promise | null> | null = null; +let loggedFailure = false; + export const cachedMessages = new Map(); -// this is probably not the best way to do this -async function cacheRecords(records: DBMessageRecord[]) { +function handleDbFailure(error: unknown) { + if (loggedFailure) return; + loggedFailure = true; + logger.error("Message storage unavailable, using memory only.", error); +} + +async function ensureDb() { + if (db) return db; + if (!dbPromise) { + dbPromise = openDB(DB_NAME, DB_VERSION, { + upgrade(database) { + const messageStore = database.createObjectStore("messages", { keyPath: "message_id" }); + messageStore.createIndex("by_channel_id", "channel_id"); + messageStore.createIndex("by_status", "status"); + messageStore.createIndex("by_timestamp", "message.timestamp"); + messageStore.createIndex("by_timestamp_and_message_id", ["channel_id", "message.timestamp"]); + } + }).then(database => { + db = database; + return database; + }).catch(error => { + handleDbFailure(error); + return null; + }); + } + + db = await dbPromise; + return db; +} + +async function withDb(fallback: T, callback: (database: IDBPDatabase) => Promise): Promise { + const database = await ensureDb(); + if (!database) return fallback; + + try { + return await callback(database); + } catch (error) { + handleDbFailure(error); + return fallback; + } +} + +async function cacheRecords(records: DBMessageRecord[], cacheAttachmentBlobs = true) { for (const r of records) { cacheRecord(r); + if (!cacheAttachmentBlobs) continue; + for (const att of r.message.attachments) { const blobUrl = await getAttachmentBlobUrl(att); if (blobUrl) { @@ -56,6 +103,7 @@ async function cacheRecords(records: DBMessageRecord[]) { } } } + return records; } @@ -68,56 +116,56 @@ async function cacheRecord(record?: DBMessageRecord | null) { } export async function initIDB() { - db = await openDB(DB_NAME, DB_VERSION, { - upgrade(db) { - const messageStore = db.createObjectStore("messages", { keyPath: "message_id" }); - messageStore.createIndex("by_channel_id", "channel_id"); - messageStore.createIndex("by_status", "status"); - messageStore.createIndex("by_timestamp", "message.timestamp"); - messageStore.createIndex("by_timestamp_and_message_id", ["channel_id", "message.timestamp"]); - } - }); + await ensureDb(); } -initIDB(); +void initIDB(); export async function hasMessageIDB(message_id: string) { - return cachedMessages.has(message_id) || (await db.count("messages", message_id)) > 0; + if (cachedMessages.has(message_id)) return true; + return withDb(false, database => database.count("messages", message_id).then(count => count > 0)); } export async function countMessagesIDB() { + if (!db) return cachedMessages.size; return db.count("messages"); } export async function countMessagesByStatusIDB(status: DBMessageStatus) { - return db.countFromIndex("messages", "by_status", status); + return withDb(0, database => database.countFromIndex("messages", "by_status", status)); } export async function getAllMessagesIDB() { - return cacheRecords(await db.getAll("messages")); + return withDb([], async database => cacheRecords(await database.getAll("messages"))); } export async function getMessagesForChannelIDB(channel_id: string) { - return cacheRecords(await db.getAllFromIndex("messages", "by_channel_id", channel_id)); + return withDb([], async database => cacheRecords(await database.getAllFromIndex("messages", "by_channel_id", channel_id))); } export async function getMessageIDB(message_id: string) { - return cacheRecord(await db.get("messages", message_id)); + return withDb(null, async database => { + const record = await database.get("messages", message_id); + await cacheRecord(record); + return record ?? null; + }); } export async function getMessagesByStatusIDB(status: DBMessageStatus) { - return cacheRecords(await db.getAllFromIndex("messages", "by_status", status)); + return withDb([], async database => cacheRecords(await database.getAllFromIndex("messages", "by_status", status))); } export async function getOldestMessagesIDB(limit: number) { - return cacheRecords(await db.getAllFromIndex("messages", "by_timestamp", undefined, limit)); + return withDb([], async database => cacheRecords(await database.getAllFromIndex("messages", "by_timestamp", undefined, limit))); } export async function* iterateAllMessagesIDB(batchSize = 100) { + if (!db && !(await ensureDb())) return; + let lastId: string | undefined; while (true) { const batch: DBMessageRecord[] = []; - // new transaction for each batch to avoid timeouts during yield - const tx = db.transaction("messages"); + + const tx = db!.transaction("messages"); const range = lastId ? IDBKeyRange.lowerBound(lastId, true) : undefined; let cursor = await tx.store.openCursor(range); @@ -129,7 +177,6 @@ export async function* iterateAllMessagesIDB(batchSize = 100) { if (batch.length === 0) break; lastId = batch[batch.length - 1].message_id; - yield await cacheRecords(batch); if (batch.length < batchSize) break; @@ -137,22 +184,20 @@ export async function* iterateAllMessagesIDB(batchSize = 100) { } export async function getOlderThanTimestampIDB(timestamp: string) { - const tx = db.transaction("messages", "readonly"); - const { store } = tx; - const index = store.index("by_timestamp"); + return withDb([], async database => { + const tx = database.transaction("messages", "readonly"); + const index = tx.store.index("by_timestamp"); + const cursor = await index.openCursor(IDBKeyRange.upperBound(timestamp)); - const cursor = await index.openCursor(IDBKeyRange.upperBound(timestamp)); + if (!cursor) return []; - if (!cursor) { - return []; - } + const messages: DBMessageRecord[] = []; + for await (const c of cursor) { + messages.push(c.value); + } - const messages: DBMessageRecord[] = []; - for await (const c of cursor) { - messages.push(c.value); - } - - return cacheRecords(messages); + return cacheRecords(messages, false); + }); } export async function getOlderThanTimestampForGuildsIDB(timestamp: string, currentChannelId?: string, preserveCurrentChannel?: boolean) { @@ -167,99 +212,106 @@ export async function getOlderThanTimestampForGuildsIDB(timestamp: string, curre } export async function getDateStortedMessagesByStatusIDB(newest: boolean, limit: number, status: DBMessageStatus) { - const tx = db.transaction("messages", "readonly"); - const { store } = tx; - const index = store.index("by_status"); + return withDb([], async database => { + const tx = database.transaction("messages", "readonly"); + const index = tx.store.index("by_status"); + const direction = newest ? "prev" : "next"; + const cursor = await index.openCursor(IDBKeyRange.only(status), direction); - const direction = newest ? "prev" : "next"; - const cursor = await index.openCursor(IDBKeyRange.only(status), direction); + if (!cursor) { + return []; + } - if (!cursor) { - console.log("No messages found"); - return []; - } + const messages: DBMessageRecord[] = []; + for await (const c of cursor) { + messages.push(c.value); + if (messages.length >= limit) break; + } - const messages: DBMessageRecord[] = []; - for await (const c of cursor) { - messages.push(c.value); - if (messages.length >= limit) break; - } - - return cacheRecords(messages); + return cacheRecords(messages); + }); } export async function getMessagesByChannelAndAfterTimestampIDB(channel_id: string, start: string) { - const tx = db.transaction("messages", "readonly"); - const { store } = tx; - const index = store.index("by_timestamp_and_message_id"); + return withDb([], async database => { + const tx = database.transaction("messages", "readonly"); + const index = tx.store.index("by_timestamp_and_message_id"); + const cursor = await index.openCursor(IDBKeyRange.bound([channel_id, start], [channel_id, "\uffff"])); - const cursor = await index.openCursor(IDBKeyRange.bound([channel_id, start], [channel_id, "\uffff"])); + if (!cursor) { + return []; + } - if (!cursor) { - console.log("No messages found in range"); - return []; - } + const messages: DBMessageRecord[] = []; + for await (const c of cursor) { + messages.push(c.value); + } - const messages: DBMessageRecord[] = []; - for await (const c of cursor) { - messages.push(c.value); - } - - return cacheRecords(messages); + return cacheRecords(messages, false); + }); } export async function addMessageIDB(message: LoggedMessageJSON, status: DBMessageStatus) { stripTransientRenderState(message); - - await db.put("messages", { - channel_id: message.channel_id, - message_id: message.id, - status, - message, - }); - cachedMessages.set(message.id, message); + + return withDb(void 0, async database => { + await database.put("messages", { + channel_id: message.channel_id, + message_id: message.id, + status, + message, + }); + }); } export async function addMessagesBulkIDB(messages: LoggedMessageJSON[], status?: DBMessageStatus) { messages.forEach(stripTransientRenderState); - - const tx = db.transaction("messages", "readwrite"); - const { store } = tx; - - await Promise.all([ - ...messages.map(message => store.add({ - channel_id: message.channel_id, - message_id: message.id, - status: status ?? getMessageStatus(message), - message, - })), - tx.done - ]); - messages.forEach(message => cachedMessages.set(message.id, message)); + + return withDb(void 0, async database => { + const tx = database.transaction("messages", "readwrite"); + const { store } = tx; + + await Promise.all([ + ...messages.map(message => store.add({ + channel_id: message.channel_id, + message_id: message.id, + status: status ?? getMessageStatus(message), + message, + })), + tx.done + ]); + }); } export async function deleteMessageIDB(message_id: string) { - await db.delete("messages", message_id); - cachedMessages.delete(message_id); + + return withDb(void 0, database => database.delete("messages", message_id)); } export async function deleteMessagesBulkIDB(message_ids: string[]) { - const tx = db.transaction("messages", "readwrite"); - const { store } = tx; - - await Promise.all([...message_ids.map(id => store.delete(id)), tx.done]); message_ids.forEach(id => cachedMessages.delete(id)); + + return withDb(void 0, async database => { + const tx = database.transaction("messages", "readwrite"); + const { store } = tx; + await Promise.all([ + ...message_ids.map(id => store.delete(id)), + tx.done + ]); + }); } export async function clearMessagesIDB() { - await db.clear("messages"); cachedMessages.clear(); - Toasts.show({ - type: Toasts.Type.MESSAGE, - message: "Cleared message log database and cache.", - id: Toasts.genId() + + return withDb(void 0, database => database.clear("messages")).then(() => { + Toasts.show({ + type: Toasts.Type.MESSAGE, + message: "Cleared message log database and cache.", + id: Toasts.genId() + }); }); } diff --git a/src/equicordplugins/messageLoggerEnhanced/utils/misc.ts b/src/equicordplugins/messageLoggerEnhanced/utils/misc.ts index 66a264abf..43c5f4166 100644 --- a/src/equicordplugins/messageLoggerEnhanced/utils/misc.ts +++ b/src/equicordplugins/messageLoggerEnhanced/utils/misc.ts @@ -24,7 +24,6 @@ import { DBMessageStatus } from "../db"; import { LoggedMessageJSON } from "../types"; import { DEFAULT_IMAGE_CACHE_DIR } from "./constants"; import { DISCORD_EPOCH } from "./index"; -import { memoize } from "./memoize"; const MessageClass: any = findLazy(m => m?.prototype?.isEdited); const AuthorClass = findLazy(m => m?.prototype?.getAvatarURL); @@ -93,7 +92,7 @@ export const mapTimestamp = (m: any) => { return m; }; -export const messageJsonToMessageClass = memoize((log: { message: LoggedMessageJSON; }) => { +export const messageJsonToMessageClass = (log: { message: LoggedMessageJSON; }) => { // console.time("message populate"); if (!log?.message) return null; @@ -123,7 +122,7 @@ export const messageJsonToMessageClass = memoize((log: { message: LoggedMessageJ // console.timeEnd("message populate"); return message; -}); +}; export function parseJSON(json?: string | null) { try { diff --git a/src/equicordplugins/messagePeek/index.tsx b/src/equicordplugins/messagePeek/index.tsx index 866e704e9..340552eac 100644 --- a/src/equicordplugins/messagePeek/index.tsx +++ b/src/equicordplugins/messagePeek/index.tsx @@ -252,10 +252,24 @@ export default definePlugin({ ], async start() { - const channels = ChannelStore.getSortedPrivateChannels(); - for (const channel of channels) { - if (!MessageStore.getLastMessage(channel.id)) { - await MessageActions.fetchMessages({ channelId: channel.id, limit: 1 }); + const channels = ChannelStore.getSortedPrivateChannels() + .slice(0, 25) + .filter(c => !MessageStore.getLastMessage(c.id)); + + for (let i = 0; i < channels.length; i += 5) { + const batch = channels.slice(i, i + 5); + + await Promise.allSettled( + batch.map(channel => + MessageActions.fetchMessages({ + channelId: channel.id, + limit: 1 + }) + ) + ); + + if (i + 5 < channels.length) { + await new Promise(r => setTimeout(r, 3000)); } } }, diff --git a/src/equicordplugins/messagePeek/style.css b/src/equicordplugins/messagePeek/style.css index e3de61ee0..a926d4187 100644 --- a/src/equicordplugins/messagePeek/style.css +++ b/src/equicordplugins/messagePeek/style.css @@ -5,14 +5,14 @@ font-size: 12px; font-weight: 500; line-height: 16px; - color: var(--text-normal); + color: var(--text-default); } .vc-message-peek-timestamp-favorites { font-size: 12px; font-weight: 500; line-height: 16px; - color: var(--text-normal); + color: var(--text-default); } [class*="interactiveSelected__"] .vc-message-peek-timestamp { @@ -44,10 +44,10 @@ gap: 4px; } -.vc-message-peek-activity-row>[class*="textXs"] { +.vc-message-peek-activity-row > [class*="textXs"] { min-width: 0; } -.vc-message-peek-activity-row>[class*="vc-bactivities-row"] { +.vc-message-peek-activity-row > [class*="vc-bactivities-row"] { flex-shrink: 0; -} +} \ No newline at end of file diff --git a/src/equicordplugins/moreCommands/index.ts b/src/equicordplugins/moreCommands/index.ts index 8e367cd0d..b174a4576 100644 --- a/src/equicordplugins/moreCommands/index.ts +++ b/src/equicordplugins/moreCommands/index.ts @@ -18,17 +18,55 @@ import { ApplicationCommandInputType, ApplicationCommandOptionType, findOption, OptionalMessageOption, RequiredMessageOption, sendBotMessage } from "@api/Commands"; import { addMessagePreEditListener, addMessagePreSendListener, MessageObject, removeMessagePreEditListener, removeMessagePreSendListener } from "@api/MessageEvents"; +import { migratePluginSettings } from "@api/Settings"; import { Devs, EquicordDevs } from "@utils/constants"; import { sendMessage } from "@utils/discord"; import definePlugin from "@utils/types"; -import { UserStore } from "@webpack/common"; +import { DraftType, UploadHandler, UploadManager, UserAffinitiesStore, UserStore } from "@webpack/common"; +import { applyPalette, GIFEncoder, quantize } from "gifenc"; -import { fromMorse, getCuteAnimeBoys, getCuteNeko, getCutePats, isMorse, makeFreaky, mock, settings, toMorse, uwuify, uwuifyArray } from "./utils"; +import { + calculateAffinityScore, + calculateCanvasSize, + FRAMES, + fromMorse, + generatePoissonDiskPosition, + getCuteAnimeBoys, + getCuteNeko, + getCutePats, + getMessage, + isMorse, + loadFriendImage, + loadImage, + makeFreaky, + mock, + resolveImage, + settings, + toMorse, + uwuify, + uwuifyArray +} from "./utils"; + +migratePluginSettings("MoreCommands", "FriendCloud", "GifRoulette", "ImgToGif", "MoreKaomoji"); export default definePlugin({ name: "MoreCommands", description: "Adds various fun and useful commands", - authors: [Devs.Arjix, Devs.amy, Devs.Samu, EquicordDevs.zyqunix, EquicordDevs.ShadyGoat, Devs.thororen, Devs.Korbo, Devs.nyx, Devs.amy], + authors: [ + Devs.Arjix, + Devs.amy, + Devs.Samu, + EquicordDevs.zyqunix, + EquicordDevs.ShadyGoat, + Devs.thororen, + Devs.Korbo, + Devs.nyx, + Devs.amy, + Devs.Samwich, + EquicordDevs.Fafa, + Devs.JacobTm, + EquicordDevs.voidbbg + ], settings, commands: [ { @@ -435,7 +473,394 @@ export default definePlugin({ content: uwuify(findOption(opts, "message", "")), }), }, + { + name: "gifroulette", + description: "Tempt fate and send a gif", + execute: (opts, other) => ({ + content: getMessage(opts, other) + }), + }, + { + inputType: ApplicationCommandInputType.BUILT_IN, + name: "friendcloud", + description: "Display user you most interact with in a cloud", + options: [ + { + name: "count", + description: "Number of users to display", + type: ApplicationCommandOptionType.NUMBER, + required: false + } + ], + execute: async (opts, cmdCtx) => { + const count = findOption(opts, "count", 25); + + if (!count) return sendBotMessage(cmdCtx.channel.id, { content: "The count must be 1 or higher!" }); + + try { + const affinities = UserAffinitiesStore.getUserAffinities(); + + if (!affinities?.length) { + return sendBotMessage(cmdCtx.channel.id, { + content: "No affinities found. Check your [privacy settings]()." + }); + } + + const users = affinities + .map(e => ({ + member: UserStore.getUser(e.otherUserId), + affinity: calculateAffinityScore(e) + })) + .filter(x => x.member?.id) + .sort((a, b) => b.affinity - a.affinity) + .slice(0, count); + + if (!users.length) { + return sendBotMessage(cmdCtx.channel.id, { + content: "No valid users found in affinities. Check your [privacy settings]()." + }); + } + + const minAffinity = Math.min(...users.map(u => u.affinity)); + const maxAffinity = Math.max(...users.map(u => u.affinity)); + const minSize = 120; + const maxSize = 240; + + const getSize = (affinity: number): number => { + if (maxAffinity === minAffinity) return (minSize + maxSize) / 2; + return minSize + ((affinity - minAffinity) / (maxAffinity - minAffinity)) * (maxSize - minSize); + }; + + const avgSize = (minSize + maxSize) / 2; + const { width: canvasWidth, height: canvasHeight } = calculateCanvasSize(users.length, avgSize); + + const canvas = document.createElement("canvas"); + canvas.width = canvasWidth; + canvas.height = canvasHeight; + const ctx = canvas.getContext("2d")!; + + const positions: Array<{ x: number, y: number, size: number; }> = []; + const userPositions = users.map(user => { + const size = getSize(user.affinity); + const pos = generatePoissonDiskPosition(positions, canvasWidth, canvasHeight, size); + positions.push({ x: pos.x, y: pos.y, size }); + return { ...user, x: pos.x, y: pos.y, size }; + }); + + let loadedImages = 0; + const totalImages = userPositions.length; + + const drawImage = async user => { + try { + const avatarUrl = user.member?.avatar + ? `https://cdn.discordapp.com/avatars/${user.member.id}/${user.member?.avatar}.webp?size=256` + : `https://cdn.discordapp.com/embed/avatars/${user.member.id as any as number % 5}.png`; + + const img = await loadFriendImage(avatarUrl); + const centerX = user.x + user.size / 2; + const centerY = user.y + user.size / 2; + + ctx.save(); + ctx.beginPath(); + ctx.arc(centerX, centerY, user.size / 2, 0, Math.PI * 2); + ctx.clip(); + ctx.drawImage(img, user.x, user.y, user.size, user.size); + ctx.restore(); + + ctx.strokeStyle = "#808080"; + ctx.lineWidth = 3; + ctx.beginPath(); + ctx.arc(centerX, centerY, user.size / 2 + 1, 0, Math.PI * 2); + ctx.stroke(); + } catch { + // we ignore + } finally { + loadedImages++; + if (loadedImages === totalImages) { + canvas.toBlob(blob => { + if (!blob) { + sendBotMessage(cmdCtx.channel.id, { content: "Couldn't generate the image :c" }); + return; + } + const file = new File([blob], "affinities-cloud.png", { type: "image/png" }); + UploadHandler.promptToUpload([file], cmdCtx.channel, DraftType.ChannelMessage); + }, "image/png"); + } + } + }; + + userPositions.forEach(drawImage); + } catch (e: unknown) { + if (e instanceof Error) sendBotMessage(cmdCtx.channel.id, { content: e.message }); + } + }, + }, + { + inputType: ApplicationCommandInputType.BUILT_IN, + name: "imgtogif", + description: "Allows you to turn an image to a gif", + options: [ + { + name: "image", + description: "Image attachment to use", + type: ApplicationCommandOptionType.ATTACHMENT + }, + { + name: "width", + description: "Width of the gif", + type: ApplicationCommandOptionType.INTEGER + }, + { + name: "height", + description: "Height of the gif", + type: ApplicationCommandOptionType.INTEGER + } + ], + execute: async (opts, cmdCtx) => { + try { + const { image, width, height } = await resolveImage(opts, cmdCtx); + if (!image) throw "No Image specified!"; + + const avatar = await loadImage(image); + + let gifWidth: number; + let gifHeight: number; + + if (width && height) { + gifWidth = width; + gifHeight = height; + } else if (width) { + gifWidth = width; + gifHeight = Math.round((avatar.height / avatar.width) * width); + } else if (height) { + gifHeight = height; + gifWidth = Math.round((avatar.width / avatar.height) * height); + } else { + gifWidth = avatar.width; + gifHeight = avatar.height; + } + + const gif = GIFEncoder(); + const canvas = document.createElement("canvas"); + canvas.width = gifWidth; + canvas.height = gifHeight; + const ctx = canvas.getContext("2d")!; + + UploadManager.clearAll(cmdCtx.channel.id, DraftType.SlashCommand); + + for (let i = 0; i < FRAMES; i++) { + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.drawImage(avatar, 0, 0, avatar.width, avatar.height, 0, 0, canvas.width, canvas.height); + + const { data } = ctx.getImageData(0, 0, canvas.width, canvas.height); + const palette = quantize(data, 256); + const index = applyPalette(data, palette); + + gif.writeFrame(index, canvas.width, canvas.height, { + transparent: true, + palette, + }); + } + + gif.finish(); + const originalName = image.name ? image.name.replace(/\.[^/.]+$/, "") : "converted"; + const file = new File([new Uint8Array(gif.bytesView())], `${originalName}.gif`, { type: "image/gif" }); + setTimeout(() => UploadHandler.promptToUpload([file], cmdCtx.channel, DraftType.ChannelMessage), 10); + } catch (err) { + UploadManager.clearAll(cmdCtx.channel.id, DraftType.SlashCommand); + sendBotMessage(cmdCtx.channel.id, { content: String(err) }); + } + }, + }, + { + name: "dissatisfaction", + description: " >﹏<", + options: [OptionalMessageOption], + execute: opts => ({ + content: findOption(opts, "message", "") + " " + " >﹏<", + }), + }, + { + name: "smug", + description: "ಠ_ಠ", + options: [OptionalMessageOption], + execute: opts => ({ + content: findOption(opts, "message", "") + " " + "ಠ_ಠ", + }), + }, + { + name: "happy", + description: "ヽ(´▽`)/", + options: [OptionalMessageOption], + execute: opts => ({ + content: findOption(opts, "message", "") + " " + "ヽ(´▽`)/", + }), + }, + { + name: "crying", + description: "ಥ_ಥ", + options: [OptionalMessageOption], + execute: opts => ({ + content: findOption(opts, "message", "") + " " + "ಥ_ಥ", + }), + }, + { + name: "angry", + description: "ヽ(`Д´)ノ", + options: [OptionalMessageOption], + execute: opts => ({ + content: findOption(opts, "message", "") + " " + "ヽ(`Д´)ノ", + }), + }, + { + name: "anger", + description: "ヽ(o`皿′o)ノ", + options: [OptionalMessageOption], + execute: opts => ({ + content: findOption(opts, "message", "") + " " + "ヽ(o`皿′o)ノ", + }), + }, + { + name: "joy", + description: "<( ̄︶ ̄)>", + options: [OptionalMessageOption], + execute: opts => ({ + content: findOption(opts, "message", "") + " " + "<( ̄︶ ̄)>", + }), + }, + { + name: "blush", + description: "૮ ˶ᵔ ᵕ ᵔ˶ ა", + options: [OptionalMessageOption], + execute: opts => ({ + content: findOption(opts, "message", "") + " " + "૮ ˶ᵔ ᵕ ᵔ˶ ა", + }), + }, + { + name: "confused", + description: "(•ิ_•ิ)?", + options: [OptionalMessageOption], + execute: opts => ({ + content: findOption(opts, "message", "") + " " + "(•ิ_•ิ)?", + }), + }, + { + name: "sleeping", + description: "(ᴗ_ᴗ)", + options: [OptionalMessageOption], + execute: opts => ({ + content: findOption(opts, "message", "") + " " + "(ᴗ_ᴗ)", + }), + }, + { + name: "laughing", + description: "o(≧▽≦)o", + options: [OptionalMessageOption], + execute: opts => ({ + content: findOption(opts, "message", "") + " " + "o(≧▽≦)o", + }), + }, + /* + even more kaomoji + */ + { + name: "giving", + description: "(ノ◕ヮ◕)ノ*:・゚✧", + options: [OptionalMessageOption], + execute: opts => ({ + content: findOption(opts, "message", "") + " " + "(ノ◕ヮ◕)ノ*:・゚✧", + }), + }, + { + name: "peace", + description: "✌(◕‿-)✌", + options: [OptionalMessageOption], + execute: opts => ({ + content: findOption(opts, "message", "") + " " + "✌(◕‿-)✌", + }), + }, + { + name: "ending1", + description: "Ꮺ ָ࣪ ۰ ͙⊹", + options: [OptionalMessageOption], + execute: opts => ({ + content: findOption(opts, "message", "") + " " + "Ꮺ ָ࣪ ۰ ͙⊹", + }), + }, + { + name: "uwu", + description: "(>⩊<)", + options: [OptionalMessageOption], + execute: opts => ({ + content: findOption(opts, "message", "") + " " + "(>⩊<)", + }), + }, + { + name: "comfy", + description: "(─‿‿─)♡", + options: [OptionalMessageOption], + execute: opts => ({ + content: findOption(opts, "message", "") + " " + "(─‿‿─)♡", + }), + }, + { + name: "lovehappy", + description: "(*≧ω≦*)", + options: [OptionalMessageOption], + execute: opts => ({ + content: findOption(opts, "message", "") + " " + "(*≧ω≦*)", + }), + }, + { + name: "loveee", + description: "(⁄ ⁄>⁄ ▽ ⁄<⁄ ⁄)", + options: [OptionalMessageOption], + execute: opts => ({ + content: findOption(opts, "message", "") + " " + "(⁄ ⁄>⁄ ▽ ⁄<⁄ ⁄)", + }), + }, + { + name: "give", + description: "(ノ= ⩊ = )ノ", + options: [OptionalMessageOption], + execute: opts => ({ + content: findOption(opts, "message", "") + " " + "(ノ= ⩊ = )ノ", + }), + }, + { + name: "lovegive", + description: "ღゝ◡╹)ノ♡", + options: [OptionalMessageOption], + execute: opts => ({ + content: findOption(opts, "message", "") + " " + "ღゝ◡╹)ノ♡", + }), + }, + { + name: "music", + description: "( ̄▽ ̄)/♫•¨•.¸¸♪", + options: [OptionalMessageOption], + execute: opts => ({ + content: findOption(opts, "message", "") + " " + "( ̄▽ ̄)/♫•¨•.¸¸♪", + }), + }, + { + name: "stars", + description: ".𖥔 ݁ ˖๋ ࣭ ⭑", + options: [OptionalMessageOption], + execute: opts => ({ + content: findOption(opts, "message", "") + " " + ".𖥔 ݁ ˖๋ ࣭ ⭑", + }), + }, + { + name: "lovegiving", + description: "⸜(。˃ ᵕ ˂ )⸝♡", + options: [OptionalMessageOption], + execute: opts => ({ + content: findOption(opts, "message", "") + " " + "⸜(。˃ ᵕ ˂ )⸝♡", + }), + } ], + patches: [ { find: ".isPureReactComponent=!0;", diff --git a/src/equicordplugins/moreCommands/utils.ts b/src/equicordplugins/moreCommands/utils.ts index a5a0f7815..0dd7ae3a1 100644 --- a/src/equicordplugins/moreCommands/utils.ts +++ b/src/equicordplugins/moreCommands/utils.ts @@ -6,6 +6,8 @@ import { definePluginSettings } from "@api/Settings"; import { OptionType } from "@utils/types"; +import { CommandArgument, CommandContext } from "@vencord/discord-types"; +import { DraftType, UploadAttachmentStore, UploadManager, UserSettingsActionCreators } from "@webpack/common"; export const settings = definePluginSettings({ addFreakyEnding: { @@ -207,3 +209,180 @@ export function uwuifyArray(arr) { return newArr; } + +export function getMessage(opts, other) { + const frecencyStore = UserSettingsActionCreators.FrecencyUserSettingsActionCreators.getCurrentValue(); + + const gifsArray = Object.keys(frecencyStore.favoriteGifs.gifs); + + const chosenGifUrl = gifsArray[Math.floor(Math.random() * gifsArray.length)]; + + return `${chosenGifUrl}`; +} + +export function calculateAffinityScore(affinity): number { + const weights = { + friend: 0.15, + dm: 0.30, + vc: 0.25, + serverMsg: 0.20, + communication: 0.10 + }; + + let score = 0; + if (affinity.isFriend) score += weights.friend * 100; + score += affinity.dmProbability * weights.dm * 100; + score += affinity.vcProbability * weights.vc * 100; + score += affinity.serverMessageProbability * weights.serverMsg * 100; + score += affinity.communicationProbability * weights.communication * 100; + + return Math.round(Math.min(100, Math.max(0, score)) * 100) / 100; +} + +// stolen from petpet thanks vee +export function loadFriendImage(source: File | string): Promise { + const isFile = source instanceof File; + const url = isFile ? URL.createObjectURL(source) : source; + + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => { + if (isFile) URL.revokeObjectURL(url); + resolve(img); + }; + img.onerror = (event, _source, _lineno, _colno, err) => reject(err || event); + img.crossOrigin = "anonymous"; + img.src = url; + }); +} + +export function generatePoissonDiskPosition( + existingPositions: Array<{ x: number, y: number, size: number; }>, + canvasWidth: number, + canvasHeight: number, + size: number +): { x: number, y: number; } { + const edgePadding = 10; + const minDist = size * 1.5; + const textSpace = 60; + const k = 30; + + function isValid(x: number, y: number) { + if ( + x < edgePadding || + x + size > canvasWidth - edgePadding || + y < edgePadding || + y + size > canvasHeight - textSpace - edgePadding + ) return false; + + return !existingPositions.some(pos => { + const dx = pos.x - x; + const dy = pos.y - y; + const dist = Math.hypot(dx, dy); + const minAllowed = (pos.size + size) / 2 + (minDist - size); + return dist < minAllowed; + }); + } + + if (existingPositions.length === 0) { + return { + x: canvasWidth / 2 - size / 2, + y: canvasHeight / 2 - size / 2 + }; + } + + for (let tries = 0; tries < 100; tries++) { + const base = existingPositions[Math.floor(Math.random() * existingPositions.length)]; + for (let i = 0; i < k; i++) { + const angle = Math.random() * Math.PI * 2; + const radius = minDist + Math.random() * minDist; + const x = base.x + Math.cos(angle) * radius; + const y = base.y + Math.sin(angle) * radius; + if (isValid(x, y)) { + return { + x: Math.max(edgePadding, Math.min(x, canvasWidth - size - edgePadding)), + y: Math.max(edgePadding, Math.min(y, canvasHeight - size - textSpace - edgePadding)) + }; + } + } + } + + for (let tries = 0; tries < 100; tries++) { + const x = Math.random() * (canvasWidth - size - edgePadding * 2) + edgePadding; + const y = Math.random() * (canvasHeight - size - textSpace - edgePadding * 2) + edgePadding; + if (isValid(x, y)) { + return { + x: Math.max(edgePadding, Math.min(x, canvasWidth - size - edgePadding)), + y: Math.max(edgePadding, Math.min(y, canvasHeight - size - textSpace - edgePadding)) + }; + } + } + + return { + x: edgePadding, + y: edgePadding + }; +} + +export function calculateCanvasSize(userCount: number, avatarSize: number): { width: number, height: number; } { + const padding = 50; + const textSpace = 60; + const itemWidth = avatarSize + padding; + const itemHeight = avatarSize + textSpace + padding; + const aspectRatio = 16 / 9; + const cols = Math.ceil(Math.sqrt(userCount * aspectRatio)); + const rows = Math.ceil(userCount / cols); + + return { + width: Math.max(1000, cols * itemWidth + padding), + height: Math.max(700, rows * itemHeight + padding) + }; +} + +export const FRAMES = 1; + +export function loadImage(source: File | string) { + const isFile = source instanceof File; + const url = isFile ? URL.createObjectURL(source) : source; + + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => { + if (isFile) URL.revokeObjectURL(url); + resolve(img); + }; + img.onerror = (event, _source, _lineno, _colno, err) => reject(err || event); + img.crossOrigin = "Anonymous"; + img.src = url; + }); +} + +export async function resolveImage(options: CommandArgument[], ctx: CommandContext): Promise<{ image: File | null; width: number | null; height: number | null; }> { + let image: File | null = null; + let width: number | null = null; + let height: number | null = null; + + for (const opt of options) { + switch (opt.name) { + case "image": + const upload = UploadAttachmentStore.getUpload(ctx.channel.id, opt.name, DraftType.SlashCommand); + if (upload) { + if (!upload.isImage) { + UploadManager.clearAll(ctx.channel.id, DraftType.SlashCommand); + throw "Upload is not an image"; + } + image = upload.item.file; + } + break; + case "width": + width = Number(opt.value); + break; + case "height": + height = Number(opt.value); + break; + } + } + + UploadManager.clearAll(ctx.channel.id, DraftType.SlashCommand); + return { image, width, height }; +} diff --git a/src/equicordplugins/moreKaomoji/index.ts b/src/equicordplugins/moreKaomoji/index.ts deleted file mode 100644 index 986b1f332..000000000 --- a/src/equicordplugins/moreKaomoji/index.ts +++ /dev/null @@ -1,216 +0,0 @@ -/* - * Vencord, a modification for Discord's desktop app - * Copyright (c) 2022 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 . -*/ - -import { findOption, OptionalMessageOption } from "@api/Commands"; -import { Devs, EquicordDevs } from "@utils/constants"; -import definePlugin from "@utils/types"; - -export default definePlugin({ - name: "MoreKaomoji", - description: "Adds more Kaomoji to discord. ヽ(´▽`)/", - authors: [Devs.JacobTm, EquicordDevs.voidbbg], - commands: [ - { - name: "dissatisfaction", - description: " >﹏<", - options: [OptionalMessageOption], - execute: opts => ({ - content: findOption(opts, "message", "") + " " + " >﹏<", - }), - }, - { - name: "smug", - description: "ಠ_ಠ", - options: [OptionalMessageOption], - execute: opts => ({ - content: findOption(opts, "message", "") + " " + "ಠ_ಠ", - }), - }, - { - name: "happy", - description: "ヽ(´▽`)/", - options: [OptionalMessageOption], - execute: opts => ({ - content: findOption(opts, "message", "") + " " + "ヽ(´▽`)/", - }), - }, - { - name: "crying", - description: "ಥ_ಥ", - options: [OptionalMessageOption], - execute: opts => ({ - content: findOption(opts, "message", "") + " " + "ಥ_ಥ", - }), - }, - { - name: "angry", - description: "ヽ(`Д´)ノ", - options: [OptionalMessageOption], - execute: opts => ({ - content: findOption(opts, "message", "") + " " + "ヽ(`Д´)ノ", - }), - }, - { - name: "anger", - description: "ヽ(o`皿′o)ノ", - options: [OptionalMessageOption], - execute: opts => ({ - content: findOption(opts, "message", "") + " " + "ヽ(o`皿′o)ノ", - }), - }, - { - name: "joy", - description: "<( ̄︶ ̄)>", - options: [OptionalMessageOption], - execute: opts => ({ - content: findOption(opts, "message", "") + " " + "<( ̄︶ ̄)>", - }), - }, - { - name: "blush", - description: "૮ ˶ᵔ ᵕ ᵔ˶ ა", - options: [OptionalMessageOption], - execute: opts => ({ - content: findOption(opts, "message", "") + " " + "૮ ˶ᵔ ᵕ ᵔ˶ ა", - }), - }, - { - name: "confused", - description: "(•ิ_•ิ)?", - options: [OptionalMessageOption], - execute: opts => ({ - content: findOption(opts, "message", "") + " " + "(•ิ_•ิ)?", - }), - }, - { - name: "sleeping", - description: "(ᴗ_ᴗ)", - options: [OptionalMessageOption], - execute: opts => ({ - content: findOption(opts, "message", "") + " " + "(ᴗ_ᴗ)", - }), - }, - { - name: "laughing", - description: "o(≧▽≦)o", - options: [OptionalMessageOption], - execute: opts => ({ - content: findOption(opts, "message", "") + " " + "o(≧▽≦)o", - }), - }, - /* - even more kaomoji - */ - { - name: "giving", - description: "(ノ◕ヮ◕)ノ*:・゚✧", - options: [OptionalMessageOption], - execute: opts => ({ - content: findOption(opts, "message", "") + " " + "(ノ◕ヮ◕)ノ*:・゚✧", - }), - }, - { - name: "peace", - description: "✌(◕‿-)✌", - options: [OptionalMessageOption], - execute: opts => ({ - content: findOption(opts, "message", "") + " " + "✌(◕‿-)✌", - }), - }, - { - name: "ending1", - description: "Ꮺ ָ࣪ ۰ ͙⊹", - options: [OptionalMessageOption], - execute: opts => ({ - content: findOption(opts, "message", "") + " " + "Ꮺ ָ࣪ ۰ ͙⊹", - }), - }, - { - name: "uwu", - description: "(>⩊<)", - options: [OptionalMessageOption], - execute: opts => ({ - content: findOption(opts, "message", "") + " " + "(>⩊<)", - }), - }, - { - name: "comfy", - description: "(─‿‿─)♡", - options: [OptionalMessageOption], - execute: opts => ({ - content: findOption(opts, "message", "") + " " + "(─‿‿─)♡", - }), - }, - { - name: "lovehappy", - description: "(*≧ω≦*)", - options: [OptionalMessageOption], - execute: opts => ({ - content: findOption(opts, "message", "") + " " + "(*≧ω≦*)", - }), - }, - { - name: "loveee", - description: "(⁄ ⁄>⁄ ▽ ⁄<⁄ ⁄)", - options: [OptionalMessageOption], - execute: opts => ({ - content: findOption(opts, "message", "") + " " + "(⁄ ⁄>⁄ ▽ ⁄<⁄ ⁄)", - }), - }, - { - name: "give", - description: "(ノ= ⩊ = )ノ", - options: [OptionalMessageOption], - execute: opts => ({ - content: findOption(opts, "message", "") + " " + "(ノ= ⩊ = )ノ", - }), - }, - { - name: "lovegive", - description: "ღゝ◡╹)ノ♡", - options: [OptionalMessageOption], - execute: opts => ({ - content: findOption(opts, "message", "") + " " + "ღゝ◡╹)ノ♡", - }), - }, - { - name: "music", - description: "( ̄▽ ̄)/♫•¨•.¸¸♪", - options: [OptionalMessageOption], - execute: opts => ({ - content: findOption(opts, "message", "") + " " + "( ̄▽ ̄)/♫•¨•.¸¸♪", - }), - }, - { - name: "stars", - description: ".𖥔 ݁ ˖๋ ࣭ ⭑", - options: [OptionalMessageOption], - execute: opts => ({ - content: findOption(opts, "message", "") + " " + ".𖥔 ݁ ˖๋ ࣭ ⭑", - }), - }, - { - name: "lovegiving", - description: "⸜(。˃ ᵕ ˂ )⸝♡", - options: [OptionalMessageOption], - execute: opts => ({ - content: findOption(opts, "message", "") + " " + "⸜(。˃ ᵕ ˂ )⸝♡", - }), - } - ] -}); diff --git a/src/equicordplugins/moreUserTags/index.tsx b/src/equicordplugins/moreUserTags/index.tsx index 4889178e0..d7852ba5f 100644 --- a/src/equicordplugins/moreUserTags/index.tsx +++ b/src/equicordplugins/moreUserTags/index.tsx @@ -6,6 +6,7 @@ import "./styles.css"; +import { migratePluginToSettings } from "@api/Settings"; import { Devs, EquicordDevs } from "@utils/constants"; import { classNameFactory } from "@utils/css"; import { getCurrentChannel, getIntlMessage } from "@utils/discord"; @@ -31,10 +32,12 @@ const genTagTypes = () => { return obj; }; +migratePluginToSettings(true, "MoreUserTags", "NoAppsAllowed", "noAppsAllowed"); + export default definePlugin({ name: "MoreUserTags", description: "Adds tags for webhooks and moderative roles (owner, admin, etc.)", - authors: [Devs.Cyn, Devs.TheSun, Devs.RyanCaoDev, Devs.LordElias, Devs.AutumnVN, EquicordDevs.Hen], + authors: [Devs.Cyn, Devs.TheSun, Devs.RyanCaoDev, Devs.LordElias, Devs.AutumnVN, EquicordDevs.Hen, EquicordDevs.meowabyte], settings, patches: [ // Make discord actually use our tags @@ -51,6 +54,18 @@ export default definePlugin({ predicate: () => settings.store.dontShowBotTag }, ], + }, + { + find: '"#{intl::APP_TAG::hash}":', + // This matches the intl bundle, english is always loaded as a fallback bundle + // if the users language is not english, we need to apply to both because the load order is random + all: true, + predicate: () => settings.store.noAppsAllowed, + replacement: { + match: /(#{intl::APP_TAG::hash}":\[").*?("\])/, + replace: "$1BOT$2", + noWarn: true, + } } ], start() { diff --git a/src/equicordplugins/moreUserTags/settings.tsx b/src/equicordplugins/moreUserTags/settings.tsx index a9be8ed83..23aa28cc1 100644 --- a/src/equicordplugins/moreUserTags/settings.tsx +++ b/src/equicordplugins/moreUserTags/settings.tsx @@ -115,4 +115,10 @@ export const settings = definePluginSettings({ component: SettingsComponent, description: "fill me" }, + noAppsAllowed: { + description: "Replaces app for bot", + type: OptionType.BOOLEAN, + default: false, + restartNeeded: true + } }); diff --git a/src/equicordplugins/musicControls/hoverOnly.css b/src/equicordplugins/musicControls/hoverOnly.css index 14f7402f7..9df7594ae 100644 --- a/src/equicordplugins/musicControls/hoverOnly.css +++ b/src/equicordplugins/musicControls/hoverOnly.css @@ -14,17 +14,8 @@ transition-property: height; } -.vc-ytm-button-row { - height: 0; - opacity: 0; - pointer-events: none; - transition: 0.2s; - transition-property: height; -} - #eq-tdl-player:hover .eq-tdl-button-row, -#vc-spotify-player:hover .vc-spotify-button-row, -#vc-ytm-player:hover .vc-ytm-button-row { +#vc-spotify-player:hover .vc-spotify-button-row { opacity: 1; height: 32px; pointer-events: auto; diff --git a/src/equicordplugins/musicControls/index.tsx b/src/equicordplugins/musicControls/index.tsx index dcf547e71..3fb672401 100644 --- a/src/equicordplugins/musicControls/index.tsx +++ b/src/equicordplugins/musicControls/index.tsx @@ -28,7 +28,6 @@ import { SpotifyLyrics } from "./spotify/lyrics/components/lyrics"; import { SpotifyPlayer } from "./spotify/PlayerComponent"; import { TidalLyrics } from "./tidal/lyrics/components/lyrics"; import { TidalPlayer } from "./tidal/TidalPlayer"; -import { YtmPlayer } from "./youtubeMusic/PlayerComponent"; export default definePlugin({ name: "MusicControls", @@ -44,15 +43,11 @@ export default definePlugin({ "Tidal", "TidalControls", "TidalLyrics", - // Youtube - "Youtube", - "YoutubeMusic", - "YoutubeMusicControls" ], patches: [ { - find: ".DISPLAY_NAME_STYLES_COACHMARK),", + find: ".DISPLAY_NAME_STYLES_COACHMARK)", replacement: { // react.jsx)(AccountPanel, { ..., showTaglessAccountPanel: blah }) match: /(?<=\i\.jsxs?\)\()(\i),{(?=[^}]*?userTag:\i,occluded:)/, @@ -91,7 +86,7 @@ export default definePlugin({ ], PanelWrapper({ VencordOriginal, ...props }) { - const { showTidalControls, showTidalLyrics, showSpotifyLyrics, showSpotifyControls, LyricsPosition, showYoutubeMusicControls } = settings.store; + const { showTidalControls, showTidalLyrics, showSpotifyLyrics, showSpotifyControls, lyricsPosition } = settings.store; return ( <> )} > - {showTidalLyrics && LyricsPosition === "above" && } + {showTidalLyrics && lyricsPosition === "above" && } {showTidalControls && } - {showTidalLyrics && LyricsPosition === "below" && } - {showSpotifyLyrics && LyricsPosition === "above" && } + {showTidalLyrics && lyricsPosition === "below" && } + {showSpotifyLyrics && lyricsPosition === "above" && } {showSpotifyControls && } - {showSpotifyLyrics && LyricsPosition === "below" && } - {showYoutubeMusicControls && } + {showSpotifyLyrics && lyricsPosition === "below" && } diff --git a/src/equicordplugins/musicControls/settings.tsx b/src/equicordplugins/musicControls/settings.tsx index 299f8b7d8..fcecad7d4 100644 --- a/src/equicordplugins/musicControls/settings.tsx +++ b/src/equicordplugins/musicControls/settings.tsx @@ -6,10 +6,12 @@ import { definePluginSettings } from "@api/Settings"; import { disableStyle, enableStyle } from "@api/Styles"; +import { ButtonCompat } from "@components/Button"; import { HeadingSecondary } from "@components/Heading"; import { Paragraph } from "@components/Paragraph"; +import { SettingsSection } from "@components/settings/tabs/plugins/components/Common"; import { makeRange, OptionType } from "@utils/types"; -import { Button, MaskedLink, showToast, Toasts } from "@webpack/common"; +import { MaskedLink, Select, showToast, TextInput, Toasts } from "@webpack/common"; import hoverOnlyStyle from "./hoverOnly.css?managed"; import { clearLyricsCache, removeTranslations } from "./spotify/lyrics/api"; @@ -36,6 +38,46 @@ function InstallInstructions() { ); } +function LyricsProviderSettings() { + const { store } = settings; + + return ( + <> + + v === value} + select={v => { setValue(v); (settings.store[settingsKey] as string) = v; }} + serialize={String} + closeOnSelect + maxVisibleItems={5} + /> + + ); +} + +function AudioBookShelfSettings() { + return ( + <> + + + + + + ); +} + +function TosuSettings() { + return ( + + ); +} + +const nameFormatOptions = [ + { label: "Use custom status name", value: NameFormat.StatusName }, + { label: "Use format 'artist - song'", value: NameFormat.ArtistFirst }, + { label: "Use format 'song - artist'", value: NameFormat.SongFirst }, + { label: "Use artist name only", value: NameFormat.ArtistOnly }, + { label: "Use song name only", value: NameFormat.SongOnly }, + { label: "Use album name", value: NameFormat.AlbumName }, +]; + +function StatsFmSettings() { + return ( + <> + + + + + + + + + + + + + + ); +} + +function JellyfinSettings() { + return ( + <> + + + + + + + + + + + + + + ); +} + +function ListenBrainzSettings() { + return ( + <> + + + + + + + + + + + + + + + ); +} + +function GensokyoRadioSettings() { + const [value, setValue] = useState(settings.store.gr_refreshInterval ?? 15); + return ( + <> + + + { setValue(v); settings.store.gr_refreshInterval = v; }} + onValueRender={v => `${v}s`} + stickToMarkers + /> + + + ); +} + +const TAB_COMPONENTS: Record = { + [ServiceTab.AudioBookShelf]: AudioBookShelfSettings, + [ServiceTab.Tosu]: TosuSettings, + [ServiceTab.StatsFm]: StatsFmSettings, + [ServiceTab.Jellyfin]: JellyfinSettings, + [ServiceTab.ListenBrainz]: ListenBrainzSettings, + [ServiceTab.GensokyoRadio]: GensokyoRadioSettings, +}; + +const TAB_LABELS: Record = { + [ServiceTab.AudioBookShelf]: "AudioBookShelf", + [ServiceTab.Tosu]: "osu!", + [ServiceTab.StatsFm]: "stats.fm", + [ServiceTab.Jellyfin]: "Jellyfin", + [ServiceTab.ListenBrainz]: "ListenBrainz", + [ServiceTab.GensokyoRadio]: "Gensokyo Radio", +}; + +const ENABLE_KEYS: Record = { + [ServiceTab.AudioBookShelf]: "abs_enabled", + [ServiceTab.Tosu]: "tosu_enabled", + [ServiceTab.StatsFm]: "sfm_enabled", + [ServiceTab.Jellyfin]: "jf_enabled", + [ServiceTab.ListenBrainz]: "lb_enabled", + [ServiceTab.GensokyoRadio]: "gr_enabled", +}; + +const TABS = Object.values(ServiceTab); + +export function SettingsPanel() { + const [currentTab, setCurrentTab] = useState(ServiceTab.AudioBookShelf); + const TabComponent = TAB_COMPONENTS[currentTab]; + + return ( +
+ + {TABS.map(tab => ( + + {TAB_LABELS[tab]} + + ))} + + + +
+ ); +} diff --git a/src/equicordplugins/richPresence/index.tsx b/src/equicordplugins/richPresence/index.tsx new file mode 100644 index 000000000..8c0d4874e --- /dev/null +++ b/src/equicordplugins/richPresence/index.tsx @@ -0,0 +1,101 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { Devs, EquicordDevs } from "@utils/constants"; +import { Logger } from "@utils/Logger"; +import definePlugin, { ReporterTestable } from "@utils/types"; + +import { migrateOldSettings } from "./migration"; +import * as abs from "./services/audiobookshelf"; +import * as gensokyoRadio from "./services/gensokyoRadio"; +import * as jellyfin from "./services/jellyfin"; +import * as listenbrainz from "./services/listenbrainz"; +import * as statsfm from "./services/statsfm"; +import * as tosu from "./services/tosu"; +import { setOnServiceChange, settings, SettingsStore } from "./settings"; +import { ServiceTab } from "./types"; + +type SettingsKey = keyof SettingsStore; + +const logger = new Logger("RichPresence"); + +const services: Record = { + [ServiceTab.AudioBookShelf]: abs, + [ServiceTab.Tosu]: tosu, + [ServiceTab.StatsFm]: statsfm, + [ServiceTab.Jellyfin]: jellyfin, + [ServiceTab.ListenBrainz]: listenbrainz, + [ServiceTab.GensokyoRadio]: gensokyoRadio, +}; + +const enableKeys: Record = { + [ServiceTab.AudioBookShelf]: "abs_enabled", + [ServiceTab.Tosu]: "tosu_enabled", + [ServiceTab.StatsFm]: "sfm_enabled", + [ServiceTab.Jellyfin]: "jf_enabled", + [ServiceTab.ListenBrainz]: "lb_enabled", + [ServiceTab.GensokyoRadio]: "gr_enabled", +}; + +const activeServices = new Set(); + +function syncServices() { + const globalEnabled = settings.store.enabled; + + for (const [id, service] of Object.entries(services)) { + const shouldRun = + globalEnabled && !!settings.store[enableKeys[id]]; + const isRunning = activeServices.has(id); + + if (shouldRun && !isRunning) { + logger.info(`Starting ${id} service`); + service.start(); + activeServices.add(id); + } else if (!shouldRun && isRunning) { + logger.info(`Stopping ${id} service`); + service.stop(); + activeServices.delete(id); + } + } +} + +function stopAllServices() { + for (const id of activeServices) { + logger.info(`Stopping ${id} service`); + services[id].stop(); + } + activeServices.clear(); +} + +export default definePlugin({ + name: "RichPresence", + description: "Unified rich presence hub for AudioBookShelf, osu!, stats.fm, Jellyfin, ListenBrainz, and Gensokyo Radio.", + authors: [ + EquicordDevs.vmohammad, + Devs.AutumnVN, + EquicordDevs.Crxa, + Devs.SerStars, + EquicordDevs.ZcraftElite, + EquicordDevs.qouesm, + Devs.RyanCaoDev, + EquicordDevs.Prince527, + EquicordDevs.creations, + ], + reporterTestable: ReporterTestable.None, + + settings, + + start() { + migrateOldSettings(); + syncServices(); + setOnServiceChange(syncServices); + }, + + stop() { + stopAllServices(); + setOnServiceChange(null); + }, +}); diff --git a/src/equicordplugins/richPresence/migration.ts b/src/equicordplugins/richPresence/migration.ts new file mode 100644 index 000000000..49f35b2ba --- /dev/null +++ b/src/equicordplugins/richPresence/migration.ts @@ -0,0 +1,125 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { migratePluginSettings, Settings } from "@api/Settings"; +import { Logger } from "@utils/Logger"; + +import { settings, SettingsStore } from "./settings"; + +type SettingsKey = keyof SettingsStore; + +const logger = new Logger("RichPresence:Migration"); + +interface MigrationMapping { + oldPlugin: string; + enableKey: SettingsKey; + keys: Record; +} + +const migrations: MigrationMapping[] = [ + { + oldPlugin: "AudioBookShelfRichPresence", + enableKey: "abs_enabled", + keys: { + serverUrl: "abs_serverUrl", + username: "abs_username", + password: "abs_password", + }, + }, + { + oldPlugin: "StatsfmPresence", + enableKey: "sfm_enabled", + keys: { + username: "sfm_username", + shareUsername: "sfm_shareUsername", + shareSong: "sfm_shareSong", + hideWithSpotify: "sfm_hideWithSpotify", + hideWithExternalRPC: "sfm_hideWithExternalRPC", + statusName: "sfm_statusName", + nameFormat: "sfm_nameFormat", + useListeningStatus: "sfm_useListeningStatus", + missingArt: "sfm_missingArt", + showStatsFmLogo: "sfm_showLogo", + alwaysHideArt: "sfm_alwaysHideArt", + }, + }, + { + oldPlugin: "JellyfinRichPresence", + enableKey: "jf_enabled", + keys: { + serverUrl: "jf_serverUrl", + apiKey: "jf_apiKey", + userId: "jf_userId", + nameDisplay: "jf_nameDisplay", + customName: "jf_customName", + coverType: "jf_coverType", + episodeFormat: "jf_episodeFormat", + showEpisodeName: "jf_showEpisodeName", + overrideRichPresenceType: "jf_overrideType", + showPausedState: "jf_showPausedState", + privacyMode: "jf_privacyMode", + }, + }, + { + oldPlugin: "ListenBrainzRPC", + enableKey: "lb_enabled", + keys: { + username: "lb_username", + mbContact: "lb_mbContact", + shareUsername: "lb_shareUsername", + shareSong: "lb_shareSong", + hideWithSpotify: "lb_hideWithSpotify", + hideWithActivity: "lb_hideWithActivity", + useTimeBar: "lb_useTimeBar", + statusName: "lb_statusName", + nameFormat: "lb_nameFormat", + useListeningStatus: "lb_useListeningStatus", + missingArt: "lb_missingArt", + useLogo: "lb_useLogo", + }, + }, + { + oldPlugin: "GensokyoRadioRPC", + enableKey: "gr_enabled", + keys: { + refreshInterval: "gr_refreshInterval", + }, + }, +]; + +function setStoreValue(key: SettingsKey, value: boolean | string | number) { + (settings.store[key] as boolean | string | number) = value; +} + +export function migrateOldSettings() { + if (Settings.plugins.RichPresence._migrated) return; + + migratePluginSettings("RichPresence", "AudioBookShelfRichPresence", "GensokyoRadioRPC", "JellyfinRichPresence", "ListenBrainzRPC", "StatsfmPresence", "TosuRPC"); + + for (const migration of migrations) { + const oldSettings = Settings.plugins[migration.oldPlugin]; + if (!oldSettings) continue; + + if (oldSettings.enabled) { + setStoreValue(migration.enableKey, true); + } + + for (const [oldKey, newKey] of Object.entries(migration.keys)) { + if (oldSettings[oldKey] != null) { + setStoreValue(newKey, oldSettings[oldKey]); + } + } + + logger.info(`Migrated settings from ${migration.oldPlugin}`); + } + + const tosuSettings = Settings.plugins.TosuRPC; + if (tosuSettings?.enabled) { + setStoreValue("tosu_enabled", true); + } + + Settings.plugins.RichPresence._migrated = true; +} diff --git a/src/equicordplugins/gensokyoRadioRPC/native.ts b/src/equicordplugins/richPresence/native.ts similarity index 67% rename from src/equicordplugins/gensokyoRadioRPC/native.ts rename to src/equicordplugins/richPresence/native.ts index 69dbe37a0..9a766bf62 100644 --- a/src/equicordplugins/gensokyoRadioRPC/native.ts +++ b/src/equicordplugins/richPresence/native.ts @@ -1,12 +1,12 @@ /* * Vencord, a Discord client mod - * Copyright (c) 2024 Vendicated and contributors + * Copyright (c) 2026 Vendicated and contributors * SPDX-License-Identifier: GPL-3.0-or-later */ -import type { TrackData } from "./types"; +import type { GrTrackData } from "./types/gensokyoRadio"; -export async function fetchTrackData(): Promise { +export async function fetchTrackData(): Promise { const song = await (await fetch("https://gensokyoradio.net/api/station/playing/")).json(); return { @@ -15,6 +15,6 @@ export async function fetchTrackData(): Promise { artist: song.SONGINFO.ARTIST, position: song.SONGTIMES.SONGSTART, duration: song.SONGTIMES.SONGEND, - artwork: song.MISC.ALBUMART ? `https://gensokyoradio.net/images/albums/500/${song.MISC.ALBUMART}` : "undefined", + artwork: song.MISC.ALBUMART ? `https://gensokyoradio.net/images/albums/500/${song.MISC.ALBUMART}` : "", }; } diff --git a/src/equicordplugins/richPresence/services/audiobookshelf.ts b/src/equicordplugins/richPresence/services/audiobookshelf.ts new file mode 100644 index 000000000..af51bf25a --- /dev/null +++ b/src/equicordplugins/richPresence/services/audiobookshelf.ts @@ -0,0 +1,148 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { Logger } from "@utils/Logger"; +import { Activity } from "@vencord/discord-types"; +import { ApplicationAssetUtils, FluxDispatcher, showToast } from "@webpack/common"; + +import { settings } from "../settings"; +import { AbsMediaData, AbsSession } from "../types/audiobookshelf"; + +const APPLICATION_ID = "1381423044907503636"; +const SOCKET_ID = "RichPresence_ABS"; +const logger = new Logger("RichPresence:AudioBookShelf"); + +let authToken: string | null = null; +let updateInterval: NodeJS.Timeout | undefined; + +async function getAsset(key: string): Promise { + return (await ApplicationAssetUtils.fetchAssetIds(APPLICATION_ID, [key]))[0]; +} + +function setActivity(activity: Activity | null) { + FluxDispatcher.dispatch({ type: "LOCAL_ACTIVITY_UPDATE", activity, socketId: SOCKET_ID }); +} + +async function authenticate(): Promise { + const { abs_serverUrl, abs_username, abs_password } = settings.store; + if (!abs_serverUrl || !abs_username || !abs_password) { + logger.warn("AudioBookShelf server URL, username, or password is not set."); + showToast("AudioBookShelf RPC is not configured.", "failure", { duration: 15000 }); + return false; + } + + try { + const baseUrl = abs_serverUrl.replace(/\/$/, ""); + const res = await fetch(`${baseUrl}/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username: abs_username, password: abs_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 function fetchMediaData(): Promise { + if (!authToken && !(await authenticate())) return null; + + try { + const baseUrl = settings.store.abs_serverUrl!.replace(/\/$/, ""); + const res = await fetch(`${baseUrl}/api/me/listening-sessions`, { + headers: { "Authorization": `Bearer ${authToken}` }, + }); + + if (!res.ok) { + if (res.status === 401) { + authToken = null; + if (await authenticate()) return fetchMediaData(); + } + throw `${res.status} ${res.statusText}`; + } + + const { sessions }: { sessions: AbsSession[]; } = await res.json(); + const activeSession = sessions.find(s => s.updatedAt && !s.isFinished); + if (!activeSession?.updatedAt || (Date.now() - activeSession.updatedAt) / 1000 > 30) return null; + + const { mediaMetadata: media, mediaType, duration, currentTime, libraryItemId } = activeSession; + if (!media) return null; + + 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 function getActivity(): Promise { + const mediaData = await fetchMediaData(); + if (!mediaData || mediaData.isFinished) return null; + + const largeImage = mediaData.imageUrl; + const assets = { + large_image: largeImage ? await getAsset(largeImage) : await getAsset("audiobookshelf"), + large_text: mediaData.series || mediaData.author || undefined, + }; + + const details = mediaData.name; + const state = mediaData.series && mediaData.author + ? `${mediaData.series} \u2022 ${mediaData.author}` + : mediaData.author || "AudioBook"; + + const timestamps = mediaData.currentTime != null && mediaData.duration != null ? { + start: Date.now() - (mediaData.currentTime * 1000), + end: Date.now() + ((mediaData.duration - mediaData.currentTime) * 1000), + } : undefined; + + return { + application_id: APPLICATION_ID, + name: "AudioBookShelf", + details, + state, + assets, + timestamps, + type: 2, + flags: 1, + }; +} + +async function updatePresence() { + try { + setActivity(await getActivity()); + } catch (e) { + logger.error("Failed to update presence", e); + setActivity(null); + } +} + +export function start() { + authToken = null; + updatePresence(); + updateInterval = setInterval(updatePresence, 10000); +} + +export function stop() { + clearInterval(updateInterval); + updateInterval = undefined; + authToken = null; + setActivity(null); +} diff --git a/src/equicordplugins/richPresence/services/gensokyoRadio.ts b/src/equicordplugins/richPresence/services/gensokyoRadio.ts new file mode 100644 index 000000000..7b61fa80b --- /dev/null +++ b/src/equicordplugins/richPresence/services/gensokyoRadio.ts @@ -0,0 +1,73 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { Logger } from "@utils/Logger"; +import { PluginNative } from "@utils/types"; +import { Activity } from "@vencord/discord-types"; +import { ActivityFlags, ActivityType } from "@vencord/discord-types/enums"; +import { ApplicationAssetUtils, FluxDispatcher } from "@webpack/common"; + +import { settings } from "../settings"; + +const Native = VencordNative.pluginHelpers.RichPresence as PluginNative; +const logger = new Logger("RichPresence:GensokyoRadio"); + +const APPLICATION_ID = "1253772057926303804"; +const SOCKET_ID = "RichPresence_GR"; + +let updateInterval: NodeJS.Timeout | undefined; + +function setActivity(activity: Activity | null) { + FluxDispatcher.dispatch({ type: "LOCAL_ACTIVITY_UPDATE", activity, socketId: SOCKET_ID }); +} + +async function getAsset(key: string): Promise { + return (await ApplicationAssetUtils.fetchAssetIds(APPLICATION_ID, [key]))[0]; +} + +async function getActivity(): Promise { + const trackData = await Native.fetchTrackData(); + if (!trackData) return null; + + return { + application_id: APPLICATION_ID, + name: "Gensokyo Radio", + details: trackData.title, + state: trackData.artist, + timestamps: { + start: trackData.position * 1000, + end: trackData.duration * 1000, + }, + assets: { + large_image: await getAsset(trackData.artwork), + large_text: trackData.album, + small_image: await getAsset("logo"), + small_text: "Gensokyo Radio", + }, + type: ActivityType.LISTENING, + flags: ActivityFlags.INSTANCE, + }; +} + +async function updatePresence() { + try { + setActivity(await getActivity()); + } catch (e) { + logger.error("Failed to update presence", e); + setActivity(null); + } +} + +export function start() { + updatePresence(); + updateInterval = setInterval(updatePresence, (settings.store.gr_refreshInterval ?? 15) * 1000); +} + +export function stop() { + clearInterval(updateInterval); + updateInterval = undefined; + setActivity(null); +} diff --git a/src/equicordplugins/richPresence/services/jellyfin.ts b/src/equicordplugins/richPresence/services/jellyfin.ts new file mode 100644 index 000000000..ae4dc8f5d --- /dev/null +++ b/src/equicordplugins/richPresence/services/jellyfin.ts @@ -0,0 +1,231 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { Logger } from "@utils/Logger"; +import { Activity } from "@vencord/discord-types"; +import { ApplicationAssetUtils, FluxDispatcher, showToast } from "@webpack/common"; + +import { settings } from "../settings"; +import { JfMediaData, JfSession } from "../types/jellyfin"; + +const APPLICATION_ID = "1381368130164625469"; +const SOCKET_ID = "RichPresence_JF"; +const logger = new Logger("RichPresence:Jellyfin"); + +let updateInterval: NodeJS.Timeout | undefined; +let hasShownError = false; + +async function getAsset(key: string): Promise { + return (await ApplicationAssetUtils.fetchAssetIds(APPLICATION_ID, [key]))[0]; +} + +function setActivity(activity: Activity | null) { + FluxDispatcher.dispatch({ type: "LOCAL_ACTIVITY_UPDATE", activity, socketId: SOCKET_ID }); +} + +async function fetchMediaData(): Promise { + const { jf_serverUrl, jf_apiKey, jf_userId } = settings.store; + if (!jf_serverUrl || !jf_apiKey || !jf_userId) { + if (!hasShownError) { + logger.warn("Jellyfin server URL, API key, or user ID is not set."); + showToast("Jellyfin RPC is not configured.", "failure", { duration: 15000 }); + hasShownError = true; + } + return null; + } + + try { + const baseUrl = (jf_serverUrl.startsWith("http") ? jf_serverUrl : `https://${jf_serverUrl}`).replace(/\/$/, ""); + const res = await fetch(`${baseUrl}/Sessions?api_key=${jf_apiKey}`); + if (!res.ok) throw `${res.status} ${res.statusText}`; + + const contentType = res.headers.get("content-type") ?? ""; + if (!contentType.includes("application/json")) { + if (!hasShownError) { + logger.error("Jellyfin returned non-JSON response. Check your server URL and API key."); + showToast("Jellyfin returned an invalid response. Your API key may be wrong.", "failure", { duration: 15000 }); + hasShownError = true; + } + return null; + } + + const sessions: JfSession[] = await res.json(); + const userSession = sessions.find(s => s.UserId === jf_userId && s.NowPlayingItem); + if (!userSession?.NowPlayingItem) return null; + + const item = userSession.NowPlayingItem; + const playState = userSession.PlayState; + + if (playState?.IsPaused && !settings.store.jf_showPausedState) return null; + + const imageUrl = item.ImageTags?.Primary + ? `${baseUrl}/Items/${item.Type === "Episode" && item.SeriesId && settings.store.jf_coverType === "series" + ? item.SeriesId : item.Id}/Images/Primary` + : undefined; + + return { + name: item.Name || "Unknown", + type: item.Type || "Unknown", + artist: item.Artists?.[0] || item.AlbumArtist, + album: item.Album, + seriesName: item.SeriesName, + seasonNumber: item.ParentIndexNumber, + episodeNumber: item.IndexNumber, + year: item.ProductionYear, + url: `${baseUrl}/web/#!/details?id=${item.Id}`, + imageUrl, + duration: item.RunTimeTicks ? Math.floor(item.RunTimeTicks / 10000000) : undefined, + position: playState?.PositionTicks ? Math.floor(playState.PositionTicks / 10000000) : undefined, + isPaused: !!playState?.IsPaused, + }; + } catch (e) { + logger.error("Failed to query Jellyfin API", e); + return null; + } +} + +async function getActivity(): Promise { + const { store } = settings; + const mediaData = await fetchMediaData(); + if (!mediaData) return null; + + let richPresenceType: number; + if (store.jf_overrideType !== "off") { + richPresenceType = parseInt(store.jf_overrideType as string, 10); + } else { + richPresenceType = mediaData.type === "Audio" ? 2 : 3; + } + + const templateReplace = (template: string) => + template + .replace(/\{name\}/g, mediaData.name || "") + .replace(/\{series\}/g, mediaData.seriesName || "") + .replace(/\{season\}/g, mediaData.seasonNumber?.toString() || "") + .replace(/\{episode\}/g, mediaData.episodeNumber?.toString() || "") + .replace(/\{artist\}/g, mediaData.artist || "") + .replace(/\{album\}/g, mediaData.album || "") + .replace(/\{year\}/g, mediaData.year?.toString() || ""); + + let appName: string; + const nameSetting = store.jf_nameDisplay || "default"; + + switch (nameSetting) { + case "full": + if (mediaData.type === "Episode" && mediaData.seriesName) { + appName = store.jf_privacyMode + ? `${mediaData.seriesName} - [Episode Hidden]` + : `${mediaData.seriesName} - ${mediaData.name}`; + } else if (mediaData.type === "Audio") { + appName = store.jf_privacyMode + ? "[Track Hidden]" + : `${mediaData.artist || "Unknown Artist"} - ${mediaData.name}`; + } else { + appName = store.jf_privacyMode ? "[Movie Hidden]" : mediaData.name || "Jellyfin"; + } + break; + case "custom": + appName = templateReplace(store.jf_customName || "{name} on Jellyfin"); + if (store.jf_privacyMode) { + appName = appName + .replace(mediaData.name || "", "[Title Hidden]") + .replace(mediaData.seriesName || "", "[Series Hidden]") + .replace(mediaData.artist || "", "[Artist Hidden]") + .replace(mediaData.album || "", "[Album Hidden]"); + } + break; + default: + if (mediaData.type === "Episode" && mediaData.seriesName) { + appName = mediaData.seriesName; + } else { + appName = store.jf_privacyMode ? "[Media Hidden]" : mediaData.name || "Jellyfin"; + } + break; + } + + const assets = { + large_image: !store.jf_privacyMode && mediaData.imageUrl + ? await getAsset(mediaData.imageUrl) : undefined, + large_text: mediaData.seriesName || mediaData.album || undefined, + }; + + const getDetails = () => { + if (mediaData.type === "Episode" && mediaData.seriesName) + return store.jf_privacyMode ? "Watching a TV Show" : mediaData.seriesName; + return store.jf_privacyMode ? "Watching Something" : mediaData.name; + }; + + const getState = () => { + if (mediaData.isPaused) return "Paused"; + if (mediaData.type === "Episode" && mediaData.seriesName) { + let episodeFormat = ""; + const season = mediaData.seasonNumber; + const episode = mediaData.episodeNumber; + const format = store.jf_episodeFormat || "long"; + + if (season != null && episode != null) { + switch (format) { + case "long": + episodeFormat = `S${season.toString().padStart(2, "0")}E${episode.toString().padStart(2, "0")}`; + break; + case "short": + episodeFormat = `${season}x${episode.toString().padStart(2, "0")}`; + break; + case "fulltext": + episodeFormat = `Season ${season} Episode ${episode}`; + break; + } + } else if (season != null) { + episodeFormat = format === "fulltext" ? `Season ${season}` : `S${season.toString().padStart(2, "0")}`; + } else if (episode != null) { + episodeFormat = format === "fulltext" ? `Episode ${episode}` : `E${episode.toString().padStart(2, "0")}`; + } + + if (store.jf_showEpisodeName && mediaData.name && !store.jf_privacyMode) + return `${episodeFormat} - ${mediaData.name}`; + return episodeFormat; + } + if (store.jf_privacyMode) + return mediaData.type === "Audio" ? "Listening to music" : (mediaData.year ? "(????)" : undefined); + return mediaData.artist || (mediaData.year ? `(${mediaData.year})` : undefined); + }; + + const timestamps = (!mediaData.isPaused && mediaData.position != null && mediaData.duration != null) ? { + start: Date.now() - (mediaData.position * 1000), + end: Date.now() + ((mediaData.duration - mediaData.position) * 1000), + } : undefined; + + return { + application_id: APPLICATION_ID, + name: appName, + details: getDetails(), + state: getState() || "something", + assets, + timestamps, + type: richPresenceType, + flags: 1, + }; +} + +async function updatePresence() { + try { + setActivity(await getActivity()); + } catch (e) { + logger.error("Failed to update presence", e); + setActivity(null); + } +} + +export function start() { + hasShownError = false; + updatePresence(); + updateInterval = setInterval(updatePresence, 10000); +} + +export function stop() { + clearInterval(updateInterval); + updateInterval = undefined; + setActivity(null); +} diff --git a/src/equicordplugins/richPresence/services/listenbrainz.ts b/src/equicordplugins/richPresence/services/listenbrainz.ts new file mode 100644 index 000000000..72d132b64 --- /dev/null +++ b/src/equicordplugins/richPresence/services/listenbrainz.ts @@ -0,0 +1,186 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { Logger } from "@utils/Logger"; +import { Activity, ActivityButton } from "@vencord/discord-types"; +import { ActivityFlags, ActivityType } from "@vencord/discord-types/enums"; +import { findByPropsLazy } from "@webpack"; +import { ApplicationAssetUtils, FluxDispatcher } from "@webpack/common"; + +import { settings } from "../settings"; +import { NameFormat } from "../types"; +import { LbTrackData } from "../types/listenbrainz"; + +const APPLICATION_ID = "1090155131007406132"; +const PLACEHOLDER_ID = "2a96cbd8b46e442fc41c2b86b821562f"; +const SOCKET_ID = "RichPresence_LB"; +const logger = new Logger("RichPresence:ListenBrainz"); +const PresenceStore = findByPropsLazy("getLocalPresence"); + +let updateInterval: NodeJS.Timeout | undefined; +let currentRecordingMBID = ""; +let currentStart = 0; + +async function getAsset(key: string): Promise { + return (await ApplicationAssetUtils.fetchAssetIds(APPLICATION_ID, [key]))[0]; +} + +function setActivity(activity: Activity | null) { + FluxDispatcher.dispatch({ type: "LOCAL_ACTIVITY_UPDATE", activity, socketId: SOCKET_ID }); +} + +async function fetchTrackData(): Promise { + if (!settings.store.lb_username) { + logger.warn("ListenBrainz username is not set."); + return null; + } + + try { + const lbRes = await fetch(`https://api.listenbrainz.org/1/user/${settings.store.lb_username}/playing-now`); + if (!lbRes.ok) throw `${lbRes.status} ${lbRes.statusText}`; + + const lbJson = await lbRes.json(); + if (lbJson.error) { + logger.error("Error from ListenBrainz API", `${lbJson.error}: ${lbJson.message}`); + return null; + } + + const listen = lbJson.payload?.listens?.[0]; + if (!listen?.playing_now) return null; + + const trackMetadata = listen.track_metadata; + const albumName = trackMetadata.release_name || "Unknown"; + const artistName = trackMetadata.artist_name || "Unknown"; + + const mbRes = await fetch( + `https://musicbrainz.org/ws/2/release/?query=release:${encodeURIComponent(albumName)}%20AND%20artist:${encodeURIComponent(artistName)}&fmt=json` + ); + if (!mbRes.ok) throw `${mbRes.status} ${mbRes.statusText}`; + + const mbJson = await mbRes.json(); + const releases = mbJson.releases || []; + const releaseGroup = releases[0]?.["release-group"]?.id; + + let url = ""; + let imageUrl = ""; + + if (releaseGroup) { + const caaRes = await fetch(`https://coverartarchive.org/release-group/${releaseGroup}`); + if (caaRes.ok) { + const caaJson = await caaRes.json(); + url = caaJson.release ?? ""; + for (const image of caaJson.images) { + imageUrl = image.thumbnails.large || ""; + if (imageUrl) break; + } + } + } + + return { + name: trackMetadata.track_name || "Unknown", + album: albumName, + artist: artistName, + durationMs: trackMetadata.additional_info?.duration_ms, + recordingMBID: trackMetadata.additional_info?.recording_mbid, + url, + imageUrl, + }; + } catch (e) { + logger.error("Failed to query ListenBrainz API", e); + return null; + } +} + +function getLargeImage(track: LbTrackData): string | undefined { + if (track.imageUrl && !track.imageUrl.includes(PLACEHOLDER_ID)) return track.imageUrl; + if (settings.store.lb_missingArt === "placeholder") return "placeholder"; +} + +async function getActivity(): Promise { + if (settings.store.lb_hideWithActivity) { + if (PresenceStore.getActivities().some(a => a.application_id !== APPLICATION_ID)) return null; + } + + if (settings.store.lb_hideWithSpotify) { + if (PresenceStore.getActivities().some(a => a.type === ActivityType.LISTENING && a.application_id !== APPLICATION_ID)) + return null; + } + + const trackData = await fetchTrackData(); + if (!trackData) return null; + + const largeImage = getLargeImage(trackData); + const assets = largeImage + ? { + large_image: await getAsset(largeImage), + large_text: trackData.album || undefined, + small_image: settings.store.lb_useLogo ? await getAsset("listenbrainz") : undefined, + small_text: "ListenBrainz", + } + : { + large_image: await getAsset("listenbrainz"), + large_text: trackData.album || undefined, + }; + + const buttons: ActivityButton[] = []; + if (settings.store.lb_shareUsername) + buttons.push({ label: "ListenBrainz Profile", url: `https://www.listenbrainz.org/user/${settings.store.lb_username}` }); + if (settings.store.lb_shareSong && trackData.url) + buttons.push({ label: "View Song", url: trackData.url }); + + const statusName = (() => { + switch (settings.store.lb_nameFormat) { + case NameFormat.ArtistFirst: return trackData.artist + " - " + trackData.name; + case NameFormat.SongFirst: return trackData.name + " - " + trackData.artist; + case NameFormat.ArtistOnly: return trackData.artist; + case NameFormat.SongOnly: return trackData.name; + case NameFormat.AlbumName: return trackData.album || settings.store.lb_statusName; + default: return settings.store.lb_statusName; + } + })(); + + if (trackData.recordingMBID && trackData.recordingMBID !== currentRecordingMBID) { + currentRecordingMBID = trackData.recordingMBID; + currentStart = Date.now(); + } + + return { + application_id: APPLICATION_ID, + name: statusName, + details: trackData.name, + state: trackData.artist, + assets, + timestamps: settings.store.lb_useTimeBar && currentStart + ? { start: currentStart, end: trackData.durationMs ? currentStart + trackData.durationMs : undefined } + : undefined, + buttons: buttons.length ? buttons.map(v => v.label) : undefined, + metadata: buttons.length ? { button_urls: buttons.map(v => v.url) } : undefined, + type: settings.store.lb_useListeningStatus ? ActivityType.LISTENING : ActivityType.PLAYING, + flags: ActivityFlags.INSTANCE, + }; +} + +async function updatePresence() { + try { + setActivity(await getActivity()); + } catch (e) { + logger.error("Failed to update presence", e); + setActivity(null); + } +} + +export function start() { + currentRecordingMBID = ""; + currentStart = 0; + updatePresence(); + updateInterval = setInterval(updatePresence, 16000); +} + +export function stop() { + clearInterval(updateInterval); + updateInterval = undefined; + setActivity(null); +} diff --git a/src/equicordplugins/richPresence/services/statsfm.ts b/src/equicordplugins/richPresence/services/statsfm.ts new file mode 100644 index 000000000..f69f5a908 --- /dev/null +++ b/src/equicordplugins/richPresence/services/statsfm.ts @@ -0,0 +1,143 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { Logger } from "@utils/Logger"; +import { Activity, ActivityButton } from "@vencord/discord-types"; +import { ActivityFlags, ActivityType } from "@vencord/discord-types/enums"; +import { findByPropsLazy } from "@webpack"; +import { ApplicationAssetUtils, FluxDispatcher } from "@webpack/common"; + +import { settings } from "../settings"; +import { NameFormat } from "../types"; +import { SfmResponse, SfmTrackData } from "../types/statsfm"; + +const APPLICATION_ID = "1325126169179197500"; +const PLACEHOLDER_ID = "2a96cbd8b46e442fc41c2b86b821562f"; +const SOCKET_ID = "RichPresence_SFM"; +const logger = new Logger("RichPresence:StatsFm"); +const PresenceStore = findByPropsLazy("getLocalPresence"); + +let updateInterval: NodeJS.Timeout | undefined; + +async function getAsset(key: string): Promise { + return (await ApplicationAssetUtils.fetchAssetIds(APPLICATION_ID, [key]))[0]; +} + +function setActivity(activity: Activity | null) { + FluxDispatcher.dispatch({ type: "LOCAL_ACTIVITY_UPDATE", activity, socketId: SOCKET_ID }); +} + +async function fetchTrackData(): Promise { + if (!settings.store.sfm_username) return null; + + try { + const res = await fetch(`https://api.stats.fm/api/v1/users/${settings.store.sfm_username}/streams/current`); + if (!res.ok) throw `${res.status} ${res.statusText}`; + + const json = await res.json() as SfmResponse; + if (!json.item) { + logger.error("Error from Stats.fm API", json); + return null; + } + + const trackData = json.item.track; + if (!trackData) return null; + + return { + name: trackData.name || "Unknown", + albums: trackData.albums.map(a => a.name).join(", ") || "Unknown", + artists: trackData.artists[0]?.name ?? "Unknown", + url: `https://stats.fm/track/${trackData.id}`, + imageUrl: trackData.albums[0]?.image, + }; + } catch (e) { + logger.error("Failed to query Stats.fm API", e); + return null; + } +} + +function getLargeImage(track: SfmTrackData): string | undefined { + if (!settings.store.sfm_alwaysHideArt && track.imageUrl && !track.imageUrl.includes(PLACEHOLDER_ID)) + return track.imageUrl; + if (settings.store.sfm_missingArt === "placeholder") return "placeholder"; +} + +async function getActivity(): Promise { + if (settings.store.sfm_hideWithExternalRPC) { + if (PresenceStore.getActivities().some(a => a.application_id !== APPLICATION_ID)) return null; + } + + if (settings.store.sfm_hideWithSpotify) { + if (PresenceStore.getActivities().some(a => a.type === ActivityType.LISTENING && a.application_id !== APPLICATION_ID)) + return null; + } + + const trackData = await fetchTrackData(); + if (!trackData) return null; + + const largeImage = getLargeImage(trackData); + const assets = largeImage + ? { + large_image: await getAsset(largeImage), + large_text: trackData.albums || undefined, + ...(settings.store.sfm_showLogo && { + small_image: await getAsset("statsfm-large"), + small_text: "Stats.fm", + }), + } : { + large_image: await getAsset("statsfm-large"), + large_text: trackData.albums || undefined, + }; + + const buttons: ActivityButton[] = []; + if (settings.store.sfm_shareUsername) + buttons.push({ label: "Stats.fm Profile", url: `https://stats.fm/${settings.store.sfm_username}` }); + if (settings.store.sfm_shareSong) + buttons.push({ label: "View Song", url: trackData.url }); + + const statusName = (() => { + switch (settings.store.sfm_nameFormat) { + case NameFormat.ArtistFirst: return trackData.artists + " - " + trackData.name; + case NameFormat.SongFirst: return trackData.name + " - " + trackData.artists; + case NameFormat.ArtistOnly: return trackData.artists; + case NameFormat.SongOnly: return trackData.name; + case NameFormat.AlbumName: return trackData.albums || settings.store.sfm_statusName; + default: return settings.store.sfm_statusName; + } + })(); + + return { + application_id: APPLICATION_ID, + name: statusName, + details: trackData.name, + state: trackData.artists, + assets, + buttons: buttons.length ? buttons.map(v => v.label) : undefined, + metadata: buttons.length ? { button_urls: buttons.map(v => v.url) } : undefined, + type: settings.store.sfm_useListeningStatus ? ActivityType.LISTENING : ActivityType.PLAYING, + flags: ActivityFlags.INSTANCE, + }; +} + +async function updatePresence() { + try { + setActivity(await getActivity()); + } catch (e) { + logger.error("Failed to update presence", e); + setActivity(null); + } +} + +export function start() { + updatePresence(); + updateInterval = setInterval(updatePresence, 16000); +} + +export function stop() { + clearInterval(updateInterval); + updateInterval = undefined; + setActivity(null); +} diff --git a/src/equicordplugins/tosuRPC/index.ts b/src/equicordplugins/richPresence/services/tosu.ts similarity index 73% rename from src/equicordplugins/tosuRPC/index.ts rename to src/equicordplugins/richPresence/services/tosu.ts index c0b990db5..388138702 100644 --- a/src/equicordplugins/tosuRPC/index.ts +++ b/src/equicordplugins/richPresence/services/tosu.ts @@ -1,98 +1,90 @@ /* * Vencord, a Discord client mod - * Copyright (c) 2024 Vendicated and contributors + * Copyright (c) 2026 Vendicated and contributors * SPDX-License-Identifier: GPL-3.0-or-later */ -import { Devs } from "@utils/constants"; -import definePlugin from "@utils/types"; import { Activity } from "@vencord/discord-types"; import { ActivityType } from "@vencord/discord-types/enums"; import { ApplicationAssetUtils, FluxDispatcher } from "@webpack/common"; -import { BanchoStatusEnum, GameState, Modes, TosuApi, UserLoginStatus } from "./type"; +import { BanchoStatusEnum, GameState, Modes, TosuApi } from "../types/tosu"; -const socketId = "tosu"; const OSU_APP_ID = "367827983903490050"; const OSU_LARGE_IMAGE = "373344233077211136"; const OSU_STARDARD_SMALL_IMAGE = "373370493127884800"; const OSU_MANIA_SMALL_IMAGE = "373370588703621136"; const OSU_TAIKO_SMALL_IMAGE = "373370519891738624"; const OSU_CATCH_SMALL_IMAGE = "373370543161999361"; - -const throttledOnMessage = throttle(onMessage, 3000, () => FluxDispatcher.dispatch({ type: "LOCAL_ACTIVITY_UPDATE", activity: null, socketId: "tosu" })); +const SOCKET_ID = "RichPresence_Tosu"; let ws: WebSocket; let wsReconnect: NodeJS.Timeout; let shouldReconnect = false; -export default definePlugin({ - name: "TosuRPC", - description: "osu! RPC with data from tosu", - authors: [Devs.AutumnVN], - start() { - shouldReconnect = true; - (function connect() { - ws = new WebSocket("ws://127.0.0.1:24050/websocket/v2"); - ws.addEventListener("error", () => ws.close()); - ws.addEventListener("close", () => { - if (!shouldReconnect) return; - wsReconnect = setTimeout(connect, 5000); - }); - ws.addEventListener("message", ({ data }) => throttledOnMessage(data)); - })(); - }, - stop() { - shouldReconnect = false; - if (wsReconnect) clearTimeout(wsReconnect); - if (ws) ws.close(); - FluxDispatcher.dispatch({ type: "LOCAL_ACTIVITY_UPDATE", activity: null, socketId: "tosu" }); - } -}); +function throttle void>(func: T, limit: number, timedOutCallback?: () => void): T { + let inThrottle: boolean; + let callbackTimeout: NodeJS.Timeout; + return function (...args: Parameters) { + if (!inThrottle) { + func(...args); + inThrottle = true; + setTimeout(() => inThrottle = false, limit); + if (timedOutCallback) { + clearTimeout(callbackTimeout); + callbackTimeout = setTimeout(timedOutCallback, limit * 2); + } + } + } as T; +} + +async function getAsset(key: string): Promise { + if (/https?:\/\/(cdn|media)\.discordapp\.(com|net)\/attachments\//.test(key)) + return "mp:" + key.replace(/https?:\/\/(cdn|media)\.discordapp\.(com|net)\//, ""); + return (await ApplicationAssetUtils.fetchAssetIds(OSU_APP_ID, [key]))[0]; +} + +const throttledOnMessage = throttle(onMessage, 3000, () => + FluxDispatcher.dispatch({ type: "LOCAL_ACTIVITY_UPDATE", activity: null, socketId: SOCKET_ID }) +); async function onMessage(data: string) { const json: TosuApi = JSON.parse(data); // @ts-ignore - if (json.error) return FluxDispatcher.dispatch({ type: "LOCAL_ACTIVITY_UPDATE", activity: null, socketId: "tosu" }); + if (json.error) return FluxDispatcher.dispatch({ type: "LOCAL_ACTIVITY_UPDATE", activity: null, socketId: SOCKET_ID }); const { state, session, profile, beatmap, play, resultsScreen } = json; + const assets: NonNullable = {}; + + switch (profile.mode.number) { + case Modes.Osu: + assets.small_image = OSU_STARDARD_SMALL_IMAGE; + assets.small_text = "osu!"; + break; + case Modes.Mania: + assets.small_image = OSU_MANIA_SMALL_IMAGE; + assets.small_text = "osu!mania"; + break; + case Modes.Taiko: + assets.small_image = OSU_TAIKO_SMALL_IMAGE; + assets.small_text = "osu!taiko"; + break; + case Modes.Fruits: + assets.small_image = OSU_CATCH_SMALL_IMAGE; + assets.small_text = "osu!catch"; + break; + } + const activity: Activity = { application_id: OSU_APP_ID, name: "osu!", type: ActivityType.PLAYING, - assets: { - large_image: OSU_LARGE_IMAGE, - large_text: profile.userStatus.number === UserLoginStatus.Connected ? `${profile.name} | #${profile.globalRank} | ${Math.round(profile.pp)}pp` : undefined, - }, - timestamps: { - start: Date.now() - session.playTime - }, + assets, + timestamps: { start: Date.now() - session.playTime }, flags: 1 << 0, }; - activity.assets = {}; - - switch (profile.mode.number) { - case Modes.Osu: - activity.assets.small_image = OSU_STARDARD_SMALL_IMAGE; - activity.assets.small_text = "osu!"; - break; - case Modes.Mania: - activity.assets.small_image = OSU_MANIA_SMALL_IMAGE; - activity.assets.small_text = "osu!mania"; - break; - case Modes.Taiko: - activity.assets.small_image = OSU_TAIKO_SMALL_IMAGE; - activity.assets.small_text = "osu!taiko"; - break; - case Modes.Fruits: - activity.assets.small_image = OSU_CATCH_SMALL_IMAGE; - activity.assets.small_text = "osu!catch"; - break; - } - - let player = ""; let mods = ""; let fc = ""; let combo = ""; @@ -101,11 +93,13 @@ async function onMessage(data: string) { let h0 = ""; let sb = ""; let pp = ""; - switch (state.number) { - case GameState.Play: - activity.type = profile.banchoStatus.number === BanchoStatusEnum.Playing ? ActivityType.PLAYING : ActivityType.WATCHING; - player = profile.banchoStatus.number === BanchoStatusEnum.Playing ? "" : `${play.playerName} | `; + switch (state.number) { + case GameState.Play: { + activity.type = profile.banchoStatus.number === BanchoStatusEnum.Playing + ? ActivityType.PLAYING : ActivityType.WATCHING; + + const player = profile.banchoStatus.number === BanchoStatusEnum.Playing ? "" : `${play.playerName} | `; mods = play.mods.name ? `+${play.mods.name} ` : ""; activity.name = `${player}${beatmap.artist} - ${beatmap.title} [${beatmap.version}] ${mods}(${beatmap.mapper}, ${beatmap.stats.stars.total.toFixed(2)}*)`; @@ -124,10 +118,11 @@ async function onMessage(data: string) { activity.state = [h100, h50, h0, sb].filter(Boolean).join(" | "); const playRank = await getAsset(`https://raw.githubusercontent.com/AutumnVN/gosu-rich-presence/main/grade/${play.rank.current.toLowerCase().replace("x", "ss")}.png`); - activity.assets.small_image = playRank; - activity.assets.small_text = undefined; + assets.small_image = playRank; + assets.small_text = undefined; break; - case GameState.ResultScreen: + } + case GameState.ResultScreen: { activity.type = ActivityType.WATCHING; mods = resultsScreen.mods.name ? `+${resultsScreen.mods.name} ` : ""; @@ -147,10 +142,11 @@ async function onMessage(data: string) { activity.state = [h100, h50, h0].filter(Boolean).join(" | "); const resultRank = await getAsset(`https://raw.githubusercontent.com/AutumnVN/gosu-rich-presence/main/grade/${resultsScreen.rank.toLowerCase().replace("x", "ss")}.png`); - activity.assets.small_image = resultRank; - activity.assets.small_text = undefined; + assets.small_image = resultRank; + assets.small_text = undefined; break; - default: + } + default: { activity.type = ActivityType.LISTENING; mods = play.mods.name ? `+${play.mods.name} ` : ""; activity.name = `${beatmap.artist} - ${beatmap.title} [${beatmap.version}] ${mods}(${beatmap.mapper}, ${beatmap.stats.stars.total.toFixed(2)}*)`; @@ -193,36 +189,35 @@ async function onMessage(data: string) { case BanchoStatusEnum.Multiplaying: activity.state = "Multiplaying"; break; case BanchoStatusEnum.OsuDirect: activity.state = "osu!direct"; break; } - break; + } } if (beatmap.set > 0) { const mapBg = await getAsset(`https://assets.ppy.sh/beatmaps/${beatmap.set}/covers/list@2x.jpg`); const res = await fetch(mapBg.replace(/^mp:/, "https://media.discordapp.net/"), { method: "HEAD" }); - if (res.ok) activity.assets.large_image = mapBg; + if (res.ok) assets.large_image = mapBg; } - FluxDispatcher.dispatch({ type: "LOCAL_ACTIVITY_UPDATE", activity, socketId: "tosu" }); + FluxDispatcher.dispatch({ type: "LOCAL_ACTIVITY_UPDATE", activity, socketId: SOCKET_ID }); } -function throttle(func: T, limit: number, timedOutCallback?: () => void): T { - let inThrottle: boolean; - let callbackTimeout: NodeJS.Timeout; - return function (this: any, ...args: any[]) { - if (!inThrottle) { - func.apply(this, args); - inThrottle = true; - setTimeout(() => inThrottle = false, limit); - if (timedOutCallback) { - clearTimeout(callbackTimeout); - callbackTimeout = setTimeout(timedOutCallback, limit * 2); - } - } - } as any; +export function start() { + shouldReconnect = true; + (function connect() { + ws = new WebSocket("ws://127.0.0.1:24050/websocket/v2"); + ws.addEventListener("error", () => ws.close()); + ws.addEventListener("close", () => { + if (!shouldReconnect) return; + wsReconnect = setTimeout(connect, 5000); + }); + ws.addEventListener("message", ({ data }) => throttledOnMessage(data)); + })(); } -async function getAsset(key: string): Promise { - if (/https?:\/\/(cdn|media)\.discordapp\.(com|net)\/attachments\//.test(key)) return "mp:" + key.replace(/https?:\/\/(cdn|media)\.discordapp\.(com|net)\//, ""); - return (await ApplicationAssetUtils.fetchAssetIds(OSU_APP_ID, [key]))[0]; +export function stop() { + shouldReconnect = false; + if (wsReconnect) clearTimeout(wsReconnect); + if (ws) ws.close(); + FluxDispatcher.dispatch({ type: "LOCAL_ACTIVITY_UPDATE", activity: null, socketId: SOCKET_ID }); } diff --git a/src/equicordplugins/richPresence/settings.ts b/src/equicordplugins/richPresence/settings.ts new file mode 100644 index 000000000..fddfa926d --- /dev/null +++ b/src/equicordplugins/richPresence/settings.ts @@ -0,0 +1,351 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { definePluginSettings } from "@api/Settings"; +import { OptionType } from "@utils/types"; + +import { SettingsPanel } from "./SettingsPanel"; +import { NameFormat } from "./types"; + +export let onServiceChange: (() => void) | null = null; +export function setOnServiceChange(fn: (() => void) | null) { onServiceChange = fn; } + +export const settings = definePluginSettings({ + enabled: { + description: "Enable rich presence services.", + type: OptionType.BOOLEAN, + default: true, + restartNeeded: false, + onChange: () => onServiceChange?.(), + }, + serviceSettings: { + type: OptionType.COMPONENT, + description: "Service configuration.", + component: SettingsPanel, + }, + + // Per-service enable toggles + abs_enabled: { + description: "Enable AudioBookShelf presence.", + type: OptionType.BOOLEAN, + default: false, + hidden: true, + onChange: () => onServiceChange?.(), + }, + tosu_enabled: { + description: "Enable osu! (tosu) presence.", + type: OptionType.BOOLEAN, + default: false, + hidden: true, + onChange: () => onServiceChange?.(), + }, + sfm_enabled: { + description: "Enable stats.fm presence.", + type: OptionType.BOOLEAN, + default: false, + hidden: true, + onChange: () => onServiceChange?.(), + }, + jf_enabled: { + description: "Enable Jellyfin presence.", + type: OptionType.BOOLEAN, + default: false, + hidden: true, + onChange: () => onServiceChange?.(), + }, + lb_enabled: { + description: "Enable ListenBrainz presence.", + type: OptionType.BOOLEAN, + default: false, + hidden: true, + onChange: () => onServiceChange?.(), + }, + gr_enabled: { + description: "Enable Gensokyo Radio presence.", + type: OptionType.BOOLEAN, + default: false, + hidden: true, + onChange: () => onServiceChange?.(), + }, + + // AudioBookShelf + abs_serverUrl: { + description: "AudioBookShelf server URL.", + type: OptionType.STRING, + default: "", + hidden: true, + }, + abs_username: { + description: "AudioBookShelf username.", + type: OptionType.STRING, + default: "", + hidden: true, + }, + abs_password: { + description: "AudioBookShelf password.", + type: OptionType.STRING, + default: "", + hidden: true, + }, + + // stats.fm + sfm_username: { + description: "Stats.fm username.", + type: OptionType.STRING, + default: "", + hidden: true, + }, + sfm_shareUsername: { + description: "Show link to stats.fm profile.", + type: OptionType.BOOLEAN, + default: false, + hidden: true, + }, + sfm_shareSong: { + description: "Show link to song on stats.fm.", + type: OptionType.BOOLEAN, + default: true, + hidden: true, + }, + sfm_hideWithSpotify: { + description: "Hide stats.fm presence if Spotify is running.", + type: OptionType.BOOLEAN, + default: false, + hidden: true, + }, + sfm_hideWithExternalRPC: { + description: "Hide stats.fm presence if an external RPC is running.", + type: OptionType.BOOLEAN, + default: false, + hidden: true, + }, + sfm_statusName: { + description: "Custom status text.", + type: OptionType.STRING, + default: "Stats.fm", + hidden: true, + }, + sfm_nameFormat: { + description: "Name format.", + type: OptionType.SELECT, + options: [ + { label: "Use custom status name", value: NameFormat.StatusName, default: true }, + { label: "Use format 'artist - song'", value: NameFormat.ArtistFirst }, + { label: "Use format 'song - artist'", value: NameFormat.SongFirst }, + { label: "Use artist name only", value: NameFormat.ArtistOnly }, + { label: "Use song name only", value: NameFormat.SongOnly }, + { label: "Use album name", value: NameFormat.AlbumName }, + ], + hidden: true, + }, + sfm_useListeningStatus: { + description: "Show listening status.", + type: OptionType.BOOLEAN, + default: true, + hidden: true, + }, + sfm_missingArt: { + description: "Fallback when art is missing.", + type: OptionType.SELECT, + options: [ + { label: "Use large Stats.fm logo", value: "StatsFmLogo", default: true }, + { label: "Use generic placeholder", value: "placeholder" }, + ], + hidden: true, + }, + sfm_showLogo: { + description: "Show Stats.fm logo next to album art.", + type: OptionType.BOOLEAN, + default: true, + hidden: true, + }, + sfm_alwaysHideArt: { + description: "Disable downloading album art.", + type: OptionType.BOOLEAN, + default: false, + hidden: true, + }, + + // Jellyfin + jf_serverUrl: { + description: "Jellyfin server URL.", + type: OptionType.STRING, + default: "", + hidden: true, + }, + jf_apiKey: { + description: "Jellyfin API key.", + type: OptionType.STRING, + default: "", + hidden: true, + }, + jf_userId: { + description: "Jellyfin user ID.", + type: OptionType.STRING, + default: "", + hidden: true, + }, + jf_nameDisplay: { + description: "Name display format.", + type: OptionType.SELECT, + options: [ + { label: "Series/Movie Name", value: "default", default: true }, + { label: "Series - Episode/Track/Movie Name", value: "full" }, + { label: "Custom", value: "custom" }, + ], + hidden: true, + }, + jf_customName: { + description: "Custom name template.", + type: OptionType.STRING, + default: "", + hidden: true, + }, + jf_coverType: { + description: "Cover type for TV shows.", + type: OptionType.SELECT, + options: [ + { label: "Series Cover", value: "series", default: true }, + { label: "Episode Cover", value: "episode" }, + ], + hidden: true, + }, + jf_episodeFormat: { + description: "Episode number format.", + type: OptionType.SELECT, + options: [ + { label: "S01E01", value: "long", default: true }, + { label: "1x01", value: "short" }, + { label: "Season 1 Episode 1", value: "fulltext" }, + ], + hidden: true, + }, + jf_showEpisodeName: { + description: "Show episode name after season/episode info.", + type: OptionType.BOOLEAN, + default: false, + hidden: true, + }, + jf_overrideType: { + description: "Override rich presence type.", + type: OptionType.SELECT, + options: [ + { label: "Off", value: "off", default: true }, + { label: "Listening", value: "2" }, + { label: "Playing", value: "0" }, + { label: "Streaming", value: "1" }, + { label: "Watching", value: "3" }, + ], + hidden: true, + }, + jf_showPausedState: { + description: "Show presence when media is paused.", + type: OptionType.BOOLEAN, + default: true, + hidden: true, + }, + jf_privacyMode: { + description: "Hide media details.", + type: OptionType.BOOLEAN, + default: false, + hidden: true, + }, + + // ListenBrainz + lb_username: { + description: "ListenBrainz username.", + type: OptionType.STRING, + default: "", + hidden: true, + }, + lb_mbContact: { + description: "MusicBrainz contact for user agent.", + type: OptionType.STRING, + default: "", + hidden: true, + }, + lb_shareUsername: { + description: "Show link to ListenBrainz profile.", + type: OptionType.BOOLEAN, + default: false, + hidden: true, + }, + lb_shareSong: { + description: "Show link to song on ListenBrainz.", + type: OptionType.BOOLEAN, + default: true, + hidden: true, + }, + lb_hideWithSpotify: { + description: "Hide presence if Spotify is running.", + type: OptionType.BOOLEAN, + default: true, + hidden: true, + }, + lb_hideWithActivity: { + description: "Hide presence if any other presence exists.", + type: OptionType.BOOLEAN, + default: false, + hidden: true, + }, + lb_useTimeBar: { + description: "Use track duration to display a time bar.", + type: OptionType.BOOLEAN, + default: true, + hidden: true, + }, + lb_statusName: { + description: "Custom status text.", + type: OptionType.STRING, + default: "some music", + hidden: true, + }, + lb_nameFormat: { + description: "Name format.", + type: OptionType.SELECT, + options: [ + { label: "Use custom status name", value: NameFormat.StatusName, default: true }, + { label: "Use format 'artist - song'", value: NameFormat.ArtistFirst }, + { label: "Use format 'song - artist'", value: NameFormat.SongFirst }, + { label: "Use artist name only", value: NameFormat.ArtistOnly }, + { label: "Use song name only", value: NameFormat.SongOnly }, + { label: "Use album name", value: NameFormat.AlbumName }, + ], + hidden: true, + }, + lb_useListeningStatus: { + description: "Show listening status.", + type: OptionType.BOOLEAN, + default: true, + hidden: true, + }, + lb_missingArt: { + description: "Fallback when art is missing.", + type: OptionType.SELECT, + options: [ + { label: "Use large ListenBrainz logo", value: "listenbrainzLogo", default: true }, + { label: "Use generic placeholder", value: "placeholder" }, + ], + hidden: true, + }, + lb_useLogo: { + description: "Show ListenBrainz logo on album art.", + type: OptionType.BOOLEAN, + default: true, + hidden: true, + }, + + // Gensokyo Radio + gr_refreshInterval: { + description: "Refresh interval in seconds.", + type: OptionType.SLIDER, + markers: [1, 2, 2.5, 3, 5, 10, 15], + default: 15, + hidden: true, + }, +}); + +export type SettingsStore = typeof settings["store"]; diff --git a/src/equicordplugins/richPresence/types/audiobookshelf.ts b/src/equicordplugins/richPresence/types/audiobookshelf.ts new file mode 100644 index 000000000..0020593aa --- /dev/null +++ b/src/equicordplugins/richPresence/types/audiobookshelf.ts @@ -0,0 +1,32 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +export interface AbsSession { + updatedAt?: number; + isFinished?: boolean; + mediaMetadata?: { + title?: string; + author?: string; + publisher?: string; + authors?: { name?: string; }[]; + series?: { name?: string; }[]; + }; + mediaType?: string; + duration?: number; + currentTime?: number; + libraryItemId?: string; +} + +export interface AbsMediaData { + name: string; + type: string; + author?: string; + series?: string; + duration?: number; + currentTime?: number; + imageUrl?: string; + isFinished?: boolean; +} diff --git a/src/equicordplugins/richPresence/types/gensokyoRadio.ts b/src/equicordplugins/richPresence/types/gensokyoRadio.ts new file mode 100644 index 000000000..aeba567f9 --- /dev/null +++ b/src/equicordplugins/richPresence/types/gensokyoRadio.ts @@ -0,0 +1,14 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +export interface GrTrackData { + title: string; + album: string; + artist: string; + artwork: string; + position: number; + duration: number; +} diff --git a/src/equicordplugins/richPresence/types/index.ts b/src/equicordplugins/richPresence/types/index.ts new file mode 100644 index 000000000..27d51173d --- /dev/null +++ b/src/equicordplugins/richPresence/types/index.ts @@ -0,0 +1,23 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +export enum ServiceTab { + AudioBookShelf = "audiobookshelf", + Tosu = "tosu", + StatsFm = "statsfm", + Jellyfin = "jellyfin", + ListenBrainz = "listenbrainz", + GensokyoRadio = "gensokyoRadio", +} + +export const enum NameFormat { + StatusName = "status-name", + ArtistFirst = "artist-first", + SongFirst = "song-first", + ArtistOnly = "artist", + SongOnly = "song", + AlbumName = "album", +} diff --git a/src/equicordplugins/richPresence/types/jellyfin.ts b/src/equicordplugins/richPresence/types/jellyfin.ts new file mode 100644 index 000000000..f0d0ea3c6 --- /dev/null +++ b/src/equicordplugins/richPresence/types/jellyfin.ts @@ -0,0 +1,46 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +export interface JfSession { + UserId: string; + NowPlayingItem?: JfNowPlayingItem; + PlayState?: { + IsPaused?: boolean; + PositionTicks?: number; + }; +} + +interface JfNowPlayingItem { + Name?: string; + Type?: string; + Id?: string; + SeriesId?: string; + SeriesName?: string; + Artists?: string[]; + AlbumArtist?: string; + Album?: string; + ParentIndexNumber?: number; + IndexNumber?: number; + ProductionYear?: number; + RunTimeTicks?: number; + ImageTags?: { Primary?: string; }; +} + +export interface JfMediaData { + name: string; + type: string; + artist?: string; + album?: string; + seriesName?: string; + seasonNumber?: number; + episodeNumber?: number; + year?: number; + url?: string; + imageUrl?: string; + duration?: number; + position?: number; + isPaused?: boolean; +} diff --git a/src/equicordplugins/richPresence/types/listenbrainz.ts b/src/equicordplugins/richPresence/types/listenbrainz.ts new file mode 100644 index 000000000..029c08b68 --- /dev/null +++ b/src/equicordplugins/richPresence/types/listenbrainz.ts @@ -0,0 +1,15 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +export interface LbTrackData { + name: string; + album: string; + artist: string; + durationMs?: number; + recordingMBID?: string; + url: string; + imageUrl?: string; +} diff --git a/src/equicordplugins/richPresence/types/statsfm.ts b/src/equicordplugins/richPresence/types/statsfm.ts new file mode 100644 index 000000000..643a1189d --- /dev/null +++ b/src/equicordplugins/richPresence/types/statsfm.ts @@ -0,0 +1,56 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +export interface SfmTrackData { + name: string; + albums: string; + artists: string; + url: string; + imageUrl?: string; +} + +interface Albums { + id: number; + image: string; + name: string; +} + +interface Artists { + id: number; + name: string; + image: string; +} + +interface ExternalIds { + spotify: string[]; + appleMusic: string[]; +} + +interface Track { + albums: Albums[]; + artists: Artists[]; + durationMs: number; + explicit: boolean; + externalIds: ExternalIds; + id: number; + name: string; + spotifyPopularity: number; + spotifyPreview: string; + appleMusicPreview: string; +} + +interface Item { + date: string; + isPlaying: boolean; + progressMs: number; + deviceName: string; + track: Track; + platform: string; +} + +export interface SfmResponse { + item: Item; +} diff --git a/src/equicordplugins/tosuRPC/type.ts b/src/equicordplugins/richPresence/types/tosu.ts similarity index 99% rename from src/equicordplugins/tosuRPC/type.ts rename to src/equicordplugins/richPresence/types/tosu.ts index 7ded0748f..0cc8ed051 100644 --- a/src/equicordplugins/tosuRPC/type.ts +++ b/src/equicordplugins/richPresence/types/tosu.ts @@ -407,7 +407,7 @@ export interface Play { accuracy: number; healthBar: HealthBar; hits: Hits; - hitErrorArray: any[]; + hitErrorArray: number[]; combo: Combo; mods: NumberName; rank: Rank; diff --git a/src/equicordplugins/scheduledMessages/styles.css b/src/equicordplugins/scheduledMessages/styles.css index 59afe9858..d17dbdd48 100644 --- a/src/equicordplugins/scheduledMessages/styles.css +++ b/src/equicordplugins/scheduledMessages/styles.css @@ -159,7 +159,7 @@ padding: 12px; background: var(--background-feedback-critical); border-radius: 5px; - color: var(--text-normal); + color: var(--text-default); font-size: 14px; } diff --git a/src/equicordplugins/scheduledMessages/utils.ts b/src/equicordplugins/scheduledMessages/utils.ts index 6fcf8e171..a99b7e0ca 100644 --- a/src/equicordplugins/scheduledMessages/utils.ts +++ b/src/equicordplugins/scheduledMessages/utils.ts @@ -42,6 +42,12 @@ export function getScheduledMessages(): ScheduledMessage[] { return [...scheduledMessages]; } +export function getScheduledMessagesForChannel(channelId?: string): ScheduledMessage[] { + const messages = getScheduledMessages(); + if (!channelId) return messages; + return messages.filter(message => message.channelId === channelId); +} + export function getChannelDisplayInfo(channelId: string): { name: string; avatar: string; } { const channel = ChannelStore.getChannel(channelId); if (!channel) return { name: "Unknown", avatar: "" }; @@ -409,6 +415,51 @@ export async function addScheduledMessage( return { success: true }; } +export async function updateScheduledMessageTime(id: string, scheduledTime: number): Promise<{ success: boolean; error?: string; }> { + const message = scheduledMessages.find(entry => entry.id === id); + if (!message) { + return { success: false, error: "Scheduled message not found" }; + } + + if (scheduledTime <= Date.now()) { + return { success: false, error: "Please select a future date and time" }; + } + + const minuteStart = Math.floor(scheduledTime / 60000) * 60000; + const count = scheduledMessages.filter(entry => + entry.id !== id + && entry.channelId === message.channelId + && entry.scheduledTime >= minuteStart + && entry.scheduledTime < minuteStart + 60000 + ).length; + + if (count >= settings.store.maxMessagesPerMinute) { + return { success: false, error: `Maximum of ${settings.store.maxMessagesPerMinute} messages per channel per minute reached` }; + } + + removePhantomMessage(message); + message.scheduledTime = scheduledTime; + scheduledMessages.sort((left, right) => left.scheduledTime - right.scheduledTime); + await saveScheduledMessages(); + await createPhantomMessage(message); + return { success: true }; +} + +export async function sendScheduledMessageNow(id: string): Promise<{ success: boolean; error?: string; }> { + const message = scheduledMessages.find(entry => entry.id === id); + if (!message) { + return { success: false, error: "Scheduled message not found" }; + } + + await removeScheduledMessage(id); + const sent = await sendScheduledMessage(message); + if (!sent) { + return { success: false, error: "Failed to send scheduled message" }; + } + + return { success: true }; +} + export async function removeScheduledMessage(id: string): Promise { const msg = scheduledMessages.find(m => m.id === id); if (msg) removePhantomMessage(msg); diff --git a/src/equicordplugins/sekaiStickers/Components/SekaiStickersModal.tsx b/src/equicordplugins/sekaiStickers/Components/SekaiStickersModal.tsx index 609319afd..1037b96a8 100644 --- a/src/equicordplugins/sekaiStickers/Components/SekaiStickersModal.tsx +++ b/src/equicordplugins/sekaiStickers/Components/SekaiStickersModal.tsx @@ -23,7 +23,7 @@ export default function SekaiStickersModal({ modalProps, settings }: { modalProp const [curve, setCurve] = React.useState(false); const [isImgLoaded, setImgLoaded] = React.useState(false); const [position, setPosition] = React.useState<{ x: number, y: number; }>({ x: characters[character].defaultText.x, y: characters[character].defaultText.y }); - const [spaceSize, setSpaceSize] = React.useState(1); + const [spaceSize, setSpaceSize] = React.useState(36); let canvast!: HTMLCanvasElement; const img = new Image(); img.crossOrigin = "anonymous"; diff --git a/src/equicordplugins/sidebarChat/index.tsx b/src/equicordplugins/sidebarChat/index.tsx index 562d2638b..7b88e2603 100644 --- a/src/equicordplugins/sidebarChat/index.tsx +++ b/src/equicordplugins/sidebarChat/index.tsx @@ -136,15 +136,15 @@ export default definePlugin({ group: true, replacement: [ { - match: /channel_renderer"\);/, - replace: "$&const vc_SidebarChat=$self.renderSidebar();" + match: /if\(null!=.{0,50}ROLE_SUBSCRIPTIONS/, + replace: "const vc_SidebarChat=$self.renderSidebar();$&" }, { match: /return(\(0,\i\.jsxs?\)\(\i\.\i,{}\))}/, replace: "return [$1, vc_SidebarChat]}" }, { - match: /(case \i\.\i.+?return)(.+?);(?=.+?params\.messageId)(?<=channel_renderer".+?)/g, + match: /(case \i\.\i.+?return)(.+?);(?=.+?params\.messageId)(?<=guild_products".+?)/g, replace: "$1[$2, vc_SidebarChat];", predicate: () => settings.store.patchCommunity } diff --git a/src/equicordplugins/songSpotlight.desktop/index.tsx b/src/equicordplugins/songSpotlight.desktop/index.tsx index ed8d84c12..cbcc35d87 100644 --- a/src/equicordplugins/songSpotlight.desktop/index.tsx +++ b/src/equicordplugins/songSpotlight.desktop/index.tsx @@ -23,30 +23,6 @@ export default definePlugin({ authors: [EquicordDevs.nexpid], settings, patches: [ - // Personal profile popout - { - find: ".WIDGETS_USER_PROFILE_ACCOUNT_POPOUT_NEW_BADGE]", - replacement: { - match: /user:(\i),bio:.{0,60}}\)/, - replace: "$&,$self.renderProfileSongs({userId:$1.id})", - }, - }, - // Message user popout - { - find: ".isProvisional?(", - replacement: { - match: /user:(\i),bio:.{0,60}}\)/, - replace: "$&,$self.renderProfileSongs({userId:$1.id})", - }, - }, - // DM sidebar profile - { - find: ".SIDEBAR}),nicknameIcons:", - replacement: { - match: /{userId:(\i)\.id}\)}\).{0,100}]}\)(?=\]\}\))/, - replace: "$&,$self.renderProfileSongs({userId:$1.id,isSidebar:true})", - }, - }, // Full profile modal sections (lazy loaded) { find: ".MUTUAL_GUILDS})),", @@ -80,6 +56,6 @@ export default definePlugin({ useAuthorizationStore.persist.rehydrate(); }, - renderProfileSongs: ErrorBoundary.wrap(ProfileSongs, { noop: true }), + renderProfileCollection: ProfileSongs, renderWidgetSongs: ErrorBoundary.wrap(WidgetSongs, { noop: true }), }); diff --git a/src/equicordplugins/songSpotlight.desktop/lib/api.ts b/src/equicordplugins/songSpotlight.desktop/lib/api.ts index 9bad09bca..c751830f1 100644 --- a/src/equicordplugins/songSpotlight.desktop/lib/api.ts +++ b/src/equicordplugins/songSpotlight.desktop/lib/api.ts @@ -10,11 +10,12 @@ import { showToast, Toasts, UserStore } from "@webpack/common"; import { useAuthorizationStore } from "./stores/AuthorizationStore"; import { useSongStore } from "./stores/SongStore"; +const api = "https://dc.songspotlight.nexpid.xyz/"; export const apiConstants = { - api: "https://dc.songspotlight.nexpid.xyz/", + api, oauth2: { clientId: "1157745434140344321", - redirectURL: "https://dc.songspotlight.nexpid.xyz/api/auth/authorize", + redirectURL: `${api}api/auth/authorize`, }, songLimit: 6, }; diff --git a/src/equicordplugins/songSpotlight.desktop/settings.tsx b/src/equicordplugins/songSpotlight.desktop/settings.tsx index 97af8cc3f..c374c6612 100644 --- a/src/equicordplugins/songSpotlight.desktop/settings.tsx +++ b/src/equicordplugins/songSpotlight.desktop/settings.tsx @@ -20,7 +20,7 @@ export default definePluginSettings({ type: OptionType.SLIDER, description: "How many songs are shown when initially clicking on a user", default: apiConstants.songLimit, - markers: makeRange(1, apiConstants.songLimit), + markers: makeRange(1, 3), }, manager: { type: OptionType.COMPONENT, diff --git a/src/equicordplugins/songSpotlight.desktop/ui/common.tsx b/src/equicordplugins/songSpotlight.desktop/ui/common.tsx index eda340ce4..5b6377db2 100644 --- a/src/equicordplugins/songSpotlight.desktop/ui/common.tsx +++ b/src/equicordplugins/songSpotlight.desktop/ui/common.tsx @@ -22,6 +22,8 @@ export const ContainerClasses = findCssClassesLazy( ); export const CardClasses = findCssClassesLazy("card", "headerText", "headerContextMenu"); export const WidgetClasses = findCssClassesLazy("tabPanelScroller", "tabListScroller"); +export const DMSideBarClasses = findCssClassesLazy("widgetPreviews"); +export const ProfileCardClasses = findCssClassesLazy("cardsList", "firstCardContainer", "card", "container"); export enum SpinnerTypes { CHASING_DOTS = "chasingDots", @@ -209,6 +211,21 @@ export function SpotifyIcon(props: JSX.IntrinsicElements["svg"]) { ); } +export function TidalIcon(props: JSX.IntrinsicElements["svg"]) { + return ( + + + + + + + ); +} export function TrashIcon(props: JSX.IntrinsicElements["svg"]) { return ( ()); const previews = useMemo(() => data?.slice(0, shownSongs), [data]); + const userId = user?.id; useEffect(() => { setRenders(new Map()); @@ -52,57 +54,67 @@ export default function CollapsedProfileSongs({ data, userId, isSidebar }: Colla } }, [previews]); - return ( -
{ - const user = UserStore.getUser(userId); - if (!user) return; + const songsSection = ( +
+
    +
  • +
    { + const user = UserStore.getUser(userId); + if (!user) return; - const guildId = SelectedGuildStore.getGuildId(); - UserProfileActions.openUserProfileModal({ - userId, - guildId, - channelId: SelectedChannelStore.getChannelId(), - analyticsLocation: { - page: guildId ? "Guild Channel" : "DM Channel", - section: "Profile Popout", - }, - tabSection: "SONG_SPOTLIGHT", - }); - }} - > -
    - Song Spotlight -
    - {previews && data - ? previews.map((song, i) => { - const render = renders.get(sid(song)); - const extra = i === shownSongs - 1 && data.length > shownSongs; + const guildId = SelectedGuildStore.getGuildId(); + UserProfileActions.openUserProfileModal({ + userId, + guildId, + channelId: SelectedChannelStore.getChannelId(), + analyticsLocation: { + page: guildId ? "Guild Channel" : "DM Channel", + section: "Profile Popout", + }, + tabSection: "SONG_SPOTLIGHT", + }); + }} + > +
    + Song Spotlight +
    + {previews && data + ? previews.map((song, i) => { + const render = renders.get(sid(song)); + const extra = i === shownSongs - 1 && data.length > shownSongs; - return ( -
    - {render?.thumbnailUrl && ( - {render.label} - )} - {extra && ( -
    - +{data.length - shownSongs} -
    - )} -
    - ); - }) - : } -
    -
    -
    + return ( +
    + {render?.thumbnailUrl && ( + {render.label} + )} + {extra && ( +
    + +{data.length - shownSongs} +
    + )} +
    + ); + }) + : } +
    +
    +
+ + + ); + + return isSideBar + ?
{songsSection}
+ : songsSection; } diff --git a/src/equicordplugins/songSpotlight.desktop/ui/songs/ProfileSongs.tsx b/src/equicordplugins/songSpotlight.desktop/ui/songs/ProfileSongs.tsx index 8137349c4..3f8428c3f 100644 --- a/src/equicordplugins/songSpotlight.desktop/ui/songs/ProfileSongs.tsx +++ b/src/equicordplugins/songSpotlight.desktop/ui/songs/ProfileSongs.tsx @@ -23,6 +23,7 @@ import { openSettingsModal } from "@equicordplugins/songSpotlight.desktop/ui/set import { sid } from "@song-spotlight/api/util"; import { copyWithToast } from "@utils/discord"; import { classes } from "@utils/index"; +import { User } from "@vencord/discord-types"; import { ContextMenuApi, FluxDispatcher, @@ -39,16 +40,16 @@ import Song from "."; import CollapsedProfileSongs from "./CollapsedProfileSongs"; export interface ProfileSongsProps { - userId: string; - isSidebar: boolean; + user: User; + isSideBar: boolean; } -export default function ProfileSongs({ userId, isSidebar }: ProfileSongsProps) { +export default function ProfileSongs({ user, isSideBar }: ProfileSongsProps) { const [failed, setFailed] = useState(false); const { isAuthorized } = useAuthorizationStore(); const { users } = useSongStore(); const { profileSongsLimit, collapseSongList } = settings.use(); - + const userId = user?.id; const data = users[userId]?.data; useEffect(() => { if (isAuthorized() && !data) listData(userId).catch(() => setFailed(true)); @@ -70,8 +71,8 @@ export default function ProfileSongs({ userId, isSidebar }: ProfileSongsProps) { return ( ); } else if (pending) { @@ -81,14 +82,14 @@ export default function ProfileSongs({ userId, isSidebar }: ProfileSongsProps) { return (
- + Song Spotlight diff --git a/src/equicordplugins/songSpotlight.desktop/ui/songs/WidgetSongs.tsx b/src/equicordplugins/songSpotlight.desktop/ui/songs/WidgetSongs.tsx index 26c613886..6b802c1f5 100644 --- a/src/equicordplugins/songSpotlight.desktop/ui/songs/WidgetSongs.tsx +++ b/src/equicordplugins/songSpotlight.desktop/ui/songs/WidgetSongs.tsx @@ -21,7 +21,7 @@ import { JSX } from "react"; import Song from "."; -interface WidgetSongsProps { +export interface WidgetSongsProps { user: User; } diff --git a/src/equicordplugins/soundBoardLogger/components/Icons.tsx b/src/equicordplugins/soundBoardLogger/components/Icons.tsx deleted file mode 100644 index 155351a07..000000000 --- a/src/equicordplugins/soundBoardLogger/components/Icons.tsx +++ /dev/null @@ -1,75 +0,0 @@ -/* - * Vencord, a Discord client mod - * Copyright (c) 2023 Vendicated and contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -import { ChatBarButton, ChatBarButtonFactory } from "@api/ChatButtons"; -import { cl } from "@equicordplugins/soundBoardLogger/utils"; -import { classes } from "@utils/misc"; -import { Button, Tooltip } from "@webpack/common"; - -import { openSoundBoardLog } from "./SoundBoardLog"; - -// Thanks svgrepo.com for the play and download icons. -// Licensed under CC Attribution License https://www.svgrepo.com/page/licensing/#CC%20Attribution - -// https://www.svgrepo.com/svg/438144/multimedia-play-icon-circle-button -export function PlayIcon() { - return ( - - ); -} - -// https://www.svgrepo.com/svg/528952/download -export function DownloadIcon() { - return ( - - ); -} - -export function LogIcon({ height = 24, width = 24, className = "chatBarLogIcon" }: { height?: number; width?: number; className?: string; }) { - return ( - - ); -} - -export function IconWithTooltip({ text, icon, onClick }) { - return - {({ onMouseEnter, onMouseLeave }) => ( -
- -
- )} -
; -} - -export const CharBarButton: ChatBarButtonFactory = ({ isMainChat }) => { - if (!isMainChat) return null; - return ( - - - - ); -}; - -export const ChatBarIcon = () => { - return - - ; -}; diff --git a/src/equicordplugins/soundBoardLogger/components/MoreUsersModal.tsx b/src/equicordplugins/soundBoardLogger/components/MoreUsersModal.tsx deleted file mode 100644 index 094bb47bd..000000000 --- a/src/equicordplugins/soundBoardLogger/components/MoreUsersModal.tsx +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Vencord, a Discord client mod - * Copyright (c) 2023 Vendicated and contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -import { BaseText } from "@components/BaseText"; -import ErrorBoundary from "@components/ErrorBoundary"; -import { Flex } from "@components/Flex"; -import { HeadingPrimary } from "@components/Heading"; -import { cl, getEmojiUrl, SoundLogEntry, User } from "@equicordplugins/soundBoardLogger/utils"; -import { closeModal, ModalContent, ModalRoot, openModal } from "@utils/modal"; -import { Clickable } from "@webpack/common"; - -export function openMoreUsersModal(item: SoundLogEntry, users: User[], onClickUser: Function) { - const key = openModal(props => ( - - - closeModal(key)} /> - - - )); -} - -export default function MoreUsersModal({ item, users, onClickUser, closeModal }: { item: SoundLogEntry, users: User[], onClickUser: Function, closeModal: Function; }) { - return ( - -
- - {item.soundId} -
-
- {users.map(user => { - const currentUser = item.users.find(({ id }) => id === user.id) ?? { id: "", plays: [0] }; - return ( - { - closeModal(); - onClickUser(item, user); - }} key={user.id}> -
- - - {user.username} - - Played {currentUser.plays.length} {currentUser.plays.length === 1 ? "time" : "times"} -
-
- ); - })} -
-
- ); -} diff --git a/src/equicordplugins/soundBoardLogger/components/SoundBoardLog.tsx b/src/equicordplugins/soundBoardLogger/components/SoundBoardLog.tsx deleted file mode 100644 index b619eeebd..000000000 --- a/src/equicordplugins/soundBoardLogger/components/SoundBoardLog.tsx +++ /dev/null @@ -1,187 +0,0 @@ -/* - * Vencord, a Discord client mod - * Copyright (c) 2023 Vendicated and contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -import { HeaderBarButton } from "@api/HeaderBar"; -import { BaseText } from "@components/BaseText"; -import ErrorBoundary from "@components/ErrorBoundary"; -import { Flex } from "@components/Flex"; -import settings from "@equicordplugins/soundBoardLogger/settings"; -import { clearLoggedSounds, getLoggedSounds } from "@equicordplugins/soundBoardLogger/store"; -import { addListener, AvatarStyles, cl, downloadAudio, getEmojiUrl, playSound, removeListener, SoundLogEntry, UserSummaryItem } from "@equicordplugins/soundBoardLogger/utils"; -import { copyWithToast } from "@utils/discord"; -import { Margins } from "@utils/margins"; -import { classes } from "@utils/misc"; -import { closeModal, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalRoot, ModalSize, openModal } from "@utils/modal"; -import { User } from "@vencord/discord-types"; -import { Button, Clickable, Tooltip, useEffect, UserUtils, useState } from "@webpack/common"; - -import { LogIcon } from "./Icons"; -import { openMoreUsersModal } from "./MoreUsersModal"; -import { openUserModal } from "./UserModal"; - -export async function openSoundBoardLog(): Promise { - - const data = await getLoggedSounds(); - const key = openModal(props => - - closeModal(key)} /> - - ); - -} - -export function OpenSBLogsButton() { - return ( - openSoundBoardLog()} - tooltip={"Open SoundBoard Log"} - icon={LogIcon} - /> - ); -} - -export default function SoundBoardLog({ data, closeModal }) { - const [sounds, setSounds] = useState(data); - const [users, setUsers] = useState([]); - const update = async () => setSounds(await getLoggedSounds()); - - // Update the sounds state when a new sound is played - useEffect(() => { - const onSound = () => update(); - addListener(onSound); - return () => removeListener(onSound); - }, []); - - const avatarsMax = 2; - - // Update the users state when a new sound is played - useEffect(() => { - (async () => { - /** Array of user IDs without a resolved user object */ - const missing = sounds - .flatMap(sound => sound.users) // Get all users who have used any sound - .map(user => user.id) // Get their ID ( user is {id: string, plays: number[]} ) - .filter((id, index, self) => index === self.indexOf(id)) // Filter the array to remove non unique values - .filter(id => !users.map(user => user.id).includes(id)); // Filter the IDs to only get the ones not already in the users state - if (!missing.length) return; // return if every user ID is already in users - - for (const id of missing) { - const user = await UserUtils.getUser(id).catch(() => void 0); - if (user) setUsers(u => [...u, user]); - } - })(); - }, [sounds]); - - function renderMoreUsers(item, itemUsers) { - return ( - { - onClickShowMoreUsers(item, itemUsers); - }} - > - - {({ onMouseEnter, onMouseLeave }) => ( -
- +{itemUsers.length - avatarsMax} -
- )} -
-
- ); - } - - /** This function is called when you click the "Show more users" button. */ - function onClickShowMoreUsers(item: SoundLogEntry, users: User[]): void { - openMoreUsersModal(item, users, onClickUser); - } - - function onClickUser(item: SoundLogEntry, user: User) { - openUserModal(item, user, sounds); - } - - return ( - <> - - SoundBoard log - - - - {sounds.length ? sounds.map(item => { - const itemUsers = users.filter(user => item.users.map(u => u.id).includes(user.id)); - - return ( -
- - - {item.soundId} - - renderMoreUsers(item, itemUsers)} - className={cl("sound-users")} - renderUser={(user: User) => ( - { - onClickUser(item, user); - }} - > - {user.username} - - )} - /> - - - - - {({ onMouseEnter, onMouseLeave }) => - - } - - -
- ); - }) : -
- - No sounds logged yet. Join a voice chat to start logging! -
- } -
- - - - - ); -} diff --git a/src/equicordplugins/soundBoardLogger/components/UserModal.tsx b/src/equicordplugins/soundBoardLogger/components/UserModal.tsx deleted file mode 100644 index c9fab21b4..000000000 --- a/src/equicordplugins/soundBoardLogger/components/UserModal.tsx +++ /dev/null @@ -1,106 +0,0 @@ -/* - * Vencord, a Discord client mod - * Copyright (c) 2023 Vendicated and contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -import { BaseText } from "@components/BaseText"; -import { Flex } from "@components/Flex"; -import { HeadingPrimary } from "@components/Heading"; -import { CopyIcon } from "@components/Icons"; -import { AvatarStyles, cl, downloadAudio, getEmojiUrl, playSound, SoundLogEntry, User, UserSummaryItem } from "@equicordplugins/soundBoardLogger/utils"; -import { copyWithToast, openUserProfile } from "@utils/discord"; -import { Margins } from "@utils/margins"; -import { classes } from "@utils/misc"; -import { closeModal, ModalContent, ModalRoot, openModal } from "@utils/modal"; -import { Clickable, Timestamp } from "@webpack/common"; -import moment from "moment"; - -import { DownloadIcon, IconWithTooltip, PlayIcon } from "./Icons"; - -export function openUserModal(item, user, sounds) { - const key = openModal(props => - - closeModal(key)} /> - - ); -} - -export default function UserModal({ item, user, sounds, closeModal }: { item: SoundLogEntry, user: User, sounds: SoundLogEntry[], closeModal: Function; }) { - const currentUser = item.users.find(({ id }) => id === user.id) ?? { id: "", plays: [0] }; - const soundsDoneByCurrentUser = sounds.filter(sound => sound.users.some(itemUser => itemUser.id === user.id) && sound.soundId !== item.soundId); - - return ( - - { - closeModal(); - openUserProfile(user.id); - }}> -
- - {user.username} -
-
- - - - {item.soundId} - Played {currentUser.plays.length} {currentUser.plays.length === 1 ? "time" : "times"}. - Last played: - - - - {soundsDoneByCurrentUser.length ? "Also played:" : " "} - - - -
-
- ... -
-
- } - className={cl("user-sounds")} - renderUser={({ soundId, emoji }) => ( - { - closeModal(); - openUserModal(sounds.find(sound => sound.soundId === soundId), user, sounds); - }} - > - {soundId} - - )} - /> -
- } onClick={() => downloadAudio(item.soundId)} /> - } onClick={() => copyWithToast(item.soundId, "ID copied to clipboard!")} /> - } onClick={() => playSound(item.soundId)} /> -
-
-
- ); -} diff --git a/src/equicordplugins/soundBoardLogger/index.tsx b/src/equicordplugins/soundBoardLogger/index.tsx deleted file mode 100644 index e704db318..000000000 --- a/src/equicordplugins/soundBoardLogger/index.tsx +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Vencord, a Discord client mod - * Copyright (c) 2024 Vendicated and contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -import { disableStyle, enableStyle } from "@api/Styles"; -import { Devs, EquicordDevs } from "@utils/constants"; -import definePlugin from "@utils/types"; -import { FluxDispatcher } from "@webpack/common"; - -import { LogIcon } from "./components/Icons"; -import { OpenSBLogsButton } from "./components/SoundBoardLog"; -import settings from "./settings"; -import { updateLoggedSounds } from "./store"; -import styles from "./styles.css?managed"; -import { getListeners } from "./utils"; - -export default definePlugin({ - name: "SoundBoardLogger", - authors: [Devs.Moxxie, EquicordDevs.Fres, Devs.amy, Devs.thororen], - description: "Logs all soundboards that are played in a voice chat and allows you to download them", - dependencies: ["AudioPlayerAPI"], - settings, - - headerBarButton: { - icon: LogIcon, - render: OpenSBLogsButton - }, - start() { - enableStyle(styles); - FluxDispatcher.subscribe("VOICE_CHANNEL_EFFECT_SEND", async sound => { - if (!sound?.soundId) return; - await updateLoggedSounds(sound); - getListeners().forEach(cb => cb()); - }); - }, - stop() { - disableStyle(styles); - } -}); diff --git a/src/equicordplugins/soundBoardLogger/settings.tsx b/src/equicordplugins/soundBoardLogger/settings.tsx deleted file mode 100644 index 47b6b037e..000000000 --- a/src/equicordplugins/soundBoardLogger/settings.tsx +++ /dev/null @@ -1,88 +0,0 @@ -/* - * Vencord, a Discord client mod - * Copyright (c) 2023 Vendicated and contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -import { definePluginSettings } from "@api/Settings"; -import { Heading } from "@components/Heading"; -import { Paragraph } from "@components/Paragraph"; -import { OptionType } from "@utils/types"; -import { Button, TextInput, useState } from "@webpack/common"; - -import { openSoundBoardLog } from "./components/SoundBoardLog"; - -const settings = definePluginSettings({ - SavedIds: { - description: "The amount of soundboard ids you want to save at a time (0 lets you save infinite)", - type: OptionType.COMPONENT, - component: ({ setValue }) => { - const value = settings.store.SavedIds ?? 50; - const [state, setState] = useState(`${value}`); - const [shouldShowWarning, setShouldShowWarning] = useState(false); - const [errorMessage, setErrorMessage] = useState(null); - - function handleChange(newValue) { - const changed = Number(newValue); - - if (Number.isNaN(changed) || changed % 1 !== 0 || changed < 0) { - let errorMsg = ""; - errorMsg += Number.isNaN(changed) ? "The value is not a number.\n" : ""; - errorMsg += (changed % 1 !== 0) ? "The value can't be a decimal number.\n" : ""; - errorMsg += (changed < 0) ? "The value can't be a negative number.\n" : ""; - setErrorMessage(errorMsg); - return; - } else { - setErrorMessage(null); - } - - if (changed < value) { - setShouldShowWarning(true); - } else { - setShouldShowWarning(false); - } - setState(newValue); - setValue(changed); - } - - return ( -
- The amount of soundboard ids you want to save at a time (0 lets you save infinite) - - {shouldShowWarning && Warning! Setting the number to a lower value will reset the log!} - {errorMessage && {errorMessage}} -
- ); - } - - }, - FileType: { - description: "the format that you want to save your file", - type: OptionType.SELECT, - options: [ - { label: ".ogg", value: ".ogg", default: true }, - { label: ".mp3", value: ".mp3" }, - { label: ".wav", value: ".wav" }, - ], - }, - OpenLogs: { - type: OptionType.COMPONENT, - description: "show the logs", - component: () => - - }, - soundVolume: { - description: "How loud the toggle sound is (0 to disable)", - type: OptionType.SLIDER, - default: 0.5, - markers: [0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1] - }, -}); - -export default settings; diff --git a/src/equicordplugins/soundBoardLogger/store.tsx b/src/equicordplugins/soundBoardLogger/store.tsx deleted file mode 100644 index 4532e1f40..000000000 --- a/src/equicordplugins/soundBoardLogger/store.tsx +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Vencord, a Discord client mod - * Copyright (c) 2023 Vendicated and contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -import { DataStore } from "@api/index"; - -import settings from "./settings"; -import { SoundEvent, SoundLogEntry } from "./utils"; - -/** Attempts to add a sound event to the log */ -export async function updateLoggedSounds(sound: SoundEvent): Promise { - const data = await getLoggedSounds(); - - if (!data) { - await DataStore.set("SoundBoardLogList", [{ ...sound, users: [{ id: sound.userId, plays: [+Date.now()] }] }]); - } else { - if (data.some(item => item.soundId === sound.soundId)) { - const newSounds = data.map(item => { - if (item.soundId !== sound.soundId) return item; - return { - ...item, - users: item.users.some(user => user.id === sound.userId) ? - item.users.map(user => { - if (user.id !== sound.userId) return user; - return { id: sound.userId, plays: [...user.plays, +Date.now()] }; - }) : - [ - ...item.users, - { id: sound.userId, plays: [+Date.now()] } - ] - }; - }); - await DataStore.set("SoundBoardLogList", newSounds); - return; - } - - let limit = settings.store.SavedIds ?? 50; - if (limit === 0) limit = Infinity; - const modified = [{ ...sound, users: [{ id: sound.userId, plays: [+Date.now()] }] }, ...data].slice(0, limit); - - await DataStore.set("SoundBoardLogList", modified); - } -} - -/** Clears the logged sounds array */ -export async function clearLoggedSounds() { - await DataStore.set("SoundBoardLogList", []); -} - -/** Returns an array with the logged sounds */ -export async function getLoggedSounds(): Promise { - const data = await DataStore.get("SoundBoardLogList"); - if (!data) { - DataStore.set("SoundBoardLogList", []); - return []; - } - else { - return data; - } -} diff --git a/src/equicordplugins/soundBoardLogger/styles.css b/src/equicordplugins/soundBoardLogger/styles.css deleted file mode 100644 index 6bec08122..000000000 --- a/src/equicordplugins/soundBoardLogger/styles.css +++ /dev/null @@ -1,151 +0,0 @@ -.vc-soundlog-more-user, -.vc-soundlog-sound { - box-sizing: border-box; - position: relative; - background: var(--background-secondary-alt); - border-radius: var(--radius-sm); - height: 40px; - width: 100%; - display: flex; - flex-direction: row; - align-items: center; - justify-content: space-between; - font-weight: 600; - border: 1px solid transparent; - overflow: hidden; - text-align: center; - margin-top: 10px; - padding-left: 10px; - padding-right: 10px -} - -.vc-soundlog-more-user-profile, -.vc-soundlog-sound-info { - gap: 10px; - align-content: center; - align-items: center; - flex: 2; -} - -.vc-soundlog-sound-buttons { - gap: 10px; - align-content: center; - flex: 1; -} - -.vc-soundlog-sound-users { - flex: 2; -} - - -.vc-soundlog-sound-emoji { - width: 24px; - height: 24px; -} - -.vc-soundlog-more, -.vc-soundlog-user { - padding: 1em; -} - -.vc-soundlog-more-header, -.vc-soundlog-user-header { - display: flex; - align-items: center; - margin-bottom: 1em; - width: fit-content; -} - -.vc-soundlog-more-soundid, -.vc-soundlog-user-name { - text-transform: none; - flex-grow: 0; - background: var(--background-base-low); - border-radius: 0 9999px 9999px 0; - padding: 6px 0.8em 6px 0.5em; - font-size: 20px; - height: 20px; - position: relative; - text-wrap: nowrap; -} - -.vc-soundlog-more-soundid { - background: unset; -} - -.vc-soundlog-user-name::before { - content: ""; - display: block; - position: absolute; - height: 100%; - width: 16px; - background: var(--background-base-low); - z-index: -1; - left: -16px; - top: 0; -} - - -.vc-soundlog-more-emoji, -.vc-soundlog-user-avatar { - height: 32px; - width: 32px; - border-radius: 50%; -} - -.vc-soundlog-more-emoji { - border-radius: 0%; -} - -.vc-soundlog-user-buttons { - margin-left: auto; - display: flex; - gap: 0.2em; -} - -.vc-soundlog-user-sound-emoji { - height: 48px; - margin-top: 20px; -} - -/* input style from https://github.com/Vendicated/Vencord/blob/ea11f2244fde469ce308f8a4e7224430be62f8f1/src/plugins/sendTimestamps/styles.css#L1-L13 */ -.vc-soundlog-clone-input { - background-color: var(--input-background-default, var(--input-background)); - color: var(--text-default); - width: 100%; - padding: 8px 8px 8px 12px; - outline: none; - border: 1px solid var(--input-background-default, var(--input-background)); - border-radius: 4px; - font-weight: 500; - font-style: inherit; - font-size: 100%; - box-sizing: border-box; - height: 40px; -} - -/* FIXME: bad name -_7f7a2c5f23327d73 */ -.vc-soundlog-discord-button { - opacity: .7; - align-items: center; - align-self: center; - border-radius: 8px; - cursor: pointer; - display: flex; - justify-content: center; - padding: 10px; - - &:hover { - background: hsl(0deg 0% 100% / 10%); - opacity: 1 - } -} - -/* _3f4f204c3133fb93 */ -.vc-soundlog-button-wrapper { - align-self: center; - display: flex; - flex-direction: row; - gap: 8px; -} \ No newline at end of file diff --git a/src/equicordplugins/soundBoardLogger/utils.ts b/src/equicordplugins/soundBoardLogger/utils.ts deleted file mode 100644 index fcb811e2c..000000000 --- a/src/equicordplugins/soundBoardLogger/utils.ts +++ /dev/null @@ -1,73 +0,0 @@ -/* - * Vencord, a Discord client mod - * Copyright (c) 2023 Vendicated and contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -import { playAudio } from "@api/AudioPlayer"; -import { classNameFactory } from "@utils/css"; -import { LazyComponent } from "@utils/react"; -import { saveFile } from "@utils/web"; -import type { User } from "@vencord/discord-types"; -import { findByCode, findByPropsLazy, findCssClassesLazy } from "@webpack"; - -import settings from "./settings"; - -export { User }; - -export interface SoundEvent { - type: "VOICE_CHANNEL_EFFECT_SEND", - emoji: { name: string, id?: string, animated: boolean; }, - channelId: string, - userId: string, - animationType: number, - animationId: number, - soundId: string, - soundVolume: number; -} - -export interface SoundLogEntry extends SoundEvent { - users: { id: string, plays: number[]; }[]; -} - -export const cl = classNameFactory("vc-soundlog-"); -const EmojiManager = findByPropsLazy("getEmojiColors", "getURL"); - -export function getEmojiUrl(emoji) { - if (!emoji) return EmojiManager.getURL("❓"); // If the sound doesn't have a related emoji - return emoji.id ? `https://cdn.discordapp.com/emojis/${emoji.id}.png?size=32` : EmojiManager.getURL(emoji.name); -} - -export const playSound = id => { - playAudio(`https://cdn.discordapp.com/soundboard-sounds/${id}`, { volume: settings.store.soundVolume * 100 }); -}; - -export async function downloadAudio(id: string): Promise { - const filename = id + settings.store.FileType; - const original = await fetch(`https://cdn.discordapp.com/soundboard-sounds/${id}`).then(res => res.arrayBuffer()); - - if (IS_DISCORD_DESKTOP) { - const data = new Uint8Array(original); - DiscordNative.fileManager.saveWithDialog(data, filename); - } else { - saveFile(new File([original], filename, { type: "audio/ogg" })); - } -} - -let listeners: Function[] = []; - -export function getListeners(): Function[] { - return listeners; -} - -export function addListener(fn): void { - listeners.push(fn); -} - -export function removeListener(fn): void { - listeners = listeners.filter(f => f !== fn); -} - -// Taken from https://github.com/Vendicated/Vencord/blob/86e94343cca10b950f2dc8d18d496d6db9f3b728/src/components/PluginSettings/PluginModal.tsx#L45 -export const UserSummaryItem = LazyComponent(() => findByCode("defaultRenderUser", "showDefaultAvatarsForNullUsers")); -export const AvatarStyles = findCssClassesLazy("moreUsers", "emptyUser", "avatarContainer", "clickableAvatar", "avatar"); diff --git a/src/equicordplugins/splitLargeMessages/index.ts b/src/equicordplugins/splitLargeMessages/index.ts index 58609b569..5fef6839e 100644 --- a/src/equicordplugins/splitLargeMessages/index.ts +++ b/src/equicordplugins/splitLargeMessages/index.ts @@ -19,7 +19,7 @@ const canSplit: () => boolean = () => { }; const autoMaxLength = () => { - const hasNitro = UserStore.getCurrentUser().premiumType === 2; + const hasNitro = UserStore.getCurrentUser()?.premiumType === 2; return hasNitro ? 4000 : 2000; }; diff --git a/src/equicordplugins/statsfmPresence/index.tsx b/src/equicordplugins/statsfmPresence/index.tsx deleted file mode 100644 index cdd2ea859..000000000 --- a/src/equicordplugins/statsfmPresence/index.tsx +++ /dev/null @@ -1,371 +0,0 @@ -/* - * Vencord, a Discord client mod - * Copyright (c) 2025 Vendicated and contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -import { definePluginSettings } from "@api/Settings"; -import { getUserSettingLazy } from "@api/UserSettings"; -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 { ActivityFlags, ActivityType } from "@vencord/discord-types/enums"; -import { findByPropsLazy } from "@webpack"; -import { ApplicationAssetUtils, FluxDispatcher } from "@webpack/common"; - -interface ActivityAssets { - large_image?: string; - large_text?: string; - small_image?: string; - small_text?: string; -} - -interface ActivityButton { - label: string; - url: string; -} - -interface Activity { - state: string; - details?: string; - timestamps?: { - start?: number; - }; - assets?: ActivityAssets; - buttons?: Array; - name: string; - application_id: string; - metadata?: { - button_urls?: Array; - }; - type: number; - flags: number; -} - -interface TrackData { - name: string; - albums: string; - artists: string; - url: string; - imageUrl?: string; -} - -const enum NameFormat { - StatusName = "status-name", - ArtistFirst = "artist-first", - SongFirst = "song-first", - ArtistOnly = "artist", - SongOnly = "song", - albumsName = "albums" -} - -interface Albums { - id: number; - image: string; - name: string; -} - -interface Artists { - id: number; - name: string; - image: string; -} - -interface ExternalIds { - spotify: string[]; - appleMusic: string[]; -} - -interface Track { - albums: Albums[]; - artists: Artists[]; - durationMs: number; - explicit: boolean; - externalIds: ExternalIds; - id: number; - name: string; - spotifyPopularity: number; - spotifyPreview: string; - appleMusicPreview: string; -} - -interface Item { - date: string; - isPlaying: boolean; - progressMs: number; - deviceName: string; - track: Track; - platform: string; -} - -interface SFMR { - item: Item; -} - -const ShowCurrentGame = getUserSettingLazy("status", "showCurrentGame")!; - -const applicationId = "1325126169179197500"; -const placeholderId = "2a96cbd8b46e442fc41c2b86b821562f"; - -const logger = new Logger("StatsfmPresence"); - -const PresenceStore = findByPropsLazy("getLocalPresence"); - -let updateInterval: NodeJS.Timeout | undefined; - -async function getApplicationAsset(key: string): Promise { - return (await ApplicationAssetUtils.fetchAssetIds(applicationId, [key]))[0]; -} - -function setActivity(activity: Activity | null) { - FluxDispatcher.dispatch({ - type: "LOCAL_ACTIVITY_UPDATE", - activity, - socketId: "StatsfmPresence", - }); -} - -const settings = definePluginSettings({ - username: { - description: "Stats.fm username", - type: OptionType.STRING, - }, - shareUsername: { - description: "Show link to stats.fm profile", - type: OptionType.BOOLEAN, - default: false, - }, - shareSong: { - description: "Show link to song on stats.fm", - type: OptionType.BOOLEAN, - default: true, - }, - hideWithSpotify: { - description: "Hide stats.fm presence if spotify is running", - type: OptionType.BOOLEAN, - default: false, - }, - hideWithExternalRPC: { - description: "Hides stats.fm presence if an external RPC is running", - type: OptionType.BOOLEAN, - default: false, - }, - statusName: { - description: "Custom status text", - type: OptionType.STRING, - default: "Stats.fm", - }, - nameFormat: { - description: "Show name of song and artist in status name", - type: OptionType.SELECT, - options: [ - { - label: "Use custom status name", - value: NameFormat.StatusName, - default: true - }, - { - label: "Use format 'artist - song'", - value: NameFormat.ArtistFirst - }, - { - label: "Use format 'song - artist'", - value: NameFormat.SongFirst - }, - { - label: "Use artist name only", - value: NameFormat.ArtistOnly - }, - { - label: "Use song name only", - value: NameFormat.SongOnly - }, - { - label: "Use albums name (falls back to custom status text if song has no albums)", - value: NameFormat.albumsName - } - ], - }, - useListeningStatus: { - description: 'Show "Listening to" status instead of "Playing"', - type: OptionType.BOOLEAN, - default: true, - }, - missingArt: { - description: "When albums or albums art is missing", - type: OptionType.SELECT, - options: [ - { - label: "Use large Stats.fm logo", - value: "StatsFmLogo", - default: true - }, - { - label: "Use generic placeholder", - value: "placeholder" - } - ], - }, - showStatsFmLogo: { - description: "Show the Stats.fm next to the albums cover", - type: OptionType.BOOLEAN, - default: true, - }, - alwaysHideArt: { - description: "Disable downloading album art", - type: OptionType.BOOLEAN, - default: false, - } -}); - -export default definePlugin({ - name: "StatsfmPresence", - description: "Statsfm presence to track your music", - authors: [EquicordDevs.Crxa], - - settingsAboutComponent: () => ( - <> - How does this work? - - Hey this is just here to explain how this works. By putting your stats.fm username in the settings, it will show what you're currently listening to on your discord profile. (this doesnt require an api but requires you to have your listening history public) - - - ), - - settings, - - start() { - this.updatePresence(); - updateInterval = setInterval(() => { this.updatePresence(); }, 16000); - }, - - stop() { - clearInterval(updateInterval); - updateInterval = undefined; - }, - - async fetchTrackData(): Promise { - if (!settings.store.username) - return null; - - try { - - const res = await fetch(`https://api.stats.fm/api/v1/users/${settings.store.username}/streams/current`); - if (!res.ok) throw `${res.status} ${res.statusText}`; - - const json = await res.json() as SFMR; - if (!json.item) { - logger.error("Error from Stats.fm API", json); - return null; - } - - const trackData = json.item.track; - if (!trackData) return null; - return { - name: trackData.name || "Unknown", - albums: trackData.albums.map(a => a.name).join(", ") ?? "Unknown", - artists: trackData.artists[0].name ?? "Unknown", - url: `https://stats.fm/track/${trackData.id}`, // https://stats.fm/track/665906 / https://twirl.cx/dj2gL.png reminder of what the id looks like to fetch track - imageUrl: trackData.albums[0].image - }; - } catch (e) { - logger.error("Failed to query Stats.fm API", e); - // will clear the rich presence if API fails - return null; - } - }, - - async updatePresence() { - setActivity(await this.getActivity()); - }, - - getLargeImage(track: TrackData): string | undefined { - if (!settings.store.alwaysHideArt && track.imageUrl && !track.imageUrl.includes(placeholderId)) - return track.imageUrl; - - if (settings.store.missingArt === "placeholder") - return "placeholder"; - }, - - async getActivity(): Promise { - if (settings.store.hideWithExternalRPC) { - if (PresenceStore.getActivities().some(a => a.application_id !== applicationId)) { - return null; - } - } - - if (settings.store.hideWithSpotify) { - if (PresenceStore.getActivities().some(a => a.type === ActivityType.LISTENING && a.application_id !== applicationId)) { - // there is already music status because of Spotify or richerCider (probably more) - return null; - } - } - - const trackData = await this.fetchTrackData(); - if (!trackData) return null; - - const largeImage = this.getLargeImage(trackData); - const assets: ActivityAssets = largeImage ? - { - large_image: await getApplicationAsset(largeImage), - large_text: trackData.albums || undefined, - ...(settings.store.showStatsFmLogo && { - small_image: await getApplicationAsset("statsfm-large"), - small_text: "Stats.fm" - }), - } : { - large_image: await getApplicationAsset("statsfm-large"), - large_text: trackData.albums || undefined, - }; - - const buttons: ActivityButton[] = []; - - if (settings.store.shareUsername) - buttons.push({ - label: "Stats.fm Profile", - url: `https://stats.fm/${settings.store.username}`, - }); - - if (settings.store.shareSong) - buttons.push({ - label: "View Song", - url: trackData.url, - }); - - const statusName = (() => { - switch (settings.store.nameFormat) { - case NameFormat.ArtistFirst: - return trackData.artists + " - " + trackData.name; - case NameFormat.SongFirst: - return trackData.name + " - " + trackData.artists; - case NameFormat.ArtistOnly: - return trackData.artists; - case NameFormat.SongOnly: - return trackData.name; - case NameFormat.albumsName: - return trackData.albums || settings.store.statusName; - default: - return settings.store.statusName; - } - })(); - - return { - application_id: applicationId, - name: statusName, - - details: trackData.name, - state: trackData.artists, - assets, - - buttons: buttons.length ? buttons.map(v => v.label) : undefined, - metadata: { - button_urls: buttons.map(v => v.url), - }, - - type: settings.store.useListeningStatus ? ActivityType.LISTENING : ActivityType.PLAYING, - flags: ActivityFlags.INSTANCE, - }; - } -}); diff --git a/src/equicordplugins/themeLibrary/index.tsx b/src/equicordplugins/themeLibrary/index.tsx index c634bb119..084ce9650 100644 --- a/src/equicordplugins/themeLibrary/index.tsx +++ b/src/equicordplugins/themeLibrary/index.tsx @@ -31,12 +31,9 @@ export default definePlugin({ Component: require("./components/ThemeTab").default, Icon: ColorPaletteIcon }); - - SettingsPlugin.settingsSectionMap.push(["EquicordThemeLibrary", "equicord_theme_library"]); }, stop() { removeFromArray(SettingsPlugin.customEntries, e => e.key === "equicord_theme_library"); - removeFromArray(SettingsPlugin.settingsSectionMap, entry => entry[1] === "equicord_theme_library"); }, }); diff --git a/src/equicordplugins/timezones/index.tsx b/src/equicordplugins/timezones/index.tsx index ae5d08995..b40c3a515 100644 --- a/src/equicordplugins/timezones/index.tsx +++ b/src/equicordplugins/timezones/index.tsx @@ -271,7 +271,7 @@ export default definePlugin({ } }, { - find: '"Message Username"', + find: "#{intl::GUILD_COMMUNICATION_DISABLED_ICON_TOOLTIP_BODY}", replacement: { // thanks https://github.com/Syncxv/vc-timezones/pull/4 match: /(?<=isVisibleOnlyOnHover.+?)id:.{1,11},timestamp.{1,50}}\),/, diff --git a/src/equicordplugins/toastNotifications/index.tsx b/src/equicordplugins/toastNotifications/index.tsx index caacca69d..cd1be8023 100644 --- a/src/equicordplugins/toastNotifications/index.tsx +++ b/src/equicordplugins/toastNotifications/index.tsx @@ -34,6 +34,7 @@ let notifyFor: string[] = []; // Functional variables. const MuteStore = findByPropsLazy("isSuppressEveryoneEnabled"); const SelectedChannelActionCreators = findByPropsLazy("selectPrivateChannel"); +const ChannelRTCActions = findByPropsLazy("updateChatOpen", "toggleParticipants"); const UserUtils = findByPropsLazy("getGlobalName"); // Adjustable variables. @@ -395,6 +396,16 @@ function switchChannels(guildId: string | null, channelId: string) { NavigationRouter.transitionTo(`/channels/${guildId ?? "@me"}/${channelId}/`); } +function navigateToChannel(channel: Channel) { + if (channel.isGuildVocal?.()) { + switchChannels(channel.guild_id, channel.id); + ChannelRTCActions.updateChatOpen(channel.id, true); + return; + } + + switchChannels(channel.guild_id, channel.id); +} + enum NotificationLevel { ALL_MESSAGES = 0, ONLY_MENTIONS = 1, @@ -458,7 +469,7 @@ async function handleGuildMessage(message: Message) { attachments: message.attachments?.length, richBody: null, permanent: false, - onClick() { switchChannels(channel.guild_id, channel.id); } + onClick() { navigateToChannel(channel); } }; if (message.embeds?.length !== 0) { @@ -527,7 +538,6 @@ async function handleGuildMessage(message: Message) { Notification.icon = undefined; } - console.log("noti that went through: " + t); await showNotification(Notification); } diff --git a/src/equicordplugins/toneIndicators/ToneIndicator.tsx b/src/equicordplugins/toneIndicators/ToneIndicator.tsx index 631b5736e..433c8ebb3 100644 --- a/src/equicordplugins/toneIndicators/ToneIndicator.tsx +++ b/src/equicordplugins/toneIndicators/ToneIndicator.tsx @@ -24,7 +24,7 @@ export default function ToneIndicator({ diff --git a/src/equicordplugins/triviaAI/settings.ts b/src/equicordplugins/triviaAI/settings.ts index b4a97e823..1ccccd6ec 100644 --- a/src/equicordplugins/triviaAI/settings.ts +++ b/src/equicordplugins/triviaAI/settings.ts @@ -40,10 +40,14 @@ export const settings = definePluginSettings({ default: "https://openrouter.ai/api/v1/chat/completions", placeholder: "Enter your OpenAI compatible AI endpoint here." }, - autoRespond: { - type: OptionType.BOOLEAN, - description: "Automatically respond to messages on receiving a response.", - default: false + mode: { + type: OptionType.SELECT, + description: "How should answers be handled?", + options: [ + { label: "Auto Reply", value: "autoreply" }, + { label: "Replace Chatbar Text", value: "chatbar", default: true }, + { label: "Clyde", value: "bot" } + ] }, supportImages: { type: OptionType.BOOLEAN, diff --git a/src/equicordplugins/triviaAI/utils.ts b/src/equicordplugins/triviaAI/utils.ts index 501a14ffb..f9f000e9e 100644 --- a/src/equicordplugins/triviaAI/utils.ts +++ b/src/equicordplugins/triviaAI/utils.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ +import { sendBotMessage } from "@api/Commands"; import { insertTextIntoChatInputBox, sendMessage } from "@utils/discord"; import { Logger } from "@utils/Logger"; import { Message } from "@vencord/discord-types"; @@ -102,18 +103,24 @@ export function parseMessageContent(message: Message): ContentPayload | null { } export async function handleResponse(message: Message, response: string): Promise { - if (settings.store.autoRespond) { - sendMessage( - message.channel_id, - { content: response }, - true, - { messageReference: { channel_id: message.channel_id, message_id: message.id } } - ); - return response; - } else { - insertTextIntoChatInputBox(response); - return response; + switch (settings.store.mode) { + case "autoreply": + sendMessage( + message.channel_id, + { content: response }, + true, + { messageReference: { channel_id: message.channel_id, message_id: message.id } } + ); + break; + case "chatbar": + insertTextIntoChatInputBox(response); + break; + case "bot": + sendBotMessage(message.channel_id, { content: response }); + break; } + + return response; } export async function getResponse(payload: ContentPayload): Promise { @@ -155,25 +162,20 @@ export async function getResponse(payload: ContentPayload): Promise { }) }); - if (!req.ok) { - const errorBody = await req.text(); - const errorMessage = `API request failed with status ${req.status}: ${errorBody}`; - logger.error(errorMessage); - showToast(errorMessage, Toasts.Type.FAILURE); + const rawBody = await req.text(); + const data: { error?: { message?: string; }, choices?: { message: { content: string; }; }[]; } = (() => { + try { return JSON.parse(rawBody); } + catch { return {}; } + })(); + + if (!req.ok || data.error) { + const errorMsg = data.error?.message ?? rawBody ?? `Status ${req.status}`; + logger.error(`API Error: ${errorMsg}`); + showToast(errorMsg, Toasts.Type.FAILURE); return ""; } - const data = await req.json(); - - if (data.error) { - const errorMessage = data.error.message ?? "An unknown error occurred"; - logger.error(`API Error: ${errorMessage}`); - showToast(errorMessage, Toasts.Type.FAILURE); - return ""; - } - - const response = data.choices[0].message.content; - + const response = data.choices?.[0]?.message?.content; if (!response?.trim()) { logger.warn("no response from AI model"); return ""; diff --git a/src/equicordplugins/unlimitedAccounts/index.ts b/src/equicordplugins/unlimitedAccounts/index.ts index 984d9d1d1..cbf77811d 100644 --- a/src/equicordplugins/unlimitedAccounts/index.ts +++ b/src/equicordplugins/unlimitedAccounts/index.ts @@ -36,7 +36,7 @@ export default definePlugin({ settings, patches: [ { - find: "pushSyncToken:null", + find: "pushSyncToken:null}),", replacement: [ { match: /(\).length>)5/, @@ -56,11 +56,7 @@ export default definePlugin({ replace: "$1$self.getMaxAccounts()", }, { - match: /(\i.length<)5/, - replace: "$1$self.getMaxAccounts()", - }, - { - match: /(\i.length>=)5/, + match: /(\i.length(<|>=))5/g, replace: "$1$self.getMaxAccounts()", }, ] diff --git a/src/equicordplugins/vcNarratorCustom/index.tsx b/src/equicordplugins/vcNarratorCustom/index.tsx index f4711d878..c9d80123c 100644 --- a/src/equicordplugins/vcNarratorCustom/index.tsx +++ b/src/equicordplugins/vcNarratorCustom/index.tsx @@ -28,6 +28,7 @@ import { import { wordsToTitle } from "@utils/text"; import definePlugin, { OptionType } from "@utils/types"; import type { User } from "@vencord/discord-types"; +import { ChannelType } from "@vencord/discord-types/enums"; import { Button as DiscordButton, ChannelStore, @@ -45,6 +46,7 @@ import { } from "@webpack/common"; import { + addUserToList, addUserToStateChangeFilterList, clean, clearTtsCache, @@ -53,7 +55,9 @@ import { getPersistentTtsCacheStats, getVoiceForUser, parseStateChangeFilterList, + parseUserIdList, parseUserVoiceMap, + removeUserFromList, removeUserFromStateChangeFilterList, removeUserVoiceFromMap, setCachedVoiceInDB, @@ -64,12 +68,6 @@ import { const cl = classNameFactory("vc-narrator-"); -/* - * TTS API maintained by example-git - * The original TikTok TTS API went offline, so I set up a new working cloudflare worker. - * I made sure it's intentionally rate-limited to keep it feasible. Please don't abuse it - * so it can stay available for plugins like this one. Thanks! - example-git - */ const API_BASE = "https://tiktok-tts-aio.exampleuser.workers.dev"; type LastApiCallStatus = { @@ -189,10 +187,6 @@ interface VoiceState { stream?: boolean; } -// Two-queue system for TTS playback: -// - mainQueue: Non-interruptable messages (user names, join/leave announcements) -// - stateQueue: Interruptable messages (mute/deafen/stream state changes) -// This allows rapid state changes to interrupt each other while preserving important announcements interface QueueItem { text: string; userId?: string; @@ -207,8 +201,6 @@ let currentAudio: HTMLAudioElement | null = null; let currentInterruptKey: string | undefined; let currentStop: (() => void) | null = null; -// Pre-cache common state action phrases on plugin start for faster playback -// Uses phonetic spellings for muted/deafened to ensure clear pronunciation across all voices const COMMON_ACTIONS = ["myooted", "un-myooted", "deafind", "un-deafind", "started streaming", "stopped streaming"]; const DEFAULT_VOICE = "en_us_001"; let preCacheInitialized = false; @@ -217,26 +209,21 @@ async function preCacheCommonActions() { if (preCacheInitialized) return; preCacheInitialized = true; - // Initial delay before starting pre-cache to not impact startup await new Promise(r => setTimeout(r, 3000)); - // Pre-fetch common action words in DEFAULT voice (universal for all users) for (const action of COMMON_ACTIONS) { const cacheKey = `${DEFAULT_VOICE}_${action}`; - // 1. Check in-memory cache first if (ttsCache.has(cacheKey)) continue; - // 2. Check persistent IndexedDB cache - load into memory if found try { const cachedBlob = await getCachedVoiceFromDB(cacheKey); if (cachedBlob) { ttsCache.set(cacheKey, URL.createObjectURL(cachedBlob)); continue; } - } catch { /* ignore */ } + } catch { } - // 3. Fetch from API and store in both memory and persistent DB try { const response = await fetch(`${API_BASE}/api/generate`, { method: "POST", @@ -252,18 +239,14 @@ async function preCacheCommonActions() { } const blob = new Blob([binaryData], { type: "audio/mpeg" }); - // Store in memory cache ttsCache.set(cacheKey, URL.createObjectURL(blob)); - // Persist to IndexedDB for future sessions await setCachedVoiceInDB(cacheKey, blob); } } catch (e) { recordApiError(e); - /* ignore pre-cache failures */ } - // Space out API requests to avoid rate limiting (2 seconds between each) await new Promise(r => setTimeout(r, 2000)); } } @@ -281,8 +264,6 @@ function interruptPlayback(key: string) { async function processQueue() { if (isSpeaking) return; - // Optimization: if main queue has intro and state queue has action with no other items, - // and they're for the same user, combine them into single API call if (mainQueue.length === 1 && stateQueue.length === 1 && mainQueue[0].userId === stateQueue[0].userId && !mainQueue[0].interruptKey) { @@ -291,8 +272,8 @@ async function processQueue() { const combined: QueueItem = { text: `${intro.text} ${action.text}`, userId: intro.userId, - interruptKey: action.interruptKey, // Keep action's interrupt key so rapid switches can interrupt - useDefaultVoice: false, // Combined uses user voice for the intro part + interruptKey: action.interruptKey, + useDefaultVoice: false, }; mainQueue.push(combined); } @@ -308,7 +289,6 @@ async function processQueue() { console.error("TTS Error:", e); } - // Delay between messages - longer for state changes to allow interruption const delay = item.interruptKey ? 800 : 500; setTimeout(() => { isSpeaking = false; @@ -320,10 +300,8 @@ async function processQueue() { function queueSpeak(text: string, userId?: string, interruptKey?: string, queue: "main" | "state" = "main", useDefaultVoice?: boolean) { if (text.trim().length === 0) return; - // Anti-spam: cap queue size const targetQueue = queue === "state" ? stateQueue : mainQueue; if (targetQueue.length >= 5) { - // If queue is full, drop new to stop the spam wave return; } @@ -352,13 +330,12 @@ async function speak(text: string, userId?: string, interruptKey?: string, useDe resolve(); }; - // Helper to play audio and resolve promise when done const playAudio = (url: string) => { const audio = new Audio(url); audio.volume = settings.store.volume; audio.playbackRate = settings.store.rate; audio.onended = onEnd; - audio.onerror = onEnd; // Resolve even on error to unblock queue + audio.onerror = onEnd; currentAudio = audio; currentInterruptKey = interruptKey; currentStop = onEnd; @@ -366,7 +343,6 @@ async function speak(text: string, userId?: string, interruptKey?: string, useDe }; void (async () => { - // Use default voice for universal actions, otherwise user-specific voice const voice = useDefaultVoice ? DEFAULT_VOICE : getVoiceForUser(userId, { @@ -375,16 +351,13 @@ async function speak(text: string, userId?: string, interruptKey?: string, useDe defaultVoice: DEFAULT_VOICE, }); - // Create a unique cache key using the voice and text. const cacheKey = `${voice}_${text}`; - // 1. Check the in-memory cache (fast check) if (ttsCache.has(cacheKey)) { playAudio(ttsCache.get(cacheKey)!); return; } - // 2. Check the persistent IndexedDB cache. try { const cachedBlob = await getCachedVoiceFromDB(cacheKey); if (cachedBlob) { @@ -397,7 +370,6 @@ async function speak(text: string, userId?: string, interruptKey?: string, useDe console.error("Error accessing IndexedDB:", err); } - // 3. Fetch from API try { const response = await fetch(`${API_BASE}/api/generate`, { method: "POST", @@ -415,7 +387,7 @@ async function speak(text: string, userId?: string, interruptKey?: string, useDe if (!response.ok) { console.error(`TTS failed: ${response.status}`); - resolve(); // Skip this message + resolve(); return; } @@ -441,9 +413,6 @@ async function speak(text: string, userId?: string, interruptKey?: string, useDe }); } -// For every user, channelId and oldChannelId will differ when moving channel. -// Only for the local user, channelId and oldChannelId will be the same when moving channel, -// for some ungodly reason let myLastChannelId: string | undefined; type NormalizedVoiceState = { @@ -452,6 +421,7 @@ type NormalizedVoiceState = { streaming: boolean; }; +const lastJoinLeaveTimestamp = new Map(); let trackedChannelId: string | null = null; let baselineReady = false; let baselineUpdateInProgress = false; @@ -464,7 +434,7 @@ function normalizeState(state: VoiceState): NormalizedVoiceState { return { muted: !!(state.mute || state.selfMute), deaf: !!(state.deaf || state.selfDeaf), - streaming: !!((state as any).selfStream || (state as any).stream), + streaming: !!(state.selfStream || state.stream), }; } @@ -480,16 +450,6 @@ function shouldAnnounce(key: string): boolean { type StateActionType = "stream" | "mute" | "deaf"; -/** - * Builds the intro and action segments for state change announcements. - * - * Format: "{USERNAME}" + "{ACTION}" - * Examples: - * - "JohnDoe" + "myooted" / "un-myooted" / "deafind" / "un-deafind" - * - "JohnDoe" + "started streaming" / "stopped streaming" - * - * Uses phonetic spellings for muted/deafened to ensure clear pronunciation. - */ function buildStateSegments( type: StateActionType, isOn: boolean, @@ -505,7 +465,6 @@ function buildStateSegments( }; } - // Mute and deaf - no verb, just name + phonetic action const action = type === "mute" ? (isOn ? "myooted" : "un-myooted") : (isOn ? "deafind" : "un-deafind"); @@ -516,14 +475,6 @@ function buildStateSegments( }; } -/** - * Queues a state change announcement as two parts: - * - Intro (username) goes to mainQueue - won't be interrupted - * - Action (state) goes to stateQueue - can be interrupted by rapid state changes - * - * Uses a shared interrupt key per user so rapid toggles (mute->unmute->mute) - * only announce the final state. - */ function queueStateSplitAnnouncement( userId: string, intro: string, @@ -536,7 +487,6 @@ function queueStateSplitAnnouncement( lastIntroSpokenAt.set(userId, Date.now()); } - // Handle interrupt key for action - remove any pending actions for this user const interruptKey = `${userId}:state:action`; for (let i = stateQueue.length - 1; i >= 0; i--) { if (stateQueue[i].interruptKey === interruptKey) { @@ -545,7 +495,6 @@ function queueStateSplitAnnouncement( } interruptPlayback(interruptKey); - // Add both items to queues BEFORE processing if (shouldQueueIntro) { mainQueue.push({ text: safeIntro, userId, interruptKey: undefined, useDefaultVoice: false }); } @@ -609,7 +558,7 @@ function playSample(tempSettings: any, type: string) { settingsobj[type + "Message"], currentUser.username, "general", - (currentUser as any).globalName ?? currentUser.username, + currentUser.globalName ?? currentUser.username, (myGuildId ? GuildMemberStore.getNick(myGuildId, currentUser.id) : null) ?? currentUser.username, settingsobj.latinOnly ) @@ -715,6 +664,18 @@ const settings = definePluginSettings({ description: "Comma-separated user IDs for whitelist/blacklist. Right-click users to add/remove.", default: "", }, + ignoredUsers: { + type: OptionType.STRING, + description: "Comma-separated user IDs to completely ignore (no join/leave/move/state announcements). Right-click users to add/remove.", + default: "", + }, + joinLeaveTimeout: { + type: OptionType.SLIDER, + description: "Per-user cooldown for join/leave/move announcements (seconds). Prevents spam from rapid rejoiners.", + default: 0, + markers: [0, 5, 10, 15, 30, 60], + stickToMarkers: false, + }, troubleshooting: { type: OptionType.COMPONENT, component: () => , @@ -725,7 +686,6 @@ interface UserContextProps { user: User; } -// Voice selection modal component function VoiceSelectModal({ modalProps, user }: { modalProps: ModalProps; user: User; }) { const DEFAULT_VALUE = "__default__"; @@ -744,10 +704,8 @@ function VoiceSelectModal({ modalProps, user }: { modalProps: ModalProps; user: setCurrentValue(map.get(user.id) ?? DEFAULT_VALUE); }, [user.id, settings.store.userVoiceMap]); - // Get the display name for preview - const displayName = (user as any).globalName ?? user.username; + const displayName = user.globalName ?? user.username; - // Preview function that speaks the user's name in the selected voice const previewVoice = async (text: string) => { setBusy(true); const voice = currentValue === DEFAULT_VALUE @@ -801,7 +759,6 @@ function VoiceSelectModal({ modalProps, user }: { modalProps: ModalProps; user: onChange={v => setCurrentValue(v as any)} /> - {/* Preview buttons */} Preview how this voice sounds with their name: @@ -869,7 +826,6 @@ function openVoiceSelectModal(user: User) { )); } -// Context menu to assign voice to user const UserContextMenuPatch: NavContextMenuPatchCallback = (children, { user }: UserContextProps) => { if (!user) return; @@ -879,6 +835,9 @@ const UserContextMenuPatch: NavContextMenuPatchCallback = (children, { user }: U ? VOICE_OPTIONS.find(v => v.value === currentVoice)?.label ?? currentVoice : "Default"; + const ignoredSet = parseUserIdList(settings.store.ignoredUsers); + const isIgnored = ignoredSet.has(user.id); + const filterMode = settings.store.stateChangeFilterMode ?? "off"; const filterSet = parseStateChangeFilterList(settings.store.stateChangeFilterList); const inFilter = filterSet.has(user.id); @@ -910,6 +869,18 @@ const UserContextMenuPatch: NavContextMenuPatchCallback = (children, { user }: U label={`Voice: ${voiceLabel}`} action={() => openVoiceSelectModal(user)} /> + { + if (isIgnored) { + settings.store.ignoredUsers = removeUserFromList(settings.store.ignoredUsers, user.id); + } else { + settings.store.ignoredUsers = addUserToList(settings.store.ignoredUsers, user.id); + } + }} + /> 1 && voiceStates.some(s => !("oldChannelId" in (s as any))); if (isBatchUpdate && myChanId) { - // Guild-open / bulk refresh: update baseline silently to avoid spam. await refreshBaseline(myChanId); return; } + const ignoredUsers = parseUserIdList(settings.store.ignoredUsers); + for (const state of voiceStates) { const { userId, channelId } = state; let { oldChannelId } = state; @@ -991,7 +962,6 @@ export default definePlugin({ const affectsMyChannel = channelId === myChanId || oldChannelId === myChanId; if (!isMe && !affectsMyChannel) continue; - // Keep snapshots in sync for join/leave/move without announcing state changes. if (myChanId) { if (oldChannelId !== myChanId && channelId === myChanId) { voiceStateSnapshot.set(userId, normalizeState(state)); @@ -1000,16 +970,34 @@ export default definePlugin({ } } - // Join/leave/move announcements (existing behavior) + const isIgnored = ignoredUsers.has(userId); + const shouldSkipAnnouncement = isIgnored || (isMe && settings.store.ignoreSelf); + const [type, id] = getTypeAndChannelId({ ...state, oldChannelId }, isMe); if (type) { - const template = settings.store[type + "Message"]; - const u = isMe && !settings.store.sayOwnName ? "" : UserStore.getUser(userId).username; - const displayName = u && ((UserStore.getUser(userId) as any).globalName ?? u); - const nickname = u && ((myGuildId ? GuildMemberStore.getNick(myGuildId, userId) : null) ?? displayName); - const channel = ChannelStore.getChannel(id)?.name ?? "channel"; + if (!shouldSkipAnnouncement) { + const timeout = settings.store.joinLeaveTimeout ?? 0; + let throttled = false; + if (!isMe && timeout > 0) { + const now = Date.now(); + const last = lastJoinLeaveTimestamp.get(userId) ?? 0; + if (now - last < timeout * 1000) { + throttled = true; + } else { + lastJoinLeaveTimestamp.set(userId, now); + } + } - queueSpeak(formatText(template, u, channel, displayName, nickname, settings.store.latinOnly), userId); + if (!throttled) { + const template = settings.store[type + "Message"]; + const u = isMe && !settings.store.sayOwnName ? "" : UserStore.getUser(userId).username; + const displayName = u && (UserStore.getUser(userId).globalName ?? u); + const nickname = u && ((myGuildId ? GuildMemberStore.getNick(myGuildId, userId) : null) ?? displayName); + const channel = ChannelStore.getChannel(id)?.name ?? "channel"; + + queueSpeak(formatText(template, u, channel, displayName, nickname, settings.store.latinOnly), userId); + } + } if (isMe && (type === "join" || type === "move") && id) { await refreshBaseline(id); @@ -1018,7 +1006,6 @@ export default definePlugin({ continue; } - // State-change announcements (mute/deafen/stream), only when user is in our current VC. if (channelId !== myChanId) continue; if (!baselineReady) continue; @@ -1027,10 +1014,11 @@ export default definePlugin({ voiceStateSnapshot.set(userId, next); if (!prev) continue; + if (shouldSkipAnnouncement) continue; if (!allowStateChange(userId, isMe)) continue; const userObj = isMe && !settings.store.sayOwnName ? "" : UserStore.getUser(userId).username; - const displayName = userObj && ((UserStore.getUser(userId) as any).globalName ?? userObj); + const displayName = userObj && (UserStore.getUser(userId).globalName ?? userObj); const nickname = userObj && ((myGuildId ? GuildMemberStore.getNick(myGuildId, userId) : null) ?? displayName); const preferredName = nickname || displayName || userObj || (isMe ? "You" : "Someone"); @@ -1042,8 +1030,6 @@ export default definePlugin({ } } - // Deafen takes priority over mute (you're always muted when deafened) - // Only announce mute if deaf state didn't change if (!isMe && settings.store.announceOthersDeafen && prev.deaf !== next.deaf) { const key = `${userId}:deaf`; if (shouldAnnounce(key)) { @@ -1077,7 +1063,6 @@ export default definePlugin({ return ( <> - {/* Author note - pinned at top */}
{authorAvatar && (
- {/* Preview sounds section */}
Preview Sounds {busy && "(playing...)"} diff --git a/src/equicordplugins/vcNarratorCustom/util.ts b/src/equicordplugins/vcNarratorCustom/util.ts index e6fe069b4..476d2d2e0 100644 --- a/src/equicordplugins/vcNarratorCustom/util.ts +++ b/src/equicordplugins/vcNarratorCustom/util.ts @@ -49,36 +49,15 @@ export const VOICE_OPTIONS = [ { label: "Latin American: Spanish MX Male (es_mx_002)", value: "es_mx_002" }, ]; -/** - * In-memory TTS audio URL cache (`cacheKey` -> `blob:` object URL). - * - * - Cleared on restart. - * - Persistent caching lives in IndexedDB. - * - Call `clearTtsCache()` to clear both memory + persistent caches. - */ export const ttsCache = new Map(); -/** - * Maximum size for the persistent TTS cache stored in IndexedDB. - * - * Implemented as a rolling LRU: when writing new entries, older entries are evicted - * (by least-recent `lastAccess`) until we're back under the limit. - */ export const PERSISTENT_TTS_CACHE_MAX_BYTES = 100 * 1024 * 1024; -/** - * Parses the per-user voice map from settings. - * - * Supported formats: - * - Preferred: `"userId:voiceId,userId2:voiceId2"` (comma-separated) - * - Legacy: `"userId,voiceId\nuserId2,voiceId2"` (newline-separated) - */ export function parseUserVoiceMap(input?: string): Map { const map = new Map(); const trimmed = input?.trim(); if (!trimmed) return map; - // Preferred format (comma-separated pairs) if (trimmed.includes(":") || trimmed.includes("=")) { for (const entry of trimmed.split(",").map(s => s.trim()).filter(Boolean)) { const [userId, voiceId] = entry.split(/[:=]/).map(s => s.trim()); @@ -87,7 +66,6 @@ export function parseUserVoiceMap(input?: string): Map { return map; } - // Legacy format (newline-separated "userId,voiceId") for (const line of trimmed.split(/\n+/)) { const [userId, voiceId] = line.split(",").map(s => s.trim()); if (userId && voiceId) map.set(userId, voiceId); @@ -96,18 +74,12 @@ export function parseUserVoiceMap(input?: string): Map { return map; } -/** - * Serializes a per-user voice map to the preferred storage format. - */ export function serializeUserVoiceMap(map: Map): string { return Array.from(map.entries()) .map(([userId, voiceId]) => `${userId}:${voiceId}`) .join(","); } -/** - * Gets the voice id for a given user id, falling back to the configured/default voice. - */ export function getVoiceForUser( userId: string | undefined, options: { userVoiceMap?: string; customVoice?: string; defaultVoice?: string; } @@ -118,27 +90,46 @@ export function getVoiceForUser( return map.get(userId) ?? defaultVoice; } -/** - * Adds or updates a user voice mapping and returns the updated serialized map. - */ export function upsertUserVoiceMap(userVoiceMap: string | undefined, userId: string, voiceId: string): string { const map = parseUserVoiceMap(userVoiceMap ?? ""); map.set(userId, voiceId); return serializeUserVoiceMap(map); } -/** - * Removes a user voice mapping and returns the updated serialized map. - */ export function removeUserVoiceFromMap(userVoiceMap: string | undefined, userId: string): string { const map = parseUserVoiceMap(userVoiceMap ?? ""); map.delete(userId); return serializeUserVoiceMap(map); } -/** - * Parses the state-change filter list from a comma-separated string. - */ +export function parseUserIdList(input?: string): Set { + const set = new Set(); + const trimmed = input?.trim(); + if (!trimmed) return set; + + for (const entry of trimmed.split(",").map(s => s.trim()).filter(Boolean)) { + set.add(entry); + } + + return set; +} + +export function serializeUserIdList(list: Set): string { + return Array.from(list).join(","); +} + +export function addUserToList(input: string | undefined, userId: string): string { + const set = parseUserIdList(input); + set.add(userId); + return serializeUserIdList(set); +} + +export function removeUserFromList(input: string | undefined, userId: string): string { + const set = parseUserIdList(input); + set.delete(userId); + return serializeUserIdList(set); +} + export function parseStateChangeFilterList(input?: string): Set { const set = new Set(); const trimmed = input?.trim(); @@ -151,34 +142,22 @@ export function parseStateChangeFilterList(input?: string): Set { return set; } -/** - * Serializes the state-change filter list into a comma-separated string. - */ export function serializeStateChangeFilterList(list: Set): string { return Array.from(list).join(","); } -/** - * Adds a user id to the state-change filter list and returns the updated string. - */ export function addUserToStateChangeFilterList(input: string | undefined, userId: string): string { const set = parseStateChangeFilterList(input); set.add(userId); return serializeStateChangeFilterList(set); } -/** - * Removes a user id from the state-change filter list and returns the updated string. - */ export function removeUserFromStateChangeFilterList(input: string | undefined, userId: string): string { const set = parseStateChangeFilterList(input); set.delete(userId); return serializeStateChangeFilterList(set); } -/** - * Sanitizes a string for TTS usage (length cap + character allow list). - */ export function clean(str: string, latinOnly?: boolean) { const replacer = latinOnly ? /[^\p{Script=Latin}\p{Number}\p{Punctuation}\s]/gu @@ -192,9 +171,6 @@ export function clean(str: string, latinOnly?: boolean) { .slice(0, 128); } -/** - * Expands the template string used in announcements. - */ export function formatText( str: string, user: string, @@ -216,93 +192,93 @@ export function formatText( ); } -/** - * IndexedDB database name used for persistent caching. - */ const DB_NAME = "VcNarratorDB"; -/** - * IndexedDB schema version. - * - * v1: `voices` store only - * v2: adds `voices_meta` for rolling size limits / LRU eviction - */ const DB_VERSION = 2; -/** - * Object store holding cached audio blobs (`cacheKey` -> Blob). - */ const VOICES_STORE = "voices"; -/** - * Object store holding metadata (`cacheKey` -> { size, createdAt, lastAccess }). - */ +import { Logger } from "@utils/Logger"; + +const logger = new Logger("VcNarratorCustom"); const META_STORE = "voices_meta"; -/** - * Metadata for an entry in the persistent cache. - */ type VoiceMeta = { size: number; createdAt: number; lastAccess: number; }; -/** - * Opens (or creates) the IndexedDB database used for persistent TTS caching. - */ -function openDB(): Promise { - return new Promise((resolve, reject) => { - const request = indexedDB.open(DB_NAME, DB_VERSION); - request.onupgradeneeded = event => { - const db = request.result; - const upgradeTx = request.transaction; - if (!db.objectStoreNames.contains(VOICES_STORE)) { - db.createObjectStore(VOICES_STORE); - } - if (!db.objectStoreNames.contains(META_STORE)) { - const meta = db.createObjectStore(META_STORE); - meta.createIndex("by_lastAccess", "lastAccess"); - } +let openDbPromise: Promise | undefined; +let loggedFailure = false; - /** - * Migration: older DBs stored only Blobs in `voices` without metadata. - * - * On upgrade to v2, backfill `voices_meta` so size limits and LRU eviction - * can work immediately. - */ - if (upgradeTx && (event?.oldVersion ?? 0) < 2) { - const now = Date.now(); - const voices = upgradeTx.objectStore(VOICES_STORE); - const meta = upgradeTx.objectStore(META_STORE); - const cursorReq = voices.openCursor(); - cursorReq.onsuccess = () => { - const cursor = cursorReq.result; - if (!cursor) return; - const blob = cursor.value as Blob | undefined; - meta.put( - { - size: blob?.size ?? 0, - createdAt: now, - lastAccess: now, - } satisfies VoiceMeta, - String(cursor.key) - ); - cursor.continue(); - }; - } - }; - request.onsuccess = () => resolve(request.result); - request.onerror = () => reject(request.error); - }); +function handleDbFailure(error: unknown) { + if (loggedFailure) return; + loggedFailure = true; + logger.error("Voice cache storage unavailable, falling back to memory.", error); } -/** - * Returns the persistent cache size + entry count (based on IndexedDB metadata). - */ -export async function getPersistentTtsCacheStats(): Promise<{ bytes: number; entries: number; }> { +function openDB(): Promise { + if (!openDbPromise) { + openDbPromise = new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION); + request.onupgradeneeded = event => { + const db = request.result; + const upgradeTx = request.transaction; + if (!db.objectStoreNames.contains(VOICES_STORE)) { + db.createObjectStore(VOICES_STORE); + } + if (!db.objectStoreNames.contains(META_STORE)) { + const meta = db.createObjectStore(META_STORE); + meta.createIndex("by_lastAccess", "lastAccess"); + } + + if (upgradeTx && (event?.oldVersion ?? 0) < 2) { + const now = Date.now(); + const voices = upgradeTx.objectStore(VOICES_STORE); + const meta = upgradeTx.objectStore(META_STORE); + const cursorReq = voices.openCursor(); + cursorReq.onsuccess = () => { + const cursor = cursorReq.result; + if (!cursor) return; + const blob = cursor.value as Blob | undefined; + meta.put( + { + size: blob?.size ?? 0, + createdAt: now, + lastAccess: now, + } satisfies VoiceMeta, + String(cursor.key) + ); + cursor.continue(); + }; + } + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }).then(db => db).catch(error => { + handleDbFailure(error); + return null; + }); + } + + return openDbPromise; +} + +async function withDb(fallback: T, callback: (db: IDBDatabase) => Promise): Promise { const db = await openDB(); - return new Promise((resolve, reject) => { + if (!db) return fallback; + + try { + return await callback(db); + } catch (error) { + handleDbFailure(error); + return fallback; + } +} + +export async function getPersistentTtsCacheStats(): Promise<{ bytes: number; entries: number; }> { + return withDb<{ bytes: number; entries: number; }>({ bytes: 0, entries: 0 }, db => new Promise<{ bytes: number; entries: number; }>((resolve, reject) => { const tx = db.transaction(META_STORE, "readonly"); const store = tx.objectStore(META_STORE); @@ -322,36 +298,28 @@ export async function getPersistentTtsCacheStats(): Promise<{ bytes: number; ent cursor.continue(); }; cursorReq.onerror = () => reject(cursorReq.error); - }); + })); } -/** - * Clears both the in-memory URL cache and the persistent IndexedDB cache. - */ export async function clearTtsCache(): Promise { for (const url of ttsCache.values()) { try { URL.revokeObjectURL(url); - } catch { /* ignore */ } + } catch { } } ttsCache.clear(); - const db = await openDB(); - return new Promise((resolve, reject) => { + await withDb(void 0, db => new Promise((resolve, reject) => { const tx = db.transaction([VOICES_STORE, META_STORE], "readwrite"); tx.objectStore(VOICES_STORE).clear(); tx.objectStore(META_STORE).clear(); tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); - }); + })); } -/** - * Enforces the persistent cache size limit by removing least-recently-used entries. - */ async function trimPersistentCacheToMaxBytes(maxBytes = PERSISTENT_TTS_CACHE_MAX_BYTES): Promise { - const db = await openDB(); - return new Promise((resolve, reject) => { + await withDb(void 0, db => new Promise((resolve, reject) => { const tx = db.transaction([VOICES_STORE, META_STORE], "readwrite"); const voicesStore = tx.objectStore(VOICES_STORE); const metaStore = tx.objectStore(META_STORE); @@ -376,7 +344,7 @@ async function trimPersistentCacheToMaxBytes(maxBytes = PERSISTENT_TTS_CACHE_MAX if (inMemoryUrl) { try { URL.revokeObjectURL(inMemoryUrl); - } catch { /* ignore */ } + } catch { } ttsCache.delete(cacheKey); } @@ -404,15 +372,11 @@ async function trimPersistentCacheToMaxBytes(maxBytes = PERSISTENT_TTS_CACHE_MAX tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); - }); + })); } -/** - * Retrieves a cached TTS audio blob from IndexedDB and updates its LRU `lastAccess`. - */ export async function getCachedVoiceFromDB(cacheKey: string): Promise { - const db = await openDB(); - return new Promise((resolve, reject) => { + return withDb(null, db => new Promise((resolve, reject) => { const tx = db.transaction([VOICES_STORE, META_STORE], "readwrite"); const voicesStore = tx.objectStore(VOICES_STORE); const metaStore = tx.objectStore(META_STORE); @@ -436,15 +400,11 @@ export async function getCachedVoiceFromDB(cacheKey: string): Promise reject(request.error); - }); + })); } -/** - * Stores a TTS audio blob in IndexedDB, updates metadata, then trims cache to max size. - */ export async function setCachedVoiceInDB(cacheKey: string, blob: Blob): Promise { - const db = await openDB(); - await new Promise((resolve, reject) => { + await withDb(void 0, db => new Promise((resolve, reject) => { const tx = db.transaction([VOICES_STORE, META_STORE], "readwrite"); const voicesStore = tx.objectStore(VOICES_STORE); const metaStore = tx.objectStore(META_STORE); @@ -458,7 +418,7 @@ export async function setCachedVoiceInDB(cacheKey: string, blob: Blob): Promise< tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); - }); + })); await trimPersistentCacheToMaxBytes(); } diff --git a/src/equicordplugins/voiceButtons/index.tsx b/src/equicordplugins/voiceButtons/index.tsx index bc4f86cb4..af960f502 100644 --- a/src/equicordplugins/voiceButtons/index.tsx +++ b/src/equicordplugins/voiceButtons/index.tsx @@ -23,7 +23,7 @@ export default definePlugin({ find: ".VOICE_PANEL}}", replacement: [ { - match: /\[\i\.\i\]:\i\}\),children:\[/, + match: /\}\),children:\[(?=.{0,200}\.VOICE_CHANNEL_GAME_ACTIVITY_INDICATOR_VIEWED)/, replace: "$&$self.renderButtons(arguments[0]?.user)," } ] diff --git a/src/equicordplugins/voiceChannelLog.dev/components/VoiceChannelLogEntryComponent.css b/src/equicordplugins/voiceChannelLog.dev/components/VoiceChannelLogEntryComponent.css deleted file mode 100644 index 4a08e56f9..000000000 --- a/src/equicordplugins/voiceChannelLog.dev/components/VoiceChannelLogEntryComponent.css +++ /dev/null @@ -1,42 +0,0 @@ -.vc-voice-channel-log { - list-style-type: none; - height: 50px; - display: grid; - grid-template-columns: 2.25rem 24px 40px max-content; - gap: 4px; - width: 4px; - background-color: var(--border-subtle); -} - -.vc-voice-channel-log-date-separator, -.vc-voice-channel-log-timestamp { - margin-left: 4px; - height: 48px; - line-height: 48px; - font-size: .75rem; - text-align: center; - display: inline-block; - vertical-align: middle; - color: var(--text-default, white); -} - -.vc-voice-channel-log-avatar { - height: 40px; - width: 100%; - display: flex; - flex-direction: row; - align-items: center; - justify-content: space-between; - font-weight: 600; - overflow: hidden; - text-align: center; - margin-top: 10px; - padding-left: 10px; - padding-right: 10px; - cursor: pointer; -} - -.vc-voice-channel-log-icon { - margin: auto 3px; - margin-left: 15px; -} \ No newline at end of file diff --git a/src/equicordplugins/voiceChannelLog.dev/components/VoiceChannelLogEntryComponent.tsx b/src/equicordplugins/voiceChannelLog.dev/components/VoiceChannelLogEntryComponent.tsx deleted file mode 100644 index 0ce2611ef..000000000 --- a/src/equicordplugins/voiceChannelLog.dev/components/VoiceChannelLogEntryComponent.tsx +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Vencord, a Discord client mod - * Copyright (c) 2023 Vendicated and contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -import "./VoiceChannelLogEntryComponent.css"; - -import { VoiceChannelLogEntry } from "@equicordplugins/voiceChannelLog.dev/logs"; -import { classes } from "@utils/misc"; -import { Channel } from "@vencord/discord-types"; -import { React, Timestamp, Tooltip, UserStore } from "@webpack/common"; -import { Util } from "Vencord"; - -import { cl } from ".."; -import Icon from "./VoiceChannelLogEntryIcons"; - -export function VoiceChannelLogEntryComponent({ logEntry, channel }: { logEntry: VoiceChannelLogEntry; channel: Channel; }) { - const user = UserStore.getUser(logEntry.userId); - return
  • - - - - {(tooltipProps: any) => ( - Util.openUserProfile(logEntry.userId)} - src={user.getAvatarURL(channel.getGuildId())} - /> - )} - -
    - { } -
    -
  • ; -} diff --git a/src/equicordplugins/voiceChannelLog.dev/components/VoiceChannelLogEntryIcons.tsx b/src/equicordplugins/voiceChannelLog.dev/components/VoiceChannelLogEntryIcons.tsx deleted file mode 100644 index 0054323f4..000000000 --- a/src/equicordplugins/voiceChannelLog.dev/components/VoiceChannelLogEntryIcons.tsx +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Vencord, a Discord client mod - * Copyright (c) 2024 Vendicated and contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -import { VoiceChannelLogEntry } from "@equicordplugins/voiceChannelLog.dev/logs"; -import { classes } from "@utils/misc"; -import { Channel } from "@vencord/discord-types"; -import { React } from "@webpack/common"; - -import { cl } from ".."; - -export default function Icon({ logEntry, channel, className }: { logEntry: VoiceChannelLogEntry; channel: Channel; className: string; }) { - // Taken from /assets/7378a83d74ce97d83380.svg - const Join = ; - // Taken from /assets/192510ade1abc3149b46.svg - const Leave = ; - // For other contributors, please DO make specific designs for these instead of how I just copied the join/leave icons and making them orange - const MovedTo = ; - const MovedFrom = ; - - if (logEntry.newChannel && !logEntry.oldChannel) return React.cloneElement(Join, { className: classes(className, cl("join")) }); - if (!logEntry.newChannel && logEntry.oldChannel) return React.cloneElement(Leave, { className: classes(className, cl("leave")) }); - if (logEntry.newChannel === channel.id && logEntry.oldChannel) return React.cloneElement(MovedFrom, { className: classes(className, cl("moved-from")) }); - if (logEntry.newChannel && logEntry.oldChannel === channel.id) return React.cloneElement(MovedTo, { className: classes(className, cl("moved-to")) }); - // we should never get here, this is just here to shut up the type checker - return ; -} diff --git a/src/equicordplugins/voiceChannelLog.dev/components/VoiceChannelLogModal.tsx b/src/equicordplugins/voiceChannelLog.dev/components/VoiceChannelLogModal.tsx deleted file mode 100644 index 6315e8b06..000000000 --- a/src/equicordplugins/voiceChannelLog.dev/components/VoiceChannelLogModal.tsx +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Vencord, a Discord client mod - * Copyright (c) 2023 Vendicated and contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -import { BaseText } from "@components/BaseText"; -import { getVcLogs, vcLogSubscribe } from "@equicordplugins/voiceChannelLog.dev/logs"; -import { classes } from "@utils/misc"; -import { ModalCloseButton, ModalContent, ModalHeader, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal"; -import { Channel } from "@vencord/discord-types"; -import { findStoreLazy } from "@webpack"; -import { React, ScrollerThin } from "@webpack/common"; - -import { cl } from ".."; -import { VoiceChannelLogEntryComponent } from "./VoiceChannelLogEntryComponent"; - -const AccessibilityStore = findStoreLazy("AccessibilityStore"); - -export function openVoiceChannelLog(channel: Channel) { - return openModal(props => ( - - )); -} - -export function VoiceChannelLogModal({ channel, props }: { channel: Channel; props: ModalProps; }) { - React.useSyncExternalStore(vcLogSubscribe, () => getVcLogs(channel.id)); - const vcLogs = getVcLogs(channel.id); - const logElements: (React.ReactNode)[] = []; - - if (vcLogs.length > 0) { - for (let i = 0; i < vcLogs.length; i++) { - const logEntry = vcLogs[i]; - if (i === 0 || logEntry.timestamp.toDateString() !== vcLogs[i - 1].timestamp.toDateString()) { - logElements.push(
    - - {logEntry.timestamp.toDateString()} - -
    ); - } else { - logElements.push(); - } - } - } else { - logElements.push(
    No logs to display.
    ); - } - - return ( - - - {channel.name} logs - - - - - - {logElements} - - - - ); -} diff --git a/src/equicordplugins/voiceChannelLog.dev/index.tsx b/src/equicordplugins/voiceChannelLog.dev/index.tsx deleted file mode 100644 index 94941f6e1..000000000 --- a/src/equicordplugins/voiceChannelLog.dev/index.tsx +++ /dev/null @@ -1,164 +0,0 @@ -/* - * Vencord, a Discord client mod - * Copyright (c) 2024 Vendicated and contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -import { findGroupChildrenByChildId, NavContextMenuPatchCallback } from "@api/ContextMenu"; -import { definePluginSettings } from "@api/Settings"; -import { Devs, EquicordDevs } from "@utils/constants"; -import { classNameFactory } from "@utils/css"; -import definePlugin, { OptionType } from "@utils/types"; -import { Message, User } from "@vencord/discord-types"; -import { findByCodeLazy } from "@webpack"; -import { FluxDispatcher, Menu, MessageActions, MessageStore, RelationshipStore, SelectedChannelStore, UserStore } from "@webpack/common"; - -import { openVoiceChannelLog } from "./components/VoiceChannelLogModal"; -import { addLogEntry } from "./logs"; - -export const cl = classNameFactory("vc-voice-channel-log-"); -const createBotMessage = findByCodeLazy('username:"Clyde"'); - -const settings = definePluginSettings({ - mode: { - type: OptionType.SELECT, - description: "How to show the voice channel log", - options: [ - { label: "Log menu", value: 1, default: true }, - { label: "Log to associated chat directly", value: 2 }, - { label: "Log to chat and log menu", value: 3 }, - ] - }, - voiceChannelChatSelf: { - type: OptionType.BOOLEAN, - description: "Log your own voice channel events in the voice channels", - default: true - }, - voiceChannelChatSilent: { - type: OptionType.BOOLEAN, - description: "Join/leave/move messages in voice channel chats will be silent", - default: true - }, - voiceChannelChatSilentSelf: { - type: OptionType.BOOLEAN, - description: "Join/leave/move messages in voice channel chats will be silent if you are in the voice channel", - default: false - }, - ignoreBlockedUsers: { - type: OptionType.BOOLEAN, - description: "Do not log blocked users", - default: false - }, -}); - -interface VoiceState { - guildId?: string; - channelId?: string; - oldChannelId?: string; - user: User; - userId: string; -} - -function getMessageFlags(selfInChannel: boolean) { - let flags = 1 << 6; - if (selfInChannel ? settings.store.voiceChannelChatSilentSelf : settings.store.voiceChannelChatSilent) flags += 1 << 12; - return flags; -} - -function sendVoiceStatusMessage(channelId: string, content: string, userId: string, selfInChannel: boolean): Message | null { - if (!channelId) return null; - - const message: Message = createBotMessage({ channelId, content, embeds: [] }); - message.flags = getMessageFlags(selfInChannel); - message.author = UserStore.getUser(userId); - // If we try to send a message into an unloaded channel, the client-sided messages get overwritten when the channel gets loaded - // This might be messy but It Works:tm: - const messagesLoaded: Promise = MessageStore.hasPresent(channelId) ? new Promise(r => r()) : MessageActions.fetchMessages({ channelId }); - messagesLoaded.then(() => { - FluxDispatcher.dispatch({ - type: "MESSAGE_CREATE", - channelId, - message, - optimistic: true, - sendMessageOptions: {}, - isPushNotification: false - }); - }); - return message; -} - -const patchChannelContextMenu: NavContextMenuPatchCallback = (children, { channel }) => { - const group = findGroupChildrenByChildId("mark-channel-read", children) ?? children; - group.push( - { openVoiceChannelLog(channel); }} - /> - ); -}; - -// Blatantly stolen from VcNarrator plugin - -// For every user, channelId and oldChannelId will differ when moving channel. -// Only for the local user, channelId and oldChannelId will be the same when moving channel, -// for some ungodly reason -let clientOldChannelId: string | undefined; - -export default definePlugin({ - name: "VoiceChannelLog", - description: "Logs who joins and leaves voice channels", - authors: [Devs.Sqaaakoi, Devs.thororen, EquicordDevs.nyx], - contextMenus: { - "channel-context": patchChannelContextMenu - }, - settings, - flux: { - VOICE_STATE_UPDATES({ voiceStates }: { voiceStates: VoiceState[]; }) { - const clientUserId = UserStore.getCurrentUser().id; - voiceStates.forEach(state => { - // mmmm hacky workaround - const { userId, channelId } = state; - const user = UserStore.getUser(userId) as User & { globalName: string; }; - const username = user.globalName || user.username; - let { oldChannelId } = state; - if (userId === clientUserId && channelId !== clientOldChannelId) { - oldChannelId = clientOldChannelId; - clientOldChannelId = channelId; - } - if (settings.store.ignoreBlockedUsers && RelationshipStore.isBlocked(userId)) return; - // Ignore events from same channel - if (oldChannelId === channelId) return; - - const logEntry = { - userId, - username, - oldChannel: oldChannelId || null, - newChannel: channelId || null, - timestamp: new Date() - }; - - if (settings.store.mode !== 2) { - addLogEntry(logEntry, oldChannelId); - addLogEntry(logEntry, channelId); - } - - if (!settings.store.voiceChannelChatSelf && userId === clientUserId) return; - if (settings.store.mode === 1) return; - // Join / Leave - if ((!oldChannelId && channelId) || (oldChannelId && !channelId)) { - // empty string is to make type checker shut up - const targetChannelId = oldChannelId || channelId || ""; - const selfInChannel = SelectedChannelStore.getVoiceChannelId() === targetChannelId; - sendVoiceStatusMessage(targetChannelId, `${(channelId ? "Joined" : "Left")} <#${targetChannelId}>`, userId, selfInChannel); - } - // Move between channels - if (oldChannelId && channelId) { - sendVoiceStatusMessage(oldChannelId, `Moved to <#${channelId}>`, userId, SelectedChannelStore.getVoiceChannelId() === oldChannelId); - sendVoiceStatusMessage(channelId, `Moved from <#${oldChannelId}>`, userId, SelectedChannelStore.getVoiceChannelId() === channelId); - } - - }); - }, - } -}); diff --git a/src/equicordplugins/voiceChannelLog.dev/logs.ts b/src/equicordplugins/voiceChannelLog.dev/logs.ts deleted file mode 100644 index 6fefe4ba7..000000000 --- a/src/equicordplugins/voiceChannelLog.dev/logs.ts +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Vencord, a Discord client mod - * Copyright (c) 2024 Vendicated and contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -export interface VoiceChannelLogEntry { - userId: string; - username: string; - oldChannel: string | null; - newChannel: string | null; - timestamp: Date; -} - -export const vcLogs = new Map(); -let vcLogSubscriptions: (() => void)[] = []; - -export function getVcLogs(channel?: string): VoiceChannelLogEntry[] { - if (!channel) return []; - if (!vcLogs.has(channel)) vcLogs.set(channel, []); - return vcLogs.get(channel) || []; -} - -export function addLogEntry(logEntry: VoiceChannelLogEntry, channel?: string) { - if (!channel) return; - vcLogs.set(channel, [...getVcLogs(channel), logEntry]); - vcLogSubscriptions.forEach(u => u()); -} - -export function clearLogs(channel?: string) { - if (!channel) return; - vcLogs.set(channel, []); - vcLogSubscriptions.forEach(u => u()); -} - -export function vcLogSubscribe(listener: () => void) { - vcLogSubscriptions = [...vcLogSubscriptions, listener]; - return () => { - vcLogSubscriptions = vcLogSubscriptions.filter(l => l !== listener); - }; -} diff --git a/src/equicordplugins/voiceChannelLog/components/LogsButton.tsx b/src/equicordplugins/voiceChannelLog/components/LogsButton.tsx new file mode 100644 index 000000000..7d6f29c8b --- /dev/null +++ b/src/equicordplugins/voiceChannelLog/components/LogsButton.tsx @@ -0,0 +1,40 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { HeaderBarButton } from "@api/HeaderBar"; +import { ChannelStore, SelectedChannelStore, useStateFromStores } from "@webpack/common"; + +import { cl } from "../utils"; +import { openVoiceChannelLog } from "./VoiceChannelLogModal"; + +function LogIcon({ height = 24, width = 24 }: { height?: number; width?: number; }) { + return ( + + + + + ); +} + +export function OpenLogsButton() { + const voiceChannelId = useStateFromStores([SelectedChannelStore], () => SelectedChannelStore.getVoiceChannelId()); + + if (!voiceChannelId) return null; + + const channel = ChannelStore.getChannel(voiceChannelId); + if (!channel) return null; + + return ( + openVoiceChannelLog(channel)} + tooltip="Voice Channel Logs" + icon={LogIcon} + /> + ); +} + +export { LogIcon }; diff --git a/src/equicordplugins/voiceChannelLog/components/VoiceChannelLogEntryComponent.css b/src/equicordplugins/voiceChannelLog/components/VoiceChannelLogEntryComponent.css new file mode 100644 index 000000000..100965cf8 --- /dev/null +++ b/src/equicordplugins/voiceChannelLog/components/VoiceChannelLogEntryComponent.css @@ -0,0 +1,178 @@ +.vc-voice-channel-log-entry { + list-style-type: none; + display: flex; + align-items: center; + gap: 12px; + padding: 8px 16px; + min-height: 44px; + border-radius: 4px; +} + +.vc-voice-channel-log-entry:hover { + background-color: var(--background-modifier-hover); +} + +.vc-voice-channel-log-entry-timestamp { + display: flex; + flex-direction: column; + align-items: flex-end; + min-width: 72px; + flex-shrink: 0; +} + +.vc-voice-channel-log-timestamp { + font-size: 0.75rem; + color: var(--text-muted); + line-height: 1.3; +} + +.vc-voice-channel-log-elapsed { + font-size: 0.625rem; + color: var(--text-muted); + line-height: 1.3; +} + +.vc-voice-channel-log-icon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 20px; +} + +.vc-voice-channel-log-icon-brand { + color: var(--brand-experiment); +} + +.vc-voice-channel-log-icon-danger { + color: var(--status-danger); +} + +.vc-voice-channel-log-icon-positive { + color: var(--status-positive); +} + +.vc-voice-channel-log-icon-warning { + color: var(--status-warning); +} + +.vc-voice-channel-log-app-icon { + width: 20px; + height: 20px; + border-radius: 4px; + flex-shrink: 0; +} + +.vc-voice-channel-log-avatar { + width: 32px; + height: 32px; + border-radius: 50%; + cursor: pointer; + flex-shrink: 0; + object-fit: cover; +} + +.vc-voice-channel-log-avatar:hover { + opacity: 0.8; +} + +.vc-voice-channel-log-content { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + flex: 1; +} + +.vc-voice-channel-log-username { + font-weight: 600; + font-size: 0.875rem; + color: var(--text-strong); + cursor: pointer; + width: fit-content; +} + +.vc-voice-channel-log-username:hover { + text-decoration: underline; +} + +.vc-voice-channel-log-description { + font-size: 0.8125rem; + color: var(--text-subtle); +} + +.vc-voice-channel-log-soundboard-info { + display: flex; + align-items: center; + gap: 6px; +} + +.vc-voice-channel-log-soundboard-emoji { + width: 16px; + height: 16px; +} + +.vc-voice-channel-log-soundboard-id { + font-size: 0.8125rem; + color: var(--text-subtle); +} + +.vc-voice-channel-log-soundboard-actions { + display: flex; + align-items: center; + gap: 4px; + margin-left: 4px; +} + +.vc-voice-channel-log-soundboard-action { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: 4px; + color: var(--interactive-icon-default); + cursor: pointer; +} + +.vc-voice-channel-log-soundboard-action:hover { + color: var(--interactive-icon-active); + background-color: var(--background-modifier-hover); +} + +.vc-voice-channel-log-date-separator { + display: flex; + align-items: center; + margin: 8px 16px; + color: var(--text-muted); + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.02em; +} + +.vc-voice-channel-log-date-separator::before, +.vc-voice-channel-log-date-separator::after { + content: ""; + flex: 1; + height: 1px; + background: var(--border-subtle); +} + +.vc-voice-channel-log-date-separator span { + padding: 0 8px; +} + +.vc-voice-channel-log-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 48px 32px; + color: var(--text-muted); + font-size: 0.875rem; +} + +.vc-voice-channel-log-scroller { + padding: 8px 0; +} diff --git a/src/equicordplugins/voiceChannelLog/components/VoiceChannelLogEntryComponent.tsx b/src/equicordplugins/voiceChannelLog/components/VoiceChannelLogEntryComponent.tsx new file mode 100644 index 000000000..e343de4ef --- /dev/null +++ b/src/equicordplugins/voiceChannelLog/components/VoiceChannelLogEntryComponent.tsx @@ -0,0 +1,127 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2023 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import "./VoiceChannelLogEntryComponent.css"; + +import { openUserProfile } from "@utils/discord"; +import { closeAllModals } from "@utils/modal"; +import { Channel } from "@vencord/discord-types"; +import { ApplicationStore, Clickable, IconUtils, NavigationRouter, Timestamp, Tooltip, UserStore } from "@webpack/common"; + +import { getCallStartTime } from "../logs"; +import { VoiceChannelLogEntry } from "../types"; +import { cl, downloadSound, formatElapsedTime, getEmojiUrl, playSound } from "../utils"; +import EventIcon from "./VoiceChannelLogEntryIcons"; + +function getEventDescription(entry: VoiceChannelLogEntry): string { + switch (entry.type) { + case "join": + return "Joined the channel"; + case "leave": + return "Left the channel"; + case "move": + if (entry.newChannelId && entry.oldChannelId && entry.channelId === entry.oldChannelId) + return "Moved to another channel"; + return "Moved from another channel"; + case "soundboard": + return ""; + case "server_mute": + return entry.enabled ? "Server muted" : "Server unmuted"; + case "server_deafen": + return entry.enabled ? "Server deafened" : "Server undeafened"; + case "self_video": + return entry.enabled ? "Turned on camera" : "Turned off camera"; + case "self_stream": + return entry.enabled ? "Started screensharing" : "Stopped screensharing"; + case "activity": + return `Started ${entry.activityName ?? "an activity"}`; + case "activity_stop": + return `Stopped ${entry.activityName ?? "an activity"}`; + } +} + +export function VoiceChannelLogEntryComponent({ logEntry, channel }: { logEntry: VoiceChannelLogEntry; channel: Channel; }) { + const user = UserStore.getUser(logEntry.userId); + const username = user?.globalName ?? user?.username ?? "Unknown"; + const elapsed = formatElapsedTime(getCallStartTime(), logEntry.timestamp); + const description = getEventDescription(logEntry); + + return ( +
  • +
    + + {elapsed && {elapsed}} +
    + {(logEntry.type === "activity" || logEntry.type === "activity_stop") && logEntry.applicationId ? ( + {logEntry.activityName + ) : ( + + )} + + {(tooltipProps: any) => ( + openUserProfile(logEntry.userId)} + src={user ? user.getAvatarURL(channel.getGuildId()) : IconUtils.getDefaultAvatarURL(logEntry.userId)} + alt={username} + /> + )} + +
    + openUserProfile(logEntry.userId)}>{username} + {logEntry.type === "soundboard" ? ( +
    + {logEntry.emoji && {logEntry.emoji.name}} + Played a soundboard sound +
    + + {tooltipProps => ( + playSound(logEntry.soundId!)}> + + + )} + + + {tooltipProps => ( + downloadSound(logEntry.soundId!)}> + + + )} + +
    +
    + ) : (logEntry.type === "activity" || logEntry.type === "activity_stop") && logEntry.applicationId ? ( +
    + {description} +
    + + {tooltipProps => ( + { + closeAllModals(); + NavigationRouter.transitionTo(`/application-directory/${logEntry.applicationId}`); + }} + > + + + )} + +
    +
    + ) : ( + {description} + )} +
    +
  • + ); +} diff --git a/src/equicordplugins/voiceChannelLog/components/VoiceChannelLogEntryIcons.tsx b/src/equicordplugins/voiceChannelLog/components/VoiceChannelLogEntryIcons.tsx new file mode 100644 index 000000000..5032c40da --- /dev/null +++ b/src/equicordplugins/voiceChannelLog/components/VoiceChannelLogEntryIcons.tsx @@ -0,0 +1,102 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { classes } from "@utils/misc"; +import { React } from "@webpack/common"; + +import { LogEventType } from "../types"; +import { cl } from "../utils"; + +const iconProps = { xmlns: "http://www.w3.org/2000/svg", height: "18", width: "18" }; + +function JoinIcon() { + return ; +} + +function LeaveIcon() { + return ; +} + +function MoveIcon() { + return ; +} + +function SoundboardIcon() { + return + + ; +} + +function MuteIcon() { + return + + + + + ; +} + +function DeafenIcon() { + return + + + + ; +} + +function VideoIcon() { + return + + + ; +} + +function StreamIcon() { + return + + + ; +} + +function ActivityIcon() { + return + + + + + ; +} + +const iconMap: Record React.ReactNode> = { + join: JoinIcon, + leave: LeaveIcon, + move: MoveIcon, + soundboard: SoundboardIcon, + server_mute: MuteIcon, + server_deafen: DeafenIcon, + self_video: VideoIcon, + self_stream: StreamIcon, + activity: ActivityIcon, + activity_stop: ActivityIcon, +}; + +const colorMap: Record = { + join: "positive", + leave: "danger", + move: "warning", + soundboard: "brand", + server_mute: "danger", + server_deafen: "danger", + self_video: "positive", + self_stream: "brand", + activity: "brand", + activity_stop: "brand", +}; + +export default function EventIcon({ type }: { type: LogEventType; }) { + const IconComponent = iconMap[type]; + return
    ; +} diff --git a/src/equicordplugins/voiceChannelLog/components/VoiceChannelLogModal.tsx b/src/equicordplugins/voiceChannelLog/components/VoiceChannelLogModal.tsx new file mode 100644 index 000000000..8437dc03d --- /dev/null +++ b/src/equicordplugins/voiceChannelLog/components/VoiceChannelLogModal.tsx @@ -0,0 +1,69 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2023 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { BaseText } from "@components/BaseText"; +import { classes } from "@utils/misc"; +import { ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal"; +import { Channel } from "@vencord/discord-types"; +import { findStoreLazy } from "@webpack"; +import { Button, React, ScrollerThin } from "@webpack/common"; + +import { clearLogs, getVcLogs, vcLogSubscribe } from "../logs"; +import { cl } from "../utils"; +import { VoiceChannelLogEntryComponent } from "./VoiceChannelLogEntryComponent"; + +const AccessibilityStore = findStoreLazy("AccessibilityStore"); + +export function openVoiceChannelLog(channel: Channel) { + return openModal(props => ( + + )); +} + +export function VoiceChannelLogModal({ channel, props }: { channel: Channel; props: ModalProps; }) { + const logs = React.useSyncExternalStore(vcLogSubscribe, () => getVcLogs(channel.id)); + + return ( + + + + {channel.name} logs + + + + + + + {logs.length > 0 ? logs.map((entry, i) => { + const elements: React.ReactNode[] = []; + + if (i === 0 || entry.timestamp.toDateString() !== logs[i - 1].timestamp.toDateString()) { + elements.push( +
    + {entry.timestamp.toDateString()} +
    + ); + } + + elements.push( + + ); + + return elements; + }) : ( +
    No logs to display.
    + )} +
    +
    + + + + +
    + ); +} diff --git a/src/equicordplugins/voiceChannelLog/index.tsx b/src/equicordplugins/voiceChannelLog/index.tsx new file mode 100644 index 000000000..a0e4d1d9c --- /dev/null +++ b/src/equicordplugins/voiceChannelLog/index.tsx @@ -0,0 +1,271 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { NavContextMenuPatchCallback } from "@api/ContextMenu"; +import { Devs, EquicordDevs } from "@utils/constants"; +import definePlugin from "@utils/types"; +import { ChannelType } from "@vencord/discord-types/enums"; +import { findByPropsLazy } from "@webpack"; +import { ApplicationStore, ChannelStore, Menu, RelationshipStore, SelectedChannelStore, UserStore, VoiceStateStore } from "@webpack/common"; + +import { LogIcon, OpenLogsButton } from "./components/LogsButton"; +import { openVoiceChannelLog } from "./components/VoiceChannelLogModal"; +import { addLogEntry, setCallStartTime } from "./logs"; +import settings from "./settings"; +import { EmbeddedActivityEvent, PreviousVoiceState, SoundEvent, VoiceChannelLogEntry, VoiceState } from "./types"; + +const { fetchApplication } = findByPropsLazy("fetchApplication"); + +const loggedActivities = new Set(); +const previousStates = new Map(); +const existingUsers = new Set(); + +let clientOldChannelId: string | undefined; +let clientJoinedAt = 0; + +function isMyChannel(channelId?: string): boolean { + return !!channelId && SelectedChannelStore.getVoiceChannelId() === channelId; +} + +function shouldLog(userId: string): boolean { + return !(settings.store.ignoreBlockedUsers && RelationshipStore.isBlocked(userId)); +} + +function log(entry: Omit) { + addLogEntry({ ...entry, timestamp: new Date() }); +} + +const VOICE_CHANNEL_TYPES = new Set([ChannelType.GUILD_VOICE, ChannelType.GUILD_STAGE_VOICE, ChannelType.DM, ChannelType.GROUP_DM]); + +const patchChannelContextMenu: NavContextMenuPatchCallback = (children, { channel }) => { + if (!VOICE_CHANNEL_TYPES.has(channel.type)) return; + children.push( + openVoiceChannelLog(channel)} + /> + ); +}; + +export default definePlugin({ + name: "VoiceChannelLog", + description: "Logs voice channel activity including joins, leaves, soundboard, mute, camera, screenshare, and more.", + authors: [Devs.Sqaaakoi, Devs.thororen, EquicordDevs.nyx, Devs.Moxxie, EquicordDevs.Fres, Devs.amy], + dependencies: ["AudioPlayerAPI"], + settings, + contextMenus: { + "channel-context": patchChannelContextMenu + }, + + toolboxActions: { + "Voice Channel Logs"() { + const channelId = SelectedChannelStore.getVoiceChannelId(); + if (!channelId) return; + const channel = ChannelStore.getChannel(channelId); + if (channel) openVoiceChannelLog(channel); + } + }, + + headerBarButton: { + icon: LogIcon, + render: OpenLogsButton + }, + + flux: { + VOICE_CHANNEL_SELECT({ channelId, currentVoiceChannelId }: { channelId: string | null; currentVoiceChannelId: string | null; }) { + const leaving = channelId == null && currentVoiceChannelId != null; + const joining = channelId != null && currentVoiceChannelId == null; + const oldChannel = currentVoiceChannelId ?? clientOldChannelId; + + clientOldChannelId = channelId ?? undefined; + + if (leaving && oldChannel) { + const userId = UserStore.getCurrentUser().id; + if (settings.store.logJoinLeave) { + log({ type: "leave", userId, channelId: oldChannel }); + } + setCallStartTime(null); + loggedActivities.clear(); + } else if (joining && channelId && channelId !== oldChannel) { + clientJoinedAt = Date.now(); + setCallStartTime(new Date()); + if (settings.store.logJoinLeave) { + log({ type: "join", userId: UserStore.getCurrentUser().id, channelId }); + } + } + }, + + VOICE_STATE_UPDATES({ voiceStates }: { voiceStates: VoiceState[]; }) { + const clientUserId = UserStore.getCurrentUser().id; + const suppressJoins = Date.now() - clientJoinedAt < 5000; + + for (const state of voiceStates) { + const { userId } = state; + const { channelId, oldChannelId } = state; + + if (userId === clientUserId) continue; + if (!shouldLog(userId)) continue; + + if (oldChannelId === channelId && !previousStates.has(userId)) { + previousStates.set(userId, { + mute: state.mute, + deaf: state.deaf, + selfVideo: state.selfVideo, + selfStream: state.selfStream ?? false, + channelId + }); + continue; + } + + const prev = previousStates.get(userId); + const inMyChannel = isMyChannel(channelId) || isMyChannel(oldChannelId); + + if (oldChannelId !== channelId) { + if (!oldChannelId && channelId) { + const skipJoin = suppressJoins || existingUsers.delete(userId); + if (!skipJoin && settings.store.logJoinLeave && isMyChannel(channelId)) { + log({ type: "join", userId, channelId }); + } + } else if (oldChannelId && !channelId) { + if (settings.store.logJoinLeave && isMyChannel(oldChannelId)) { + log({ type: "leave", userId, channelId: oldChannelId }); + } + } else if (oldChannelId && channelId) { + if (settings.store.logJoinLeave) { + if (isMyChannel(oldChannelId)) { + log({ type: "move", userId, channelId: oldChannelId, oldChannelId, newChannelId: channelId }); + } + if (isMyChannel(channelId)) { + log({ type: "move", userId, channelId, oldChannelId, newChannelId: channelId }); + } + } + } + } + + if (prev && channelId && inMyChannel) { + if (settings.store.logMuteDeafen) { + if (state.mute !== prev.mute) { + log({ type: "server_mute", userId, channelId, enabled: state.mute }); + } + if (state.deaf !== prev.deaf) { + log({ type: "server_deafen", userId, channelId, enabled: state.deaf }); + } + } + + if (settings.store.logVideo && state.selfVideo !== prev.selfVideo) { + log({ type: "self_video", userId, channelId, enabled: state.selfVideo }); + } + + if (settings.store.logStream && (state.selfStream ?? false) !== prev.selfStream) { + log({ type: "self_stream", userId, channelId, enabled: state.selfStream ?? false }); + } + } + + previousStates.set(userId, { + mute: state.mute, + deaf: state.deaf, + selfVideo: state.selfVideo, + selfStream: state.selfStream ?? false, + channelId + }); + + if (!channelId) { + previousStates.delete(userId); + } + } + }, + + EMBEDDED_ACTIVITY_UPDATE_V2(event: EmbeddedActivityEvent) { + if (!settings.store.logActivity) return; + + const channelId = event.location?.channel_id; + if (!channelId || !isMyChannel(channelId)) return; + + const appId = event.applicationId; + const currentUserIds = new Set((event.participants ?? []).map(p => p.user_id)); + + const joined: string[] = []; + for (const p of event.participants ?? []) { + if (!shouldLog(p.user_id)) continue; + const dedupKey = `${p.user_id}-${appId}`; + if (loggedActivities.has(dedupKey)) continue; + loggedActivities.add(dedupKey); + joined.push(p.user_id); + } + + const left: string[] = []; + for (const key of loggedActivities) { + if (!key.endsWith(`-${appId}`)) continue; + const userId = key.slice(0, -(appId.length + 1)); + if (!currentUserIds.has(userId)) { + loggedActivities.delete(key); + left.push(userId); + } + } + + if (!joined.length && !left.length) return; + + const app = ApplicationStore.getApplication(appId); + const logWithName = (activityName: string) => { + for (const userId of joined) + log({ type: "activity", userId, channelId, activityName, applicationId: appId }); + for (const userId of left) + log({ type: "activity_stop", userId, channelId, activityName, applicationId: appId }); + }; + + if (app) { + logWithName(app.name); + } else { + fetchApplication(appId).then(fetched => logWithName(fetched?.name ?? "Unknown activity")).catch(() => logWithName("Unknown activity")); + } + }, + + VOICE_CHANNEL_EFFECT_SEND(event: SoundEvent) { + if (!settings.store.logSoundboard) return; + if (!event.soundId) return; + if (!isMyChannel(event.channelId)) return; + if (!shouldLog(event.userId)) return; + + log({ + type: "soundboard", + userId: event.userId, + channelId: event.channelId, + soundId: event.soundId, + emoji: event.emoji + }); + } + }, + + start() { + clientOldChannelId = SelectedChannelStore.getVoiceChannelId() ?? undefined; + if (clientOldChannelId) { + clientJoinedAt = Date.now(); + setCallStartTime(new Date()); + if (settings.store.logJoinLeave) { + log({ type: "join", userId: UserStore.getCurrentUser().id, channelId: clientOldChannelId }); + } + const states = VoiceStateStore.getVoiceStatesForChannel(clientOldChannelId); + for (const [userId, s] of Object.entries(states)) { + existingUsers.add(userId); + previousStates.set(userId, { + mute: s.mute, + deaf: s.deaf, + selfVideo: s.selfVideo, + selfStream: s.selfStream ?? false, + channelId: clientOldChannelId + }); + } + } + }, + + stop() { + previousStates.clear(); + loggedActivities.clear(); + existingUsers.clear(); + setCallStartTime(null); + } +}); diff --git a/src/equicordplugins/voiceChannelLog/logs.ts b/src/equicordplugins/voiceChannelLog/logs.ts new file mode 100644 index 000000000..1e08fb429 --- /dev/null +++ b/src/equicordplugins/voiceChannelLog/logs.ts @@ -0,0 +1,46 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { VoiceChannelLogEntry } from "./types"; + +const vcLogs = new Map(); +let vcLogSubscriptions: (() => void)[] = []; + +let callStartTime: Date | null = null; + +export function getCallStartTime(): Date | null { + return callStartTime; +} + +export function setCallStartTime(time: Date | null) { + callStartTime = time; +} + +const EMPTY_LOGS: VoiceChannelLogEntry[] = []; + +export function getVcLogs(channelId?: string): VoiceChannelLogEntry[] { + if (!channelId) return EMPTY_LOGS; + return vcLogs.get(channelId) ?? EMPTY_LOGS; +} + +export function addLogEntry(entry: VoiceChannelLogEntry) { + const existing = vcLogs.get(entry.channelId) ?? []; + vcLogs.set(entry.channelId, [...existing, entry]); + vcLogSubscriptions.forEach(fn => fn()); +} + +export function clearLogs(channelId?: string) { + if (!channelId) return; + vcLogs.set(channelId, []); + vcLogSubscriptions.forEach(fn => fn()); +} + +export function vcLogSubscribe(listener: () => void) { + vcLogSubscriptions = [...vcLogSubscriptions, listener]; + return () => { + vcLogSubscriptions = vcLogSubscriptions.filter(l => l !== listener); + }; +} diff --git a/src/equicordplugins/voiceChannelLog/settings.ts b/src/equicordplugins/voiceChannelLog/settings.ts new file mode 100644 index 000000000..a1b924c24 --- /dev/null +++ b/src/equicordplugins/voiceChannelLog/settings.ts @@ -0,0 +1,63 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { definePluginSettings } from "@api/Settings"; +import { OptionType } from "@utils/types"; + +const settings = definePluginSettings({ + logJoinLeave: { + type: OptionType.BOOLEAN, + description: "Log when users join, leave, or move between voice channels.", + default: true + }, + logSoundboard: { + type: OptionType.BOOLEAN, + description: "Log when users play soundboard sounds.", + default: true + }, + logMuteDeafen: { + type: OptionType.BOOLEAN, + description: "Log when users are server muted or deafened.", + default: true + }, + logVideo: { + type: OptionType.BOOLEAN, + description: "Log when users turn their camera on or off.", + default: true + }, + logStream: { + type: OptionType.BOOLEAN, + description: "Log when users start or stop screensharing.", + default: true + }, + logActivity: { + type: OptionType.BOOLEAN, + description: "Log when users start embedded activities.", + default: true + }, + ignoreBlockedUsers: { + type: OptionType.BOOLEAN, + description: "Do not log blocked users.", + default: false + }, + soundboardFileType: { + type: OptionType.SELECT, + description: "File format for downloading soundboard sounds.", + options: [ + { label: ".ogg", value: ".ogg", default: true }, + { label: ".mp3", value: ".mp3" }, + { label: ".wav", value: ".wav" }, + ], + }, + soundboardVolume: { + type: OptionType.SLIDER, + description: "Preview volume for soundboard sounds (0 to disable).", + default: 0.5, + markers: [0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1] + }, +}); + +export default settings; diff --git a/src/equicordplugins/voiceChannelLog/types.ts b/src/equicordplugins/voiceChannelLog/types.ts new file mode 100644 index 000000000..60bf8d8a5 --- /dev/null +++ b/src/equicordplugins/voiceChannelLog/types.ts @@ -0,0 +1,81 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { User } from "@vencord/discord-types"; + +export type LogEventType = + | "join" + | "leave" + | "move" + | "soundboard" + | "server_mute" + | "server_deafen" + | "self_video" + | "self_stream" + | "activity" + | "activity_stop"; + +export interface Emoji { + name: string; + id?: string; + animated: boolean; +} + +export interface VoiceChannelLogEntry { + type: LogEventType; + userId: string; + channelId: string; + timestamp: Date; + oldChannelId?: string | null; + newChannelId?: string | null; + soundId?: string; + emoji?: Emoji; + enabled?: boolean; + activityName?: string; + applicationId?: string; +} + +export interface VoiceState { + guildId?: string; + channelId?: string; + oldChannelId?: string; + user: User; + userId: string; + mute: boolean; + deaf: boolean; + selfMute: boolean; + selfDeaf: boolean; + selfVideo: boolean; + selfStream?: boolean; +} + +export interface SoundEvent { + type: "VOICE_CHANNEL_EFFECT_SEND"; + emoji?: Emoji; + channelId: string; + userId: string; + soundId?: string; +} + +export interface EmbeddedActivityEvent { + applicationId: string; + location?: { + channel_id?: string; + guild_id?: string; + }; + participants?: Array<{ + user_id: string; + session_id: string; + }>; +} + +export interface PreviousVoiceState { + mute: boolean; + deaf: boolean; + selfVideo: boolean; + selfStream: boolean; + channelId?: string; +} diff --git a/src/equicordplugins/voiceChannelLog/utils.ts b/src/equicordplugins/voiceChannelLog/utils.ts new file mode 100644 index 000000000..4cddd3fca --- /dev/null +++ b/src/equicordplugins/voiceChannelLog/utils.ts @@ -0,0 +1,51 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { playAudio } from "@api/AudioPlayer"; +import { classNameFactory } from "@utils/css"; +import { saveFile } from "@utils/web"; +import { findByPropsLazy } from "@webpack"; + +import settings from "./settings"; +import { Emoji } from "./types"; + +export const cl = classNameFactory("vc-voice-channel-log-"); + +const EmojiManager = findByPropsLazy("getEmojiColors", "getURL"); + +export function getEmojiUrl(emoji?: Emoji): string { + if (!emoji) return EmojiManager.getURL("❓"); + return emoji.id ? `https://cdn.discordapp.com/emojis/${emoji.id}.png?size=32` : EmojiManager.getURL(emoji.name); +} + +export const playSound = (id: string) => { + playAudio(`https://cdn.discordapp.com/soundboard-sounds/${id}`, { volume: settings.store.soundboardVolume * 100 }); +}; + +export async function downloadSound(id: string): Promise { + const filename = id + settings.store.soundboardFileType; + const original = await fetch(`https://cdn.discordapp.com/soundboard-sounds/${id}`).then(res => res.arrayBuffer()); + + if (IS_DISCORD_DESKTOP) { + DiscordNative.fileManager.saveWithDialog(new Uint8Array(original), filename); + } else { + saveFile(new File([original], filename, { type: "audio/ogg" })); + } +} + +export function formatElapsedTime(startTime: Date | null, eventTime: Date): string | null { + if (!startTime) return null; + const elapsed = eventTime.getTime() - startTime.getTime(); + if (elapsed < 0) return null; + + const totalMinutes = Math.floor(elapsed / 60000); + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + + if (hours > 0) return `${hours}h ${minutes}m into the call`; + if (minutes > 0) return `${minutes}m into the call`; + return "start of call"; +} diff --git a/src/equicordplugins/voiceChatUtils/index.tsx b/src/equicordplugins/voiceChatUtils/index.tsx index 64baf1ba5..421000cf7 100644 --- a/src/equicordplugins/voiceChatUtils/index.tsx +++ b/src/equicordplugins/voiceChatUtils/index.tsx @@ -6,7 +6,7 @@ import { NavContextMenuPatchCallback } from "@api/ContextMenu"; import { definePluginSettings } from "@api/Settings"; -import { Devs } from "@utils/constants"; +import { Devs, EquicordDevs } from "@utils/constants"; import { insertTextIntoChatInputBox } from "@utils/discord"; import definePlugin, { makeRange, OptionType } from "@utils/types"; import type { Channel } from "@vencord/discord-types"; @@ -174,7 +174,7 @@ const settings = definePluginSettings({ export default definePlugin({ name: "VoiceChatUtilities", description: "This plugin allows you to perform multiple actions on an entire channel (move, mute, disconnect, etc.) (originally by dutake)", - authors: [Devs.D3SOX], + authors: [Devs.D3SOX, EquicordDevs.nickwoah], settings, diff --git a/src/equicordplugins/voiceRejoin/index.tsx b/src/equicordplugins/voiceRejoin/index.tsx index ad882d0df..12042df43 100644 --- a/src/equicordplugins/voiceRejoin/index.tsx +++ b/src/equicordplugins/voiceRejoin/index.tsx @@ -7,11 +7,14 @@ import * as DataStore from "@api/DataStore"; import { definePluginSettings } from "@api/Settings"; import { EquicordDevs } from "@utils/constants"; +import { Logger } from "@utils/Logger"; import definePlugin, { makeRange, OptionType } from "@utils/types"; +import { VoiceState } from "@vencord/discord-types"; import { ChannelStore, FluxDispatcher, UserStore, VoiceStateStore } from "@webpack/common"; const DATASTORE_KEY = "VCLastVoiceChannel"; const DATASTORE_SESSION_KEY = "VCLastVoiceChannelSession"; +const logger = new Logger("VoiceRejoin"); const settings = definePluginSettings({ rejoinDelay: { @@ -48,12 +51,15 @@ const settings = definePluginSettings({ export default definePlugin({ name: "VoiceRejoin", description: "Rejoins DM/Server call automatically when restarting Discord.", - authors: [EquicordDevs.omaw], + authors: [EquicordDevs.omaw, EquicordDevs.keircn], settings, flux: { - VOICE_STATE_UPDATES({ voiceStates }: { voiceStates: any[]; }) { - const myUserId = UserStore.getCurrentUser().id; + VOICE_STATE_UPDATES({ voiceStates }: { voiceStates: VoiceState[]; }) { + const currentUser = UserStore.getCurrentUser(); + if (!currentUser) return; + + const myUserId = currentUser.id; const myState = voiceStates.find(s => s.userId === myUserId); if (!myState) return; @@ -63,77 +69,92 @@ export default definePlugin({ channelId: myState.channelId, timestamp: Date.now(), }; - DataStore.set(DATASTORE_KEY, saved); - DataStore.set(DATASTORE_SESSION_KEY, true); + void Promise.all([ + DataStore.set(DATASTORE_KEY, saved), + DataStore.set(DATASTORE_SESSION_KEY, true) + ]).catch(err => logger.error("Failed to persist last voice channel", err)); } else { - DataStore.set(DATASTORE_SESSION_KEY, false); + void DataStore.set(DATASTORE_SESSION_KEY, false) + .catch(err => logger.error("Failed to persist voice session state", err)); } }, async CONNECTION_OPEN() { const wasInVC = await DataStore.get(DATASTORE_SESSION_KEY); if (wasInVC === false) { - DataStore.del(DATASTORE_KEY); + await DataStore.del(DATASTORE_KEY); return; } setTimeout(async () => { - const saved = await DataStore.get(DATASTORE_KEY); - if (!saved?.channelId) return; + try { + const saved = await DataStore.get(DATASTORE_KEY); + if (!saved?.channelId) return; - const channel = ChannelStore.getChannel(saved.channelId); - const isDM = channel.isDM() || channel.isGroupDM() || channel.isMultiUserDM(); - const myUserId = UserStore.getCurrentUser().id; - const myVoiceState = VoiceStateStore.getVoiceStateForUser(myUserId); - const preventionMode = settings.store.preventReconnectIfCallEnded; - const timeoutMs = settings.store.rejoinTimeout * 1000; + let channel = ChannelStore.getChannel(saved.channelId); + for (let i = 0; i < 20 && !channel; i++) { + await new Promise(resolve => setTimeout(resolve, 250)); + channel = ChannelStore.getChannel(saved.channelId); + } - if (saved.timestamp && Date.now() - saved.timestamp > timeoutMs) { - DataStore.set(DATASTORE_SESSION_KEY, false); - return; - } - - if (settings.store.applyOnlyToDms && !isDM) { - DataStore.set(DATASTORE_SESSION_KEY, false); - return; - } - - if (preventionMode !== "none") { if (!channel) { - DataStore.set(DATASTORE_SESSION_KEY, false); + await DataStore.set(DATASTORE_SESSION_KEY, false); return; } - const shouldPrevent = - preventionMode === "both" || - (preventionMode === "dms" && isDM) || - (preventionMode === "servers" && !isDM); + const currentUser = UserStore.getCurrentUser(); + if (!currentUser) return; - if (shouldPrevent) { - const connectedUsers = VoiceStateStore.getVoiceStatesForChannel(saved.channelId); - const othersInCall = Object.values(connectedUsers).filter( - (vs: any) => vs.userId !== UserStore.getCurrentUser().id - ); + const isDM = channel.isDM() || channel.isGroupDM() || channel.isMultiUserDM(); + const myUserId = currentUser.id; + const myVoiceState = VoiceStateStore.getVoiceStateForUser(myUserId); + const preventionMode = settings.store.preventReconnectIfCallEnded; + const timeoutMs = settings.store.rejoinTimeout * 1000; - if (othersInCall.length === 0) { - DataStore.set(DATASTORE_SESSION_KEY, false); - return; + if (saved.timestamp && Date.now() - saved.timestamp > timeoutMs) { + await DataStore.set(DATASTORE_SESSION_KEY, false); + return; + } + + if (settings.store.applyOnlyToDms && !isDM) { + await DataStore.set(DATASTORE_SESSION_KEY, false); + return; + } + + if (preventionMode !== "none") { + const shouldPrevent = + preventionMode === "both" || + (preventionMode === "dms" && isDM) || + (preventionMode === "servers" && !isDM); + + if (shouldPrevent) { + const connectedUsers = VoiceStateStore.getVoiceStatesForChannel(saved.channelId) as Record; + const othersInCall = Object.values(connectedUsers).filter( + vs => vs.userId !== myUserId + ); + + if (othersInCall.length === 0) { + await DataStore.set(DATASTORE_SESSION_KEY, false); + return; + } } } + + if (myVoiceState?.channelId) { + await DataStore.set(DATASTORE_SESSION_KEY, false); + return; + } + + FluxDispatcher.dispatch({ + type: "VOICE_CHANNEL_SELECT", + guildId: saved.guildId, + channelId: saved.channelId, + }); + + await DataStore.set(DATASTORE_SESSION_KEY, true); + } catch (err) { + logger.error("Failed to run voice rejoin", err); } - - if (myVoiceState?.channelId) { - DataStore.set(DATASTORE_SESSION_KEY, false); - return; - } - - FluxDispatcher.dispatch({ - type: "VOICE_CHANNEL_SELECT", - guildId: saved.guildId, - channelId: saved.channelId, - }); - - DataStore.set(DATASTORE_SESSION_KEY, true); }, settings.store.rejoinDelay * 1000); }, }, diff --git a/src/equicordplugins/whosWatching/index.tsx b/src/equicordplugins/whosWatching/index.tsx index c224a2da4..2412d1827 100644 --- a/src/equicordplugins/whosWatching/index.tsx +++ b/src/equicordplugins/whosWatching/index.tsx @@ -4,12 +4,14 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ +import "./styles.css"; + import { definePluginSettings } from "@api/Settings"; import ErrorBoundary from "@components/ErrorBoundary"; import { Flex } from "@components/Flex"; import { Heading, HeadingSecondary } from "@components/Heading"; import { Paragraph } from "@components/Paragraph"; -import { EquicordDevs } from "@utils/constants"; +import { Devs, EquicordDevs } from "@utils/constants"; import { classNameFactory } from "@utils/css"; import { getIntlMessage, openUserProfile } from "@utils/discord"; import { Margins } from "@utils/margins"; @@ -25,12 +27,49 @@ interface WatchingProps { guildId?: string; } -const cl = classNameFactory("whosWatching-"); +const ApplicationStreamingStore = findStoreLazy("ApplicationStreamingStore"); +const UserSummaryItem = findComponentByCodeLazy("defaultRenderUser", "showDefaultAvatarsForNullUsers"); +const AvatarStyles = findCssClassesLazy("moreUsers", "clickableAvatar", "avatar"); +const cl = classNameFactory("vc-whos-watching-"); -function getUsername(user: any): string { +function getUsername(user: User): string { return RelationshipStore.getNickname(user.id) || user.globalName || user.username; } +function Watching({ userIds, guildId }: WatchingProps): JSX.Element { + let missingUsers = 0; + const users = userIds.map(id => UserStore.getUser(id)).filter(user => Boolean(user) ? true : (missingUsers += 1, false)); + return ( +
    + {userIds.length ? + ( +
    + {getIntlMessage("SPECTATORS", { numViewers: userIds.length })} + + {users.map(user => ( + + + {getUsername(user)} + + ))} + {missingUsers > 0 && + + {`+${getIntlMessage("NUM_USERS", { num: missingUsers })}`} + + } + +
    + ) + : ( + + No spectators + + ) + } +
    + ); +} + const settings = definePluginSettings({ showPanel: { description: "Show spectators under screenshare panel", @@ -40,53 +79,23 @@ const settings = definePluginSettings({ }, }); -function Watching({ userIds, guildId }: WatchingProps): JSX.Element { - // Missing Users happen when UserStore.getUser(id) returns null - // The client should automatically cache spectators, so this might not be possible but it's better to be sure just in case - let missingUsers = 0; - const users = userIds.map(id => UserStore.getUser(id)).filter(user => Boolean(user) ? true : (missingUsers += 1, false)); - return ( -
    - {userIds.length ? - (<> - {getIntlMessage("SPECTATORS", { numViewers: userIds.length })} - - {users.map(user => ( - - - {getUsername(user)} - - ))} - {missingUsers > 0 && {`+${getIntlMessage("NUM_USERS", { num: missingUsers })}`}} - - ) - : (No spectators)} -
    - ); -} - -const ApplicationStreamingStore = findStoreLazy("ApplicationStreamingStore"); - -const UserSummaryItem = findComponentByCodeLazy("defaultRenderUser", "showDefaultAvatarsForNullUsers"); -const AvatarStyles = findCssClassesLazy("moreUsers", "emptyUser", "avatarContainer", "clickableAvatar", "avatar"); - export default definePlugin({ name: "WhosWatching", description: "Hover over the screenshare icon to view what users are watching your stream", - authors: [EquicordDevs.Fres], + authors: [EquicordDevs.Fres, Devs.thororen], settings, patches: [ { find: ".Masks.STATUS_SCREENSHARE,width:32", replacement: { - match: /jsx\)\((\i\.\i),{mask:/, - replace: "jsx)($self.component({OriginalComponent:$1}),{mask:" + match: /\((\i\.\i)(?=,{mask:\i\.\i\.Masks\.STATUS_SCREENSHARE)/, + replace: "($self.component({OriginalComponent:$1})" } }, { find: "this.renderEmbeddedActivity()", replacement: { - match: /"div"(?=.{0,50}this.renderActions)/, + match: /"div"(?=.{0,40}?,this.renderActions\(\))/, replace: "$self.WrapperComponent" } } @@ -95,8 +104,8 @@ export default definePlugin({ const stream = useStateFromStores([ApplicationStreamingStore], () => ApplicationStreamingStore.getCurrentUserActiveStream()); if (!stream) return
    {props.children}
    ; - const userIds: string[] = ApplicationStreamingStore.getViewerIds(stream); let missingUsers = 0; + const userIds: string[] = ApplicationStreamingStore.getViewerIds(stream); const users = userIds.map(id => UserStore.getUser(id)).filter(user => Boolean(user) ? true : (missingUsers += 1, false)); function renderMoreUsers(_label: string, count: number) { @@ -117,14 +126,14 @@ export default definePlugin({ } return ( - <> +
    {props.children}
    -
    - +
    + {getIntlMessage("SPECTATORS", { numViewers: userIds.length })} {users.length ? - <> +
    )} /> - - : No spectators +
    + : + No spectators + }
    - +
    ); }), component: function ({ OriginalComponent }) { - return ErrorBoundary.wrap((props: any) => { - const stream = useStateFromStores( - [ApplicationStreamingStore], - () => ApplicationStreamingStore.getCurrentUserActiveStream() - ); + return ErrorBoundary.wrap(props => { + const stream = useStateFromStores([ApplicationStreamingStore], () => ApplicationStreamingStore.getCurrentUserActiveStream()); if (!stream) return null; + const viewers = ApplicationStreamingStore.getViewerIds(stream); return }> {({ onMouseEnter, onMouseLeave }) => ( diff --git a/src/equicordplugins/whosWatching/styles.css b/src/equicordplugins/whosWatching/styles.css new file mode 100644 index 000000000..66a965fb3 --- /dev/null +++ b/src/equicordplugins/whosWatching/styles.css @@ -0,0 +1,11 @@ +.vc-whos-watching-user-avatar { + border-radius: 8px; + width: 16px; + height: 16px; +} + +.vc-whos-watching-spectating-header { + margin-top: 8px; + margin-bottom: 0; + text-transform: uppercase; +} \ No newline at end of file diff --git a/src/plugins/_api/badges/index.tsx b/src/plugins/_api/badges/index.tsx index cd366010c..004213b9f 100644 --- a/src/plugins/_api/badges/index.tsx +++ b/src/plugins/_api/badges/index.tsx @@ -138,7 +138,7 @@ export default definePlugin({ replace: "...$1.props,$&" }, { - match: /(?<=forceOpen:.{0,40}?\i\((\i)\.id\).{0,100}?)children:/, + match: /(?<=forceOpen:.{0,40}?ariaHidden:!0,)children:(?=.{0,50}?(\i)\.id)/, replace: "children:$1.component?$self.renderBadgeComponent({...$1}) :" }, // handle onClick and onContextMenu diff --git a/src/plugins/_api/dynamicImageModalApi.ts b/src/plugins/_api/dynamicImageModalApi.ts index 104b782a5..0b7723391 100644 --- a/src/plugins/_api/dynamicImageModalApi.ts +++ b/src/plugins/_api/dynamicImageModalApi.ts @@ -13,7 +13,6 @@ export default definePlugin({ description: "Allows you to omit either width or height when opening an image modal", patches: [ { - // TODO: bundler compat find: ".renderLinkComponent", replacement: { // widthAndHeightPassed = w != null && w !== 0 && h == null || h === 0 diff --git a/src/plugins/_api/messageDecorations/index.tsx b/src/plugins/_api/messageDecorations/index.tsx index 590e50bd7..c21e691d1 100644 --- a/src/plugins/_api/messageDecorations/index.tsx +++ b/src/plugins/_api/messageDecorations/index.tsx @@ -30,7 +30,7 @@ export default definePlugin({ patches: [ { - find: '"Message Username"', + find: "#{intl::GUILD_COMMUNICATION_DISABLED_ICON_TOOLTIP_BODY}", replacement: { match: /#{intl::GUILD_COMMUNICATION_DISABLED_BOTTOM_SHEET_TITLE}.+?renderPopout:.+?(?=\])/, replace: "$&,Vencord.Api.MessageDecorations.__addDecorationsToMessage(arguments[0])" diff --git a/src/plugins/_api/notices.ts b/src/plugins/_api/notices.ts index 49f1bf268..ace58e705 100644 --- a/src/plugins/_api/notices.ts +++ b/src/plugins/_api/notices.ts @@ -35,12 +35,6 @@ export default definePlugin({ { match: /(?<=,NOTICE_DISMISS:function\(\i\){)return null!=(\i)/, replace: (m, notice) => `if(${notice}?.id=="EquicordNotice")return(${notice}=null,Vencord.Api.Notices.nextNotice(),true);${m}` - }, - // FIXME(Bundler agressive inline): Remove the non used compability once enough time has passed - { - match: /(?<=function (\i)\(\i\){)return null!=(\i)(?=.+?NOTICE_DISMISS:\1)/, - replace: (m, _, notice) => `if(${notice}?.id=="EquicordNotice")return(${notice}=null,Vencord.Api.Notices.nextNotice(),true);${m}`, - noWarn: true } ] } diff --git a/src/plugins/_core/settings.tsx b/src/plugins/_core/settings.tsx index eb87cb137..61c928dae 100644 --- a/src/plugins/_core/settings.tsx +++ b/src/plugins/_core/settings.tsx @@ -20,7 +20,7 @@ import { gitHashShort } from "@shared/vencordUserAgent"; import { Devs } from "@utils/constants"; import { isTruthy } from "@utils/guards"; import definePlugin, { IconProps, OptionType } from "@utils/types"; -import { findByPropsLazy } from "@webpack"; +import { waitFor } from "@webpack"; import { React } from "@webpack/common"; import type { ComponentType, PropsWithChildren, ReactNode } from "react"; @@ -46,7 +46,14 @@ const enum LayoutType { CUSTOM = 18 } -const LayoutTypes: typeof LayoutType = findByPropsLazy("SECTION", "SIDEBAR_ITEM", "PANEL"); +let LayoutTypes = { + SECTION: 1, + SIDEBAR_ITEM: 2, + PANEL: 3, + CATEGORY: 5, + CUSTOM: 19, +}; +waitFor(["SECTION", "SIDEBAR_ITEM", "PANEL", "CUSTOM"], v => LayoutTypes = v); const enum SectionType { HEADER = "HEADER", @@ -103,18 +110,6 @@ const settings = definePluginSettings({ } }); -const settingsSectionMap: [string, string][] = [ - ["EquicordSettings", "equicord_main_panel"], - ["EquicordPlugins", "equicord_plugins_panel"], - ["EquicordThemes", "equicord_themes_panel"], - ["EquicordUpdater", "equicord_updater_panel"], - ["EquicordChangelog", "equicord_changelog_panel"], - ["EquicordCloud", "equicord_cloud_panel"], - ["EquicordBackupAndRestore", "equicord_backup_restore_panel"], - ["EquicordPatchHelper", "equicord_patch_helper_panel"], - ["EquibopSettings", "equicord_equibop_settings_panel"], -]; - export default definePlugin({ name: "Settings", description: "Adds Settings UI and debug info", @@ -122,7 +117,6 @@ export default definePlugin({ required: true, settings, - settingsSectionMap, patches: [ { @@ -139,13 +133,6 @@ export default definePlugin({ return `${m},$self.makeInfoElements(${component},${props}).map(e=>${makeRow}e})),`; } }, - { - match: /"text-xs\/normal".{0,300}?\[\(0,\i\.jsxs?\)\((.{1,10}),(\{[^{}}]+\{.{0,20}className:\i.\i,.+?\})\)," "/, - replace: (m, component, props) => { - props = props.replace(/children:\[.+\]/, ""); - return `${m},$self.makeInfoElements(${component},${props})`; - } - }, { match: /copyValue:\i\.join\(" "\)/g, replace: "$& + $self.getInfoString()" @@ -158,13 +145,6 @@ export default definePlugin({ match: /(\i)\.buildLayout\(\)(?=\.map)/, replace: "$self.buildLayout($1)" } - }, - { - find: "getWebUserSettingFromSection", - replacement: { - match: /new Map\(\[(?=\[.{0,10}\.ACCOUNT,.{0,10}\.ACCOUNT_PANEL)/, - replace: "new Map([...$self.getSettingsSectionMappings()," - } } ], @@ -175,24 +155,25 @@ export default definePlugin({ key: key + "_panel", type: LayoutTypes.PANEL, useTitle: () => panelTitle, - buildLayout: () => [], - StronglyDiscouragedCustomComponent: () => , - render: () => , + buildLayout: () => [{ + type: LayoutTypes.CATEGORY, + key: key + "_category", + buildLayout: () => [{ + type: LayoutTypes.CUSTOM, + key: key + "_custom", + Component: Component, + useSearchTerms: () => [title] + }] + }] }; - return { + return ({ key, type: LayoutTypes.SIDEBAR_ITEM, - legacySearchKey: title.toUpperCase(), - getLegacySearchKey: () => title.toUpperCase(), useTitle: () => title, icon: () => , buildLayout: () => [panel] - }; - }, - - getSettingsSectionMappings() { - return settingsSectionMap; + }); }, buildLayout(originalLayoutBuilder: SettingsLayoutBuilder) { diff --git a/src/plugins/accountPanelServerProfile/index.tsx b/src/plugins/accountPanelServerProfile/index.tsx index 2fc49e85c..12f5f5cbb 100644 --- a/src/plugins/accountPanelServerProfile/index.tsx +++ b/src/plugins/accountPanelServerProfile/index.tsx @@ -75,11 +75,11 @@ export default definePlugin({ patches: [ { - find: ".WIDGETS_RTC_UPSELL_COACHMARK)", + find: "handleOpenSettingsContextMenu=", group: true, replacement: [ { - match: /(\.AVATAR,children:.+?renderPopout:\((\i),\i\)=>){(.+?)}(?=,position)(?<=currentUser:(\i).+?)/, + match: /(\.AVATAR,children:.+?renderPopout:(\i)=>){(.+?)}(?=,position)(?<=currentUser:(\i).+?)/, replace: (_, rest, popoutProps, originalPopout, currentUser) => `${rest}$self.UserProfile({popoutProps:${popoutProps},currentUser:${currentUser},originalRenderPopout:()=>{${originalPopout}}})` }, { diff --git a/src/plugins/alwaysAnimate/index.ts b/src/plugins/alwaysAnimate/index.ts index 3c26219de..071596253 100644 --- a/src/plugins/alwaysAnimate/index.ts +++ b/src/plugins/alwaysAnimate/index.ts @@ -92,8 +92,8 @@ export default definePlugin({ find: ".MINI_PREVIEW,[", predicate: () => settings.store.nameplates, replacement: { - match: /animate:\i,loop:(?:!0===\i(?:&&)?){2}/, - replace: "animate:true,loop:true" + match: /animate:\i,loop:/, + replace: "animate:true,loop:true,_loop:" }, }, { diff --git a/src/plugins/anonymiseFileNames/index.tsx b/src/plugins/anonymiseFileNames/index.tsx index 2ecd5a43c..f136d08a0 100644 --- a/src/plugins/anonymiseFileNames/index.tsx +++ b/src/plugins/anonymiseFileNames/index.tsx @@ -134,7 +134,7 @@ export default definePlugin({ const newFilename = (() => { switch (settings.store.method) { case Methods.Random: - const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + const chars = "0123456789bdfhjkmnpqrstvwxz"; const returnedName = Array.from( { length: settings.store.randomisedLength }, () => chars[Math.floor(Math.random() * chars.length)] diff --git a/src/plugins/betterFolders/index.tsx b/src/plugins/betterFolders/index.tsx index d2566ce36..caaa38991 100644 --- a/src/plugins/betterFolders/index.tsx +++ b/src/plugins/betterFolders/index.tsx @@ -28,6 +28,7 @@ import { ChannelStore, FluxDispatcher, ReadStateStore } from "@webpack/common"; import { ReactNode } from "react"; import FolderSideBar from "./FolderSideBar"; +import { areNestedRelated as areNestedRelatedInMap, getChildFolderIds as getChildFolderIdsFromMap, getDescendantFolderIds as getDescendantFolderIdsFromMap, getGuildNavigationExpandTargets, hasParentInChain as hasParentInChainInMap, sanitizeNestedFolderMap } from "./nestedFolders"; enum FolderIconDisplay { Never, @@ -66,13 +67,27 @@ const FolderUtils = findByPropsLazy("move", "toggleGuildFolderExpand"); const FolderItem = findComponentByCodeLazy("FolderItem", "onExpandCollapse", "folderButtonSize"); const MAX_TREE_FILTER_DEPTH = 1000; -let lastGuildId = null as string | null; let dispatchingFoldersClose = false; function getGuildFolder(id: string) { return SortedGuildStore.getGuildFolders().find((folder: GuildFolder) => folder.guildIds.includes(id)); } +function expandGuildNavigationPath(guildId: string | null | undefined) { + if (!guildId || guildId === "@me") return; + + for (const folderId of getGuildNavigationExpandTargets( + getNestedFolderMap(), + SortedGuildStore.getGuildFolders(), + guildId, + ExpandedGuildFolderStore.getExpandedFolders() + )) { + if (!ExpandedGuildFolderStore.isFolderExpanded(folderId)) { + FolderUtils.toggleGuildFolderExpand(folderId); + } + } +} + function closeFolders() { for (const id of ExpandedGuildFolderStore.getExpandedFolders()) FolderUtils.toggleGuildFolderExpand(id); @@ -116,7 +131,19 @@ function filterTreeWithTargetNode(children: any, predicate: (node: any) => boole } function getNestedFolderMap(): Record { - return settings.store.nestedFolders ?? {}; + const nestedFolders = settings.store.nestedFolders ?? {}; + const validFolderIds = new Set( + SortedGuildStore.getGuildFolders() + .map((folder: GuildFolder) => folder.folderId?.toString()) + .filter((folderId): folderId is string => folderId != null) + ); + const sanitizedFolders = sanitizeNestedFolderMap(nestedFolders, validFolderIds); + + if (JSON.stringify(sanitizedFolders) !== JSON.stringify(nestedFolders)) { + settings.store.nestedFolders = sanitizedFolders; + } + + return sanitizedFolders; } function saveNestedFolderMap(map: Record) { @@ -128,24 +155,11 @@ export function getParentFolderId(childId: string | number): string | undefined } export function getChildFolderIds(parentId: string | number): string[] { - const map = getNestedFolderMap(); - return Object.entries(map) - .filter(([, pid]) => pid === String(parentId)) - .map(([cid]) => cid); + return getChildFolderIdsFromMap(getNestedFolderMap(), parentId); } function getDescendantFolderIds(parentId: string | number): string[] { - const descendants: string[] = []; - const queue = [...getChildFolderIds(parentId)]; - - while (queue.length) { - const current = queue.shift(); - if (!current) continue; - descendants.push(current); - queue.push(...getChildFolderIds(current)); - } - - return descendants; + return getDescendantFolderIdsFromMap(getNestedFolderMap(), parentId); } function nestFolder(childId: string, parentId: string) { @@ -168,20 +182,11 @@ function unnestFolder(childId: string) { } function hasParentInChain(childId: string, parentId: string): boolean { - const seen = new Set(); - let current = getParentFolderId(childId); - - while (current != null && !seen.has(current)) { - if (current === parentId) return true; - seen.add(current); - current = getParentFolderId(current); - } - - return false; + return hasParentInChainInMap(getNestedFolderMap(), childId, parentId); } function areNestedRelated(firstId: string, secondId: string): boolean { - return hasParentInChain(firstId, secondId) || hasParentInChain(secondId, firstId); + return areNestedRelatedInMap(getNestedFolderMap(), firstId, secondId); } export const settings = definePluginSettings({ @@ -254,7 +259,7 @@ export default definePlugin({ settings, start() { - settings.store.nestedFolders ??= {}; + settings.store.nestedFolders = getNestedFolderMap(); }, patches: [ @@ -426,8 +431,8 @@ export default definePlugin({ replace: "canDrop:e=>$self.canDropOnFolder(e,arguments[1],arguments[3])" }, { - match: /drop\(\i\)\{(?=.{0,25}!==\i\.\i\.FOLDER)/, - replace: "$&if($self.handleFolderDrop(arguments[0],arguments[1],arguments[2],arguments[3]))return;" + match: /(\i),(\i)\)\{let (\i)=arguments.*?,(\i)=arguments.{0,250}drop\(\i\)\{(?=.{0,25}!==\i\.\i\.FOLDER)/, + replace: "$&if($self.handleFolderDrop($1,$2,$3,$4))return;" }, { match: /\[\i\.\i\.GUILD\](?=.{0,250}#{intl::DND_DROP_COMBINE})/, @@ -445,24 +450,25 @@ export default definePlugin({ ], flux: { + GUILD_SELECT({ guildId }: { guildId: string | null; }) { + if (guildId == null) return; + expandGuildNavigationPath(guildId); + }, + CHANNEL_SELECT(data) { - if (!settings.store.closeAllFolders && !settings.store.forceOpen && !settings.store.closeServerFolder) - return; + const guildFolder = getGuildFolder(data.guildId); - if (lastGuildId !== data.guildId) { - lastGuildId = data.guildId; - const guildFolder = getGuildFolder(data.guildId); + if (guildFolder?.folderId) { + expandGuildNavigationPath(data.guildId); - if (guildFolder?.folderId) { - if (settings.store.forceOpen && !ExpandedGuildFolderStore.isFolderExpanded(guildFolder.folderId)) { - FolderUtils.toggleGuildFolderExpand(guildFolder.folderId); - } - if (settings.store.closeServerFolder && ExpandedGuildFolderStore.isFolderExpanded(guildFolder.folderId)) { - FolderUtils.toggleGuildFolderExpand(guildFolder.folderId); - } - } else if (settings.store.closeAllFolders) { - closeFolders(); + if (settings.store.forceOpen && !ExpandedGuildFolderStore.isFolderExpanded(guildFolder.folderId)) { + FolderUtils.toggleGuildFolderExpand(guildFolder.folderId); } + if (settings.store.closeServerFolder && ExpandedGuildFolderStore.isFolderExpanded(guildFolder.folderId)) { + FolderUtils.toggleGuildFolderExpand(guildFolder.folderId); + } + } else if (settings.store.closeAllFolders) { + closeFolders(); } }, @@ -646,23 +652,28 @@ export default definePlugin({ handleFolderDrop(dragItem: FolderDragItem, targetNode: GuildTreeNode, _moveToBelow: boolean, isCombine: boolean): boolean { if (dragItem.type !== "folder") return false; + if (targetNode.type === "folder") { + const childId = dragItem.nodeId?.toString(); + const parentId = targetNode.id?.toString(); + + if (childId == null || parentId == null) return false; + if (childId === parentId || hasParentInChain(parentId, childId)) return false; + + try { + nestFolder(childId, parentId); + FluxDispatcher.dispatch({ type: "BETTER_FOLDERS_NESTED_UPDATE" }); + return true; + } catch { + return false; + } + } + if (!isCombine) { unnestFolder(dragItem.nodeId?.toString()); return false; } - if (targetNode.type !== "folder") return false; - - const childId = dragItem.nodeId?.toString(); - const parentId = targetNode.id?.toString(); - - try { - nestFolder(childId, parentId); - FluxDispatcher.dispatch({ type: "BETTER_FOLDERS_NESTED_UPDATE" }); - return true; - } catch { - return false; - } + return false; }, shouldShowCombineTarget(noCombine: boolean, targetNode: GuildTreeNode): boolean { @@ -681,8 +692,7 @@ export default definePlugin({ if (dragItem.nodeId === targetNode.id) return false; if (dragItem.type === "folder" && targetNode.type === "folder") { - if (isCombine) return !hasParentInChain(targetNode.id?.toString(), dragItem.nodeId?.toString()); - return targetNode.parentId == null; + return !hasParentInChain(targetNode.id?.toString(), dragItem.nodeId?.toString()); } if (isCombine && dragItem.type === "folder") return false; diff --git a/src/plugins/betterFolders/nestedFolders.ts b/src/plugins/betterFolders/nestedFolders.ts new file mode 100644 index 000000000..3c6428c80 --- /dev/null +++ b/src/plugins/betterFolders/nestedFolders.ts @@ -0,0 +1,118 @@ +/* + * Vencord, a modification for Discord's desktop app + * Copyright (c) 2026 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 . +*/ + +export type NestedFolderMap = Record; + +export interface GuildFolderLike { + folderId?: string | number | null; + guildIds?: string[]; +} + +export function getChildFolderIds(map: NestedFolderMap, parentId: string | number): string[] { + return Object.entries(map) + .filter(([, id]) => id === String(parentId)) + .map(([id]) => id); +} + +export function hasParentInChain(map: NestedFolderMap, childId: string, parentId: string): boolean { + const seen = new Set(); + let current = map[childId]; + + while (current != null && !seen.has(current)) { + if (current === parentId) return true; + seen.add(current); + current = map[current]; + } + + return false; +} + +export function areNestedRelated(map: NestedFolderMap, firstId: string, secondId: string): boolean { + return hasParentInChain(map, firstId, secondId) || hasParentInChain(map, secondId, firstId); +} + +export function getAncestorFolderIds(map: NestedFolderMap, childId: string | number): string[] { + const ancestors: string[] = []; + const seen = new Set([String(childId)]); + let current = map[String(childId)]; + + while (current != null && !seen.has(current)) { + ancestors.push(current); + seen.add(current); + current = map[current]; + } + + return ancestors; +} + +export function getDescendantFolderIds(map: NestedFolderMap, parentId: string | number): string[] { + const descendants: string[] = []; + const rootId = String(parentId); + const seen = new Set([rootId]); + const queue = [...getChildFolderIds(map, parentId)]; + + while (queue.length) { + const current = queue.shift(); + if (!current || seen.has(current)) continue; + + seen.add(current); + descendants.push(current); + queue.push(...getChildFolderIds(map, current)); + } + + return descendants; +} + +export function sanitizeNestedFolderMap(map: NestedFolderMap, validFolderIds: Set): NestedFolderMap { + const sanitized: NestedFolderMap = {}; + + for (const [childId, parentId] of Object.entries(map)) { + if (!validFolderIds.has(childId) || !validFolderIds.has(parentId)) continue; + if (childId === parentId) continue; + delete sanitized[childId]; + if (hasParentInChain(sanitized, parentId, childId)) continue; + + sanitized[childId] = parentId; + } + + return sanitized; +} + +export function getGuildFolderId(folders: GuildFolderLike[], guildId: string | null | undefined): string | undefined { + if (!guildId) return; + + return folders + .find(folder => folder.guildIds?.includes(guildId)) + ?.folderId + ?.toString(); +} + +export function getGuildNavigationExpandTargets( + map: NestedFolderMap, + folders: GuildFolderLike[], + guildId: string | null | undefined, + expandedFolderIds: Iterable +): string[] { + const folderId = getGuildFolderId(folders, guildId); + if (!folderId) return []; + + const expandedIds = new Set(Array.from(expandedFolderIds, id => String(id))); + const targetFolderIds = [...getAncestorFolderIds(map, folderId).reverse(), folderId]; + + return targetFolderIds.filter(id => !expandedIds.has(id)); +} diff --git a/src/plugins/betterSessions/components/RenameButton.tsx b/src/plugins/betterSessions/components/RenameButton.tsx index b9857f01d..c1c24d2e2 100644 --- a/src/plugins/betterSessions/components/RenameButton.tsx +++ b/src/plugins/betterSessions/components/RenameButton.tsx @@ -30,3 +30,15 @@ export function RenameButton({ session, state }: { session: SessionInfo["session ); } + +export function NewButton() { + return ( + + ); +} diff --git a/src/plugins/betterSessions/components/icons.tsx b/src/plugins/betterSessions/components/icons.tsx index a9f0f989e..cbfbe0053 100644 --- a/src/plugins/betterSessions/components/icons.tsx +++ b/src/plugins/betterSessions/components/icons.tsx @@ -25,6 +25,7 @@ export const DiscordIcon = (props: React.PropsWithChildren @@ -35,6 +36,7 @@ export const ChromeIcon = (props: React.PropsWithChildren @@ -48,6 +50,7 @@ export const EdgeIcon = (props: React.PropsWithChildren> {...props} fill="currentColor" viewBox="0 0 24 24" + style={{ marginLeft: "0.0.0043403em", marginTop: "0.46px" }} > @@ -58,6 +61,7 @@ export const FirefoxIcon = (props: React.PropsWithChildren @@ -68,6 +72,7 @@ export const IEIcon = (props: React.PropsWithChildren>) {...props} fill="currentColor" viewBox="0 0 512 512" + style={{ marginLeft: "0.0.00434028em", marginTop: "0.456px" }} > @@ -78,6 +83,7 @@ export const OperaIcon = (props: React.PropsWithChildren {...props} fill="currentColor" viewBox="0 0 496 512" + style={{ marginLeft: "0.00434027777em", marginTop: "2px" }} > @@ -88,6 +94,7 @@ export const SafariIcon = (props: React.PropsWithChildren @@ -98,6 +105,7 @@ export const UnknownIcon = (props: React.PropsWithChildren diff --git a/src/plugins/betterSessions/index.tsx b/src/plugins/betterSessions/index.tsx index b6dabbaa0..fcdfbfece 100644 --- a/src/plugins/betterSessions/index.tsx +++ b/src/plugins/betterSessions/index.tsx @@ -26,7 +26,7 @@ import definePlugin, { OptionType } from "@utils/types"; import { findComponentByCodeLazy, findCssClassesLazy, findStoreLazy } from "@webpack"; import { Constants, React, RestAPI, SettingsRouter, Tooltip } from "@webpack/common"; -import { RenameButton } from "./components/RenameButton"; +import { NewButton, RenameButton } from "./components/RenameButton"; import { Session, SessionInfo } from "./types"; import { fetchNamesFromDataStore, getDefaultName, GetOsColor, GetPlatformIcon, savedSessionsCache, saveSessionsToDataStore } from "./utils"; @@ -61,7 +61,7 @@ export default definePlugin({ patches: [ { - find: "#{intl::AUTH_SESSIONS_SESSION_LOG_OUT}", + find: "#{intl::AUTH_SESSIONS_OTHERS_LOG_OUT_SELECTED_ACTION}", replacement: [ // Replace children with a single label with state { @@ -74,8 +74,8 @@ export default definePlugin({ }, // Replace the icon { - match: /children:\[(?=.{0,125}?width:"32")(?<=,icon:(\i)\}.+?)/, - replace: "children:[$self.renderIcon({...arguments[0],DeviceIcon:$1}),false&&" + match: /(?<=Icon:(\i).{0,250}className:\i\.\i,children:\[)/, + replace: "$self.renderIcon({...arguments[0],DeviceIcon:$1}),false&&" } ] } @@ -92,15 +92,7 @@ export default definePlugin({ <> {title} {(savedSession == null || savedSession.isNew) && ( -
    - NEW -
    + )} @@ -143,7 +135,7 @@ export default definePlugin({ color: "var(--background-base-lower)", }} > - +
    } lowerBadgeSize={{ diff --git a/src/plugins/betterSessions/styles.css b/src/plugins/betterSessions/styles.css index 47613ac71..adfc283f0 100644 --- a/src/plugins/betterSessions/styles.css +++ b/src/plugins/betterSessions/styles.css @@ -7,4 +7,13 @@ .vc-betterSessions-rename-btn { margin-left: 4px; translate: 0 -2px; + width: fit-content; +} + +.vc-betterSessions-new-btn { + margin-left: 4px; + translate: 0 -2px; + width: fit-content; + pointer-events: none; + padding: 1px 4px; } \ No newline at end of file diff --git a/src/plugins/betterSettings/index.tsx b/src/plugins/betterSettings/index.tsx index 7360c32a3..fb95d41ba 100644 --- a/src/plugins/betterSettings/index.tsx +++ b/src/plugins/betterSettings/index.tsx @@ -161,9 +161,6 @@ export default definePlugin({ }, ], - buildPluginMenuEntries, - buildThemeMenuEntries, - // This is the very outer layer of the entire ui, so we can't wrap this in an ErrorBoundary // without possibly also catching unrelated errors of children. // diff --git a/src/plugins/biggerStreamPreview/index.tsx b/src/plugins/biggerStreamPreview/index.tsx index e77e3a02b..2e8beb86a 100644 --- a/src/plugins/biggerStreamPreview/index.tsx +++ b/src/plugins/biggerStreamPreview/index.tsx @@ -21,11 +21,8 @@ import { ScreenshareIcon } from "@components/Icons"; import { Devs } from "@utils/constants"; import { openImageModal } from "@utils/discord"; import definePlugin from "@utils/types"; -import { Channel, User } from "@vencord/discord-types"; -import { Menu } from "@webpack/common"; - -import { ApplicationStreamingStore, ApplicationStreamPreviewStore } from "./webpack/stores"; -import { ApplicationStream, Stream } from "./webpack/types/stores"; +import { ApplicationStream, Channel, Stream, User } from "@vencord/discord-types"; +import { ApplicationStreamingStore, ApplicationStreamPreviewStore, Menu } from "@webpack/common"; export interface UserContextProps { channel: Channel, diff --git a/src/plugins/biggerStreamPreview/webpack/stores.ts b/src/plugins/biggerStreamPreview/webpack/stores.ts deleted file mode 100644 index ba5227baa..000000000 --- a/src/plugins/biggerStreamPreview/webpack/stores.ts +++ /dev/null @@ -1,24 +0,0 @@ -/* - * 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 . -*/ - -import { findStoreLazy } from "@webpack"; - -import * as t from "./types/stores"; - -export const ApplicationStreamPreviewStore: t.ApplicationStreamPreviewStore = findStoreLazy("ApplicationStreamPreviewStore"); -export const ApplicationStreamingStore: t.ApplicationStreamingStore = findStoreLazy("ApplicationStreamingStore"); diff --git a/src/plugins/characterCounter/README.md b/src/plugins/characterCounter/README.md new file mode 100644 index 000000000..7064707ee --- /dev/null +++ b/src/plugins/characterCounter/README.md @@ -0,0 +1,7 @@ +# Character Counter + +Adds a character counter to the message input box + +![](https://github.com/user-attachments/assets/57f8c01e-a122-46d2-afda-679edb7a996b) + +![](https://github.com/user-attachments/assets/640bcca4-77a3-4407-8aa0-15c490dd9256) diff --git a/src/plugins/characterCounter/index.tsx b/src/plugins/characterCounter/index.tsx new file mode 100644 index 000000000..209924bc2 --- /dev/null +++ b/src/plugins/characterCounter/index.tsx @@ -0,0 +1,74 @@ +/* + * 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 ErrorBoundary from "@components/ErrorBoundary"; +import { Devs } from "@utils/constants"; +import { classNameFactory } from "@utils/css"; +import definePlugin, { OptionType } from "@utils/types"; +import { UserStore } from "@webpack/common"; + +const cl = classNameFactory("vc-charCounter-"); + +const settings = definePluginSettings({ + colorEffects: { + type: OptionType.BOOLEAN, + description: "Enable yellow/red colouring as you get closer to the character limit", + default: true, + } +}); + +function getCounterColor(percentage: number) { + if (!settings.store.colorEffects) return "var(--primary-330)"; + if (percentage < 50) return "var(--text-muted)"; + if (percentage < 75) return "var(--yellow-330)"; + if (percentage < 90) return "var(--orange-330)"; + return "var(--red-360)"; +} + +export default definePlugin({ + name: "CharacterCounter", + description: "Adds a character counter to the chat input", + authors: [Devs.thororen], + settings, + patches: [ + { + find: ".CREATE_FORUM_POST||", + replacement: [ + { + match: /(?<=textValue:(\i),editorHeight:\i,channelId:\i\.id\}\)),\i/, + replace: ",$self.renderCharCounter({text:$1})" + } + ] + }, + { + find: "#{intl::PREMIUM_MESSAGE_LENGTH_UPSELL_TOOLTIP}", + replacement: { + match: /return \i\?\i\(\):\i\(\)(?<=#{intl::PREMIUM_MESSAGE_LENGTH_UPSELL_TOOLTIP_WITHOUT_LINK}.{0,200}?)/, + replace: "return null" + } + } + ], + + renderCharCounter: ErrorBoundary.wrap(({ text }: { text: string; }) => { + if (!text.length) return null; + + const premiumType = UserStore.getCurrentUser().premiumType ?? 0; + const charMax = premiumType === 2 ? 4000 : 2000; + + const color = getCounterColor((text.length / charMax) * 100); + + return ( +
    + {text.length} + / + {charMax} +
    + ); + }, { noop: true }) +}); diff --git a/src/equicordplugins/characterCounter/style.css b/src/plugins/characterCounter/style.css similarity index 63% rename from src/equicordplugins/characterCounter/style.css rename to src/plugins/characterCounter/style.css index 6dcdb79ff..072d42bef 100644 --- a/src/equicordplugins/characterCounter/style.css +++ b/src/plugins/characterCounter/style.css @@ -1,19 +1,15 @@ -.vc-char-counter { +.vc-charCounter-counter { font-size: 12px; color: var(--text-muted); position: absolute; bottom: 4px; - right: 6px; + right: 11px; pointer-events: none; z-index: 2; background: transparent; transition: color 0.3s ease; } -.vc-char-counter .vc-char-count { +.vc-charCounter-count { transition: color 0.3s ease; -} - -.vc-char-counter .vc-char-max { - color: var(--primary-330); } \ No newline at end of file diff --git a/src/plugins/copyFileContents/index.tsx b/src/plugins/copyFileContents/index.tsx index 5305c0389..be7ec07a2 100644 --- a/src/plugins/copyFileContents/index.tsx +++ b/src/plugins/copyFileContents/index.tsx @@ -14,7 +14,7 @@ import definePlugin from "@utils/types"; import { Tooltip, useState } from "@webpack/common"; const CheckMarkIcon = () => { - return + return ; }; @@ -26,10 +26,18 @@ export default definePlugin({ patches: [ { find: "#{intl::PREVIEW_BYTES_LEFT}", - replacement: { - match: /fileName:\i,fileSize:\i}\),(?=.{0,75}?setLanguage:)(?<=fileContents:(\i),bytesLeft:(\i).+?)/g, - replace: "$&$self.addCopyButton({fileContents:$1,bytesLeft:$2})," - } + replacement: [ + // Inline preview + { + match: /fileContents:(\i),bytesLeft:(\i)\}\):null,/, + replace: "$&$self.addCopyButton({fileContents:$1,bytesLeft:$2})," + }, + // Modal + { + match: /align:"\i"\}\),(?=\(0,\i\.jsx\)\(\i,\{wordWrap:\i,setWordWrap:\i)/, + replace: "$&$self.addCopyButton(arguments[0])," + } + ] } ], @@ -51,7 +59,12 @@ export default definePlugin({ } }} > - {recentlyCopied ? : bytesLeft > 0 ? : } + {recentlyCopied + ? + : bytesLeft > 0 + ? + : + }
    )}
    diff --git a/src/plugins/copyFileContents/style.css b/src/plugins/copyFileContents/style.css index 818652539..df6855b3a 100644 --- a/src/plugins/copyFileContents/style.css +++ b/src/plugins/copyFileContents/style.css @@ -1,9 +1,6 @@ .vc-cfc-button { - color: var(--interactive-icon-default); + display: flex; + align-items: center; + margin-left: 16px; cursor: pointer; - padding-left: 4px; -} - -.vc-cfc-button:hover { - color: var(--interactive-icon-hover); } \ No newline at end of file diff --git a/src/plugins/crashHandler/index.ts b/src/plugins/crashHandler/index.ts index 89457c306..37f1d878b 100644 --- a/src/plugins/crashHandler/index.ts +++ b/src/plugins/crashHandler/index.ts @@ -19,13 +19,12 @@ import { showNotification } from "@api/Notifications"; import { definePluginSettings } from "@api/Settings"; import { Devs } from "@utils/constants"; -import { openInviteModal } from "@utils/discord"; import { Logger } from "@utils/Logger"; import { closeAllModals } from "@utils/modal"; import definePlugin, { OptionType } from "@utils/types"; import { maybePromptToUpdate } from "@utils/updater"; import { filters, findBulk, proxyLazyWebpack } from "@webpack"; -import { DraftType, ExpressionPickerStore, FluxDispatcher, NavigationRouter, SelectedChannelStore, showToast } from "@webpack/common"; +import { DraftType, ExpressionPickerStore, FluxDispatcher, NavigationRouter, SelectedChannelStore } from "@webpack/common"; const CrashHandlerLogger = new Logger("CrashHandler"); @@ -99,11 +98,8 @@ export default definePlugin({ showNotification({ color: "#eed202", title: "Discord has crashed!", - body: "Awn :( Discord has crashed two times rapidly, not attempting to recover. Click here to join our support server!", + body: "Awn :( Discord has crashed two times rapidly, not attempting to recover.", noPersist: true, - onClick: () => openInviteModal("wKgT9j2xfN").catch(() => - showToast("Invalid or expired invite"), - ) }); } catch { } @@ -137,11 +133,8 @@ export default definePlugin({ showNotification({ color: "#eed202", title: "Discord has crashed!", - body: "Attempting to recover... Click here to join our support server!", + body: "Attempting to recover...", noPersist: true, - onClick: () => openInviteModal("wKgT9j2xfN").catch(() => - showToast("Invalid or expired invite"), - ) }); } catch { } diff --git a/src/plugins/customCommands/CreateTagModal.tsx b/src/plugins/customCommands/CreateTagModal.tsx new file mode 100644 index 000000000..652f18503 --- /dev/null +++ b/src/plugins/customCommands/CreateTagModal.tsx @@ -0,0 +1,133 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { BaseText } from "@components/BaseText"; +import { Button } from "@components/Button"; +import { Card } from "@components/Card"; +import { InlineCode } from "@components/CodeBlock"; +import { ExpandableSection } from "@components/ExpandableCard"; +import { Flex } from "@components/Flex"; +import { HeadingSecondary } from "@components/Heading"; +import { InfoIcon } from "@components/Icons"; +import { Margins } from "@components/margins"; +import { Paragraph } from "@components/Paragraph"; +import { ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal"; +import { TextArea, TextInput, useState } from "@webpack/common"; + +import { parseTagArguments } from "."; +import { addTag, getTag, Tag } from "./settings"; + +export function openCreateTagModal(initialValue: Tag = { name: "", message: "" }) { + openModal(modalProps => ( + + )); +} + +const EXAMPLE_RESPONSE = "Hello {{user}}! I am feeling {{mood = great}}."; + +function Modal({ initialValue, modalProps }: { initialValue: Tag; modalProps: ModalProps; }) { + const [name, setName] = useState(initialValue.name); + const [message, setMessage] = useState(initialValue.message.replaceAll("\\n", "\n")); + + const detectedArguments = parseTagArguments(message); + const hasReservedEphemeral = detectedArguments.some(arg => arg.name === "ephemeral"); + const nameAlreadyExists = name !== initialValue.name && getTag(name); + + return ( + + + Create Tag + + + + + + Create a new tag which will be registered as a slash command. + +
    + Name + +
    + +
    + Response +