Merge branch 'dev' into translation

This commit is contained in:
thororen1234 2026-05-07 11:37:41 -04:00
commit f41df7769e
No known key found for this signature in database
170 changed files with 10498 additions and 6866 deletions

View file

@ -68,11 +68,12 @@ jobs:
rm release/*.map
- name: Upload Equicord Stable
if: ${{ !vars.ACT }}
run: |
gh release upload latest --clobber dist/release/*
- name: Upload Plugin JSONs to Equibored repo
if: github.repository == 'Equicord/Equicord'
if: github.repository == 'Equicord/Equicord' && !vars.ACT
run: |
git config --global user.name "GitHub-Actions"
git config --global user.email actions@github.com

View file

@ -4,6 +4,7 @@ on:
push:
branches:
- "**"
- "!pr-**"
workflow_dispatch:
inputs:
branch:
@ -90,6 +91,7 @@ jobs:
bun run generateDevsList dist/devs.json
- name: Upload to Equibuilds repo
if: ${{ !vars.ACT }}
run: |
BRANCH="${{ github.event.inputs.branch || github.ref_name }}"
COMMIT_SHA=$(git rev-parse --short HEAD)

View file

@ -69,6 +69,7 @@ jobs:
run: echo "::set-output name=date::$(date +'%Y-%m-%d')"
- name: Upload Equicord Stable
if: ${{ !vars.ACT }}
run: |
gh release create ${{ steps.date.outputs.date }} --latest=false
gh release upload ${{ steps.date.outputs.date }} --clobber dist/release/*

View file

@ -79,12 +79,16 @@ jobs:
- name: Create sources zip
if: steps.check.outputs.release == 'true'
run: zip -r dist/extension-sources.zip . -x "node_modules/*" -x "dist/*" -x ".git/*"
run: zip -r dist/extension-sources.zip . -x "node_modules/*" -x "dist/*" -x
".git/*"
- name: Publish extension
if: steps.check.outputs.release == 'true'
run: pnpx @equicord/publish-browser-extension@latest --chrome-zip dist/extension-chrome.zip --firefox-zip dist/extension-firefox.zip --firefox-sources-zip dist/extension-sources.zip --edge-zip dist/extension-chrome.zip
if: steps.check.outputs.release == 'true' && !vars.ACT
run: pnpx @equicord/publish-browser-extension@latest --chrome-zip
dist/extension-chrome.zip --firefox-zip
dist/extension-firefox.zip --firefox-sources-zip
dist/extension-sources.zip --edge-zip
dist/extension-chrome.zip
env:
CHROME_EXTENSION_ID: ${{ secrets.CHROME_EXTENSION_ID }}
CHROME_PUBLISHER_ID: ${{ secrets.CHROME_PUBLISHER_ID }}

3
.gitignore vendored
View file

@ -1,6 +1,9 @@
dist
node_modules
.VSCodeCounter
.vars
.secrets
.input
*.exe
vencord_installer

View file

@ -137,6 +137,8 @@ export default defineConfig(
"use-isnan": "error",
"prefer-const": ["error", { destructuring: "all" }],
"prefer-spread": "error",
// These are old deprecated browser globals which may be used by mistake, e.g. `addEventListener(e => console.log(event))`
"no-restricted-globals": ["error", "event", "name"],
// Plugin Rules
"simple-import-sort/imports": "error",

View file

@ -1,7 +1,7 @@
{
"name": "equicord",
"private": "true",
"version": "1.14.11.0",
"version": "1.14.12.0",
"description": "The other cutest Discord client mod",
"homepage": "https://github.com/Equicord/Equicord#readme",
"bugs": {

View file

@ -331,6 +331,15 @@ declare enum PopoutAnimation {
type PopoutPosition = "top" | "bottom" | "left" | "right" | "center" | "window_center";
export interface PopoutProps {
position: PopoutPosition;
nudge: number;
isPositioned: boolean;
setPopoutRef(ref: any): void;
closePopout(): void;
updatePosition(): void;
}
export type Popout = ComponentType<{
children(
thing: {
@ -347,14 +356,8 @@ export type Popout = ComponentType<{
): ReactNode;
shouldShow?: boolean;
targetElementRef: RefObject<any>;
renderPopout(args: {
closePopout(): void;
isPositioned: boolean;
nudge: number;
position: PopoutPosition;
setPopoutRef(ref: any): void;
updatePosition(): void;
}): ReactNode;
renderPopout(props: PopoutProps): ReactNode;
preload?(): Promise<any>;
onRequestOpen?(): void;
onRequestClose?(): void;

View file

@ -147,7 +147,7 @@ export const globPlugins = kind => ({
});
build.onLoad({ filter, namespace: "import-plugins" }, async () => {
const pluginDirs = ["plugins/_api", "plugins/_core", "plugins", "equicordplugins", "equicordplugins/_api", "userplugins"];
const pluginDirs = ["plugins/_api", "plugins/_core", "plugins", "equicordplugins/_api", "equicordplugins/_core", "equicordplugins", "userplugins"];
let code = "";
let pluginsCode = "\n";
let metaCode = "\n";

View file

@ -31,11 +31,11 @@ import { getEntryPoint, isPluginFile, parseDevs, parseEquicordDevs, parseFile, P
let dirs: string[];
if (equicordFlag) {
dirs = ["src/equicordplugins"];
dirs = ["src/equicordplugins/_core", "src/equicordplugins"];
} else if (vencordFlag) {
dirs = ["src/plugins", "src/plugins/_core"];
} else {
dirs = ["src/plugins", "src/plugins/_core", "src/equicordplugins"];
dirs = ["src/plugins", "src/plugins/_core", "src/equicordplugins/_core", "src/equicordplugins"];
}
const outputPath = args.find(a => !a.startsWith("--")) ?? null;

View file

@ -42,8 +42,7 @@ import { popNotice, showNotice } from "./api/Notices";
import { NotificationData, showNotification } from "./api/Notifications";
import { initPluginManager, PMLogger, startAllPlugins } from "./api/PluginManager";
import { PlainSettings, Settings, SettingsStore } from "./api/Settings";
import { getCloudSettings, putCloudSettings, shouldCloudSync } from "./api/SettingsSync/cloudSync";
import { localStorage } from "./utils/localStorage";
import { areLocalSettingsDirty, getCloudSettings, getCloudSyncDirection, markLocalSettingsDirty, putCloudSettings, shouldCloudSync } from "./api/SettingsSync/cloudSync";
import { relaunch } from "./utils/native";
import { checkForUpdates, isOutdated as getIsOutdated, update, UpdateLogger } from "./utils/updater";
import { onceReady } from "./webpack";
@ -54,11 +53,6 @@ if (IS_REPORTER) {
}
async function syncSettings() {
// Check if cloud auth exists for current user before attempting sync
if (localStorage.Vencord_cloudSyncDirection === undefined) {
// by default, sync bi-directionally
localStorage.Vencord_cloudSyncDirection = "both";
}
const hasCloudAuth = await dsGet("Vencord_cloudSecret");
if (!hasCloudAuth) {
if (Settings.cloud.authenticated) {
@ -93,9 +87,9 @@ async function syncSettings() {
if (
Settings.cloud.settingsSync && // if it's enabled
Settings.cloud.authenticated && // if cloud integrations are enabled
localStorage.Vencord_cloudSyncDirection !== "manual" // if we're not in manual mode
getCloudSyncDirection() !== "manual" // if we're not in manual mode
) {
if (localStorage.Vencord_settingsDirty && shouldCloudSync("push")) {
if (areLocalSettingsDirty() && shouldCloudSync("push")) {
await putCloudSettings();
} else if (shouldCloudSync("pull") && await getCloudSettings(false)) { // if we synchronized something (false means no sync)
// we show a notification here instead of allowing getCloudSettings() to show one to declutter the amount of
@ -118,7 +112,7 @@ async function syncSettings() {
}, 60_000);
SettingsStore.addGlobalChangeListener(() => {
localStorage.Vencord_settingsDirty = true;
markLocalSettingsDirty();
saveSettingsOnFrequentAction();
});
}

View file

@ -190,3 +190,22 @@ addContextMenuPatch("textarea-context", (children, args) => {
</Menu.MenuItem>
);
});
// Wrapper stuff is down here to avoid conflicts with above
export type ChatBarButtonWrapper = (buttons: ReactNode) => ReactNode;
/**
* Registry for plugins that need to wrap the entire chat bar button container
*/
export const ChatBarButtonWrappers = new Map<string, ChatBarButtonWrapper>();
export const addChatBarButtonWrapper = (id: string, wrapper: ChatBarButtonWrapper) => ChatBarButtonWrappers.set(id, wrapper);
export const removeChatBarButtonWrapper = (id: string) => ChatBarButtonWrappers.delete(id);
export function _wrapButtons(buttons: ReactNode) {
let wrapped = buttons;
for (const wrapper of ChatBarButtonWrappers.values()) {
wrapped = wrapper(wrapped);
}
return wrapped;
}

View file

@ -17,6 +17,7 @@
*/
import ErrorBoundary from "@components/ErrorBoundary";
import { Logger } from "@utils/Logger";
import { t } from "@utils/translation";
import { JSX, ReactNode } from "react";
@ -47,23 +48,28 @@ export function _modifyAccessories(
elements: JSX.Element[],
props: Record<string, any>
) {
for (const [key, accessory] of accessories.entries()) {
const res = (
<ErrorBoundary noop message={t("api.messageAccessories.fallback", { key: key })} key={key}>
<accessory.render {...props} />
</ErrorBoundary>
);
try {
for (const [key, accessory] of accessories.entries()) {
const res = (
<ErrorBoundary noop message={t("api.messageAccessories.fallback", { key: key })} key={key}>
<accessory.render {...props} />
</ErrorBoundary>
);
elements.splice(
accessory.position != null
? accessory.position < 0
? elements.length + accessory.position
: accessory.position
: elements.length,
0,
res
);
elements.splice(
accessory.position != null
? accessory.position < 0
? elements.length + accessory.position
: accessory.position
: elements.length,
0,
res
);
}
return elements;
} catch (e) {
new Logger("MessageAccessories").error("Failed to modify message accessories", e);
return elements;
}
return elements;
}

View file

@ -15,7 +15,7 @@
position: absolute;
z-index: 2147483647;
right: 1rem;
width: 25vw;
width: min(25vw, 400px);
min-height: 10vh;
}

View file

@ -17,7 +17,7 @@
*/
import { addProfileBadge, removeProfileBadge } from "@api/Badges";
import { addChatBarButton, removeChatBarButton } from "@api/ChatButtons";
import { addChatBarButton, addChatBarButtonWrapper, removeChatBarButton, removeChatBarButtonWrapper } from "@api/ChatButtons";
import { registerCommand, unregisterCommand } from "@api/Commands";
import { addContextMenuPatch, removeContextMenuPatch } from "@api/ContextMenu";
import { addMemberListDecorator, removeMemberListDecorator } from "@api/MemberListDecorators";
@ -32,7 +32,7 @@ import { traceFunction } from "@debug/Tracer";
import { Logger } from "@utils/Logger";
import { onlyOnce } from "@utils/onlyOnce";
import { canonicalizeFind, canonicalizeReplacement } from "@utils/patches";
import { Patch, Plugin, PluginDef, ReporterTestable, StartAt } from "@utils/types";
import { DefinedSettings, Patch, Plugin, PluginDef, PluginSettingDef, ReporterTestable, StartAt } from "@utils/types";
import { FluxEvents } from "@vencord/discord-types";
import { FluxDispatcher } from "@webpack/common";
import { patches } from "@webpack/patcher";
@ -43,6 +43,7 @@ export { Plugins as plugins };
import { addAudioProcessor, removeAudioProcessor } from "./AudioPlayer";
import { addChannelToolbarButton, addHeaderBarButton, removeChannelToolbarButton, removeHeaderBarButton } from "./HeaderBar";
import { addProfileCollection, removeProfileCollection } from "./ProfileCollections";
import { addProfileSection, removeProfileSection } from "./ProfileSections";
import { addUserAreaButton, removeUserAreaButton } from "./UserArea";
const logger = new Logger("PluginManager", "#a6d189");
@ -60,13 +61,32 @@ export function isPluginEnabled(p: string) {
Settings.plugins[p]?.enabled
) ?? false;
}
export function isPluginRequired(p: string) {
return (
Plugins[p]?.required
) ?? false;
}
export function isSettingHidden(settings: DefinedSettings, setting: PluginSettingDef) {
if (!("hidden" in setting)) return false;
return typeof setting.hidden === "function"
? setting.hidden.call(settings)
: Boolean(setting.hidden);
}
export function isSettingDisabled(settings: DefinedSettings, setting: PluginSettingDef) {
if (!("disabled" in setting)) return false;
return typeof setting.disabled === "function"
? setting.disabled.call(settings)
: Boolean(setting.disabled);
}
export function hasAnyVisibleSettings({ settings }: Plugin) {
return !!settings && Object.values(settings.def).some(s => !isSettingHidden(settings, s));
}
export function addPatch(newPatch: Omit<Patch, "plugin">, pluginName: string, pluginPath = `Vencord.Plugins.plugins[${JSON.stringify(pluginName)}]`) {
// TODO: this causes crashes
if (pluginName === "Vesktop" && newPatch.find === ".STREAMING_AUTO_STREAMER_MODE,") return;
@ -200,7 +220,8 @@ export const startPlugin = traceFunction("startPlugin", function startPlugin(p:
onBeforeMessageEdit, onBeforeMessageSend, onMessageClick,
chatBarButton, renderMemberListDecorator, renderMessageAccessory, renderMessageDecoration, messagePopoverButton,
// Custom
renderNicknameIcon, headerBarButton, audioProcessor, userAreaButton, renderProfileCollection
renderNicknameIcon, headerBarButton, audioProcessor, userAreaButton, renderProfileCollection, chatBarButtonWrapper,
renderProfileSection
} = p;
if (p.start) {
@ -268,6 +289,8 @@ export const startPlugin = traceFunction("startPlugin", function startPlugin(p:
if (audioProcessor) addAudioProcessor(name, audioProcessor);
if (userAreaButton) addUserAreaButton(name, userAreaButton.render, userAreaButton.priority);
if (renderProfileCollection) addProfileCollection(name, renderProfileCollection);
if (chatBarButtonWrapper) addChatBarButtonWrapper(name, chatBarButtonWrapper);
if (renderProfileSection) addProfileSection(name, renderProfileSection);
return true;
}, p => `startPlugin ${p.name}`);
@ -278,7 +301,8 @@ export const stopPlugin = traceFunction("stopPlugin", function stopPlugin(p: Plu
onBeforeMessageEdit, onBeforeMessageSend, onMessageClick,
chatBarButton, renderMemberListDecorator, renderMessageAccessory, renderMessageDecoration, messagePopoverButton,
// Custom
renderNicknameIcon, headerBarButton, audioProcessor, userAreaButton, renderProfileCollection
renderNicknameIcon, headerBarButton, audioProcessor, userAreaButton, renderProfileCollection, chatBarButtonWrapper,
renderProfileSection
} = p;
if (p.stop) {
@ -344,6 +368,8 @@ export const stopPlugin = traceFunction("stopPlugin", function stopPlugin(p: Plu
if (audioProcessor) removeAudioProcessor(name);
if (userAreaButton) removeUserAreaButton(name);
if (renderProfileCollection) removeProfileCollection(name);
if (chatBarButtonWrapper) removeChatBarButtonWrapper(name);
if (renderProfileSection) removeProfileSection(name);
return true;
}, p => `stopPlugin ${p.name}`);
@ -399,6 +425,8 @@ export const initPluginManager = onlyOnce(function init() {
if (p.audioProcessor) neededApiPlugins.add("AudioPlayerAPI");
if (p.userAreaButton) neededApiPlugins.add("UserAreaAPI");
if (p.renderProfileCollection) neededApiPlugins.add("ProfileCollectionsAPI");
if (p.chatBarButtonWrapper) neededApiPlugins.add("ChatInputButtonAPI");
if (p.renderProfileSection) neededApiPlugins.add("ProfileSectionsAPI");
for (const key of pluginKeysToBind) {
p[key] &&= p[key].bind(p) as any;
@ -412,22 +440,11 @@ export const initPluginManager = onlyOnce(function init() {
for (const p of pluginsValues) {
if (p.settings) {
p.options ??= {};
p.settings.pluginName = p.name;
for (const name in p.settings.def) {
const def = p.settings.def[name];
const checks = p.settings.checks?.[name];
p.options[name] = { ...def, ...checks };
}
}
if (p.options) {
for (const name in p.options) {
const opt = p.options[name];
if (opt.onChange != null) {
SettingsStore.addChangeListener(`plugins.${p.name}.${name}`, opt.onChange);
}
for (const [key, def] of Object.entries(p.settings.def)) {
if (def.onChange)
SettingsStore.addChangeListener(`plugins.${p.name}.${key}`, def.onChange);
}
}

View file

@ -6,12 +6,13 @@
import ErrorBoundary from "@components/ErrorBoundary";
import { Logger } from "@utils/Logger";
import { User } from "@vencord/discord-types";
import { useEffect, useState } from "@webpack/common";
import type { ReactNode } from "react";
const logger = new Logger("ProfileCollectionAPI");
export type ProfileCollectionFactory = (props: any) => ReactNode | null;
export type ProfileCollectionFactory = (props: { user: User; isSideBar: boolean; }) => ReactNode | Promise<ReactNode> | null;
interface CollectionEntry {
render: ProfileCollectionFactory;

View file

@ -0,0 +1,73 @@
/*
* 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("ProfileSectionsAPI");
export type ProfileSectionFactory = (props: { userId: string; isSideBar: boolean; }) => ReactNode | Promise<ReactNode> | null;
interface SectionEntry {
render: ProfileSectionFactory;
priority: number;
}
const profileSections = new Map<string, SectionEntry>();
const profileSectionListeners = new Set<() => void>();
/**
* Adds a section to the user profile panel near the 'Member Since' area.
*
* @param id - Unique identifier for the section (e.g., "my-plugin-profile-section")
* @param render - Function that returns the section JSX.
* Receives `{ userId, isSideBar }` so you can style accordingly.
* @param priority - Higher values appear first. Default: 0
*
* @example
* addProfileSection("my-section", ({ userId, isSideBar }) => (
* <div>Custom content for {userId}</div>
* ));
*/
export function addProfileSection(id: string, render: ProfileSectionFactory, priority = 0) {
profileSections.set(id, { render, priority });
profileSectionListeners.forEach(listener => listener());
}
/**
* Removes a section from the user profile panel.
*
* @param id - The identifier used when adding the section
*/
export function removeProfileSection(id: string) {
profileSections.delete(id);
profileSectionListeners.forEach(listener => listener());
}
function ProfileSections({ userId, isSideBar }: { userId: string; isSideBar: boolean; }) {
const [, forceUpdate] = useState(0);
useEffect(() => {
const listener = () => forceUpdate(n => n + 1);
profileSectionListeners.add(listener);
return () => { profileSectionListeners.delete(listener); };
}, []);
return Array.from(profileSections)
.sort(([, a], [, b]) => b.priority - a.priority)
.map(([id, { render: Section }]) => (
<ErrorBoundary noop key={id} onError={e => logger.error(`Failed to render profile section: ${id}`, e.error)}>
<Section userId={userId} isSideBar={isSideBar} />
</ErrorBoundary>
));
}
/** @internal Injected by ProfileSectionsAPI patch (do NOT call directly) */
export function renderProfileSections(props: { userId: string; isSideBar: boolean; }) {
return <ProfileSections {...props} />;
}

View file

@ -25,11 +25,11 @@ export const enum ServerListRenderPosition {
Below,
}
const componentsIn = new Set<ComponentType>();
const componentsAbove = new Set<ComponentType>();
const componentsBelow = new Set<ComponentType>();
const componentsIn = new Map<ComponentType, number>();
const componentsAbove = new Map<ComponentType, number>();
const componentsBelow = new Map<ComponentType, number>();
function getRenderFunctions(position: ServerListRenderPosition) {
function getRenderMap(position: ServerListRenderPosition) {
switch (position) {
case ServerListRenderPosition.Above:
return componentsAbove;
@ -40,21 +40,20 @@ function getRenderFunctions(position: ServerListRenderPosition) {
}
}
export function addServerListElement(position: ServerListRenderPosition, renderFunction: ComponentType) {
getRenderFunctions(position).add(renderFunction);
export function addServerListElement(position: ServerListRenderPosition, renderFunction: ComponentType, priority = 0) {
getRenderMap(position).set(renderFunction, priority);
}
export function removeServerListElement(position: ServerListRenderPosition, renderFunction: ComponentType) {
getRenderFunctions(position).delete(renderFunction);
getRenderMap(position).delete(renderFunction);
}
export const renderAll = (position: ServerListRenderPosition) => {
return Array.from(
getRenderFunctions(position),
(Component, i) => (
return Array.from(getRenderMap(position).entries())
.sort((a, b) => b[1] - a[1])
.map(([Component], i) => (
<ErrorBoundary noop key={i}>
<Component />
</ErrorBoundary>
)
);
));
};

View file

@ -178,7 +178,7 @@ export const SettingsStore = new SettingsStoreClass(settings, {
if (path.startsWith("plugins.")) {
const plugin = path.slice("plugins.".length);
if (plugin in plugins) {
const setting = plugins[plugin].options?.[key];
const setting = plugins[plugin].settings?.def[key];
if (!setting) return v;
if ("default" in setting)
@ -208,7 +208,7 @@ if (!IS_REPORTER) {
* Same as {@link Settings} but unproxied. You should treat this as readonly,
* as modifying properties on this will not save to disk or call settings
* listeners.
* WARNING: default values specified in plugin.options will not be ensured here. In other words,
* WARNING: default values specified in plugin.settings will not be ensured here. In other words,
* settings for which you specified a default value may be uninitialised. If you need proper
* handling for default values, use {@link Settings}
*/
@ -353,7 +353,13 @@ export function definePluginSettings<
Checks extends SettingsChecks<Def>,
PrivateSettings extends object = {}
>(def: Def, checks?: Checks) {
const definedSettings: DefinedSettings<Def, Checks, PrivateSettings> = {
if (checks) {
for (const [name, check] of Object.entries(checks)) {
Object.assign(def[name], check);
}
}
const definedSettings: DefinedSettings<Def, PrivateSettings> = {
get store() {
if (!definedSettings.pluginName) throw new Error("Cannot access settings before plugin is initialized");
return Settings.plugins[definedSettings.pluginName] as any;
@ -368,11 +374,10 @@ export function definePluginSettings<
: [`plugins.${definedSettings.pluginName}.*`]
) as UseSettings<Settings>[]).plugins[definedSettings.pluginName] as any,
def,
checks: checks ?? {} as any,
pluginName: "",
withPrivateSettings<T extends object>() {
return this as DefinedSettings<Def, Checks, T>;
return this as DefinedSettings<Def, T>;
}
};

View file

@ -25,6 +25,14 @@ const API_VERSION_STORE_KEY = "Vencord_cloudApiVersions";
type ApiVersion = "v2" | "v1";
const SYNC_DIRECTION_KEY = "Vencord_cloudSyncDirection";
const SETTINGS_DIRTY_KEY = "Vencord_settingsDirty";
export const getCloudSyncDirection = () => localStorage.getItem(SYNC_DIRECTION_KEY) || "both";
export const setCloudSyncDirection = (direction: "push" | "pull" | "both" | "manual") => localStorage.setItem(SYNC_DIRECTION_KEY, direction);
export const areLocalSettingsDirty = () => localStorage.getItem(SETTINGS_DIRTY_KEY) === "true";
export const markLocalSettingsDirty = () => localStorage.setItem(SETTINGS_DIRTY_KEY, "true");
export const markLocalSettingsClean = () => localStorage.removeItem(SETTINGS_DIRTY_KEY);
async function loadApiVersionMap(): Promise<Record<string, ApiVersion>> {
return await DataStore.get<Record<string, ApiVersion>>(API_VERSION_STORE_KEY) ?? {};
}

View file

@ -35,6 +35,7 @@ import * as $Notifications from "./Notifications";
import * as $UserArea from "./UserArea";
export * as PluginManager from "./PluginManager";
import * as $ProfileCollections from "./ProfileCollections";
import * as $ProfileSections from "./ProfileSections";
import * as $ServerList from "./ServerList";
import * as $Settings from "./Settings";
import * as $Styles from "./Styles";
@ -176,3 +177,8 @@ export const isEquicord = true;
* An API allowing you to add other collections where discords game collection is.
*/
export const ProfileCollections = $ProfileCollections;
/**
* An API allowing you to add sections near the 'Member Since' area of user profile panels.
*/
export const ProfileSections = $ProfileSections;

View file

@ -730,6 +730,34 @@ export function ClockIcon(props: IconProps) {
);
}
export function ChevronSmallDownIcon(props: IconProps) {
return (
<Icon
{...props}
viewBox="0 0 24 24"
>
<path
fill={props.fill || "currentColor"}
d="M5.3 9.3a1 1 0 0 1 1.4 0l5.3 5.29 5.3-5.3a1 1 0 1 1 1.4 1.42l-6 6a1 1 0 0 1-1.4 0l-6-6a1 1 0 0 1 0-1.42Z"
/>
</Icon>
);
}
export function ChevronSmallUpIcon(props: IconProps) {
return (
<Icon
{...props}
viewBox="0 0 24 24"
>
<path
fill={props.fill || "currentColor"}
d="M5.3 14.7a1 1 0 0 0 1.4 0l5.3-5.29 5.3 5.3a1 1 0 1 0 1.4-1.42l-6-6a1 1 0 0 0-1.4 0l-6 6a1 1 0 0 0 0 1.42Z"
/>
</Icon>
);
}
export function DownArrow(props: IconProps) {
return (
<Icon

View file

@ -5,13 +5,13 @@
*/
import { showNotice } from "@api/Notices";
import { isPluginEnabled, pluginRequiresRestart, startDependenciesRecursive, startPlugin, stopPlugin } from "@api/PluginManager";
import { hasAnyVisibleSettings, isPluginEnabled, pluginRequiresRestart, startDependenciesRecursive, startPlugin, stopPlugin } from "@api/PluginManager";
import { Settings } from "@api/Settings";
import { CogWheel, InfoIcon } from "@components/Icons";
import { AddonCard } from "@components/settings/AddonCard";
import { classNameFactory } from "@utils/css";
import { Logger } from "@utils/Logger";
import { OptionType, Plugin } from "@utils/types";
import { Plugin } from "@utils/types";
import { React, showToast, Toasts } from "@webpack/common";
import { PluginMeta } from "~plugins";
@ -146,7 +146,7 @@ export function PluginCard({ plugin, disabled, onRestartNeeded, onMouseEnter, on
onClick={() => openPluginModal(plugin, onRestartNeeded)}
className={cl("info-button")}
>
{plugin.settings?.def && Object.values(plugin.settings.def).some(s => s.type !== OptionType.CUSTOM && !s.hidden)
{hasAnyVisibleSettings(plugin)
? <CogWheel className={cl("info-icon")} />
: <InfoIcon className={cl("info-icon")} />
}

View file

@ -19,6 +19,7 @@
import "./PluginModal.css";
import { generateId } from "@api/Commands";
import { hasAnyVisibleSettings, isSettingHidden } from "@api/PluginManager";
import { useSettings } from "@api/Settings";
import { BaseText } from "@components/BaseText";
import { Button } from "@components/Button";
@ -86,7 +87,7 @@ function PluginTags({ tags }: { tags: PluginTag[]; }) {
export default function PluginModal({ plugin, onRestartNeeded, onClose, transitionState }: PluginModalProps) {
const pluginSettings = useSettings([`plugins.${plugin.name}.*`]).plugins[plugin.name];
const hasSettings = Boolean(pluginSettings && plugin.options && !isObjectEmpty(plugin.options));
const hasSettings = hasAnyVisibleSettings(plugin);
// avoid layout shift by showing dummy users while loading users
const fallbackAuthors = useMemo(() => [makeDummyUser({ username: "Loading...", id: "-1465912127305809920" })], []);
@ -114,14 +115,17 @@ export default function PluginModal({ plugin, onRestartNeeded, onClose, transiti
}
function renderSettings() {
if (!hasSettings || !plugin.options)
const { settings } = plugin;
if (!hasSettings || !settings)
return <Paragraph>There are no settings for this plugin.</Paragraph>;
const options = Object.entries(plugin.options).map(([key, setting]) => {
if (setting.type === OptionType.CUSTOM || setting.hidden) return null;
const options = Object.entries(settings.def).map(([key, setting]) => {
if (setting.type === OptionType.CUSTOM) return null;
if (isSettingHidden(settings, setting)) return null;
function onChange(newValue: any) {
const option = plugin.options?.[key];
const option = plugin.settings!.def[key];
if (!option || option.type === OptionType.CUSTOM) return;
pluginSettings[key] = newValue;
@ -134,10 +138,10 @@ export default function PluginModal({ plugin, onRestartNeeded, onClose, transiti
<ErrorBoundary noop key={key}>
<Component
id={key}
option={setting}
setting={setting}
onChange={debounce(onChange)}
pluginSettings={pluginSettings}
definedSettings={plugin.settings}
definedSettings={settings}
/>
</ErrorBoundary>
);

View file

@ -16,20 +16,21 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { isSettingDisabled } from "@api/PluginManager";
import { Switch } from "@components/Switch";
import { PluginOptionBoolean } from "@utils/types";
import { PluginSettingBooleanDef } from "@utils/types";
import { React, useState } from "@webpack/common";
import { resolveError, SettingProps, SettingsSection } from "./Common";
export function BooleanSetting({ option, pluginSettings, definedSettings, id, onChange }: SettingProps<PluginOptionBoolean>) {
const def = pluginSettings[id] ?? option.default;
export function BooleanSetting({ setting, pluginSettings, definedSettings, id, onChange }: SettingProps<PluginSettingBooleanDef>) {
const def = pluginSettings[id] ?? setting.default;
const [state, setState] = useState(def ?? false);
const [error, setError] = useState<string | null>(null);
function handleChange(newValue: boolean): void {
const isValid = option.isValid?.call(definedSettings, newValue) ?? true;
const isValid = setting.isValid?.call(definedSettings, newValue) ?? true;
setState(newValue);
setError(resolveError(isValid));
@ -40,11 +41,11 @@ export function BooleanSetting({ option, pluginSettings, definedSettings, id, on
}
return (
<SettingsSection tag="label" name={id} description={option.description} error={error} inlineSetting>
<SettingsSection tag="label" name={id} description={setting.description} error={error} inlineSetting>
<Switch
checked={state}
onChange={handleChange}
disabled={option.disabled?.call(definedSettings) ?? false}
disabled={isSettingDisabled(definedSettings, setting)}
/>
</SettingsSection>
);

View file

@ -8,24 +8,24 @@ import { BaseText } from "@components/BaseText";
import { classNameFactory } from "@utils/css";
import { classes } from "@utils/misc";
import { wordsFromCamel, wordsToTitle } from "@utils/text";
import { DefinedSettings, PluginOptionBase } from "@utils/types";
import { DefinedSettings, PluginSettingDefCommon } from "@utils/types";
import { PropsWithChildren } from "react";
export const cl = classNameFactory("vc-plugins-setting-");
interface SettingBaseProps<T> {
option: T;
setting: T;
onChange(newValue: any): void;
pluginSettings: {
[setting: string]: any;
enabled: boolean;
};
id: string;
definedSettings?: DefinedSettings;
definedSettings: DefinedSettings;
}
export type SettingProps<T extends PluginOptionBase> = SettingBaseProps<T>;
export type ComponentSettingProps<T extends Omit<PluginOptionBase, "description" | "placeholder">> = SettingBaseProps<T>;
export type SettingProps<T extends PluginSettingDefCommon> = SettingBaseProps<T>;
export type ComponentSettingProps<T extends Omit<PluginSettingDefCommon, "description" | "placeholder">> = SettingBaseProps<T>;
export function resolveError(isValidResult: boolean | string) {
if (typeof isValidResult === "string") return isValidResult;

View file

@ -16,10 +16,10 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { PluginOptionComponent } from "@utils/types";
import { PluginSettingComponentDef } from "@utils/types";
import { ComponentSettingProps } from "./Common";
export function ComponentSetting({ option, onChange }: ComponentSettingProps<PluginOptionComponent>) {
return option.component({ setValue: onChange, option });
export function ComponentSetting({ setting, onChange }: ComponentSettingProps<PluginSettingComponentDef>) {
return setting.component({ setValue: onChange, option: setting });
}

View file

@ -16,24 +16,25 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { OptionType, PluginOptionNumber } from "@utils/types";
import { isSettingDisabled } from "@api/PluginManager";
import { OptionType, PluginSettingBigIntDef, PluginSettingNumberDef } from "@utils/types";
import { React, TextInput, useState } from "@webpack/common";
import { resolveError, SettingProps, SettingsSection } from "./Common";
const MAX_SAFE_NUMBER = BigInt(Number.MAX_SAFE_INTEGER);
export function NumberSetting({ option, pluginSettings, definedSettings, id, onChange }: SettingProps<PluginOptionNumber>) {
export function NumberSetting({ setting, pluginSettings, definedSettings, id, onChange }: SettingProps<PluginSettingNumberDef | PluginSettingBigIntDef>) {
function serialize(value: any) {
if (option.type === OptionType.BIGINT) return BigInt(value);
if (setting.type === OptionType.BIGINT) return BigInt(value);
return Number(value);
}
const [state, setState] = useState<any>(`${pluginSettings[id] ?? option.default ?? 0}`);
const [state, setState] = useState<any>(`${pluginSettings[id] ?? setting.default ?? 0}`);
const [error, setError] = useState<string | null>(null);
function handleChange(newValue: any) {
const isValid = option.isValid?.call(definedSettings, newValue) ?? true;
const isValid = setting.isValid?.call(definedSettings, newValue) ?? true;
setError(resolveError(isValid));
@ -41,7 +42,7 @@ export function NumberSetting({ option, pluginSettings, definedSettings, id, onC
onChange(serialize(newValue));
}
if (option.type === OptionType.NUMBER && BigInt(newValue) >= MAX_SAFE_NUMBER) {
if (setting.type === OptionType.NUMBER && BigInt(newValue) >= MAX_SAFE_NUMBER) {
setState(`${Number.MAX_SAFE_INTEGER}`);
} else {
setState(newValue);
@ -49,15 +50,15 @@ export function NumberSetting({ option, pluginSettings, definedSettings, id, onC
}
return (
<SettingsSection name={id} description={option.description} error={error}>
<SettingsSection name={id} description={setting.description} error={error}>
<TextInput
type="number"
pattern="-?[0-9]+"
placeholder={option.placeholder ?? "Enter a number"}
placeholder={setting.placeholder ?? "Enter a number"}
value={state}
onChange={handleChange}
disabled={option.disabled?.call(definedSettings) ?? false}
{...option.componentProps}
disabled={isSettingDisabled(definedSettings, setting)}
{...setting.componentProps}
/>
</SettingsSection>
);

View file

@ -16,19 +16,20 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { PluginOptionSelect } from "@utils/types";
import { isSettingDisabled } from "@api/PluginManager";
import { PluginSettingSelectDef } from "@utils/types";
import { React, Select, useState } from "@webpack/common";
import { resolveError, SettingProps, SettingsSection } from "./Common";
export function SelectSetting({ option, pluginSettings, definedSettings, onChange, id }: SettingProps<PluginOptionSelect>) {
const def = pluginSettings[id] ?? option.options?.find(o => o.default)?.value;
export function SelectSetting({ setting, pluginSettings, definedSettings, onChange, id }: SettingProps<PluginSettingSelectDef>) {
const def = pluginSettings[id] ?? setting.options?.find(o => o.default)?.value;
const [state, setState] = useState<any>(def ?? null);
const [error, setError] = useState<string | null>(null);
function handleChange(newValue: any) {
const isValid = option.isValid?.call(definedSettings, newValue) ?? true;
const isValid = setting.isValid?.call(definedSettings, newValue) ?? true;
setState(newValue);
setError(resolveError(isValid));
@ -39,17 +40,17 @@ export function SelectSetting({ option, pluginSettings, definedSettings, onChang
}
return (
<SettingsSection name={id} description={option.description} error={error}>
<SettingsSection name={id} description={setting.description} error={error}>
<Select
placeholder={option.placeholder ?? "Select an option"}
options={option.options}
placeholder={setting.placeholder ?? "Select an option"}
options={setting.options}
maxVisibleItems={5}
closeOnSelect={true}
select={handleChange}
isSelected={v => v === state}
serialize={v => String(v)}
isDisabled={option.disabled?.call(definedSettings) ?? false}
{...option.componentProps}
isDisabled={isSettingDisabled(definedSettings, setting)}
{...setting.componentProps}
/>
</SettingsSection>
);

View file

@ -16,18 +16,19 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { PluginOptionSlider } from "@utils/types";
import { isSettingDisabled } from "@api/PluginManager";
import { PluginSettingSliderDef } from "@utils/types";
import { React, Slider, useState } from "@webpack/common";
import { resolveError, SettingProps, SettingsSection } from "./Common";
export function SliderSetting({ option, pluginSettings, definedSettings, id, onChange }: SettingProps<PluginOptionSlider>) {
const def = pluginSettings[id] ?? option.default;
export function SliderSetting({ setting, pluginSettings, definedSettings, id, onChange }: SettingProps<PluginSettingSliderDef>) {
const def = pluginSettings[id] ?? setting.default;
const [error, setError] = useState<string | null>(null);
function handleChange(newValue: number): void {
const isValid = option.isValid?.call(definedSettings, newValue) ?? true;
const isValid = setting.isValid?.call(definedSettings, newValue) ?? true;
setError(resolveError(isValid));
@ -37,17 +38,17 @@ export function SliderSetting({ option, pluginSettings, definedSettings, id, onC
}
return (
<SettingsSection name={id} description={option.description} error={error}>
<SettingsSection name={id} description={setting.description} error={error}>
<Slider
markers={option.markers}
minValue={option.markers[0]}
maxValue={option.markers[option.markers.length - 1]}
markers={setting.markers}
minValue={setting.markers[0]}
maxValue={setting.markers[setting.markers.length - 1]}
initialValue={def}
onValueChange={handleChange}
onValueRender={(v: number) => String(v.toFixed(2))}
stickToMarkers={option.stickToMarkers ?? true}
disabled={option.disabled?.call(definedSettings) ?? false}
{...option.componentProps}
stickToMarkers={setting.stickToMarkers ?? true}
disabled={isSettingDisabled(definedSettings, setting)}
{...setting.componentProps}
/>
</SettingsSection>
);

View file

@ -16,17 +16,18 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { PluginOptionString } from "@utils/types";
import { isSettingDisabled } from "@api/PluginManager";
import { PluginSettingStringDef } from "@utils/types";
import { React, TextArea, TextInput, useState } from "@webpack/common";
import { resolveError, SettingProps, SettingsSection } from "./Common";
export function TextSetting({ option, pluginSettings, definedSettings, id, onChange }: SettingProps<PluginOptionString>) {
const [state, setState] = useState(pluginSettings[id] ?? option.default ?? null);
export function TextSetting({ setting, pluginSettings, definedSettings, id, onChange }: SettingProps<PluginSettingStringDef>) {
const [state, setState] = useState(pluginSettings[id] ?? setting.default ?? null);
const [error, setError] = useState<string | null>(null);
function handleChange(newValue: string) {
const isValid = option.isValid?.call(definedSettings, newValue) ?? true;
const isValid = setting.isValid?.call(definedSettings, newValue) ?? true;
setState(newValue);
setError(resolveError(isValid));
@ -37,22 +38,22 @@ export function TextSetting({ option, pluginSettings, definedSettings, id, onCha
}
return (
<SettingsSection name={id} description={option.description} error={error}>
{option.multiline
<SettingsSection name={id} description={setting.description} error={error}>
{setting.multiline
? <TextArea
placeholder={option.placeholder ?? "Enter a value"}
placeholder={setting.placeholder ?? "Enter a value"}
value={state}
onChange={handleChange}
disabled={option.disabled?.call(definedSettings) ?? false}
{...option.componentProps} />
disabled={isSettingDisabled(definedSettings, setting)}
{...setting.componentProps} />
: <TextInput
type="text"
placeholder={option.placeholder ?? "Enter a value"}
placeholder={setting.placeholder ?? "Enter a value"}
value={state}
onChange={handleChange}
maxLength={null}
disabled={option.disabled?.call(definedSettings) ?? false}
{...option.componentProps}
disabled={isSettingDisabled(definedSettings, setting)}
{...setting.componentProps}
/>
}
</SettingsSection>

View file

@ -142,7 +142,7 @@ export function Updatable(props: CommonProps) {
) : isOutdated ? (
<>
<Paragraph>
There {updates.length === 1 ? "is 1 update" : `are ${updates.length} updates`} available. Click the button below to download and install.
There {updates.length === 1 ? "is 1 update" : `are ${updates.length} updates`} available. Click the button above to download and install.
</Paragraph>
<Changes updates={updates} {...props} />
</>

View file

@ -0,0 +1,40 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 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: "ProfileSectionsAPI",
description: "API to add sections near the 'Member Since' area of user profile panels.",
authors: [Devs.thororen],
patches: [
// DM User Sidebar
{
find: "#{intl::PREMIUM_GIFTING_BUTTON}),action:",
replacement: {
match: /(#{intl::USER_PROFILE_MEMBER_SINCE}\),.{0,100}userId:(\i\.id)}\)}\))/,
replace: "$1,Vencord.Api.ProfileSections.renderProfileSections({userId:$2,isSideBar:true})",
}
},
// User Profile Modal
{
find: ",applicationRoleConnection:",
replacement: {
match: /(#{intl::USER_PROFILE_MEMBER_SINCE}\),.{0,100}userId:(\i\.id),.{0,100}}\)}\)),/,
replace: "$1,Vencord.Api.ProfileSections.renderProfileSections({userId:$2,isSideBar:false}),",
}
},
// User Profile Modal v2
{
find: ".MODAL_V2,onClose:",
replacement: {
match: /(#{intl::USER_PROFILE_MEMBER_SINCE}\),.{0,100}userId:(\i\.id),.{0,100}}\)}\)),/,
replace: "$1,Vencord.Api.ProfileSections.renderProfileSections({userId:$2,isSideBar:false}),",
}
}
]
});

View file

@ -26,7 +26,7 @@ export default definePlugin({
}
}
],
set iconsModule(value: any) {
iconsModule(value: IconsDef) {
iconsModule = value;
},
});

View file

@ -5,7 +5,6 @@
*/
import { Devs } from "@utils/constants";
import { openUserProfile } from "@utils/discord";
import { t } from "@utils/translation";
import definePlugin from "@utils/types";
@ -18,13 +17,10 @@ export default definePlugin({
{
find: '"view-profile"',
replacement: {
match: /(\.POPOUT_CLOSE.{0,200})return(?<=userId:(\i\.id).*?)/,
replace: "$1return $self.openUserModal($2);"
match: /(\i)=.{0,45}\(0,\i\.openUserProfileModal.{0,300}(?=return)/,
replace: "$&return $1();"
},
all: true
},
],
openUserModal(userId: string) {
openUserProfile(userId);
}
});

View file

@ -89,8 +89,11 @@ export default definePlugin({
);
},
Header(inviter: User | undefined, guildName: string) {
if (!inviter) return null;
const userId = UserStore.getCurrentUser().id;
if (!inviter || userId === inviter.id) return null;
const isSelf = userId === inviter.id;
return (
<div className="vc-bi-header-inner">
<img
@ -102,7 +105,9 @@ export default definePlugin({
: "/assets/1f0bfc0865d324c2587920a7d80c609b.png?size=128"}
/>
<div className="vc-bi-header-text">
{t("betterInvites.ui.invitedYouTo", { inviter: inviter.global_name || inviter.username, guild: guildName })}
{isSelf
? t("betterInvites.ui.invitedYouTo", { inviter: inviter.global_name || inviter.username, guild: guildName })
: `${inviter.global_name || inviter.username} invited you to ${guildName}`}
</div>
</div>
);

View file

@ -1,7 +1,7 @@
.vc-bi-inviter-avatar {
margin: 0;
height: 14px;
width: 14px;
height: 18px;
width: 18px;
position: static;
flex-shrink: 0;
}
@ -10,10 +10,10 @@
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
gap: 4px;
margin: 0;
margin-bottom: 5px;
font-size: 12px;
font-size: 14px;
}
.vc-bi-header-text {
@ -24,7 +24,7 @@
.vc-bi-tooltip-icon {
color: var(--text-muted);
width: 16px;
height: 16px;
margin-top: 1px;
}
width: 18px;
height: 18px;
margin-top: 1.5px;
}

View file

@ -7,7 +7,7 @@
import { BaseText } from "@components/BaseText";
import { ChannelTabsProps, closeTab, ensureUnreadFallbackCountsLoaded, getNotificationDotState, getUnreadFallbackCounts, isTabSelected, moveDraggedTabs, moveToTab, openedTabs, settings, updateUnreadFallbackCounts } from "@equicordplugins/channelTabs/util";
import { ActivityIcon, CircleQuestionIcon, DiscoveryIcon, EnvelopeIcon, FriendsIcon, ICYMIIcon, NitroIcon, QuestIcon, ShopIcon } from "@equicordplugins/channelTabs/util/icons";
import { activeQuestIntervals } from "@equicordplugins/questify"; // sorry murphy!
import { getActiveAutoCompletes } from "@equicordplugins/questify/utils/completion";
import { classNameFactory } from "@utils/css";
import { getGuildAcronym, getIntlMessage, getUniqueUsername } from "@utils/discord";
import { classes } from "@utils/misc";
@ -467,8 +467,7 @@ export default function ChannelTab(props: ChannelTabsProps & { index: number; })
}), []);
drag(drop(ref));
// check if quests running (questify momentLet)
const hasActiveQuests = activeQuestIntervals.size > 0;
const hasActiveQuests = getActiveAutoCompletes().length > 0;
return <div
className={cl("tab", {
"tab-compact": compact,

View file

@ -33,6 +33,7 @@ export default function ChannelsTabsContainer(props: BasicChannelTabsProps) {
showBookmarkBar,
widerTabsAndBookmarks,
tabWidthScale,
tabHeightScale,
enableNumberKeySwitching,
numberKeySwitchCount,
enableCloseTabShortcut,
@ -65,6 +66,7 @@ export default function ChannelsTabsContainer(props: BasicChannelTabsProps) {
"showBookmarkBar",
"widerTabsAndBookmarks",
"tabWidthScale",
"tabHeightScale",
"enableNumberKeySwitching",
"numberKeySwitchCount",
"enableCloseTabShortcut",
@ -322,7 +324,7 @@ export default function ChannelsTabsContainer(props: BasicChannelTabsProps) {
!compactAutoExpandOnHover && cl("no-compact-hover-expand")
)}
ref={ref}
style={{ "--tab-width-scale": tabWidthScale / 100 } as React.CSSProperties}
style={{ "--tab-width-scale": tabWidthScale / 100, "--tab-height-scale": tabHeightScale / 100 } as React.CSSProperties}
onContextMenu={e => ContextMenuApi.openContextMenu(e, () => <BasicContextMenu />)}
>
{showBookmarkBar && <>

View file

@ -49,7 +49,7 @@ export default definePlugin({
name: "ChannelTabs",
description: t("equicord.channelTabs.description"),
tags: ["Appearance", "Customisation", "Organisation", "Servers"],
authors: [Devs.TheSun, Devs.TheKodeToad, EquicordDevs.keifufu, Devs.Nickyux, EquicordDevs.DiabeloDEV, EquicordDevs.justjxke],
authors: [Devs.TheSun, Devs.TheKodeToad, EquicordDevs.keifufu, Devs.Nickyux, EquicordDevs.DiabeloDEV, EquicordDevs.justjxke, EquicordDevs.keircn],
dependencies: ["ContextMenuAPI"],
contextMenus: {
"channel-mention-context": contextMenuPatch,

View file

@ -212,7 +212,8 @@
border-color 0.15s ease,
box-shadow 0.15s ease,
opacity 0.15s ease;
padding: 6px 8px;
min-height: calc(38px * var(--tab-height-scale, 1));
padding: calc(6px * var(--tab-height-scale, 1)) 8px;
position: relative;
cursor: grab;
flex-shrink: 0;
@ -1338,4 +1339,4 @@
/* disable transitions when compact expand animation is disabled */
.vc-channeltabs-no-compact-animation .vc-channeltabs-tab-number-badge {
transition: none !important;
}
}

View file

@ -39,11 +39,9 @@ function AnimationSettings(): JSX.Element {
{ label: "Active Quests Gradient", value: "quests-active", selected: settings.store.animationQuestsActive }
];
const [currentValue, setCurrentValue] = useState(animationOptions.filter(option => option.selected));
function updateSettingsTruthy(enabled: DynamicDropdownSettingOption[]) {
const enabledValues = enabled.map(option => option.value);
const [currentValue, setCurrentValue] = useState(animationOptions.filter(option => option.selected).map(option => option.value));
function updateSettingsTruthy(enabledValues: string[]) {
animationOptions.forEach(option => {
option.selected = enabledValues.includes(option.value);
});
@ -64,23 +62,24 @@ function AnimationSettings(): JSX.Element {
settings.store.animationResizeHandle = enabledValues.includes("resize-handle");
settings.store.animationQuestsActive = enabledValues.includes("quests-active");
setCurrentValue(enabled);
setCurrentValue(enabledValues);
}
function handleChange(values: Array<DynamicDropdownSettingOption | string>) {
if (values.length === 0) {
updateSettingsTruthy([]);
const valueStrings = values.map(v => typeof v === "string" ? v : v.value);
const toggled = valueStrings.length > currentValue.length
? valueStrings.find(v => !currentValue.includes(v))
: currentValue.find(v => !valueStrings.includes(v));
if (toggled == null) {
updateSettingsTruthy(valueStrings);
return;
}
const stringlessValues = values.filter(v => typeof v !== "string") as DynamicDropdownSettingOption[];
const selectedOption = values.find(v => typeof v === "string") as string;
const option = animationOptions.find(option => option.value === selectedOption) as DynamicDropdownSettingOption;
if (option.selected) {
updateSettingsTruthy(stringlessValues.filter(v => v.value !== selectedOption));
if (currentValue.includes(toggled)) {
updateSettingsTruthy(currentValue.filter(v => v !== toggled));
} else {
updateSettingsTruthy([...stringlessValues, option]);
updateSettingsTruthy([...currentValue, toggled]);
}
}
@ -179,6 +178,12 @@ export const settings = definePluginSettings({
hidden: true,
restartNeeded: false
},
tabHeightScale: {
type: OptionType.NUMBER,
description: "Tab height scale (percentage).",
default: 100,
restartNeeded: false
},
renderAllTabs: {
type: OptionType.BOOLEAN,
description: "Keep all tabs cached in memory for faster switching (caches scroll position and state)",

View file

@ -21,13 +21,14 @@ const ORIGINAL_NAME = Symbol("cleanChannelName.original");
let editingChannelId: string | null = null;
function computeClean(name: string, type: number): string {
return name
const cleaned = name
.normalize("NFKC")
.replace(/[ᴀʙᴄᴅᴇꜰɢʜɪᴊᴋʟᴍɴᴏᴘǫʀꜱᴛᴜᴠᴡxʏᴢ]/g, m => SMALL_CAPS[m])
.replace(/[^ -~]?\p{Extended_Pictographic}[^ -~]?/ug, "")
.replace(/-?[^\p{Letter} -~]-?/ug, [2, 4].includes(type) ? " " : "-")
.replace(/(^-|-$)/g, "")
.replace(/-+/g, "-");
return cleaned || name;
}
export default definePlugin({

View file

@ -38,6 +38,12 @@ const settings = definePluginSettings({
default: true,
restartNeeded: true
},
enableSpeakingIndicators: {
type: OptionType.BOOLEAN,
description: "Enable speaking indicators",
default: true,
restartNeeded: true
},
enableAdvancedSignals: {
type: OptionType.BOOLEAN,
description: t("equicord.clipsEnhancements.settings.enableAdvancedSignals"),
@ -102,8 +108,8 @@ export default definePlugin({
{
find: "2026-03-clips-experiment",
replacement: {
match: /defaultConfig:\{enableClips:!\d,ignorePlatformRestriction:!\d,showClipsHeaderEntrypoint:!\d,enableScreenshotKeybind:!\d,enableVoiceOnlyClips:!\d,enableAdvancedSignals:!\d\}/,
replace: "defaultConfig:{enableClips:!0,ignorePlatformRestriction:$self.settings.store.ignorePlatformRestriction,showClipsHeaderEntrypoint:!0,enableScreenshotKeybind:$self.settings.store.enableScreenshotKeybind,enableVoiceOnlyClips:$self.settings.store.enableVoiceOnlyClips,enableAdvancedSignals:$self.settings.store.enableAdvancedSignals}"
match: /defaultConfig:\{enableClips:!\d,ignorePlatformRestriction:!\d,enableScreenshotKeybind:!\d,enableVoiceOnlyClips:!\d,enableSpeakingIndicators:!\d,enableAdvancedSignals:!\d\}/,
replace: "defaultConfig:{enableClips:!0,ignorePlatformRestriction:$self.settings.store.ignorePlatformRestriction,enableScreenshotKeybind:$self.settings.store.enableScreenshotKeybind,enableVoiceOnlyClips:$self.settings.store.enableVoiceOnlyClips,enableSpeakingIndicators:$self.settings.store.enableSpeakingIndicators,enableAdvancedSignals:$self.settings.store.enableAdvancedSignals}"
}
}
],

View file

@ -109,7 +109,7 @@ export default definePlugin({
name: "CollapsibleUI",
description: "Native collapsible channel, member, chat button, and user area surfaces.",
tags: ["Appearance", "Customisation", "Chat", "Servers"],
dependencies: ["HeaderBarAPI"],
dependencies: ["HeaderBarAPI", "ChatInputButtonAPI"],
authors: [EquicordDevs.benjii],
searchTerms: ["ui", "sidebar", "layout"],
managedStyle,
@ -130,16 +130,8 @@ export default definePlugin({
replace: "className:$self.userAreaControlsClass($1),"
}
},
{
find: '"sticker")',
replacement: {
match: /(?<="div",\{.{0,15}children:)(.+?)\}/,
replace: "$self.wrapChatButtons($1)}"
}
}
],
wrapChatButtons(buttons: ReactNode[]) {
chatBarButtonWrapper: (buttons: ReactNode) => {
if (!Array.isArray(buttons) || buttons.length === 0) return buttons;
return <ChatButtonsRow buttons={buttons} />;
},

View file

@ -41,6 +41,12 @@ export const panelRegistry = {
label: "Title Bar",
collapsedKey: "titleBarCollapsed",
},
headerBar: {
id: "headerBar",
classId: "header-bar",
label: "Header Bar",
collapsedKey: "headerBarCollapsed",
},
userArea: {
id: "userArea",
classId: "user-area",
@ -51,7 +57,7 @@ export const panelRegistry = {
export type PanelId = keyof typeof panelRegistry;
export const toolbarPanelOrder = ["guildBar", "channelList", "membersList", "chatButtons", "titleBar", "userArea"] as const satisfies readonly PanelId[];
export const toolbarPanelOrder = ["guildBar", "channelList", "membersList", "chatButtons", "titleBar", "headerBar", "userArea"] as const satisfies readonly PanelId[];
export const settings = definePluginSettings({
collapsedSize: {
@ -91,6 +97,11 @@ export const settings = definePluginSettings({
description: "Persist the title bar as collapsed.",
default: false,
},
headerBarCollapsed: {
type: OptionType.BOOLEAN,
description: "Persist the header bar as collapsed.",
default: false,
},
userAreaCollapsed: {
type: OptionType.BOOLEAN,
description: "Persist the user area as collapsed.",

View file

@ -14,6 +14,7 @@
--vc-cui-chat-buttons-width: min(320px, 36vw);
--vc-cui-hover-peek: var(--vc-cui-collapsed-size);
--vc-cui-hover-delay: 120ms;
--vc-cui-header-bar-height: 32px;
--vc-cui-user-area-collapsed-width: 52px;
--vc-cui-ease: cubic-bezier(.2, 0, 0, 1);
--vc-cui-fade-transition: opacity var(--vc-cui-transition) var(--vc-cui-ease);
@ -99,19 +100,20 @@ body:has(.vc-collapsible-ui-transition-320) {
--vc-cui-transition: 320ms
}
body:has(.vc-collapsible-ui-guild-bar-collapsed) nav[class*="guilds_"] {
body:has(.vc-collapsible-ui-guild-bar-collapsed) nav[class*="guilds_"]:not(:is(.vc-betterFolders-sidebar nav[class*="guilds_"])) {
position: relative;
z-index: 1;
overflow: hidden !important;
transition: var(--vc-cui-inline-transition) !important
}
body:has(.vc-collapsible-ui-guild-bar-collapsed) nav[class*="guilds_"]:is(:hover, :focus-within) {
z-index: 3;
transition-delay: var(--vc-cui-hover-delay)
body:has(.vc-collapsible-ui-guild-bar-collapsed) nav[class*="guilds_"]:not(:is(.vc-betterFolders-sidebar nav[class*="guilds_"])):is(:hover, :focus-within) {
z-index: 3
}
body:has(.vc-collapsible-ui-guild-bar-collapsed) nav[class*="guilds_"]:not(:is(:hover, :focus-within)) {
body:has(.vc-collapsible-ui-guild-bar-collapsed) nav[class*="guilds_"]:not(:is(.vc-betterFolders-sidebar nav[class*="guilds_"]), :is(:hover, :focus-within), :is(.vc-betterFolders-sidebar-grid:has(.vc-betterFolders-sidebar:is(:hover, :focus-within)) nav[class*="guilds_"])) {
background: var(--background-gradient-low, var(--background-base-lowest)) !important;
border-start-end-radius: var(--radius-sm, 8px);
width: var(--vc-cui-collapsed-size) !important;
inline-size: var(--vc-cui-collapsed-size) !important;
min-width: var(--vc-cui-collapsed-size) !important;
@ -121,6 +123,28 @@ body:has(.vc-collapsible-ui-guild-bar-collapsed) nav[class*="guilds_"]:not(:is(:
flex: 0 0 var(--vc-cui-collapsed-size) !important
}
body:has(.vc-collapsible-ui-guild-bar-collapsed) .vc-betterFolders-sidebar {
position: relative;
z-index: 1;
overflow: hidden !important;
transition: var(--vc-cui-inline-transition) !important
}
body:has(.vc-collapsible-ui-guild-bar-collapsed) .vc-betterFolders-sidebar-grid:has(nav[class*="guilds_"]:is(:hover, :focus-within), .vc-betterFolders-sidebar:is(:hover, :focus-within)) .vc-betterFolders-sidebar {
z-index: 3;
transition-delay: var(--vc-cui-hover-delay)
}
body:has(.vc-collapsible-ui-guild-bar-collapsed) .vc-betterFolders-sidebar-grid:not(:has(nav[class*="guilds_"]:is(:hover, :focus-within), .vc-betterFolders-sidebar:is(:hover, :focus-within))) .vc-betterFolders-sidebar {
width: 0 !important;
inline-size: 0 !important;
min-width: 0 !important;
min-inline-size: 0 !important;
max-width: 0 !important;
max-inline-size: 0 !important;
flex: 0 0 0 !important
}
body:has(.vc-collapsible-ui-channel-list-collapsed) [class*="sidebarList_"] {
overflow: hidden !important;
transition: var(--vc-cui-inline-transition) !important
@ -205,17 +229,6 @@ body:has(.vc-collapsible-ui-members-list-collapsed) [class*="membersWrap"]:not(:
transition: var(--vc-cui-fade-transition)
}
body:has(.vc-collapsible-ui-guild-bar-collapsed) nav[class*="guilds_"]:is(:hover, :focus-within) > * {
opacity: 1;
transition: var(--vc-cui-fade-transition);
transition-delay: var(--vc-cui-hover-delay)
}
body:has(.vc-collapsible-ui-guild-bar-collapsed) nav[class*="guilds_"]:not(:is(:hover, :focus-within)) > * {
opacity: 0;
transition: var(--vc-cui-fade-transition)
}
body:has(.vc-collapsible-ui-user-area-collapsed) [class*="sidebar_"] > [class*="panels_"]:is(:hover, :focus-within) > * {
opacity: 1;
pointer-events: auto;
@ -282,11 +295,84 @@ body:has(.vc-collapsible-ui-title-bar-collapsed) section[class*="title_"][class*
transition: var(--vc-cui-fade-transition)
}
/* stylelint-disable no-descending-specificity */
body:has(.vc-collapsible-ui-header-bar-collapsed) [class*="bar_"]:has(> [class*="title_"]) {
position: relative;
z-index: 3;
overflow: hidden !important;
transition: var(--vc-cui-block-transition) !important
}
body:has(.vc-collapsible-ui-header-bar-collapsed) [class*="base_"]:has(> [class*="bar_"] > [class*="title_"]) {
/* stylelint-disable-next-line value-keyword-case */
grid-template-rows: [top] var(--vc-cui-hover-peek) [titleBarEnd] auto [noticeEnd] minmax(0, 1fr) [end] !important
}
body:has(.vc-collapsible-ui-header-bar-collapsed) [class*="base_"]:has(> [class*="bar_"]:is(:hover, :focus-within) > [class*="title_"]) {
/* stylelint-disable-next-line value-keyword-case */
grid-template-rows: [top] var(--vc-cui-header-bar-height) [titleBarEnd] auto [noticeEnd] minmax(0, 1fr) [end] !important
}
body:has(.vc-collapsible-ui-header-bar-collapsed) [class*="bar_"]:has(> [class*="title_"]):is(:hover, :focus-within) {
height: var(--vc-cui-header-bar-height) !important;
block-size: var(--vc-cui-header-bar-height) !important;
min-height: var(--vc-cui-header-bar-height) !important;
min-block-size: var(--vc-cui-header-bar-height) !important;
max-height: var(--vc-cui-header-bar-height) !important;
max-block-size: var(--vc-cui-header-bar-height) !important;
transition-delay: var(--vc-cui-hover-delay)
}
body:has(.vc-collapsible-ui-header-bar-collapsed) [class*="bar_"]:has(> [class*="title_"]):is(:hover, :focus-within) > * {
opacity: 1;
pointer-events: auto;
transition: var(--vc-cui-fade-transition);
transition-delay: var(--vc-cui-hover-delay)
}
body:has(.vc-collapsible-ui-header-bar-collapsed) [class*="bar_"]:has(> [class*="title_"]):not(:is(:hover, :focus-within)) {
height: var(--vc-cui-hover-peek) !important;
block-size: var(--vc-cui-hover-peek) !important;
min-height: var(--vc-cui-hover-peek) !important;
min-block-size: var(--vc-cui-hover-peek) !important;
max-height: var(--vc-cui-hover-peek) !important;
max-block-size: var(--vc-cui-hover-peek) !important;
padding-block: 0 !important
}
body:has(.vc-collapsible-ui-header-bar-collapsed) [class*="bar_"]:has(> [class*="title_"]):not(:is(:hover, :focus-within)) > * {
opacity: 0;
pointer-events: none;
transition: var(--vc-cui-fade-transition)
}
/* stylelint-enable no-descending-specificity */
/* stylelint-disable no-descending-specificity */
body:has(.vc-collapsible-ui-guild-bar-collapsed) .vc-betterFolders-sidebar-grid:has(nav[class*="guilds_"]:is(:hover, :focus-within), .vc-betterFolders-sidebar:is(:hover, :focus-within)) .vc-betterFolders-sidebar > *,
body:has(.vc-collapsible-ui-guild-bar-collapsed) nav[class*="guilds_"]:not(:is(.vc-betterFolders-sidebar nav[class*="guilds_"])):is(:hover, :focus-within) > * {
opacity: 1;
pointer-events: auto;
transition: var(--vc-cui-fade-transition);
transition-delay: calc(var(--vc-cui-hover-delay) + var(--vc-cui-transition))
}
body:has(.vc-collapsible-ui-guild-bar-collapsed) nav[class*="guilds_"]:not(:is(.vc-betterFolders-sidebar nav[class*="guilds_"])):is(:hover, :focus-within) > [class*="unreadMentionsIndicator"] {
pointer-events: none
}
body:has(.vc-collapsible-ui-guild-bar-collapsed) .vc-betterFolders-sidebar-grid:not(:has(nav[class*="guilds_"]:is(:hover, :focus-within), .vc-betterFolders-sidebar:is(:hover, :focus-within))) .vc-betterFolders-sidebar > *,
body:has(.vc-collapsible-ui-guild-bar-collapsed) nav[class*="guilds_"]:not(:is(.vc-betterFolders-sidebar nav[class*="guilds_"]), :is(:hover, :focus-within), :is(.vc-betterFolders-sidebar-grid:has(.vc-betterFolders-sidebar:is(:hover, :focus-within)) nav[class*="guilds_"])) > * {
opacity: 0;
pointer-events: none;
transition: var(--vc-cui-fade-transition)
}
body:has(.vc-collapsible-ui-channel-list-collapsed):not(:has([class*="sidebar_"] > [class*="panels_"]:is(:hover, :focus-within))) [class*="sidebarList_"]:not(:is(:hover, :focus-within)) > * {
opacity: 0;
pointer-events: none;
transition: var(--vc-cui-fade-transition)
}
/* stylelint-enable no-descending-specificity */
.vc-collapsible-ui-chat-buttons {
display: flex;

View file

@ -204,17 +204,8 @@ export default definePlugin({
description: t("equicord.commandPalette.description"),
authors: [EquicordDevs.justjxke, EquicordDevs.Ethan],
tags: ["Appearance", "Customisation", "Commands", "Shortcuts"],
dependencies: ["ChatInputButtonAPI"],
settings,
patches: [
{
find: '"sticker")',
replacement: {
match: /("div",\{.{0,15}children:)(.+?)\}/,
replace: "$1$self.wrapChatBarChildren($2)}"
}
}
],
start() {
registerBuiltInCommands();
window.addEventListener("keydown", this.handleKeydown);
@ -239,5 +230,5 @@ export default definePlugin({
});
},
wrapChatBarChildren,
chatBarButtonWrapper: wrapChatBarChildren,
});

View file

@ -7,15 +7,21 @@
import { NavContextMenuPatchCallback } from "@api/ContextMenu";
import { copyToClipboard } from "@utils/clipboard";
import { EquicordDevs } from "@utils/constants";
import { Logger } from "@utils/Logger";
import { t } from "@utils/translation";
import definePlugin from "@utils/types";
import { Menu, Toasts, UserProfileStore } from "@webpack/common";
import { User } from "@vencord/discord-types";
import { Menu, SelectedGuildStore, Toasts, UserProfileStore } from "@webpack/common";
function getProfileColors(userId) {
const logger = new Logger("CopyProfileColors");
function getProfileColors(userId: string, guildId?: string) {
try {
const profile = UserProfileStore.getUserProfile(userId);
const profile = guildId
? UserProfileStore.getGuildMemberProfile(userId, guildId)
: UserProfileStore.getUserProfile(userId);
if (!profile || !profile.themeColors || profile.themeColors.length < 2) {
if (!profile?.themeColors || profile.themeColors.length < 2) {
return null;
}
@ -24,13 +30,13 @@ function getProfileColors(userId) {
return { primaryColor, secondaryColor };
} catch (e) {
console.error("Failed to get profile colors:", e);
logger.error("Failed to get profile colors:", e);
return null;
}
}
function copyProfileColors(userId) {
const colors = getProfileColors(userId);
function copyProfileColors(userId: string, guildId?: string) {
const colors = getProfileColors(userId, guildId);
if (!colors) {
Toasts.show({
@ -54,7 +60,7 @@ function copyProfileColors(userId) {
id: Toasts.genId()
});
} catch (e) {
console.error("Failed to copy to clipboard:", e);
logger.error("Failed to copy to clipboard:", e);
Toasts.show({
type: Toasts.Type.FAILURE,
message: t("equicord.copyProfileColors.toast.error"),
@ -76,8 +82,15 @@ export function ColorIcon() {
);
}
// spawn in the context menu
const userContextMenuPatch: NavContextMenuPatchCallback = (children, { user }) => {
const userContextMenuPatch: NavContextMenuPatchCallback = (children, { user, guildId }: { user?: User; guildId?: string; }) => {
if (!user) return;
const effectiveGuildId = guildId ?? SelectedGuildStore.getGuildId();
const guildProfile = effectiveGuildId
? UserProfileStore.getGuildMemberProfile(user.id, effectiveGuildId)
: null;
const hasGuildColors = guildProfile?.themeColors && guildProfile.themeColors.length >= 2;
children.push(
<Menu.MenuItem
id="CopyProfileColors"
@ -86,13 +99,24 @@ const userContextMenuPatch: NavContextMenuPatchCallback = (children, { user }) =
action={() => copyProfileColors(user.id)}
/>
);
if (hasGuildColors && effectiveGuildId) {
children.push(
<Menu.MenuItem
id="CopyServerProfileColors"
icon={ColorIcon}
label="Copy Server Profile Colors"
action={() => copyProfileColors(user.id, effectiveGuildId)}
/>
);
}
};
export default definePlugin({
name: "CopyProfileColors",
description: t("equicord.copyProfileColors.description"),
tags: ["Appearance", "Customisation"],
authors: [EquicordDevs.Crxa, EquicordDevs.Cortex],
authors: [EquicordDevs.Crxa, EquicordDevs.Cortex, EquicordDevs.Gir0fa],
contextMenus: {
"user-context": userContextMenuPatch,
"user-profile-actions": userContextMenuPatch

View file

@ -12,6 +12,7 @@
padding: 10px 0;
cursor: pointer;
top: -8px;
z-index: 10;
border: solid grey 1px;
border-radius: 5px;
}

View file

@ -11,6 +11,7 @@ import { definePluginSettings, migrateOldSettingToNewPlugin, migratePluginSettin
import { Divider } from "@components/Divider";
import { HeadingSecondary } from "@components/Heading";
import { Notice } from "@components/Notice";
import decor from "@plugins/decor";
import { classNameFactory } from "@utils/css";
import { Devs, EquicordDevs } from "@utils/index";
import { t, Translate } from "@utils/translation";
@ -171,12 +172,12 @@ export default definePlugin({
patches: [
{
// Avatar decoration
find: "getAvatarDecorationURL:",
find: "isAvatarDecorationAnimating:",
replacement: {
match: /(?<=function \i\(\i\){)(?=.{0,150}let{avatarDecoration)/,
replace: "$&return null;"
match: /(?<=\{avatarDecoration:.{0,40}?)(void 0!==\i\?\i:)\i(?=\)?,canAnimate:)/,
replace: "$1null"
},
predicate: () => settings.store.removeAvatarDecoration && !isPluginEnabled("Decor"),
predicate: () => settings.store.removeAvatarDecoration && !isPluginEnabled(decor.name),
},
{
// Nameplate

View file

@ -173,6 +173,22 @@ export default definePlugin({
}
]
},
// Fix a race condition?
{
find: ".completeOperation(",
replacement: {
match: /(?<=this\.nextId\(\);)(\i\(\i\)),(.{0,200}reject:\i\}\))/,
replace: "$2,$1"
}
},
// catch if it cant open
{
find: "discarding speculative database",
replacement: {
match: /await (\i)\((\i)\)(?=;.{0,15}this\.databases)/,
replace: "$&.catch(()=>null)"
}
},
// When focused on voice channel or group chat voice call
{
find: ".STATUS_WARNING_BACKGROUND})})",
@ -266,7 +282,7 @@ export default definePlugin({
},
// Removes Modal Animation
{
find: "renderLurkerModeUpsellPopout,position:",
find: ".SWITCH_THUMB_BACKGROUND_SELECTED_DEFAULT)",
predicate: () => settings.store.noModalAnimation,
replacement: {
match: /200:300/g,
@ -310,7 +326,7 @@ export default definePlugin({
}
],
predicate: () => Settings.winNativeTitleBar,
}
},
],
renderMessageAccessory(props) {
return (

View file

@ -5,7 +5,7 @@
*/
import { openNotificationLogModal } from "@api/Notifications/notificationLog";
import { isPluginEnabled, plugins } from "@api/PluginManager";
import { isPluginEnabled, isSettingDisabled, isSettingHidden, plugins } from "@api/PluginManager";
import { Settings, useSettings } from "@api/Settings";
import { openPluginModal, openSettingsTabModal, PluginsTab, ThemesTab } from "@components/settings";
import { useAwaiter } from "@utils/react";
@ -82,8 +82,8 @@ export function buildPluginMenuEntries(includeEmpty = false) {
let hasAnyOption = false;
if (p.options) for (const [key, option] of Object.entries(p.options)) {
if ("hidden" in option && option.hidden) continue;
if (p.settings) for (const [key, option] of Object.entries(p.settings.def)) {
if (isSettingHidden(p.settings, option)) continue;
hasAnyOption = true;
@ -93,7 +93,7 @@ export function buildPluginMenuEntries(includeEmpty = false) {
id: `${p.name}-${key}`,
key: key,
label: wordsToTitle(wordsFromCamel(key)),
disabled: "disabled" in option ? option.disabled?.call(p.settings) : false,
disabled: isSettingDisabled(p.settings, option)
};
switch (option.type) {

View file

@ -75,7 +75,7 @@ function interceptUploadAddFiles(event: unknown): void {
if (payload.draftType !== DraftType.ChannelMessage) return;
if (!Boolean((settings.store as { interceptDiscordUpload?: boolean; }).interceptDiscordUpload) || !isConfigured()) return;
if (!settings.store.bypassDiscordUpload || !isConfigured()) return;
const files = [
...extractFilesFromValue(payload.files),
@ -105,6 +105,21 @@ function handlePaste(event: ClipboardEvent) {
void uploadProvidedFiles(files);
}
function formatBytes(bytes: number): string {
if (!bytes) return "";
const units = ["B", "KB", "MB", "GB"];
let value = bytes;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex++;
}
return `${value.toFixed(value >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
}
const ProgressBarInner = () => {
const [state, setState] = useState(getUploadState);
@ -113,6 +128,9 @@ const ProgressBarInner = () => {
if (state.phase === "idle") return null;
const percentage = Math.max(0, Math.min(100, state.percent));
const progressLabel = state.totalBytes > 0
? `${Math.round(percentage)}% - ${formatBytes(state.transferredBytes)} of ${formatBytes(state.totalBytes)}`
: `${Math.round(percentage)}%`;
return (
<div
@ -124,6 +142,9 @@ const ProgressBarInner = () => {
{state.status || "Uploading..."}
</div>
<div className={cl("progress-meta")}>
<span className={cl("progress-percent")}>
{progressLabel}
</span>
<span className={cl("progress-attempt")}>
{state.attempt > 0 && state.totalAttempts > 0 ? `${state.attempt}/${state.totalAttempts}` : ""}
</span>
@ -284,7 +305,7 @@ export default definePlugin({
}
},
shouldBypassDiscordUploadSizeCheck(): boolean {
return Boolean((settings.store as { interceptDiscordUpload?: boolean; }).interceptDiscordUpload) && isConfigured();
return Boolean(settings.store.bypassDiscordUpload) && isConfigured();
},
renderUploadProgress() {
return <ProgressBar />;

View file

@ -376,6 +376,88 @@ export async function uploadToFilebin(
}
}
export async function uploadToPixelVault(
_: IpcMainInvokeEvent,
fileBuffer: ArrayBuffer,
filename: string,
uploadKey: string
): Promise<NativeUploadResult> {
try {
const formData = new FormData();
formData.append("file", new Blob([fileBuffer]), filename);
const response = await fetch("https://pixelvault.co/", {
method: "POST",
headers: {
Authorization: uploadKey
},
body: formData
});
const text = await response.text();
let data: { resource?: string; url?: string; } | null = null;
try {
data = text ? JSON.parse(text) : null;
} catch {
data = null;
}
if (!response.ok) {
return { success: false, error: `Upload failed: ${response.status} ${text}` };
}
const url = data?.resource || data?.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 uploadToPixelDrain(
_: IpcMainInvokeEvent,
fileBuffer: ArrayBuffer,
filename: string,
apiKey?: string
): Promise<NativeUploadResult> {
try {
const headers: Record<string, string> = {};
if (apiKey?.trim()) {
headers.Authorization = `Basic ${Buffer.from(`:${apiKey.trim()}`).toString("base64")}`;
}
const response = await fetch(`https://pixeldrain.com/api/file/${encodeURIComponent(filename)}`, {
method: "PUT",
headers,
body: new Blob([fileBuffer])
});
const text = await response.text();
let data: { id?: string; message?: string; } | null = null;
try {
data = text ? JSON.parse(text) : null;
} catch {
data = null;
}
if (!response.ok) {
return { success: false, error: data?.message || `Upload failed: ${response.status} ${text}` };
}
if (!data?.id) {
return { success: false, error: data?.message || "No URL returned from upload" };
}
return { success: true, url: `https://pixeldrain.com/u/${data.id}` };
} catch (e) {
return { success: false, error: e instanceof Error ? e.message : "Unknown error" };
}
}
export async function fetchFile(
_: IpcMainInvokeEvent,

View file

@ -8,14 +8,18 @@ import { definePluginSettings } from "@api/Settings";
import { Button } from "@components/Button";
import { SettingsSection } from "@components/settings/tabs/plugins/components/Common";
import { Switch } from "@components/Switch";
import { classNameFactory } from "@utils/css";
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 { fallbackServiceOrder, serviceLabels, ServiceType } from "./types";
import { parseShareXConfig } from "./utils/sharex";
const defaultFallbackOrder = fallbackServiceOrder.join(",");
const cl = classNameFactory("vc-file-upload-settings-");
const serviceOptions = [
{ label: "Zipline", value: ServiceType.ZIPLINE, default: true },
{ label: "E-Z Host", value: ServiceType.EZHOST },
@ -29,6 +33,8 @@ const serviceOptions = [
{ label: "buzzheavier.com", value: ServiceType.BUZZHEAVIER },
{ label: "temp.sh", value: ServiceType.TEMPSH },
{ label: "filebin.net", value: ServiceType.FILEBIN },
{ label: "PixelVault", value: ServiceType.PIXELVAULT },
{ label: "PixelDrain", value: ServiceType.PIXELDRAIN },
{ label: "ShareX Custom Uploader", value: ServiceType.SHAREX }
];
@ -190,6 +196,24 @@ export const settings = definePluginSettings({
default: "",
hidden: true
},
fallbackOrder: {
type: OptionType.STRING,
description: "Fallback uploader order",
default: defaultFallbackOrder,
hidden: true
},
pixelVaultKey: {
type: OptionType.STRING,
description: "PixelVault upload key",
default: "",
hidden: true
},
pixelDrainKey: {
type: OptionType.STRING,
description: "Optional PixelDrain API key",
default: "",
hidden: true
},
uploadTimeoutMs: {
type: OptionType.NUMBER,
description: "Upload timeout in milliseconds",
@ -261,7 +285,7 @@ function SettingTextInput(props: {
const { description, name, onChange, placeholder, value } = props;
return (
<SettingsSection name={name} description={description}>
<SettingsSection name={name} description={description ?? ""}>
<TextInput
value={value}
onChange={onChange}
@ -271,6 +295,126 @@ function SettingTextInput(props: {
);
}
function SettingGroup(props: {
children: React.ReactNode;
description?: string;
name: string;
}) {
const { children, description, name } = props;
return (
<SettingsSection name={name} description={description ?? ""}>
<div className={cl("group")}>
{children}
</div>
</SettingsSection>
);
}
function SettingSwitch(props: {
checked: boolean;
description: string;
name: string;
onChange: (value: boolean) => void;
}) {
const { checked, description, name, onChange } = props;
return (
<SettingsSection tag="label" name={name} description={description} inlineSetting>
<Switch checked={checked} onChange={onChange} />
</SettingsSection>
);
}
function ServicePicker(props: {
onChange: (service: ServiceType) => void;
value: ServiceType;
}) {
const { onChange, value } = props;
return (
<div className={cl("service-grid")}>
{serviceOptions.map(option => (
<button
key={option.value}
type="button"
className={cl("service-option")}
data-selected={option.value === value}
onClick={() => onChange(option.value)}
>
<span className={cl("service-option-label")}>{option.label}</span>
</button>
))}
</div>
);
}
function FallbackOrderSettings() {
const update = useForceUpdater();
const { store } = settings;
const [dragIndex, setDragIndex] = React.useState<number | null>(null);
const [order, setOrder] = React.useState<ServiceType[]>(() => {
const configured = (store.fallbackOrder || defaultFallbackOrder)
.split(/[\n,]/)
.map(entry => entry.trim())
.filter((entry): entry is ServiceType => Object.values(ServiceType).includes(entry as ServiceType));
return configured.length === fallbackServiceOrder.length && new Set(configured).size === fallbackServiceOrder.length
? configured
: fallbackServiceOrder;
});
const commitOrder = (nextOrder: ServiceType[]) => {
setOrder(nextOrder);
store.fallbackOrder = nextOrder.join(",");
update();
};
return (
<SettingsSection name="Fallback Order" description="Drag hosts to reorder fallback attempts. The selected host is tried first, then this order is used.">
<div className={cl("fallback-order-list")}>
{order.map((service, index) => (
<div
key={service}
className={cl("fallback-order-item")}
draggable
onDragStart={event => {
setDragIndex(index);
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", String(index));
}}
onDragOver={event => event.preventDefault()}
onDrop={event => {
event.preventDefault();
const sourceIndex = dragIndex ?? Number(event.dataTransfer.getData("text/plain"));
if (!Number.isInteger(sourceIndex) || sourceIndex === index || sourceIndex < 0 || sourceIndex >= order.length) {
setDragIndex(null);
return;
}
const nextOrder = [...order];
const [moved] = nextOrder.splice(sourceIndex, 1);
nextOrder.splice(index, 0, moved);
setDragIndex(null);
commitOrder(nextOrder);
}}
onDragEnd={() => setDragIndex(null)}
data-dragging={dragIndex === index}
>
<span className={cl("fallback-order-label")}>{serviceLabels[service]}</span>
<span className={cl("fallback-order-handle")}>Drag</span>
</div>
))}
</div>
<div className={cl("fallback-order-actions")}>
<Button size="small" onClick={() => commitOrder(fallbackServiceOrder)}>
Reset to default
</Button>
</div>
</SettingsSection>
);
}
export function SettingsComponent() {
const update = useForceUpdater();
const { store } = settings;
@ -282,6 +426,8 @@ export function SettingsComponent() {
const isCatbox = store.serviceType === ServiceType.CATBOX;
const isLitterbox = store.serviceType === ServiceType.LITTERBOX;
const isGofile = store.serviceType === ServiceType.GOFILE;
const isPixelVault = store.serviceType === ServiceType.PIXELVAULT;
const isPixelDrain = store.serviceType === ServiceType.PIXELDRAIN;
const isShareX = store.serviceType === ServiceType.SHAREX;
const validateShareXConfig = () => {
@ -321,21 +467,18 @@ export function SettingsComponent() {
return (
<>
<SettingsSection name="Service Type" description="The upload service to use">
<Select
options={serviceOptions}
isSelected={v => v === store.serviceType}
select={v => {
store.serviceType = v;
<SettingsSection name="Upload Service" description="Choose where FileUpload sends new files.">
<ServicePicker
value={store.serviceType as ServiceType}
onChange={service => {
store.serviceType = service;
update();
}}
serialize={v => v}
placeholder="Select a service"
/>
</SettingsSection>
{isZipline && (
<>
<SettingGroup name="Zipline" description="Connection details for your Zipline instance.">
<SettingTextInput
name="Service URL"
description="The URL of your Zipline instance"
@ -357,31 +500,35 @@ export function SettingsComponent() {
onChange={v => store.folderId = v}
placeholder="Leave empty for no folder"
/>
</>
</SettingGroup>
)}
{isEzHost && (
<SettingTextInput
name="E-Z Host API Key"
description="Your E-Z Host API key"
value={store.ezHostKey}
onChange={v => store.ezHostKey = v}
placeholder="Your E-Z Host API key"
/>
<SettingGroup name="E-Z Host" description="Connection details for E-Z Host uploads.">
<SettingTextInput
name="E-Z Host API Key"
description="Your E-Z Host API key"
value={store.ezHostKey}
onChange={v => store.ezHostKey = v}
placeholder="Your E-Z Host API key"
/>
</SettingGroup>
)}
{isNest && (
<SettingTextInput
name="Nest Token"
description="Your Nest API authorization token"
value={store.nestToken}
onChange={v => store.nestToken = v}
placeholder="Your Nest API token"
/>
<SettingGroup name="Nest" description="Connection details for Nest uploads.">
<SettingTextInput
name="Nest Token"
description="Your Nest API authorization token"
value={store.nestToken}
onChange={v => store.nestToken = v}
placeholder="Your Nest API token"
/>
</SettingGroup>
)}
{isS3 && (
<>
<SettingGroup name="S3-Compatible Storage" description="Connection details and object naming for your bucket.">
<SettingTextInput
name="S3 Endpoint URL"
description="S3-compatible endpoint (e.g. https://<accountid>.r2.cloudflarestorage.com)"
@ -438,23 +585,25 @@ export function SettingsComponent() {
onChange={v => store.s3Prefix = v}
placeholder="uploads/discord"
/>
<SettingsSection tag="label" name="Use Path-Style Endpoint" description="Use endpoint/bucket/key format (recommended for R2)" inlineSetting>
<Switch
checked={store.s3ForcePathStyle}
onChange={v => store.s3ForcePathStyle = v}
/>
</SettingsSection>
</>
<SettingSwitch
name="Use Path-Style Endpoint"
description="Use endpoint/bucket/key format, recommended for R2."
checked={store.s3ForcePathStyle}
onChange={v => store.s3ForcePathStyle = v}
/>
</SettingGroup>
)}
{isCatbox && (
<SettingTextInput
name="Catbox Userhash"
description="Your Catbox userhash for account binding (leave empty for anonymous uploads)"
value={store.catboxUserhash}
onChange={v => store.catboxUserhash = v}
placeholder="Your Catbox userhash"
/>
<SettingGroup name="Catbox" description="Optional account binding for Catbox uploads.">
<SettingTextInput
name="Catbox Userhash"
description="Your Catbox userhash for account binding, leave empty for anonymous uploads."
value={store.catboxUserhash}
onChange={v => store.catboxUserhash = v}
placeholder="Your Catbox userhash"
/>
</SettingGroup>
)}
{isLitterbox && (
@ -473,17 +622,43 @@ export function SettingsComponent() {
)}
{isGofile && (
<SettingTextInput
name="GoFile Token"
description="Optional GoFile token to upload into your account"
value={store.gofileToken}
onChange={v => store.gofileToken = v}
placeholder="Optional GoFile token"
/>
<SettingGroup name="GoFile" description="Optional account binding for GoFile uploads.">
<SettingTextInput
name="GoFile Token"
description="Optional GoFile token to upload into your account."
value={store.gofileToken}
onChange={v => store.gofileToken = v}
placeholder="Optional GoFile token"
/>
</SettingGroup>
)}
{isPixelVault && (
<SettingGroup name="PixelVault" description="Connection details for PixelVault uploads.">
<SettingTextInput
name="PixelVault Upload Key"
description="Your PixelVault authorization key."
value={store.pixelVaultKey}
onChange={v => store.pixelVaultKey = v}
placeholder="Your PixelVault upload key"
/>
</SettingGroup>
)}
{isPixelDrain && (
<SettingGroup name="PixelDrain" description="Optional account binding for PixelDrain uploads.">
<SettingTextInput
name="PixelDrain API Key"
description="Optional PixelDrain API key for authenticated uploads. Leave empty for anonymous uploads."
value={store.pixelDrainKey}
onChange={v => store.pixelDrainKey = v}
placeholder="Your PixelDrain API key"
/>
</SettingGroup>
)}
{isShareX && (
<>
<SettingGroup name="ShareX Custom Uploader" description="Paste, import, or validate a ShareX custom uploader config.">
<SettingsSection
name="ShareX Custom Uploader Config"
description="Paste your ShareX custom uploader JSON (.sxcu/.json). DestinationType must include FileUploader or ImageUploader."
@ -496,7 +671,7 @@ export function SettingsComponent() {
/>
</SettingsSection>
<SettingsSection name="ShareX Config Actions" description="Import from file or validate pasted config">
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
<div className={cl("actions")}>
<Button size="small" onClick={triggerShareXFileUpload}>Import .sxcu/.json</Button>
<Button size="small" onClick={validateShareXConfig}>Validate</Button>
</div>
@ -508,134 +683,142 @@ export function SettingsComponent() {
onChange={handleShareXFileUpload}
/>
</SettingsSection>
</>
</SettingGroup>
)}
<SettingsSection tag="label" name="Strip Query Parameters" description="Strip query parameters from the uploaded file URL" inlineSetting>
<Switch
<SettingGroup name="Upload Behavior" description="Control what FileUpload does after a host returns a URL.">
<SettingSwitch
name="Strip Query Parameters"
description="Strip query parameters from the uploaded file URL."
checked={store.stripQueryParams}
onChange={v => store.stripQueryParams = v}
/>
</SettingsSection>
<SettingsSection tag="label" name="Use Embed Proxy" description="Wrap uploaded video links with an embed proxy service for better Discord previews" inlineSetting>
<Switch
<SettingSwitch
name="Use Embed Proxy"
description="Wrap uploaded video links with an embed proxy service for better Discord previews."
checked={store.embedProxyEnabled}
onChange={v => {
store.embedProxyEnabled = v;
update();
}}
/>
</SettingsSection>
{store.embedProxyEnabled && (
<SettingsSection name="Embed Proxy Service" description="Choose which embed proxy service to use for uploaded video links">
<Select
options={embedProxyOptions}
isSelected={v => v === store.embedProxyService}
select={v => {
store.embedProxyService = v;
update();
}}
serialize={v => v}
placeholder="Select an embed proxy service"
/>
</SettingsSection>
)}
{store.embedProxyEnabled && (
<SettingsSection name="Embed Proxy Service" description="Choose which embed proxy service to use for uploaded video links">
<Select
options={embedProxyOptions}
isSelected={v => v === store.embedProxyService}
select={v => {
store.embedProxyService = v;
update();
}}
serialize={v => v}
placeholder="Select an embed proxy service"
/>
</SettingsSection>
)}
<SettingTextInput
name="CORS Proxy URL"
description="CORS proxy used for web uploads. Leave empty to use the default proxy"
value={store.corsProxyUrl || ""}
onChange={v => store.corsProxyUrl = v}
placeholder="https://your-cors-proxy.example.com"
/>
<SettingsSection name="Default CORS Proxy Source" description="Source code for the default CORS proxy">
<a href="https://codeberg.org/key/corsproxy" target="_blank" rel="noreferrer">codeberg.org/key/corsproxy</a>
</SettingsSection>
<SettingsSection tag="label" name="Convert APNG to GIF" description="Convert APNG files to GIF format" inlineSetting>
<Switch
<SettingSwitch
name="Convert APNG to GIF"
description="Convert APNG files to GIF format."
checked={store.apngToGif}
onChange={v => store.apngToGif = v}
/>
</SettingsSection>
<SettingsSection tag="label" name="Preserve Original Filename" description="Use the original filename instead of naming uploads as upload.ext" inlineSetting>
<Switch
<SettingSwitch
name="Preserve Original Filename"
description="Use the original filename instead of naming uploads as upload.ext."
checked={Boolean((store as { preserveOriginalFilename?: boolean; }).preserveOriginalFilename)}
onChange={v => (store as { preserveOriginalFilename?: boolean; }).preserveOriginalFilename = v}
/>
</SettingsSection>
<SettingsSection tag="label" name="Auto Copy URL" description="Automatically copy the uploaded file URL to clipboard" inlineSetting>
<Switch
<SettingSwitch
name="Auto Copy URL"
description="Automatically copy the uploaded file URL to clipboard."
checked={store.autoCopy}
onChange={v => store.autoCopy = v}
/>
</SettingsSection>
<SettingsSection tag="label" name="Disable Fallback Uploaders" description="Only use the selected uploader without trying fallback hosts" inlineSetting>
<Switch
<SettingSwitch
name="Disable Fallback Uploaders"
description="Only use the selected uploader without trying fallback hosts."
checked={store.disableFallbacks}
onChange={v => store.disableFallbacks = v}
/>
</SettingsSection>
<SettingsSection tag="label" name="Insert URL into Chat Input" description="After upload, insert the resulting URL into the current chat input" inlineSetting>
<Switch
<SettingSwitch
name="Insert URL into Chat Input"
description="After upload, insert the resulting URL into the current chat input."
checked={store.autoSend}
onChange={v => store.autoSend = v}
/>
</SettingsSection>
<SettingsSection tag="label" name="Format Inserted URL" description="Wrap inserted URLs in angle brackets to avoid Discord preview embedding" inlineSetting>
<Switch
<SettingSwitch
name="Format Inserted URL"
description="Wrap inserted URLs in angle brackets to avoid Discord preview embedding."
checked={store.autoFormat}
onChange={v => store.autoFormat = v}
/>
</SettingsSection>
</SettingGroup>
<SettingsSection tag="label" name="Bypass Discord Upload Button" description="Use FileUpload when uploading through Discord's file picker" inlineSetting>
<Switch
<SettingGroup name="Discord Integration" description="Choose when FileUpload takes over Discord file handling.">
<SettingSwitch
name="Bypass Discord Upload Button"
description="Use FileUpload when uploading through Discord's file picker."
checked={Boolean((store as { bypassDiscordUpload?: boolean; }).bypassDiscordUpload)}
onChange={v => (store as { bypassDiscordUpload?: boolean; }).bypassDiscordUpload = v}
/>
</SettingsSection>
<SettingsSection tag="label" name="Auto Upload Pasted Files" description="Automatically upload files from clipboard to image host when pasting in chat input" inlineSetting>
<Switch
<SettingSwitch
name="Auto Upload Pasted Files"
description="Automatically upload files from clipboard to image host when pasting in chat input."
checked={store.autoUploadPastedFiles}
onChange={v => store.autoUploadPastedFiles = v}
/>
</SettingsSection>
<SettingsSection tag="label" name="Respect Discord File Size Limit" description="Use FileUpload only for files larger than your current Discord upload limit" inlineSetting>
<Switch
<SettingSwitch
name="Respect Discord File Size Limit"
description="Use FileUpload only for files larger than your current Discord upload limit."
checked={store.bypassDiscordUploadOnlyOverLimit}
onChange={v => store.bypassDiscordUploadOnlyOverLimit = v}
/>
</SettingsSection>
</SettingGroup>
<SettingsSection name="Upload Timeout" description="Maximum time to wait per upload attempt before switching to fallback">
<Select
options={[
{ label: "30 seconds", value: 30000 },
{ label: "1 minute", value: 60000 },
{ label: "2 minutes", value: 120000 },
{ label: "5 minutes", value: 300000, default: true },
{ label: "10 minutes", value: 600000 }
]}
isSelected={v => v === (store.uploadTimeoutMs || 300000)}
select={v => {
store.uploadTimeoutMs = v;
update();
}}
serialize={v => v}
placeholder="Select timeout"
<SettingGroup name="Network" description="Configure browser upload proxying and timeouts.">
<SettingTextInput
name="CORS Proxy URL"
description="CORS proxy used for web uploads. Leave empty to use the default proxy."
value={store.corsProxyUrl || ""}
onChange={v => store.corsProxyUrl = v}
placeholder="https://your-cors-proxy.example.com"
/>
</SettingsSection>
<SettingsSection name="Default CORS Proxy Source" description="Source code for the default CORS proxy">
<a href="https://codeberg.org/key/corsproxy" target="_blank" rel="noreferrer">codeberg.org/key/corsproxy</a>
</SettingsSection>
<SettingsSection name="Upload Timeout" description="Maximum time to wait per upload attempt before switching to fallback">
<Select
options={[
{ label: "30 seconds", value: 30000 },
{ label: "1 minute", value: 60000 },
{ label: "2 minutes", value: 120000 },
{ label: "5 minutes", value: 300000, default: true },
{ label: "10 minutes", value: 600000 }
]}
isSelected={v => v === (store.uploadTimeoutMs || 300000)}
select={v => {
store.uploadTimeoutMs = v;
update();
}}
serialize={v => v}
placeholder="Select timeout"
/>
</SettingsSection>
</SettingGroup>
<FallbackOrderSettings />
</>
);
}

View file

@ -32,6 +32,12 @@
color: var(--text-muted);
}
.vc-file-upload-progress-percent {
font-size: 12px;
color: var(--text-default);
white-space: nowrap;
}
.vc-file-upload-progress-cancel {
border: none;
border-radius: 6px;
@ -70,4 +76,81 @@
.vc-file-upload-progress-wrap[data-phase="success"] .vc-file-upload-progress-fill {
background: var(--status-positive);
}
}
.vc-file-upload-settings-fallback-order-list {
display: grid;
gap: 6px;
}
.vc-file-upload-settings-fallback-order-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 10px;
border-radius: 8px;
border: 1px solid var(--border-subtle);
background: var(--background-secondary);
color: var(--text-default);
cursor: grab;
}
.vc-file-upload-settings-fallback-order-item[data-dragging="true"] {
background: var(--background-modifier-hover);
}
.vc-file-upload-settings-fallback-order-label {
color: var(--text-default);
}
.vc-file-upload-settings-fallback-order-handle {
color: var(--text-muted);
font-size: 12px;
}
.vc-file-upload-settings-fallback-order-actions {
margin-top: 8px;
}
.vc-file-upload-settings-group {
display: grid;
gap: 10px;
}
.vc-file-upload-settings-service-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 8px;
}
.vc-file-upload-settings-service-option {
min-height: 38px;
border: 1px solid var(--border-subtle);
border-radius: 8px;
background: var(--background-secondary);
color: var(--text-default);
cursor: pointer;
padding: 8px 10px;
text-align: left;
}
.vc-file-upload-settings-service-option:hover {
background: var(--background-modifier-hover);
}
.vc-file-upload-settings-service-option[data-selected="true"] {
border-color: var(--brand-500);
background: var(--background-modifier-selected);
}
.vc-file-upload-settings-service-option-label {
font-size: 13px;
font-weight: 500;
}
.vc-file-upload-settings-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}

View file

@ -17,7 +17,9 @@ export enum ServiceType {
TMPFILES = "tmpfiles",
BUZZHEAVIER = "buzzheavier",
TEMPSH = "tempsh",
FILEBIN = "filebin"
FILEBIN = "filebin",
PIXELVAULT = "pixelvault",
PIXELDRAIN = "pixeldrain"
}
export const serviceLabels: Record<ServiceType, string> = {
@ -33,9 +35,29 @@ export const serviceLabels: Record<ServiceType, string> = {
[ServiceType.TMPFILES]: "tmpfiles.org",
[ServiceType.BUZZHEAVIER]: "buzzheavier.com",
[ServiceType.TEMPSH]: "temp.sh",
[ServiceType.FILEBIN]: "filebin.net"
[ServiceType.FILEBIN]: "filebin.net",
[ServiceType.PIXELVAULT]: "PixelVault",
[ServiceType.PIXELDRAIN]: "PixelDrain"
};
export const fallbackServiceOrder: ServiceType[] = [
ServiceType.ZIPLINE,
ServiceType.EZHOST,
ServiceType.NEST,
ServiceType.S3,
ServiceType.CATBOX,
ServiceType.ZEROX0,
ServiceType.LITTERBOX,
ServiceType.GOFILE,
ServiceType.TMPFILES,
ServiceType.BUZZHEAVIER,
ServiceType.TEMPSH,
ServiceType.FILEBIN,
ServiceType.PIXELVAULT,
ServiceType.PIXELDRAIN,
ServiceType.SHAREX
];
export interface UploadResponse {
files: {
id: string;

View file

@ -21,6 +21,14 @@ type S3Store = {
corsProxyUrl?: string;
};
type UploadRequestResponse = {
ok: boolean;
status: number;
text(): Promise<string>;
};
type UploadRequest = (url: string, options: RequestInit) => Promise<UploadRequestResponse>;
const textEncoder = new TextEncoder();
function toArrayBuffer(data: ArrayBuffer | Uint8Array | string): ArrayBuffer {
@ -90,7 +98,8 @@ export function isS3Configured(): boolean {
export async function uploadToS3(
fileBlob: Blob,
filename: string,
native: PluginNative<typeof import("../native")> | null
native: PluginNative<typeof import("../native")> | null,
uploadRequest: UploadRequest
): Promise<string> {
const {
s3Endpoint,
@ -189,7 +198,7 @@ export async function uploadToS3(
throw new Error(result.error || "Upload failed");
}
} else {
const response = await fetch(toProxiedUrl(uploadUrl.toString(), settings.store.corsProxyUrl), {
const response = await uploadRequest(toProxiedUrl(uploadUrl.toString(), settings.store.corsProxyUrl), {
method: "PUT",
headers: requestHeaders,
body: fileBlob

View file

@ -6,7 +6,7 @@
import { normalizeCorsProxyUrl, toProxiedUrl } from "@equicordplugins/fileUpload/constants";
import { settings } from "@equicordplugins/fileUpload/settings";
import { serviceLabels, ServiceType, ShareXUploaderConfig, UploadResponse } from "@equicordplugins/fileUpload/types";
import { fallbackServiceOrder, serviceLabels, ServiceType, ShareXUploaderConfig, UploadResponse } from "@equicordplugins/fileUpload/types";
import { copyToClipboard } from "@utils/clipboard";
import { insertTextIntoChatInputBox } from "@utils/discord";
import { Logger } from "@utils/Logger";
@ -48,6 +48,8 @@ export interface UploadProgressState {
attempt: number;
totalAttempts: number;
percent: number;
transferredBytes: number;
totalBytes: number;
status: string;
canCancel: boolean;
}
@ -60,6 +62,8 @@ const defaultUploadState: UploadProgressState = {
attempt: 0,
totalAttempts: 0,
percent: 0,
transferredBytes: 0,
totalBytes: 0,
status: "",
canCancel: false
};
@ -67,6 +71,7 @@ const defaultUploadState: UploadProgressState = {
let uploadState: UploadProgressState = { ...defaultUploadState };
const uploadStateListeners = new Set<() => void>();
let activeAbortController: AbortController | null = null;
let activeXhr: XMLHttpRequest | null = null;
let cancelRequested = false;
function isUploadCancelledError(error: unknown): boolean {
@ -77,6 +82,18 @@ function isUploadCancelledError(error: unknown): boolean {
return message.includes("cancelled") || message.includes("canceled") || message.includes("aborted") || message.includes("aborterror");
}
function getFallbackServices(): ServiceType[] {
const configuredOrder = (settings.store as { fallbackOrder?: string; }).fallbackOrder || fallbackServiceOrder.join(",");
const services = configuredOrder
.split(/[\n,]/)
.map(service => service.trim())
.filter((service): service is ServiceType => Object.values(ServiceType).includes(service as ServiceType));
return services.length === fallbackServiceOrder.length && new Set(services).size === fallbackServiceOrder.length
? services
: fallbackServiceOrder;
}
function emitUploadState() {
for (const listener of uploadStateListeners) {
listener();
@ -109,6 +126,7 @@ export function cancelCurrentUpload() {
cancelRequested = true;
activeAbortController?.abort();
activeXhr?.abort();
setUploadState({
phase: "cancelled",
status: "Upload cancelled.",
@ -151,6 +169,103 @@ async function fetchWithTimeout(url: string, options: RequestInit): Promise<Resp
}
}
function getHeaderEntries(headers?: HeadersInit): [string, string][] {
if (!headers) return [];
if (headers instanceof Headers) return Array.from(headers.entries());
if (Array.isArray(headers)) return headers.map(([key, value]) => [key, value]);
return Object.entries(headers);
}
class XhrResponse {
ok: boolean;
headers: Headers;
status: number;
statusText: string;
url: string;
constructor(private xhr: XMLHttpRequest) {
this.status = xhr.status;
this.statusText = xhr.statusText;
this.url = xhr.responseURL;
this.ok = this.status >= 200 && this.status < 300;
this.headers = new Headers();
const rawHeaders = xhr.getAllResponseHeaders();
for (const line of rawHeaders.trim().split(/[\r\n]+/)) {
if (!line) continue;
const separatorIndex = line.indexOf(":");
if (separatorIndex < 0) continue;
this.headers.append(line.slice(0, separatorIndex).trim(), line.slice(separatorIndex + 1).trim());
}
}
async text(): Promise<string> {
return typeof this.xhr.response === "string"
? this.xhr.response
: this.xhr.responseText;
}
async json(): Promise<unknown> {
return JSON.parse(await this.text());
}
}
function setXhrUploadProgress(event: ProgressEvent) {
if (!event.lengthComputable || event.total <= 0) {
setUploadState({
status: uploadState.currentServiceLabel
? `Uploading via ${uploadState.currentServiceLabel}...`
: "Uploading..."
});
return;
}
const percent = Math.round(Math.max(0, Math.min(100, event.loaded / event.total * 100)));
setUploadState({
phase: "uploading",
percent,
transferredBytes: event.loaded,
totalBytes: event.total,
status: uploadState.currentServiceLabel
? `Uploading via ${uploadState.currentServiceLabel}...`
: "Uploading..."
});
}
async function uploadRequestWithTimeout(url: string, options: RequestInit): Promise<XhrResponse> {
const requestUrl = toProxyUrl(url);
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
activeXhr = xhr;
const timeout = setTimeout(() => xhr.abort(), getUploadTimeoutMs());
xhr.open(options.method || "GET", requestUrl);
for (const [key, value] of getHeaderEntries(options.headers)) {
xhr.setRequestHeader(key, value);
}
xhr.upload.onprogress = setXhrUploadProgress;
xhr.onload = () => resolve(new XhrResponse(xhr));
xhr.onerror = () => reject(new Error("Upload failed"));
xhr.onabort = () => reject(new Error(cancelRequested ? "Upload cancelled by user" : "Upload timed out"));
xhr.onloadend = () => {
clearTimeout(timeout);
xhr.upload.onprogress = null;
if (activeXhr === xhr) {
activeXhr = null;
}
};
const { body } = options;
xhr.send(body instanceof ReadableStream ? null : body as XMLHttpRequestBodyInit | null);
});
}
function resolveShareXRequestValue(value: string | number | boolean, filename: string): string {
return String(value)
.replace(/\$filename\$/g, filename)
@ -213,7 +328,7 @@ async function uploadToShareX(fileBlob: Blob, filename: string): Promise<string>
throw new Error(`Unsupported ShareX Body type: ${config.Body || "unknown"}`);
}
const response = await fetchWithTimeout(requestUrl, { method, headers, body });
const response = await uploadRequestWithTimeout(requestUrl, { method, headers, body });
const responseText = await response.text();
let responseJson: unknown = null;
@ -260,7 +375,7 @@ async function uploadToZipline(fileBlob: Blob, filename: string): Promise<string
headers["x-zipline-folder"] = folderId;
}
const response = await fetchWithTimeout(`${baseUrl}/api/upload`, {
const response = await uploadRequestWithTimeout(`${baseUrl}/api/upload`, {
method: "POST",
headers,
body: formData
@ -277,7 +392,7 @@ async function uploadToZipline(fileBlob: Blob, filename: string): Promise<string
throw new Error("Server returned invalid response (not JSON)");
}
const data: UploadResponse = await response.json();
const data = await response.json() as UploadResponse;
if (data.files && data.files.length > 0 && data.files[0].url) {
return data.files[0].url;
@ -311,7 +426,7 @@ async function uploadToNest(fileBlob: Blob, filename: string): Promise<string> {
const formData = new FormData();
formData.append("file", fileBlob, filename);
const response = await fetchWithTimeout("https://nest.rip/api/files/upload", {
const response = await uploadRequestWithTimeout("https://nest.rip/api/files/upload", {
method: "POST",
headers: {
"Authorization": nestToken
@ -362,7 +477,10 @@ export function isConfigured(): boolean {
case ServiceType.BUZZHEAVIER:
case ServiceType.TEMPSH:
case ServiceType.FILEBIN:
case ServiceType.PIXELDRAIN:
return true;
case ServiceType.PIXELVAULT:
return Boolean((settings.store as { pixelVaultKey?: string; }).pixelVaultKey);
case ServiceType.SHAREX:
try {
parseShareXConfigFromSettings();
@ -401,7 +519,7 @@ async function uploadToEzHost(fileBlob: Blob, filename: string): Promise<string>
const headers: Record<string, string> = { key: ezHostKey };
const response = await fetchWithTimeout("https://api.e-z.host/files", {
const response = await uploadRequestWithTimeout("https://api.e-z.host/files", {
method: "POST",
headers,
body: formData
@ -412,12 +530,14 @@ async function uploadToEzHost(fileBlob: Blob, filename: string): Promise<string>
throw new Error(`Upload failed: ${response.status} ${text}`);
}
const data = await response.json();
const data = await response.json() as { success?: boolean; error?: string; imageUrl?: string; rawUrl?: string; };
if (!data || !data.success) {
throw new Error(data?.error || "Upload failed");
}
return data.imageUrl || data.rawUrl;
const url = data.imageUrl || data.rawUrl;
if (!url) throw new Error("No URL returned from upload");
return url;
}
async function uploadToCatbox(fileBlob: Blob, filename: string): Promise<string> {
@ -434,7 +554,7 @@ async function uploadToCatbox(fileBlob: Blob, filename: string): Promise<string>
if (catboxUserhash) formData.append("userhash", catboxUserhash);
formData.append("fileToUpload", fileBlob, filename);
const response = await fetchWithTimeout("https://catbox.moe/user/api.php", {
const response = await uploadRequestWithTimeout("https://catbox.moe/user/api.php", {
method: "POST",
body: formData
});
@ -478,7 +598,7 @@ async function uploadToLitterbox(fileBlob: Blob, filename: string): Promise<stri
formData.append("time", expiry);
formData.append("fileToUpload", fileBlob, filename);
const response = await fetchWithTimeout("https://litterbox.catbox.moe/resources/internals/api.php", {
const response = await uploadRequestWithTimeout("https://litterbox.catbox.moe/resources/internals/api.php", {
method: "POST",
body: formData
});
@ -505,7 +625,7 @@ async function uploadToGofile(fileBlob: Blob, filename: string): Promise<string>
formData.append("file", fileBlob, filename);
const uploadUrl = "https://upload.gofile.io/uploadfile";
const response = await fetchWithTimeout(uploadUrl, {
const response = await uploadRequestWithTimeout(uploadUrl, {
method: "POST",
body: formData
});
@ -540,7 +660,7 @@ async function uploadToTmpfiles(fileBlob: Blob, filename: string): Promise<strin
formData.append("file", fileBlob, filename);
const uploadUrl = "https://tmpfiles.org/api/v1/upload";
const response = await fetchWithTimeout(uploadUrl, {
const response = await uploadRequestWithTimeout(uploadUrl, {
method: "POST",
body: formData
});
@ -568,7 +688,7 @@ async function uploadToBuzzheavier(fileBlob: Blob, filename: string): Promise<st
}
const uploadUrl = `https://w.buzzheavier.com/${encodeURIComponent(filename)}`;
const response = await fetchWithTimeout(uploadUrl, {
const response = await uploadRequestWithTimeout(uploadUrl, {
method: "PUT",
body: fileBlob
});
@ -603,7 +723,7 @@ async function uploadToTempSh(fileBlob: Blob, filename: string): Promise<string>
formData.append("file", fileBlob, filename);
const uploadUrl = "https://temp.sh/upload";
const response = await fetchWithTimeout(uploadUrl, {
const response = await uploadRequestWithTimeout(uploadUrl, {
method: "POST",
body: formData
});
@ -635,7 +755,7 @@ async function uploadToFilebin(fileBlob: Blob, filename: string): Promise<string
const formData = new FormData();
formData.append("file", fileBlob, filename);
const response = await fetchWithTimeout(uploadUrl, {
const response = await uploadRequestWithTimeout(uploadUrl, {
method: "POST",
body: formData
});
@ -647,6 +767,98 @@ async function uploadToFilebin(fileBlob: Blob, filename: string): Promise<string
return `https://filebin.net/${binId}/${encodeURIComponent(filename)}`;
}
async function uploadToPixelVault(fileBlob: Blob, filename: string): Promise<string> {
const { pixelVaultKey } = settings.store as { pixelVaultKey?: string; };
if (!pixelVaultKey?.trim()) {
throw new Error("PixelVault upload key is required");
}
if (Native) {
const result = await Native.uploadToPixelVault(await fileBlob.arrayBuffer(), filename, pixelVaultKey.trim());
if (!result.success) {
throw new Error(result.error || "Upload failed");
}
if (!result.url) {
throw new Error("No URL returned from upload");
}
return result.url;
}
const formData = new FormData();
formData.append("file", fileBlob, filename);
const response = await uploadRequestWithTimeout("https://pixelvault.co/", {
method: "POST",
headers: {
Authorization: pixelVaultKey.trim()
},
body: formData
});
const text = await response.text();
let data: { resource?: string; url?: string; } | null = null;
try {
data = text ? JSON.parse(text) : null;
} catch {
data = null;
}
if (!response.ok) {
throw new Error(`Upload failed: ${response.status} ${text}`);
}
const url = data?.resource || data?.url || text.trim();
if (!url) {
throw new Error("No URL returned from upload");
}
return url;
}
async function uploadToPixelDrain(fileBlob: Blob, filename: string): Promise<string> {
const { pixelDrainKey } = settings.store as { pixelDrainKey?: string; };
if (Native) {
const result = await Native.uploadToPixelDrain(await fileBlob.arrayBuffer(), filename, pixelDrainKey?.trim() || undefined);
if (!result.success || !result.url) throw new Error(result.error || "No URL returned from upload");
return result.url;
}
const headers: Record<string, string> = {};
if (pixelDrainKey?.trim()) {
headers.Authorization = `Basic ${btoa(`:${pixelDrainKey.trim()}`)}`;
}
const response = await uploadRequestWithTimeout(`https://pixeldrain.com/api/file/${encodeURIComponent(filename)}`, {
method: "PUT",
headers,
body: fileBlob
});
const text = await response.text();
let data: { id?: string; success?: boolean; message?: string; } | null = null;
try {
data = text ? JSON.parse(text) : null;
} catch {
data = null;
}
if (!response.ok) {
throw new Error(data?.message || `Upload failed: ${response.status} ${text}`);
}
if (!data?.id) {
throw new Error(data?.message || "No URL returned from upload");
}
return `https://pixeldrain.com/u/${data.id}`;
}
async function uploadToService(serviceType: ServiceType, fileBlob: Blob, filename: string): Promise<string> {
switch (serviceType) {
case ServiceType.ZIPLINE:
@ -656,7 +868,7 @@ async function uploadToService(serviceType: ServiceType, fileBlob: Blob, filenam
case ServiceType.EZHOST:
return uploadToEzHost(fileBlob, filename);
case ServiceType.S3:
return uploadToS3(fileBlob, filename, Native);
return uploadToS3(fileBlob, filename, Native, uploadRequestWithTimeout);
case ServiceType.CATBOX:
return uploadToCatbox(fileBlob, filename);
case ServiceType.ZEROX0:
@ -675,22 +887,15 @@ async function uploadToService(serviceType: ServiceType, fileBlob: Blob, filenam
return uploadToTempSh(fileBlob, filename);
case ServiceType.FILEBIN:
return uploadToFilebin(fileBlob, filename);
case ServiceType.PIXELVAULT:
return uploadToPixelVault(fileBlob, filename);
case ServiceType.PIXELDRAIN:
return uploadToPixelDrain(fileBlob, filename);
default:
throw new Error("Unknown service type");
}
}
const FALLBACK_SERVICES: ServiceType[] = [
ServiceType.CATBOX,
ServiceType.LITTERBOX,
ServiceType.ZEROX0,
ServiceType.TMPFILES,
ServiceType.GOFILE,
ServiceType.BUZZHEAVIER,
ServiceType.TEMPSH,
ServiceType.FILEBIN
];
const EXE_BLOCKED_SERVICES = new Set<ServiceType>([
ServiceType.CATBOX,
ServiceType.LITTERBOX,
@ -768,12 +973,12 @@ function buildUploadOrder(primary: ServiceType, fileName: string): ServiceType[]
const disableFallbacks = Boolean((settings.store as { disableFallbacks?: boolean; }).disableFallbacks);
const effectivePrimary = normalizePrimaryService(primary, fileName);
if (disableFallbacks || effectivePrimary === ServiceType.SHAREX || effectivePrimary === ServiceType.S3 || effectivePrimary === ServiceType.ZIPLINE || effectivePrimary === ServiceType.NEST || effectivePrimary === ServiceType.EZHOST) {
return [effectivePrimary];
const order: ServiceType[] = [effectivePrimary];
if (disableFallbacks) {
return order;
}
const order: ServiceType[] = [effectivePrimary];
for (const fallback of FALLBACK_SERVICES) {
for (const fallback of getFallbackServices()) {
if (fallback !== effectivePrimary && canServiceHandleFile(fallback, fileName)) {
order.push(fallback);
}
@ -796,6 +1001,14 @@ function finalizeUploadedUrl(url: string): string {
}
}
function getFilenameExtension(filename: string): string | undefined {
const dotIndex = filename.lastIndexOf(".");
if (dotIndex < 1 || dotIndex === filename.length - 1) return undefined;
const ext = filename.slice(dotIndex + 1).toLowerCase();
return ext.length <= 10 ? ext : undefined;
}
async function notifyUploadSuccess(finalUrl: string): Promise<void> {
if (settings.store.autoCopy) {
if (!finalUrl || !finalUrl.trim()) {
@ -832,6 +1045,8 @@ async function uploadWithFallbacks(fileBlob: Blob, filename: string, primary: Se
totalAttempts: uploadOrder.length,
attempt: 1,
percent: 5,
transferredBytes: 0,
totalBytes: fileBlob.size,
status: `Starting upload via ${serviceLabels[uploadOrder[0]]}...`,
currentService: uploadOrder[0],
currentServiceLabel: serviceLabels[uploadOrder[0]],
@ -842,12 +1057,15 @@ async function uploadWithFallbacks(fileBlob: Blob, filename: string, primary: Se
if (cancelRequested) throw new Error("Upload cancelled by user");
const attempt = attempted.length + 1;
setUploadState({
phase: attempt === 1 ? "uploading" : "retrying",
attempt,
currentService: service,
currentServiceLabel: serviceLabels[service],
percent: Math.min(90, 10 + Math.round((attempt / uploadOrder.length) * 70)),
transferredBytes: 0,
totalBytes: fileBlob.size,
percent: 0,
status: attempt === 1
? `Uploading via ${serviceLabels[service]}...`
: `Retrying with ${serviceLabels[service]} (${attempt}/${uploadOrder.length})...`
@ -902,8 +1120,20 @@ async function uploadWithFallbacks(fileBlob: Blob, filename: string, primary: Se
}
async function normalizeUploadBlob(blob: Blob, sourceUrl?: string): Promise<{ blob: Blob; filename: string; }> {
let sourceFileName = "";
if (blob instanceof File && blob.name) {
sourceFileName = blob.name;
} else if (sourceUrl && URL.canParse(sourceUrl)) {
const segment = new URL(sourceUrl).pathname.split("/").pop();
if (segment) sourceFileName = decodeURIComponent(segment);
}
const extGuessFromSource = sourceUrl ? getUrlExtension(sourceUrl) : undefined;
let ext = await getExtensionFromBytes(blob) || getExtensionFromMime(blob.type) || extGuessFromSource || "png";
let ext = await getExtensionFromBytes(blob)
|| getExtensionFromMime(blob.type)
|| getFilenameExtension(sourceFileName)
|| extGuessFromSource
|| "bin";
if (ext === "apng" && settings.store.apngToGif) {
const gifBlob = await convertApngToGif(blob);
@ -917,14 +1147,6 @@ async function normalizeUploadBlob(blob: Blob, sourceUrl?: string): Promise<{ bl
const mimeType = getMimeFromExtension(ext);
const { preserveOriginalFilename } = settings.store;
let sourceFileName = "";
if (blob instanceof File && blob.name) {
sourceFileName = blob.name;
} else if (sourceUrl && URL.canParse(sourceUrl)) {
const segment = new URL(sourceUrl).pathname.split("/").pop();
if (segment) sourceFileName = decodeURIComponent(segment);
}
let filename = `upload.${ext}`;
if (preserveOriginalFilename && sourceFileName) {
const dotIndex = sourceFileName.lastIndexOf(".");
@ -1022,6 +1244,7 @@ export async function uploadFile(url: string): Promise<void> {
} finally {
isUploading = false;
activeAbortController = null;
activeXhr = null;
setTimeout(() => resetUploadState(), 1800);
}
}
@ -1085,6 +1308,7 @@ export async function uploadProvidedFiles(files: readonly File[]): Promise<void>
} finally {
isUploading = false;
activeAbortController = null;
activeXhr = null;
setTimeout(() => resetUploadState(), 1800);
}
}

View file

@ -5,7 +5,7 @@
*/
import { ChatBarButton } from "@api/ChatButtons";
import { definePluginSettings } from "@api/Settings";
import { definePluginSettings, migratePluginSetting } from "@api/Settings";
import { EquicordDevs } from "@utils/constants";
import { t } from "@utils/translation";
import definePlugin, { OptionType, StartAt } from "@utils/types";
@ -15,12 +15,12 @@ import type { MouseEventHandler, ReactNode } from "react";
let hidechatbuttonsopen: boolean | undefined;
const settings = definePluginSettings({
Color: {
color: {
type: OptionType.BOOLEAN,
description: t("equicord.hideChatButtons.settings.color"),
default: false,
},
Open: {
open: {
type: OptionType.BOOLEAN,
description: t("equicord.hideChatButtons.settings.open"),
default: false,
@ -36,7 +36,7 @@ function HideToggleButton(props: { open: boolean | undefined, onClick: MouseEven
tooltip={props.open ? t("equicord.hideChatButtons.ui.close") : t("equicord.hideChatButtons.ui.open")}
>
<svg
fill={settings.store.Color && props.open ? "#c32a32" : "currentColor"}
fill={settings.store.color && props.open ? "#c32a32" : "currentColor"}
fillRule="evenodd"
width="20"
height="20"
@ -79,29 +79,23 @@ function ButtonsInnerComponent({ buttons }: { buttons: ReactNode; }) {
);
}
migratePluginSetting("HideChatButtons", "open", "Open");
migratePluginSetting("HideChatButtons", "color", "Color");
export default definePlugin({
name: "HideChatButtons",
description: t("equicord.hideChatButtons.description"),
tags: ["Chat", "Utility"],
settings: settings,
authors: [EquicordDevs.iamme],
patches: [
{
find: '"sticker")',
replacement: {
match: /(?<="div",\{.{0,15}children:)(.+?)\}/,
replace: "$self.buttonsInner($1)}"
}
}
],
dependencies: ["ChatInputButtonAPI"],
settings: settings,
startAt: StartAt.Init,
buttonsInner(buttons: ReactNode) {
chatBarButtonWrapper: (buttons: ReactNode) => {
return <ButtonsInnerComponent buttons={buttons} />;
},
start() {
hidechatbuttonsopen = settings.store.Open;
hidechatbuttonsopen = settings.store.open;
},
stop() {
hidechatbuttonsopen = undefined;
}
},
});

View file

@ -101,7 +101,7 @@ export default definePlugin({
},
},
{
find: "keybindActionTypes()",
find: '"push-to-talk-priority"',
predicate: () => settings.store.keybindScreenshare,
replacement: {
match: /=\[(\{id:.{0,25}value:\i\.\i\.UNASSIGNED)/,

View file

@ -324,24 +324,22 @@ export default definePlugin({
patches: [
{
find: "#{intl::UNREADS_TAB_LABEL})}",
group: true,
replacement: [
{
match: /#{intl::Fn6Odn::raw}\)\}\):null/,
replace: "$&,$self.keywordTabBar()"
},
{
match: /:(\i)===\i\.\i\.MENTIONS\?/,
match: /:(\i)===\i\.\i\.MENTIONS\?\(0,.{0,500}null}/,
replace: ": $1 === 8 ? $self.keywordClearButton() $&"
},
{
match: /:(\i)===\i\.\i\.MENTIONS\?\(0,.{0,500}onJump:(\i)}\)/,
replace: ": $1 === 8 ? $self.tryKeywordMenu($2) $&"
}
]
},
{
find: /.FOR_YOU\?.{0,30}\.MENTIONS\?/,
replacement: {
match: /:(\i)===\i\.\i\.MENTIONS\?\(0,.+?onJump:(\i)}\)/,
replace: ": $1 === 8 ? $self.tryKeywordMenu($2) $&"
}
},
{
find: ".guildFilter:null",
replacement: [

View file

@ -29,7 +29,7 @@ const ActivityClasses = findCssClassesLazy("textWithIconContainer", "icon", "tru
const MessageActions = findByPropsLazy("fetchMessages", "sendMessage");
const hasRelevantActivity: (props: ActivityCheckProps) => boolean = findByCodeLazy(".OFFLINE||", ".INVISIBLE)return!1");
const ActivityText: React.ComponentType<ActivityTextProps> = findComponentByCodeLazy("hasQuest:", "hideEmoji:");
const ActivityText: React.ComponentType<ActivityTextProps> = findComponentByCodeLazy('"ActivityStatus"');
const ONE_HOUR_MS = 60 * 60 * 1000;

View file

@ -8,7 +8,8 @@ import * as DataStore from "@api/DataStore";
import { removeRecentStickerByPackId } from "./components";
import { DynamicPackSetMeta, DynamicStickerPackMeta, StickerPack, StickerPackMeta } from "./types";
import { Mutex } from "./utils";
import { corsFetch, Mutex } from "./utils";
const mutex = new Mutex();
const PACKS_KEY = "MoreStickers:Packs";
@ -117,7 +118,7 @@ export async function deleteStickerPack(id: string, packsKey: string = PACKS_KEY
// ---------------------------- Dynamic Packs ----------------------------
export async function getDynamicStickerPack(dspm: DynamicStickerPackMeta): Promise<StickerPack | null> {
const dsp = await fetch(dspm.dynamic.refreshUrl, {
const dsp = await corsFetch(dspm.dynamic.refreshUrl, {
headers: dspm.dynamic.authHeaders,
});
if (!dsp.ok) return null;
@ -133,7 +134,7 @@ function hasDynamicPackSetMeta(dpsm: DynamicPackSetMeta, metas?: DynamicPackSetM
}
export async function fetchDynamicPackSetMeta(dpsm: DynamicPackSetMeta): Promise<DynamicPackSetMeta | null> {
const dpsm_ = await fetch(dpsm.refreshUrl, {
const dpsm_ = await corsFetch(dpsm.refreshUrl, {
headers: dpsm.authHeaders,
});
if (!dpsm_.ok) return null;

View file

@ -6,13 +6,13 @@
import { getMimeFromExtension } from "@equicordplugins/fileUpload/utils/getMediaUrl";
import { FFmpeg } from "@ffmpeg/ffmpeg";
import { fetchFile } from "@ffmpeg/util";
import { insertTextIntoChatInputBox, MessageOptions } from "@utils/discord";
import { CloudUploadPlatform } from "@vencord/discord-types/enums";
import { ChannelStore, CloudUploader, Constants, DraftStore, FluxDispatcher, MessageActions, PendingReplyStore, RestAPI, showToast, SnowflakeUtils, Toasts, UploadHandler } from "@webpack/common";
import { settings } from ".";
import { FFmpegState, Sticker } from "./types";
import { corsFetch } from "./utils";
type SendStickerOptions = {
channelId: string;
@ -75,7 +75,10 @@ async function resizeImage(url: string) {
async function toGIF(url: string, ffmpeg: FFmpeg): Promise<File> {
const filename = (new URL(url)).pathname.split("/").pop() ?? "image.png";
await ffmpeg.writeFile(filename, await fetchFile(url));
const res = await corsFetch(url);
if (!res.ok) throw new Error("Failed to fetch image for GIF conversion");
const arr = new Uint8Array(await res.arrayBuffer());
await ffmpeg.writeFile(filename, arr);
const outputFilename = "output.gif";
await ffmpeg.exec(["-i", filename,
@ -127,7 +130,8 @@ export async function sendSticker({ channelId, sticker, ctrlKey, shiftKey, ffmpe
if (!ffmpegState?.ffmpeg || !ffmpegState.isLoaded) throw new Error("FFmpeg not ready");
file = await toGIF(sticker.image, ffmpegState.ffmpeg);
} else {
const res = await fetch(sticker.image);
const res = await corsFetch(sticker.image);
if (!res.ok) throw new Error("Failed to fetch sticker image");
const blobUrl = URL.createObjectURL(await res.blob());
const processed = await resizeImage(blobUrl);
const filename = sticker.filename ?? new URL(sticker.image).pathname.split("/").pop()!;

View file

@ -18,7 +18,6 @@
import "./spotifyStyles.css";
import { Settings } from "@api/Settings";
import { Flex } from "@components/Flex";
import { CopyIcon, ImageIcon, LinkIcon, OpenExternalIcon } from "@components/Icons";
import { Paragraph } from "@components/Paragraph";
@ -29,6 +28,7 @@ import { copyWithToast, openImageModal } from "@utils/discord";
import { classes } from "@utils/misc";
import { ContextMenuApi, FluxDispatcher, Menu, React, useEffect, useState, useStateFromStores } from "@webpack/common";
import { settings } from "../settings";
import { SeekBar } from "./SeekBar";
import { SpotifyStore, Track } from "./SpotifyStore";
@ -132,7 +132,7 @@ function Controls() {
<Shuffle />
</Button>
<Button onClick={() => {
Settings.plugins.MusicControls.previousButtonRestartsTrack && SpotifyStore.position > 3000 ? SpotifyStore.seek(0) : SpotifyStore.prev();
settings.store.previousButtonRestartsTrack && SpotifyStore.position > 3000 ? SpotifyStore.seek(0) : SpotifyStore.prev();
}}>
<SkipPrev />
</Button>

View file

@ -17,11 +17,12 @@
*/
import { isPluginEnabled } from "@api/PluginManager";
import { Settings } from "@api/Settings";
import OpenInAppPlugin from "@plugins/openInApp";
import { findByProps, findByPropsLazy, proxyLazyWebpack } from "@webpack";
import { Flux, FluxDispatcher } from "@webpack/common";
import { settings } from "../settings";
export interface Track {
id: string;
name: string;
@ -91,7 +92,7 @@ export const SpotifyStore = proxyLazyWebpack(() => {
public isSettingPosition = false;
public openExternal(path: string) {
const url = Settings.plugins.SpotifyControls.useSpotifyUris || isPluginEnabled(OpenInAppPlugin.name)
const url = settings.store.useSpotifyUris || isPluginEnabled(OpenInAppPlugin.name)
? "spotify:" + path.replaceAll("/", (_, idx) => idx === 0 ? "" : ":")
: "https://open.spotify.com" + path;

View file

@ -75,6 +75,13 @@ export default definePlugin({
},
]
},
{
find: ".ORBS,label:",
replacement: {
match: /\{enabled:(\i).{0,25}"collectibles_shop_header_bar"\}\)/,
replace: "$1=true"
}
}
],
getPlatform(bypass, userId?: any) {
const platform = settings.store.platform ?? "desktop";

View file

@ -0,0 +1,368 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { plugins } from "@api/PluginManager";
import { ErrorBoundary } from "@components/index";
import { findComponentByCodeLazy } from "@webpack";
import { ContextMenuApi, Menu, NavigationRouter, useState } from "@webpack/common";
import type { CSSProperties, JSX, MouseEvent } from "react";
import { getQuestifySettings, useQuestifySettings } from "../settings/access";
import type { QuestButtonAction, QuestButtonDisplayMode, QuestButtonIndicatorMode } from "../settings/def";
import { getIgnoredQuestIDs, ignoreAllQuests, resetIgnoredQuests } from "../settings/ignoredQuests";
import { initialQuestDataFetched } from "../state";
import { fetchAndAlertQuests } from "../utils/fetching";
import { decimalToRGB, formatLowerBadge, isDarkish, leftClick, middleClick, q, QUEST_PAGE, rightClick } from "../utils/ui";
import { openQuestifySettingsModal } from "./settingsModal";
const GuildlessServerListItemComponent = findComponentByCodeLazy("tooltip:", "lowerBadgeSize:");
const ServerListItemPillComponent = findComponentByCodeLazy('"pill":"empty"');
const ServerListItemLowerBadgeComponent = findComponentByCodeLazy("BADGE_NOTIFICATION_BACKGROUND", "let{count:");
interface QuestButtonLowerBadgeProps {
count: number;
color?: string;
style?: CSSProperties;
maxDigits?: number;
}
interface QuestButtonViewProps {
id: string;
className: string;
badgeProps: QuestButtonLowerBadgeProps;
hasUnread: boolean;
selected: boolean;
visible: boolean;
onPress: (event: MouseEvent<Element>) => void;
}
export function QuestIcon(height: number, width: number, className?: string): JSX.Element {
return (
<svg
viewBox="0 0 24 24"
height={height}
width={width}
fill="none"
className={className}
>
<path fill="currentColor" d="M7.5 21.7a8.95 8.95 0 0 1 9 0 1 1 0 0 0 1-1.73c-.6-.35-1.24-.64-1.9-.87.54-.3 1.05-.65 1.52-1.07a3.98 3.98 0 0 0 5.49-1.8.77.77 0 0 0-.24-.95 3.98 3.98 0 0 0-2.02-.76A4 4 0 0 0 23 10.47a.76.76 0 0 0-.71-.71 4.06 4.06 0 0 0-1.6.22 3.99 3.99 0 0 0 .54-5.35.77.77 0 0 0-.95-.24c-.75.36-1.37.95-1.77 1.67V6a4 4 0 0 0-4.9-3.9.77.77 0 0 0-.6.72 4 4 0 0 0 3.7 4.17c.89 1.3 1.3 2.95 1.3 4.51 0 3.66-2.75 6.5-6 6.5s-6-2.84-6-6.5c0-1.56.41-3.21 1.3-4.51A4 4 0 0 0 11 2.82a.77.77 0 0 0-.6-.72 4.01 4.01 0 0 0-4.9 3.96A4.02 4.02 0 0 0 3.73 4.4a.77.77 0 0 0-.95.24 3.98 3.98 0 0 0 .55 5.35 4 4 0 0 0-1.6-.22.76.76 0 0 0-.72.71l-.01.28a4 4 0 0 0 2.65 3.77c-.75.06-1.45.33-2.02.76-.3.22-.4.62-.24.95a4 4 0 0 0 5.49 1.8c.47.42.98.78 1.53 1.07-.67.23-1.3.52-1.91.87a1 1 0 1 0 1 1.73Z" />
</svg>
);
}
function splitClassNames(className: string): string[] {
const classNames = className.split(/\s+/).filter(Boolean);
return classNames.length ? classNames : [q("quest-button")];
}
function suffixClassNames(classNames: string[], suffix: string): string {
return classNames.map(className => `${className}-${suffix}`).join(" ");
}
function QuestButtonLowerBadge(props: QuestButtonLowerBadgeProps & { className: string; }): JSX.Element {
return <ServerListItemLowerBadgeComponent {...props} />;
}
function QuestButtonView({
id,
className,
badgeProps,
hasUnread,
selected,
visible,
onPress,
}: QuestButtonViewProps): JSX.Element {
const [hovered, setHovered] = useState(false);
const baseClasses = splitClassNames(className);
const mainContainerClass = suffixClassNames(baseClasses, "container");
const iconContainerClass = suffixClassNames(baseClasses, "icon-container");
const buttonContainerClass = suffixClassNames(baseClasses, "server-list-button-container");
const pillContainerClass = suffixClassNames(baseClasses, "pill-container");
const buttonClass = suffixClassNames(baseClasses, "button");
const pillClass = suffixClassNames(baseClasses, "pill");
const lowerBadgeClass = suffixClassNames(baseClasses, "lower-badge");
const lowerBadgeSize = { width: formatLowerBadge(badgeProps.count, badgeProps.maxDigits)[1] };
const lowerBadge = badgeProps.count === 0
? null
: (
<QuestButtonLowerBadge
{...badgeProps}
className={lowerBadgeClass}
style={{ ...(badgeProps.style ?? {}), ...lowerBadgeSize }}
/>
);
const icon = (
<div className={iconContainerClass}>
{QuestIcon(26, 26)}
</div>
);
return (
<ErrorBoundary>
{visible && (
<div id={id} className={mainContainerClass}>
<div className={pillContainerClass}>
<ServerListItemPillComponent
unread={hasUnread}
selected={selected}
hovered={hovered}
className={`${pillClass}${selected ? " selected" : hovered ? " hovered" : ""}`}
/>
</div>
<div className={buttonContainerClass}>
<GuildlessServerListItemComponent
icon={() => icon}
tooltip="Quests"
showPill={false}
selected={selected}
className={buttonClass}
onClick={onPress}
onMouseDown={onPress}
onContextMenu={onPress}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
{...(lowerBadge ? { lowerBadge, lowerBadgeSize } : {})}
/>
</div>
</div>
)}
</ErrorBoundary>
);
}
export function showQuestButton(displayMode: QuestButtonDisplayMode, badgeCount: number, isOnQuestsPage: boolean): boolean {
return displayMode !== "never" && (displayMode === "always" || badgeCount > 0 || isOnQuestsPage);
}
export function disguiseHomeButton(pathname: string): boolean {
const buttonSettings = useQuestifySettings(["questButtonDisplay", "questButtonBadgeCount", "isOnQuestsPage"]);
if (!showQuestButton(buttonSettings.questButtonDisplay, buttonSettings.questButtonBadgeCount, buttonSettings.isOnQuestsPage)) {
return false;
}
return pathname === QUEST_PAGE;
}
function resolveQuestButtonAction(
event: MouseEvent<Element>,
leftAction: QuestButtonAction,
middleAction: QuestButtonAction,
rightAction: QuestButtonAction,
): QuestButtonAction | null {
if (event.button === middleClick) {
return middleAction;
}
if (event.button === rightClick) {
return rightAction;
}
if (event.button === leftClick) {
return leftAction;
}
return null;
}
function getQuestButtonLowerBadgeProps(
badgeCount: number,
indicatorMode: QuestButtonIndicatorMode,
badgeColor: number | null,
): QuestButtonLowerBadgeProps {
const resolvedColor = badgeColor === null ? null : decimalToRGB(badgeColor);
return {
count: indicatorMode === "badge" || indicatorMode === "both" ? badgeCount : 0,
maxDigits: 2,
...(resolvedColor
? { color: `rgb(${resolvedColor.r}, ${resolvedColor.g}, ${resolvedColor.b})` }
: {}),
...(resolvedColor
? { style: { color: isDarkish(resolvedColor) ? "white" : "black" } }
: {}),
};
}
export function QuestButtonContextMenu({ dummy = false }: { dummy?: boolean; }): JSX.Element {
const navId = q(dummy ? "dummy-quest-button-context-menu" : "quest-button-context-menu");
const markAllIgnoredDisabled = dummy || getQuestifySettings().questButtonBadgeCount <= 0;
const resetIgnoredDisabled = dummy || getIgnoredQuestIDs().length <= 0;
const fetchQuestsDisabled = dummy;
return (
<Menu.Menu
navId={navId}
onClose={ContextMenuApi.closeContextMenu}
aria-label="Quest Button Menu"
>
<Menu.MenuItem
id={q(`${navId}-mark-all-ignored`)}
label="Mark All Ignored"
action={ignoreAllQuests}
disabled={markAllIgnoredDisabled}
/>
<Menu.MenuItem
id={q(`${navId}-reset-ignored-list`)}
label="Reset Ignored List"
action={resetIgnoredQuests}
disabled={resetIgnoredDisabled}
/>
<Menu.MenuItem
id={q(`${navId}-fetch-quests`)}
label="Fetch Quests"
action={() => { void fetchAndAlertQuests("MANUAL_FETCH"); }}
disabled={fetchQuestsDisabled}
/>
</Menu.Menu>
);
}
export function QuestButton(): JSX.Element {
const {
isOnQuestsPage,
questButtonBadgeColor,
questButtonDisplay,
questButtonBadgeCount,
questButtonLeftClickAction,
questButtonMiddleClickAction,
questButtonRightClickAction,
questButtonIndicator,
} = useQuestifySettings([
"isOnQuestsPage",
"questButtonBadgeColor",
"questButtonDisplay",
"questButtonBadgeCount",
"questButtonLeftClickAction",
"questButtonMiddleClickAction",
"questButtonRightClickAction",
"questButtonIndicator",
]);
const staleData = !initialQuestDataFetched;
const badgeColor = questButtonBadgeColor;
const badgeCount = staleData ? 0 : questButtonBadgeCount;
const onQuestsPage = staleData ? false : isOnQuestsPage;
const displayMode = questButtonDisplay as QuestButtonDisplayMode;
const leftClickAction = questButtonLeftClickAction as QuestButtonAction;
const middleClickAction = questButtonMiddleClickAction as QuestButtonAction;
const rightClickAction = questButtonRightClickAction as QuestButtonAction;
const indicatorMode = questButtonIndicator as QuestButtonIndicatorMode;
const lowerBadgeProps = getQuestButtonLowerBadgeProps(badgeCount, indicatorMode, badgeColor);
const hasUnread = indicatorMode === "pill" || indicatorMode === "both";
const isVisible = showQuestButton(displayMode, badgeCount, onQuestsPage);
function handlePress(event: MouseEvent<Element>): void {
// List items do not support onAuxClick, so middle-click is handled on mousedown.
if (event.type === "mousedown" && event.button !== middleClick) {
return;
}
event.preventDefault();
event.stopPropagation();
const action = resolveQuestButtonAction(
event,
leftClickAction,
middleClickAction,
rightClickAction,
);
if (action === "open-quests") {
NavigationRouter.transitionTo(QUEST_PAGE);
return;
}
if (action === "plugin-settings") {
openQuestifySettingsModal(plugins.Questify);
return;
}
if (action === "context-menu") {
ContextMenuApi.openContextMenu(event, () => <QuestButtonContextMenu />);
}
}
return (
<QuestButtonView
id={q("quest-button")}
className={q("quest-button")}
selected={onQuestsPage}
visible={isVisible}
hasUnread={badgeCount > 0 && hasUnread}
badgeProps={lowerBadgeProps}
onPress={handlePress}
/>
);
}
export interface DummyQuestButtonProps {
badgeColor: number | null;
leftClickAction: QuestButtonAction;
middleClickAction: QuestButtonAction;
rightClickAction: QuestButtonAction;
showBadge: boolean;
showPill: boolean;
visible: boolean;
}
export function DummyQuestButton({
badgeColor,
leftClickAction,
middleClickAction,
rightClickAction,
showBadge,
showPill,
visible,
}: DummyQuestButtonProps): JSX.Element {
const badgeCount = showBadge ? 3 : 0;
const indicatorMode = showBadge ? (showPill ? "both" : "badge") : (showPill ? "pill" : "none");
const lowerBadgeProps = getQuestButtonLowerBadgeProps(badgeCount, indicatorMode, badgeColor);
const [dummySelected, setDummySelected] = useState(false);
function handlePress(event: MouseEvent<Element>): void {
// List items do not support onAuxClick, so middle-click is handled on mousedown.
if (event.type === "mousedown" && event.button !== middleClick) {
return;
}
event.preventDefault();
event.stopPropagation();
const action = resolveQuestButtonAction(
event,
leftClickAction,
middleClickAction,
rightClickAction,
);
if (action === "open-quests") {
setDummySelected(!dummySelected);
return;
}
if (action === "context-menu") {
ContextMenuApi.openContextMenu(event, () => <QuestButtonContextMenu dummy={true} />);
}
}
return (
<QuestButtonView
id={q("dummy-quest-button")}
className={q("dummy-quest-button", "quest-button")}
selected={dummySelected}
visible={visible}
hasUnread={showPill}
badgeProps={lowerBadgeProps}
onPress={handlePress}
/>
);
}

View file

@ -0,0 +1,276 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { QuestRewardType, QuestTaskType } from "@vencord/discord-types/enums";
import type { JSX } from "react";
import { getQuestifySettings, useQuestifySettings } from "../settings/access";
import { type QuestButtonAction, type QuestButtonDisplayMode, type QuestButtonIncludedTypes, type QuestButtonIndicatorMode } from "../settings/def";
import { startAutoFetchingQuests } from "../settings/fetching";
import { validateIgnoredQuests } from "../settings/ignoredQuests";
import { canShowBadge, canShowButton, canShowPill } from "../utils/ui";
import { DummyQuestButton } from "./questButton";
import { type ManaSelectOption, SettingsCard, SettingsColorPicker, SettingsDescription, SettingsHeader, SettingsRow, SettingsRowItem, SettingsSelect, SettingsSubheader } from "./shared";
interface QuestButtonIncludedTypeOption {
label: string;
value: QuestTaskType | QuestRewardType;
}
const questButtonDisplayOptions = [
{ label: "Always", value: "always" },
{ label: "Unclaimed", value: "unclaimed" },
{ label: "Never", value: "never" },
] as const satisfies readonly { label: string; value: QuestButtonDisplayMode; }[];
const questButtonIndicatorOptions = [
{ label: "Pill", value: "pill" },
{ label: "Badge", value: "badge" },
{ label: "Both", value: "both" },
{ label: "None", value: "none" },
] as const satisfies readonly { label: string; value: QuestButtonIndicatorMode; }[];
const questButtonClickOptions = [
{ label: "Open Quests", value: "open-quests" },
{ label: "Context Menu", value: "context-menu" },
{ label: "Plugin Settings", value: "plugin-settings" },
{ label: "Nothing", value: "nothing" },
] as const satisfies readonly { label: string; value: QuestButtonAction; }[];
const questButtonRewardTypeOptions = [
{ label: "Orbs", value: QuestRewardType.VIRTUAL_CURRENCY },
{ label: "Nitro Codes", value: QuestRewardType.FRACTIONAL_PREMIUM },
{ label: "Reward Codes", value: QuestRewardType.REWARD_CODE },
{ label: "In Game Items", value: QuestRewardType.IN_GAME },
{ label: "Profile Collectibles", value: QuestRewardType.COLLECTIBLE },
] as const satisfies readonly QuestButtonIncludedTypeOption[];
const questButtonQuestTypeOptions = [
{ label: "Watch Video", value: QuestTaskType.WATCH_VIDEO },
{ label: "Watch Video on Mobile", value: QuestTaskType.WATCH_VIDEO_ON_MOBILE },
{ label: "Achievement in Activity", value: QuestTaskType.ACHIEVEMENT_IN_ACTIVITY },
{ label: "Achievement in Game", value: QuestTaskType.ACHIEVEMENT_IN_GAME },
{ label: "Play Activity", value: QuestTaskType.PLAY_ACTIVITY },
{ label: "Play on Desktop", value: QuestTaskType.PLAY_ON_DESKTOP },
{ label: "Play on Desktop V2", value: QuestTaskType.PLAY_ON_DESKTOP_V2 },
{ label: "Stream on Desktop", value: QuestTaskType.STREAM_ON_DESKTOP },
{ label: "Play on PlayStation", value: QuestTaskType.PLAY_ON_PLAYSTATION },
{ label: "Play on Xbox", value: QuestTaskType.PLAY_ON_XBOX },
] as const satisfies readonly QuestButtonIncludedTypeOption[];
function toManaOptions<T extends string | number>(options: readonly { label: string; value: T; }[]): ManaSelectOption[] {
return options.map(({ label, value }) => ({
id: String(value),
label,
value: String(value),
}));
}
const questButtonDisplayManaOptions = toManaOptions(questButtonDisplayOptions);
const questButtonIndicatorManaOptions = toManaOptions(questButtonIndicatorOptions);
const questButtonClickManaOptions = toManaOptions(questButtonClickOptions);
const questButtonRewardTypeManaOptions = toManaOptions(questButtonRewardTypeOptions);
const questButtonQuestTypeManaOptions = toManaOptions(questButtonQuestTypeOptions);
export function QuestButtonSetting(): JSX.Element {
const questButton = useQuestifySettings([
"disableQuestsEverything",
"questButtonDisplay",
"questButtonIncludedTypes",
"questButtonIndicator",
"questButtonBadgeCount",
"questButtonBadgeColor",
"questButtonLeftClickAction",
"questButtonMiddleClickAction",
"questButtonRightClickAction",
]);
const disabled = questButton.disableQuestsEverything;
const includedTypes = questButton.questButtonIncludedTypes as QuestButtonIncludedTypes;
const selectedRewardTypes = questButtonRewardTypeOptions
.filter(({ value }) => includedTypes[value])
.map(({ value }) => String(value));
const selectedQuestTypes = questButtonQuestTypeOptions
.filter(({ value }) => includedTypes[value])
.map(({ value }) => String(value));
function updateQuestButtonDisplay(value: string | string[] | null) {
if (typeof value !== "string") return;
getQuestifySettings().questButtonDisplay = value as QuestButtonDisplayMode;
startAutoFetchingQuests(true);
}
function updateQuestButtonIndicator(value: string | string[] | null) {
if (typeof value !== "string") return;
getQuestifySettings().questButtonIndicator = value as QuestButtonIndicatorMode;
startAutoFetchingQuests(true);
}
function updateQuestButtonAction(key: "questButtonLeftClickAction" | "questButtonMiddleClickAction" | "questButtonRightClickAction", value: string | string[] | null) {
if (typeof value !== "string") return;
getQuestifySettings()[key] = value as QuestButtonAction;
}
function updateBadgeColor(value: number | null) {
getQuestifySettings().questButtonBadgeColor = value;
}
function updateIncludedTypes(options: readonly QuestButtonIncludedTypeOption[], value: string | string[] | null) {
const selectedValues = new Set(Array.isArray(value) ? value : value ? [value] : []);
const nextIncludedTypes = { ...getQuestifySettings().questButtonIncludedTypes };
for (const option of options) {
nextIncludedTypes[option.value] = selectedValues.has(String(option.value));
}
getQuestifySettings().questButtonIncludedTypes = nextIncludedTypes;
validateIgnoredQuests();
}
return (
<SettingsCard>
<SettingsRow>
<SettingsRowItem>
<SettingsHeader> Quest Button </SettingsHeader>
<SettingsDescription> Show a Quest Button in the server list with an optional indicator for unclaimed and unignored Quests. </SettingsDescription>
</SettingsRowItem>
<SettingsRowItem width="content">
<DummyQuestButton
badgeColor={questButton.questButtonBadgeColor}
leftClickAction={questButton.questButtonLeftClickAction}
middleClickAction={questButton.questButtonMiddleClickAction}
rightClickAction={questButton.questButtonRightClickAction}
showBadge={canShowBadge(questButton.questButtonIndicator)}
showPill={canShowPill(questButton.questButtonIndicator)}
visible={canShowButton(questButton.questButtonDisplay)}
/>
</SettingsRowItem>
</SettingsRow>
<SettingsSubheader className="no-top-margin"> Button Behavior </SettingsSubheader>
<SettingsRow>
<SettingsRowItem>
<SettingsSelect
label="Left Click Action:"
options={questButtonClickManaOptions}
value={questButton.questButtonLeftClickAction}
selectionMode="single"
disabled={disabled}
fullWidth={true}
onSelectionChange={value => updateQuestButtonAction("questButtonLeftClickAction", value)}
/>
</SettingsRowItem>
<SettingsRowItem>
<SettingsSelect
label="Middle Click Action:"
options={questButtonClickManaOptions}
value={questButton.questButtonMiddleClickAction}
selectionMode="single"
disabled={disabled}
fullWidth={true}
onSelectionChange={value => updateQuestButtonAction("questButtonMiddleClickAction", value)}
/>
</SettingsRowItem>
<SettingsRowItem>
<SettingsSelect
label="Right Click Action:"
options={questButtonClickManaOptions}
value={questButton.questButtonRightClickAction}
selectionMode="single"
disabled={disabled}
fullWidth={true}
onSelectionChange={value => updateQuestButtonAction("questButtonRightClickAction", value)}
/>
</SettingsRowItem>
</SettingsRow>
<SettingsRow>
<SettingsRowItem>
<SettingsSelect
label="Button Visibility:"
options={questButtonDisplayManaOptions}
value={questButton.questButtonDisplay}
selectionMode="single"
disabled={disabled}
fullWidth={true}
onSelectionChange={updateQuestButtonDisplay}
tooltip={{
position: "top",
text: "Always shows the Quest Button whenever this feature is enabled."
+ "\n\nUnclaimed only shows it while you have relevant unclaimed Quest rewards."
+ "\n\nNever hides the Quest Button."
}}
/>
</SettingsRowItem>
<SettingsRowItem>
<SettingsSelect
label="Unclaimed Indicator:"
options={questButtonIndicatorManaOptions}
value={questButton.questButtonIndicator}
selectionMode="single"
disabled={disabled}
fullWidth={true}
onSelectionChange={updateQuestButtonIndicator}
tooltip={{
position: "top",
text: "Pill shows Discord's unread-style marker beside the Quest Button."
+ "\n\nBadge shows the number of relevant unclaimed Quest rewards."
+ "\n\nBoth shows the pill and badge together."
+ "\n\nNone hides unclaimed indicators."
}}
/>
</SettingsRowItem>
<SettingsRowItem>
<SettingsColorPicker
label="Badge Color:"
className="quest-button-color-picker"
color={questButton.questButtonBadgeColor}
disabled={disabled}
onChange={updateBadgeColor}
showEyeDropper={true}
/>
</SettingsRowItem>
</SettingsRow>
<SettingsSubheader> Quest Relevancy </SettingsSubheader>
<SettingsSelect
label="Included Reward Types:"
wrapTags={true}
options={questButtonRewardTypeManaOptions}
value={selectedRewardTypes}
closeOnSelect={false}
maxOptionsVisible={questButtonRewardTypeManaOptions.length}
selectionMode="multiple"
disabled={disabled}
fullWidth={true}
onSelectionChange={value => updateIncludedTypes(questButtonRewardTypeOptions, value)}
tooltip={{
position: "top",
text: "Only count Quests with these reward types as unclaimed when determining button visibility, badge count, and alert behavior."
}}
/>
<SettingsSelect
label="Included Quest Types:"
labelClassName="margin-top-9"
wrapTags={true}
options={questButtonQuestTypeManaOptions}
value={selectedQuestTypes}
closeOnSelect={false}
maxOptionsVisible={questButtonQuestTypeManaOptions.length}
selectionMode="multiple"
disabled={disabled}
fullWidth={true}
onSelectionChange={value => updateIncludedTypes(questButtonQuestTypeOptions, value)}
tooltip={{
position: "top",
text: "Only count Quests with these task types as unclaimed when determining button visibility, badge count, and alert behavior."
}}
/>
</SettingsCard>
);
}

View file

@ -0,0 +1,366 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { QuestTaskType } from "@vencord/discord-types/enums";
import type { JSX } from "react";
import { getQuestifySettings, useQuestifySettings } from "../settings/access";
import { autoCompleteQuestTaskTypes, defaultAllowChangingDangerousSettings, defaultAutoCompleteQuestsSimultaneously, defaultAutoCompleteQuestTypes, defaultCompleteVideoQuestsQuicker, defaultMakeMobileVideoQuestsDesktopCompatible, defaultResumeInterruptedQuests, isDesktopCompatible } from "../settings/def";
import { Alerts, q } from "../utils/ui";
import { ManaButton, type ManaSelectOption, SettingsCard, SettingsDescription, SettingsHeader, SettingsNotice, SettingsParagraph, SettingsSelect, SettingsSubheader, SettingsSubtleSwitch } from "./shared";
type QuestDisableSettingKey =
| "disableAccountPanelPromo"
| "disableAccountPanelQuestProgress"
| "disableFriendsListPromo"
| "disableMembersListPromo"
| "disableOrbsAndQuestsBadges"
| "disableRelocationNotices"
| "disableSponsoredBanner";
type QuestModifySettingKey =
| "autoCompleteQuestsSimultaneously"
| "resumeInterruptedQuests"
| "completeVideoQuestsQuicker"
| "makeMobileVideoQuestsDesktopCompatible";
interface QuestDisableOption {
key: QuestDisableSettingKey;
label: string;
}
const disableFeatureOptions = [
{
key: "disableSponsoredBanner",
label: "Sponsored Banner",
},
{
key: "disableRelocationNotices",
label: "Relocation Notices",
},
{
key: "disableFriendsListPromo",
label: "Friends List Promo",
},
{
key: "disableMembersListPromo",
label: "Members List Promo",
},
{
key: "disableAccountPanelPromo",
label: "Account Panel Promo",
},
{
key: "disableAccountPanelQuestProgress",
label: "Account Panel Progress",
},
{
key: "disableOrbsAndQuestsBadges",
label: "Quest & Orbs Badges",
},
] as const satisfies readonly QuestDisableOption[];
const disableManaOptions: ManaSelectOption[] = disableFeatureOptions.map(({ key, label }) => ({
id: key,
label,
value: key,
}));
const autoCompleteQuestTypeLabels = {
[QuestTaskType.WATCH_VIDEO]: "Watch Video",
[QuestTaskType.WATCH_VIDEO_ON_MOBILE]: "Watch Video on Mobile",
[QuestTaskType.ACHIEVEMENT_IN_ACTIVITY]: "Achievement in Activity",
[QuestTaskType.PLAY_ACTIVITY]: "Play Activity",
[QuestTaskType.PLAY_ON_DESKTOP]: "Play on Desktop",
[QuestTaskType.PLAY_ON_PLAYSTATION]: "Play on PlayStation",
[QuestTaskType.PLAY_ON_XBOX]: "Play on Xbox",
} as const satisfies Record<typeof autoCompleteQuestTaskTypes[number], string>;
const autoCompleteQuestTypeOptions = autoCompleteQuestTaskTypes.map(questType => ({
label: autoCompleteQuestTypeLabels[questType],
value: questType,
})) satisfies { label: string; value: QuestTaskType; }[];
const autoCompleteQuestTypeManaOptions: ManaSelectOption[] = autoCompleteQuestTypeOptions.map(({ label, value }) => ({
id: String(value),
label,
value: String(value),
disabled: !isDesktopCompatible(value),
}));
interface SettingsAllowDangerousButtonProps {
allowed: boolean;
disabled?: boolean;
onClick?: (e: React.MouseEvent) => void;
}
function SettingsAllowDangerousButton({
allowed,
disabled,
onClick,
}: SettingsAllowDangerousButtonProps): JSX.Element {
return (
<div className={q("settings-button", "allow-dangerous-button")}>
<ManaButton
text={allowed
? "Reset and disallow changing dangerous settings..."
: "Allow changing dangerous settings..."}
variant={allowed ? "critical-secondary" : "critical-primary"}
fullWidth={true}
disabled={disabled}
onClick={onClick}
size="sm"
/>
</div>
);
}
export function QuestFeaturesSetting(): JSX.Element {
const questFeatures = useQuestifySettings([
"disableQuestsEverything",
"disableSponsoredBanner",
"disableRelocationNotices",
"disableFriendsListPromo",
"disableMembersListPromo",
"disableAccountPanelPromo",
"disableAccountPanelQuestProgress",
"disableOrbsAndQuestsBadges",
"resumeInterruptedQuests",
"allowChangingDangerousSettings",
"makeMobileVideoQuestsDesktopCompatible",
"autoCompleteQuestsSimultaneously",
"autoCompleteQuestTypes",
"completeVideoQuestsQuicker",
]);
const selectedDisableValues = disableFeatureOptions
.filter(({ key }) => questFeatures[key])
.map(({ key }) => key);
const selectedAutoCompleteQuestTypeValues = autoCompleteQuestTypeOptions
.filter(({ value }) => isDesktopCompatible(value) && questFeatures.autoCompleteQuestTypes[value] === true)
.map(({ value }) => String(value));
function updateDisableValue(value: string | string[] | null) {
const selectedKeys = new Set(Array.isArray(value) ? value : value ? [value] : []);
for (const { key } of disableFeatureOptions) {
getQuestifySettings()[key] = selectedKeys.has(key);
}
}
function confirmDangerousSettingChange(body: string, onConfirm: () => void) {
Alerts.show({
title: "Are you sure?",
body,
confirmText: "Continue",
confirmVariant: "critical-primary",
cancelText: "Cancel",
onConfirm,
});
}
function updateAutoCompleteQuestTypes(value: string | string[] | null) {
const selectedValues = new Set(Array.isArray(value) ? value : value ? [value] : []);
const nextAutoCompleteQuestTypes = { ...defaultAutoCompleteQuestTypes };
for (const { value: questType } of autoCompleteQuestTypeOptions) {
const nextValue = selectedValues.has(String(questType))
&& isDesktopCompatible(questType);
nextAutoCompleteQuestTypes[questType] = nextValue;
}
getQuestifySettings().autoCompleteQuestTypes = nextAutoCompleteQuestTypes;
}
function updateDisableEverything(checked: boolean) {
function setDisableEverything() {
getQuestifySettings().disableQuestsEverything = checked;
}
if (checked) {
confirmDangerousSettingChange(
"This will completely disable Quest functionality.",
() => {
resetDangerousSettings();
setDisableEverything();
}
);
} else {
setDisableEverything();
}
}
function resetDangerousSettings() {
const settings = getQuestifySettings();
settings.allowChangingDangerousSettings = defaultAllowChangingDangerousSettings;
settings.autoCompleteQuestsSimultaneously = defaultAutoCompleteQuestsSimultaneously;
settings.completeVideoQuestsQuicker = defaultCompleteVideoQuestsQuicker;
settings.makeMobileVideoQuestsDesktopCompatible = defaultMakeMobileVideoQuestsDesktopCompatible;
settings.resumeInterruptedQuests = defaultResumeInterruptedQuests;
settings.autoCompleteQuestTypes = { ...defaultAutoCompleteQuestTypes };
}
function updateDangerousAccess(checked: boolean) {
function setDangerousAccess() {
getQuestifySettings().allowChangingDangerousSettings = checked;
}
if (checked) {
confirmDangerousSettingChange(
"This will allow changing dangerous settings.",
setDangerousAccess
);
} else {
resetDangerousSettings();
setDangerousAccess();
}
}
function updateModifyValue(key: QuestModifySettingKey, checked: boolean) {
getQuestifySettings()[key] = checked;
}
return (
<SettingsCard>
<SettingsHeader> Quest Features </SettingsHeader>
<SettingsDescription> Modify how Quests behave to enhance or remove functionality. </SettingsDescription>
<SettingsSubheader> Disable Features </SettingsSubheader>
<SettingsSubtleSwitch
checked={questFeatures.disableQuestsEverything}
label="Completely disable Quest functionality:"
onChange={updateDisableEverything}
bottomSpacing="10"
tooltip={{
position: "top",
text: "This will disable all plugin enhancements, hide the Quests page and Quest elements across Discord, and prevent Discord from fetching Quest data. This will not affect the shop as Orbs are too intrinsically tied to it as a secondary currency."
}}
/>
<SettingsSelect
label="Disable specific features:"
wrapTags={true}
options={disableManaOptions}
value={selectedDisableValues}
closeOnSelect={false}
maxOptionsVisible={7}
selectionMode="multiple"
disabled={questFeatures.disableQuestsEverything}
onSelectionChange={updateDisableValue}
tooltip={{
position: "top",
text: "Sponsored Banner is a paid-for Quest banner at the top of the Quests page."
+ "\n\nRelocation Notices are indicators such as in the Discovery page about Quests moving to DMs."
+ "\n\nFriends List Promo is a card that displays on the \"Active Now\" section of your Friends List while a user you share a server with is playing a game with an active Quest."
+ "\n\nMembers List Promo is an icon that displays on members in a server's Members List while they are playing a game with an active Quest."
+ "\n\nAccount Panel Promo is a paid-for Quest promotion that appears above your user account panel."
+ "\n\nAccount Panel Progress is the active or completed Quest progress shown above your user account panel."
+ "\n\nQuest & Orbs Badges are badges on user profiles for when someone has completed at least one Quest or bought the Orbs badge respectively."
}}
/>
<SettingsSubheader> Modify Features </SettingsSubheader>
<SettingsNotice className={["notice-card-red", questFeatures.disableQuestsEverything ? "dimmed-settings-item" : undefined, questFeatures.allowChangingDangerousSettings ? undefined : "notice-card-solo", "no-bottom-margin"].filter(c => c !== undefined)}>
<SettingsParagraph>
Discord has began issuing warnings to users of scripts or plugins that modify how Quests are completed, which is against their <a href="https://discord.com/safety/platform-manipulation-policy-explainer" target="_blank" rel="noreferrer">Terms of Service</a>.
</SettingsParagraph>
<br />
<SettingsParagraph>
The warnings appear limited to threat of loss of access to Quests or their rewards, but Discord may escalate at any time.
</SettingsParagraph>
<br />
<SettingsParagraph>
Due to the various methods Discord uses to track users, there's no way to realistically evade detection. If you proceed, understand that Discord likely will detect it at some point.
</SettingsParagraph>
<br />
<SettingsParagraph>
Use the following toggle to access potentially dangerous settings at your own risk.
</SettingsParagraph>
<SettingsAllowDangerousButton
allowed={questFeatures.allowChangingDangerousSettings}
disabled={questFeatures.disableQuestsEverything}
onClick={() => updateDangerousAccess(!questFeatures.allowChangingDangerousSettings)}
/>
{questFeatures.allowChangingDangerousSettings && <>
<SettingsSubtleSwitch
disabled={questFeatures.disableQuestsEverything || !questFeatures.allowChangingDangerousSettings}
checked={questFeatures.completeVideoQuestsQuicker}
label="Accelerate Video Quest auto-completion:"
onChange={checked => updateModifyValue("completeVideoQuestsQuicker", checked)}
topSpacing="10"
bottomSpacing="5"
tooltip={{
position: "top",
text: "Discord allows Video Quests to be completed once 24 seconds less than the duration of the video has passed since you enrolled into the Quest."
+ "\n\nThis means that if a Video Quest is 24 seconds or less, or if you enroll in a Video Quest and return later to complete it, it can be completed immediately."
+ "\n\nThis setting will only apply to auto-completing Video Quests and relies on the auto-complete setting below. Manually completing Video Quests will still require waiting the full duration and is not dependent on enrollment time."
}}
/>
<SettingsSubtleSwitch
disabled={questFeatures.disableQuestsEverything || !questFeatures.allowChangingDangerousSettings}
checked={questFeatures.makeMobileVideoQuestsDesktopCompatible}
label="Make some mobile-only Video Quests completable on desktop:"
onChange={checked => updateModifyValue("makeMobileVideoQuestsDesktopCompatible", checked)}
bottomSpacing="5"
tooltip={{
position: "top",
text: "Some mobile-only Video Quests can be enrolled in on desktop, but still must be completed on mobile. This setting will allow those to be completed on desktop."
+ "\n\nSome mobile-only Video Quests are only enrollable on mobile. For this setting to affect those, you must enroll in those Quests on your mobile device before returning to desktop and refreshing your Quests."
+ "\n\nThis setting, when enabled independently, applies only to manually completing Video Quests. Auto-completing mobile Video Quests from desktop relies on the auto-complete setting below, and will implicitly enable this setting as well."
}}
/>
<SettingsSubtleSwitch
disabled={questFeatures.disableQuestsEverything || !questFeatures.allowChangingDangerousSettings}
checked={questFeatures.autoCompleteQuestsSimultaneously}
label="Auto-complete Quests simultaneously rather than sequentially:"
onChange={checked => updateModifyValue("autoCompleteQuestsSimultaneously", checked)}
bottomSpacing="5"
tooltip={{
position: "top",
text: "By default, attempting to auto-complete multiple Quests will queue them to be completed in order."
+ "\n\nThis setting will alternatively allow all auto-complete Quests to run at the same time."
+ "\n\nThis setting will only apply to auto-completing Quests and relies on the auto-complete setting below."
}}
/>
<SettingsSubtleSwitch
disabled={questFeatures.disableQuestsEverything || !questFeatures.allowChangingDangerousSettings}
checked={questFeatures.resumeInterruptedQuests}
label="Resume interrupted auto-completions after a reload or restart:"
onChange={checked => updateModifyValue("resumeInterruptedQuests", checked)}
bottomSpacing="5"
tooltip={{
position: "top",
text: "This setting will automatically resume any interrupted auto-completions caused by reloads or restarts, including requeuing Quests which had yet to start auto-completing but had been queued."
}}
/>
<SettingsSelect
label="Auto-complete specific Quest types:"
labelClassName="margin-top-9"
wrapTags={true}
options={autoCompleteQuestTypeManaOptions}
value={selectedAutoCompleteQuestTypeValues}
closeOnSelect={false}
maxOptionsVisible={7}
selectionMode="multiple"
disabled={questFeatures.disableQuestsEverything || !questFeatures.allowChangingDangerousSettings}
onSelectionChange={updateAutoCompleteQuestTypes}
tooltip={{
position: "top",
wider: true,
text: "Watch Video on Mobile Quests will only work on mobile Quests which are enrollable on desktop. If a Quest is locked to enrollment on mobile, you must first enroll in it on your mobile device before returning to desktop and refreshing your Quests."
+ "\n\nAll video related Quests usually send a stack trace with the progress reports. This means Discord would know exactly which functions were called, and can therefore verify that their own functions initiated the progress. Questify erases this stack trace, but the absence of it will be just as telling as its presence."
+ "\n\nPlay on Desktop, Play on PlayStation, Play on Xbox, and Play Activity Quests are only available on official desktop clients due to a limitation imposed by Discord. 3rd party clients such as web extensions, Vesktop, Equibop, and others, do not support auto-completing these Quest types."
+ "\n\nAll game related Quests usually send a game fingerprint with the heartbeat reports. This means Discord can use data from tens of thousands of users to determine whether other users are likely using a real game or are emulating the executable. There is no reasonable method to spoof this value, so Questify leaves it blank, but the absence of it will be just as telling as poorly spoofing the value."
+ "\n\nAchievement in Activity Quests can only be auto-completed by completing them immediately. This method may be patched at any time."
+ "\n\nAuto-completing Quests is done by clicking their respective buttons on the Quests page. Quests will be auto-completed in the order they were queued, unless the simultaneous completion setting is enabled above."
+ "\n\nAuto-completing Quests is the riskiest dangerous setting available. Enable it at your own risk."
}}
/>
</>}
</SettingsNotice>
</SettingsCard>
);
}

View file

@ -0,0 +1,336 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { type AudioPlayerInterface, createAudioPlayer, defaultAudioNames } from "@api/AudioPlayer";
import { useEffect, useMemo, useRef, useState } from "@webpack/common";
import type { JSX, MouseEvent } from "react";
import { getQuestifySettings, useQuestifySettings } from "../settings/access";
import { startAutoFetchingQuests } from "../settings/fetching";
import { q } from "../utils/ui";
import { ManaSelectFormattedOption, ManaSelectOption, SettingsCard, SettingsDescription, SettingsHeader, SettingsRow, SettingsRowItem, SettingsSelect, SettingsSlider, SettingsSubheader, SettingsSubtleSwitch } from "./shared";
const questFetchIntervalOptions = [
{ id: "30-minutes", label: "30 Minutes", value: String(30 * 60) },
{ id: "45-minutes", label: "45 Minutes", value: String(45 * 60) },
{ id: "1-hour", label: "1 Hour", value: String(60 * 60) },
{ id: "3-hours", label: "3 Hours", value: String(3 * 60 * 60) },
{ id: "6-hours", label: "6 Hours", value: String(6 * 60 * 60) },
{ id: "12-hours", label: "12 Hours", value: String(12 * 60 * 60) },
] satisfies ManaSelectOption[];
function SoundIcon({ className }: { className?: string; }): JSX.Element {
return (
<svg
viewBox="0 0 24 24"
height={18}
width={18}
fill="none"
className={className}
aria-hidden={true}
>
<path fill="currentColor" d="M12 3a1 1 0 0 0-1-1h-.06a1 1 0 0 0-.74.32L5.92 7H3a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h2.92l4.28 4.68a1 1 0 0 0 .74.32H11a1 1 0 0 0 1-1V3ZM15.1 20.75c-.58.14-1.1-.33-1.1-.92v-.03c0-.5.37-.92.85-1.05a7 7 0 0 0 0-13.5A1.11 1.11 0 0 1 14 4.2v-.03c0-.6.52-1.06 1.1-.92a9 9 0 0 1 0 17.5Z" />
<path fill="currentColor" d="M15.16 16.51c-.57.28-1.16-.2-1.16-.83v-.14c0-.43.28-.8.63-1.02a3 3 0 0 0 0-5.04c-.35-.23-.63-.6-.63-1.02v-.14c0-.63.59-1.1 1.16-.83a5 5 0 0 1 0 9.02Z" />
</svg>
);
}
function getSoundOptions(): ManaSelectOption[] {
return defaultAudioNames()
.map(sound => ({
id: sound,
label: formatSoundName(sound),
value: sound,
}))
.sort((a, b) => a.label.localeCompare(b.label));
}
function formatSoundName(sound: string): string {
return sound
.toUpperCase()
.replace(/_/g, " ")
.replace(/(\d+)/g, " $1");
}
interface QuestNotificationSoundSelectProps {
disabled?: boolean;
label: string;
onChange: (value: string | null) => void;
onPreview: (sound: string) => void;
options: ManaSelectOption[];
playingSound: string | null;
tooltip?: { position: "top" | "bottom", text: string; };
value: string | null;
}
function QuestNotificationSoundSelect({
disabled,
label,
onChange,
onPreview,
options,
playingSound,
tooltip,
value,
}: QuestNotificationSoundSelectProps): JSX.Element {
function formatOption(option: ManaSelectOption): ManaSelectFormattedOption {
const sound = option.value || null;
const isPlaying = sound != null && playingSound === sound;
function handlePreviewMouseDown(event: MouseEvent<HTMLButtonElement>) {
event.preventDefault();
event.stopPropagation();
}
function handlePreviewClick(event: MouseEvent<HTMLButtonElement>) {
event.preventDefault();
event.stopPropagation();
if (sound && !disabled) {
onPreview(sound);
}
}
return {
...option,
trailing: sound
? (
<button
type="button"
className={q("sound-preview-button", isPlaying ? "playing-audio" : undefined)}
aria-label={`Preview ${option.label}`}
disabled={disabled}
onMouseDown={handlePreviewMouseDown}
onClick={handlePreviewClick}
>
<SoundIcon />
</button>
)
: undefined,
};
}
return (
<SettingsSelect
label={label}
options={options}
value={value}
selectionMode="single"
disabled={disabled}
clearable={true}
fullWidth={true}
maxOptionsVisible={7}
placeholder="DISABLED"
selectClassName="sound-select"
formatOption={formatOption}
tooltip={tooltip}
onSelectionChange={nextValue => {
if (nextValue != null && typeof nextValue !== "string") return;
onChange(nextValue ?? null);
}}
/>
);
}
export function QuestNotificationsSetting(): JSX.Element {
const questNotifications = useQuestifySettings([
"newExcludedQuestAlertSound",
"newExcludedQuestAlertVolume",
"newQuestAlertSound",
"newQuestAlertVolume",
"questFetchInterval",
"disableQuestsEverything",
"notifyOnNewExcludedQuests",
"notifyOnNewQuests",
"notifyOnQuestComplete",
"questCompletedAlertSound",
"questCompletedAlertVolume",
]);
const soundOptions = useMemo(getSoundOptions, []);
const activePlayer = useRef<AudioPlayerInterface | null>(null);
const [playingSound, setPlayingSound] = useState<string | null>(null);
const disabled = questNotifications.disableQuestsEverything;
function clearActivePlayer(): void {
const player = activePlayer.current;
activePlayer.current = null;
player?.stop();
setPlayingSound(null);
}
function previewSound(sound: string, volume: number): void {
if (playingSound === sound) {
clearActivePlayer();
return;
}
clearActivePlayer();
const player = createAudioPlayer(sound, { volume: Math.max(0, Math.min(100, volume)), onEnded: clearActivePlayer });
activePlayer.current = player;
setPlayingSound(sound);
player?.play();
}
useEffect(() => clearActivePlayer, []);
useEffect(() => {
if (disabled) {
clearActivePlayer();
}
}, [disabled]);
function updateFetchInterval(value: string | string[] | null): void {
if (value != null && typeof value !== "string") return;
const interval = value == null ? 0 : Number(value);
getQuestifySettings().questFetchInterval = interval;
startAutoFetchingQuests(true);
}
return (
<SettingsCard>
<SettingsHeader> Quest Notifications </SettingsHeader>
<SettingsDescription>Configure Quest completed and new Quest detected notifications and alerts. </SettingsDescription>
<SettingsSubheader> Quest Completed </SettingsSubheader>
<SettingsSubtleSwitch
checked={questNotifications.notifyOnQuestComplete}
disabled={disabled}
label="Show a notification when a Quest is completed:"
onChange={checked => { getQuestifySettings().notifyOnQuestComplete = checked; }}
bottomSpacing="5"
/>
<SettingsRow>
<SettingsRowItem className="sound-select-row-item">
<QuestNotificationSoundSelect
label="Play a sound when a Quest is completed:"
disabled={disabled}
options={soundOptions}
value={questNotifications.questCompletedAlertSound}
playingSound={playingSound}
onPreview={sound => previewSound(sound, questNotifications.questCompletedAlertVolume)}
onChange={value => { getQuestifySettings().questCompletedAlertSound = value; }}
/>
</SettingsRowItem>
<SettingsRowItem className="volume-slider-row-item">
<SettingsSlider
label="Volume:"
className="inline-volume-slider"
disabled={disabled}
value={questNotifications.questCompletedAlertVolume}
onChange={value => { getQuestifySettings().questCompletedAlertVolume = value; }}
/>
</SettingsRowItem>
</SettingsRow>
<SettingsSubheader> New Quests Detected </SettingsSubheader>
<SettingsSubtleSwitch
checked={questNotifications.notifyOnNewQuests}
disabled={disabled}
label="Show a notification when new Quests are detected:"
bottomSpacing="5"
onChange={checked => {
getQuestifySettings().notifyOnNewQuests = checked;
startAutoFetchingQuests(true);
}}
/>
<SettingsRow>
<SettingsRowItem className="sound-select-row-item">
<QuestNotificationSoundSelect
label="Play a sound when new Quests are detected:"
disabled={disabled}
options={soundOptions}
value={questNotifications.newQuestAlertSound}
playingSound={playingSound}
onPreview={sound => previewSound(sound, questNotifications.newQuestAlertVolume)}
onChange={value => {
getQuestifySettings().newQuestAlertSound = value;
startAutoFetchingQuests(true);
}}
/>
</SettingsRowItem>
<SettingsRowItem className="volume-slider-row-item">
<SettingsSlider
label="Volume:"
className="inline-volume-slider"
disabled={disabled}
value={questNotifications.newQuestAlertVolume}
onChange={value => { getQuestifySettings().newQuestAlertVolume = value; }}
/>
</SettingsRowItem>
</SettingsRow>
<SettingsSubtleSwitch
className="margin-top-14"
checked={questNotifications.notifyOnNewExcludedQuests}
disabled={disabled}
label="Show a notification when new excluded Quests are detected:"
bottomSpacing="5"
onChange={checked => {
getQuestifySettings().notifyOnNewExcludedQuests = checked;
startAutoFetchingQuests(true);
}}
tooltip={{
position: "top",
text: "Some Quests are excluded from your available Quests list due to region or platform restrictions."
+ "\n\nWhen enabled, Questify will fetch their Quest configs, apply your included Quest and reward type filters, print the resolved excluded Quest data to console, and show a separate notification for matching excluded Quests."
}}
/>
<SettingsRow>
<SettingsRowItem className="sound-select-row-item">
<QuestNotificationSoundSelect
label="Play a sound when new excluded Quests are detected:"
disabled={disabled}
options={soundOptions}
value={questNotifications.newExcludedQuestAlertSound}
playingSound={playingSound}
onPreview={sound => previewSound(sound, questNotifications.newExcludedQuestAlertVolume)}
onChange={value => {
getQuestifySettings().newExcludedQuestAlertSound = value;
startAutoFetchingQuests(true);
}}
tooltip={{
position: "top",
text: "Some Quests are excluded from your available Quests list due to region or platform restrictions."
+ "\n\nWhen a sound is selected, Questify will fetch their Quest configs, apply your included Quest and reward type filters, print the resolved excluded Quest data to console, and play this sound for matching excluded Quests."
}}
/>
</SettingsRowItem>
<SettingsRowItem className="volume-slider-row-item">
<SettingsSlider
label="Volume:"
className="inline-volume-slider"
disabled={disabled}
value={questNotifications.newExcludedQuestAlertVolume}
onChange={value => { getQuestifySettings().newExcludedQuestAlertVolume = value; }}
/>
</SettingsRowItem>
</SettingsRow>
<SettingsRow>
<SettingsRowItem>
<SettingsSelect
className="margin-top-12"
label="Quest Fetch Interval:"
options={questFetchIntervalOptions}
value={questNotifications.questFetchInterval > 0 ? String(questNotifications.questFetchInterval) : null}
selectionMode="single"
disabled={disabled}
clearable={true}
fullWidth={true}
placeholder="DISABLED"
maxOptionsVisible={questFetchIntervalOptions.length}
onSelectionChange={updateFetchInterval}
tooltip={{
position: "top",
text: "Discord only fetches Quests on load and when you visit the Quests page."
+ "\n\nThis interval periodically fetches Quests for you while the client stays open, so Quest Button indicators and new Quest alerts can stay up to date throughout the day."
+ "\n\nThis only runs if enabled and if the Quest Button or Quest Notifications settings are configured in a way which makes fetching periodically meaningful."
}}
/>
</SettingsRowItem>
</SettingsRow>
</SettingsCard>
);
}

View file

@ -0,0 +1,81 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { copyToClipboard } from "@utils/index";
import type { Quest } from "@vencord/discord-types";
import { Menu } from "@webpack/common";
import type { ReactNode } from "react";
import { addIgnoredQuest, questIsIgnored, removeIgnoredQuest } from "../settings/ignoredQuests";
import { rerenderQuests } from "../settings/rerender";
import { canAutoCompleteQuest, getQuestAutoCompleteEntry, processQuestForAutoComplete, stopQuestAutoComplete } from "../utils/completion";
import { q } from "../utils/ui";
export function QuestTileContextMenu(
children: ReactNode[],
props: { quest?: Quest; },
isClaimedMenu: boolean = false,
): void {
const { quest } = props;
if (!quest) {
return;
}
const isIgnored = questIsIgnored(quest.id);
const isEnrolled = Boolean(quest.userStatus?.enrolledAt);
const isAutoCompleting = getQuestAutoCompleteEntry(quest) != null;
const canStartAutoComplete = !isClaimedMenu && isEnrolled && canAutoCompleteQuest(quest);
children.unshift((
<Menu.MenuGroup>
{!isClaimedMenu && (!isIgnored ? (
<Menu.MenuItem
id={q("ignore-quest")}
label="Mark as Ignored"
action={() => addIgnoredQuest(quest.id)}
/>
) : (
<Menu.MenuItem
id={q("unignore-quest")}
label="Unmark as Ignored"
action={() => removeIgnoredQuest(quest.id)}
/>
))}
{isAutoCompleting ? (
<Menu.MenuItem
id={q("stop-auto-complete")}
label="Stop Auto-Complete"
action={() => {
stopQuestAutoComplete(quest, {
manual: true,
preserveResume: false,
terminalHeartbeat: true,
});
rerenderQuests();
}}
/>
) : canStartAutoComplete ? (
<Menu.MenuItem
id={q("start-auto-complete")}
label="Start Auto-Complete"
action={() => {
processQuestForAutoComplete(quest, {
force: true,
source: "manual",
});
rerenderQuests();
}}
/>
) : null}
<Menu.MenuItem
id={q("copy-quest-id")}
label="Copy Quest ID"
action={() => copyToClipboard(quest.id)}
/>
</Menu.MenuGroup>
));
}

View file

@ -0,0 +1,330 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import type { Quest } from "@vencord/discord-types";
import { findComponentByCodeLazy } from "@webpack";
import { QuestStore, useEffect, useMemo, useRef, useState, useStateFromStores } from "@webpack/common";
import type { JSX, SyntheticEvent } from "react";
import { getQuestifySettings, useQuestifySettings } from "../settings/access";
import { defaultQuestTileClaimedColorSetting, defaultQuestTileExpiredColorSetting, defaultQuestTileIgnoredColorSetting, defaultQuestTileUnclaimedColorSetting, type QuestTileColorSetting, type QuestTileGradient } from "../settings/def";
import { rerenderQuests } from "../settings/rerender";
import { getQuestTileClasses, getQuestTileStyle } from "../utils/questTiles";
import { q } from "../utils/ui";
import { ManaButton, type ManaSelectOption, SettingsCard, SettingsColorPicker, SettingsDescription, SettingsHeader, SettingsRow, SettingsRowItem, SettingsSelect, SettingsSubheader } from "./shared";
const QuestTile = findComponentByCodeLazy(".rowIndex,trackGuildAndChannelMetadata") as React.ComponentType<{
className?: string;
quest: Quest;
}>;
const gradientOptions = [
{ label: "Intense Restyle Gradient", value: "intense" },
{ label: "Default Restyle Gradient", value: "default" },
{ label: "Subtle Black Gradient", value: "black" },
{ label: "No Gradient", value: "hide" },
] as const satisfies readonly { label: string, value: QuestTileGradient; }[];
const gradientManaOptions: ManaSelectOption[] = gradientOptions.map(({ label, value }) => ({
id: value,
label,
value,
}));
const preloadManaOptions: ManaSelectOption[] = [
{ id: "true", label: "Load All Quest Assets On Page Load", value: "true" },
{ id: "false", label: "Load Quest Assets During Page Scroll", value: "false" },
];
type QuestTileColorKey =
| "questTileUnclaimedColor"
| "questTileClaimedColor"
| "questTileIgnoredColor"
| "questTileExpiredColor";
interface QuestTileColorOption {
key: QuestTileColorKey;
label: string;
defaultValue: QuestTileColorSetting;
}
const colorOptions = [
{
key: "questTileUnclaimedColor",
label: "Unclaimed",
defaultValue: defaultQuestTileUnclaimedColorSetting,
},
{
key: "questTileClaimedColor",
label: "Claimed",
defaultValue: defaultQuestTileClaimedColorSetting,
},
{
key: "questTileIgnoredColor",
label: "Ignored",
defaultValue: defaultQuestTileIgnoredColorSetting,
},
{
key: "questTileExpiredColor",
label: "Expired",
defaultValue: defaultQuestTileExpiredColorSetting,
},
] as const satisfies readonly QuestTileColorOption[];
const defaultPreviewColorKey: QuestTileColorKey = "questTileUnclaimedColor";
function getRandomQuest(): Quest | null {
const quests = Array.from(QuestStore.quests.values());
return quests.length > 0 ? quests[Math.floor(Math.random() * quests.length)] : null;
}
function cloneDummyQuest(quest: Quest, dummyColor: QuestTileColorSetting): Quest & { dummyColor: QuestTileColorSetting; } {
return {
...structuredClone(quest),
dummyColor,
};
}
function stopDummyQuestInteraction(event: SyntheticEvent): void {
event.preventDefault();
event.stopPropagation();
event.nativeEvent.stopImmediatePropagation();
}
function DummyQuestTile({
disabled,
dummyQuest,
dummyGradient,
}: {
disabled?: boolean;
dummyQuest: Quest & { dummyColor: QuestTileColorSetting; };
dummyGradient: QuestTileGradient;
}): JSX.Element {
const blockerRef = useRef<HTMLDivElement>(null);
const classes = getQuestTileClasses(q("dummy-quest"), dummyQuest, dummyGradient);
const style = getQuestTileStyle(dummyQuest);
useEffect(() => {
const blocker = blockerRef.current;
if (!blocker) return;
const eventNames = [
"auxclick",
"click",
"contextmenu",
"dblclick",
"dragstart",
"mousedown",
"mouseup",
"pointercancel",
"pointerdown",
"pointerup",
];
function stopEvent(event: Event) {
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
}
for (const eventName of eventNames) {
blocker.addEventListener(eventName, stopEvent, true);
}
return () => {
for (const eventName of eventNames) {
blocker.removeEventListener(eventName, stopEvent, true);
}
};
}, []);
return (
<div
ref={blockerRef}
className={q("dummy-quest-preview", disabled ? "dimmed-settings-item" : undefined)}
style={style}
onAuxClickCapture={stopDummyQuestInteraction}
onClickCapture={stopDummyQuestInteraction}
onContextMenuCapture={stopDummyQuestInteraction}
onDoubleClickCapture={stopDummyQuestInteraction}
onDragStartCapture={stopDummyQuestInteraction}
onMouseDownCapture={stopDummyQuestInteraction}
onMouseUpCapture={stopDummyQuestInteraction}
onPointerCancelCapture={stopDummyQuestInteraction}
onPointerDownCapture={stopDummyQuestInteraction}
onPointerUpCapture={stopDummyQuestInteraction}
>
<QuestTile
className={classes}
quest={dummyQuest}
/>
</div>
);
}
function DummyQuestPreview({
disabled,
dummyColor,
dummyGradient,
}: {
disabled?: boolean;
dummyColor: QuestTileColorSetting;
dummyGradient: QuestTileGradient;
}): JSX.Element | null {
const sourceQuest = useStateFromStores([QuestStore], getRandomQuest);
const dummyQuest = useMemo(
() => sourceQuest ? cloneDummyQuest(sourceQuest, dummyColor) : null,
[dummyColor, sourceQuest]
);
if (!dummyQuest) return null;
return (
<DummyQuestTile
disabled={disabled}
dummyQuest={dummyQuest}
dummyGradient={dummyGradient}
/>
);
}
export function QuestTilesSetting(): JSX.Element {
const questTiles = useQuestifySettings([
"disableQuestsEverything",
"questTileUnclaimedColor",
"questTileClaimedColor",
"questTileIgnoredColor",
"questTileExpiredColor",
"questTileGradient",
"questTilePreload",
]);
const [previewColorKey, setPreviewColorKey] = useState<QuestTileColorKey>(defaultPreviewColorKey);
const disabled = questTiles.disableQuestsEverything;
const previewColor = questTiles[previewColorKey] as QuestTileColorSetting;
function updateColor(key: QuestTileColorKey, nextColor: QuestTileColorSetting): void {
setPreviewColorKey(key);
getQuestifySettings()[key] = nextColor;
}
function updateColorValue(key: QuestTileColorKey, setting: QuestTileColorSetting, value: number | null): void {
if (typeof value !== "number") return;
updateColor(key, {
enabled: setting.enabled,
color: value,
});
}
function updateColorEnabled(key: QuestTileColorKey, setting: QuestTileColorSetting, enabled: boolean): void {
updateColor(key, {
...setting,
enabled,
});
}
function updateGradient(value: string | string[] | null): void {
if (typeof value !== "string") return;
getQuestifySettings().questTileGradient = value as QuestTileGradient;
}
function updatePreload(value: string | string[] | null): void {
if (typeof value !== "string") return;
const preload = value === "true";
getQuestifySettings().questTilePreload = preload;
rerenderQuests();
}
return (
<SettingsCard>
<SettingsHeader> Quest Tiles </SettingsHeader>
<SettingsDescription> Highlight Quests with optional theme colors for visibility. </SettingsDescription>
<SettingsSubheader> Tile Behavior </SettingsSubheader>
<SettingsRow className="quest-tile-behavior-row">
<SettingsRowItem className="quest-tile-gradient-row-item">
<SettingsSelect
label="Gradient Style:"
options={gradientManaOptions}
value={questTiles.questTileGradient}
selectionMode="single"
disabled={disabled}
fullWidth={true}
maxOptionsVisible={gradientManaOptions.length}
onSelectionChange={updateGradient}
tooltip={{
position: "top",
text: "Intense and Default use the selected tile color in the asset gradient."
+ "\n\nSubtle Black keeps a darker neutral gradient for contrast."
+ "\n\nNo Gradient removes the asset gradient, which can make some Quest artwork harder to read."
}}
/>
</SettingsRowItem>
<SettingsRowItem className="quest-tile-preload-row-item">
<SettingsSelect
label="Asset Preload:"
options={preloadManaOptions}
value={String(questTiles.questTilePreload)}
selectionMode="single"
disabled={disabled}
fullWidth={true}
maxOptionsVisible={preloadManaOptions.length}
onSelectionChange={updatePreload}
tooltip={{
position: "top",
text: "Loading all assets when the Quests page opens reduces layout shifting while scrolling."
+ "\n\nLoading during page scroll is closer to Discord's default behavior and may use less work up front."
}}
/>
</SettingsRowItem>
</SettingsRow>
<SettingsSubheader> Tile Colors </SettingsSubheader>
<SettingsRow className="quest-tile-color-row">
{colorOptions.map(({ key, label }) => {
const setting = questTiles[key] as QuestTileColorSetting;
return (
<SettingsRowItem key={key} className="quest-tile-color-row-item">
<div
onFocusCapture={() => setPreviewColorKey(key)}
onPointerDownCapture={() => setPreviewColorKey(key)}
>
<SettingsColorPicker
label={`${label}:`}
className={["quest-tile-color-picker", setting.enabled ? "" : "disabled-color-picker"].filter(Boolean)}
color={setting.color}
disabled={disabled || !setting.enabled}
onChange={value => updateColorValue(key, setting, value)}
showEyeDropper={true}
/>
</div>
<div className={q("settings-button", "quest-tile-color-button")}>
<ManaButton
text={setting.enabled ? "Disable" : "Enable"}
variant={setting.enabled ? "critical-secondary" : "primary"}
disabled={disabled}
fullWidth={true}
size="sm"
onClick={() => updateColorEnabled(key, setting, !setting.enabled)}
/>
</div>
</SettingsRowItem>
);
})}
</SettingsRow>
<DummyQuestPreview
disabled={disabled}
dummyColor={previewColor}
dummyGradient={questTiles.questTileGradient as QuestTileGradient}
/>
</SettingsCard>
);
}

View file

@ -0,0 +1,246 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import type { JSX } from "react";
import { getQuestifySettings, useQuestifySettings } from "../settings/access";
import { defaultQuestOrder, type QuestOrderStatus, type QuestSubsort } from "../settings/def";
import { rerenderQuests } from "../settings/rerender";
import { type ManaSelectOption, SettingsCard, SettingsDescription, SettingsHeader, SettingsRow, SettingsRowItem, SettingsSelect, SettingsSubheader, SettingsSubtleSwitch } from "./shared";
const questStatusOptions = [
{ label: "Unclaimed", value: "UNCLAIMED" },
{ label: "Claimed", value: "CLAIMED" },
{ label: "Ignored", value: "IGNORED" },
{ label: "Expired", value: "EXPIRED" },
] as const satisfies readonly { label: string, value: QuestOrderStatus; }[];
const questStatusManaOptions: ManaSelectOption[] = questStatusOptions.map(({ label, value }) => ({
id: value,
label,
value,
}));
const baseSubsortOptions = [
{ label: "Added (Newest)", value: "Recent DESC" },
{ label: "Added (Oldest)", value: "Recent ASC" },
] as const satisfies readonly { label: string, value: QuestSubsort; }[];
const expiringSubsortOptions = [
...baseSubsortOptions,
{ label: "Expiring (Soonest)", value: "Expiring ASC" },
{ label: "Expiring (Latest)", value: "Expiring DESC" },
] as const satisfies readonly { label: string, value: QuestSubsort; }[];
const expiredSubsortOptions = [
...baseSubsortOptions,
{ label: "Expired (Most Recent)", value: "Expiring DESC" },
{ label: "Expired (Least Recent)", value: "Expiring ASC" },
] as const satisfies readonly { label: string, value: QuestSubsort; }[];
const claimedSubsortOptions = [
...baseSubsortOptions,
{ label: "Claimed (Most Recent)", value: "Claimed DESC" },
{ label: "Claimed (Least Recent)", value: "Claimed ASC" },
] as const satisfies readonly { label: string, value: QuestSubsort; }[];
function toManaOptions(options: readonly { label: string, value: QuestSubsort; }[]): ManaSelectOption[] {
return options.map(({ label, value }) => ({
id: value,
label,
value,
}));
}
const unclaimedSubsortManaOptions = toManaOptions(expiringSubsortOptions);
const claimedSubsortManaOptions = toManaOptions(claimedSubsortOptions);
const ignoredSubsortManaOptions = toManaOptions(expiringSubsortOptions);
const expiredSubsortManaOptions = toManaOptions(expiredSubsortOptions);
const positionLabels = ["First", "Second", "Third", "Fourth"] as const;
const subsortTooltips = {
unclaimedSubsort: "Completed but unclaimed Quests stay below incomplete unclaimed Quests, then this subsort is applied within those groups.",
claimedSubsort: "Claimed Quests can be sorted by claim time or by when the Quest was added.",
ignoredSubsort: "Ignored Quests still keep their ignored group position, then this subsort controls their order inside that group.",
expiredSubsort: "Expired Quests can be sorted by expiration time or by when the Quest was added.",
} as const;
function sanitizeQuestOrder(order: unknown): QuestOrderStatus[] {
const validStatuses = new Set<QuestOrderStatus>(defaultQuestOrder);
const sanitized = Array.isArray(order)
? order.filter((status): status is QuestOrderStatus => validStatuses.has(status as QuestOrderStatus))
: [];
for (const status of defaultQuestOrder) {
if (!sanitized.includes(status)) {
sanitized.push(status);
}
}
return sanitized.slice(0, defaultQuestOrder.length);
}
export function ReorderQuestsSetting(): JSX.Element {
const reorderQuests = useQuestifySettings([
"disableQuestsEverything",
"questOrder",
"unclaimedSubsort",
"claimedSubsort",
"ignoredSubsort",
"expiredSubsort",
"rememberQuestPageSort",
"rememberQuestPageFilters",
]);
const disabled = reorderQuests.disableQuestsEverything;
const questOrder = sanitizeQuestOrder(reorderQuests.questOrder);
function updateQuestOrder(index: number, value: string | string[] | null): void {
if (typeof value !== "string") return;
const nextStatus = value as QuestOrderStatus;
const nextOrder = [...questOrder];
const previousStatus = nextOrder[index];
const existingIndex = nextOrder.indexOf(nextStatus);
if (existingIndex !== -1 && existingIndex !== index) {
nextOrder[existingIndex] = previousStatus;
}
nextOrder[index] = nextStatus;
getQuestifySettings().questOrder = nextOrder;
rerenderQuests();
}
function updateSubsort(key: "unclaimedSubsort" | "claimedSubsort" | "ignoredSubsort" | "expiredSubsort", value: string | string[] | null): void {
if (typeof value !== "string") return;
getQuestifySettings()[key] = value;
rerenderQuests();
}
function updateRememberSetting(key: "rememberQuestPageSort" | "rememberQuestPageFilters", checked: boolean): void {
getQuestifySettings()[key] = checked;
}
return (
<SettingsCard>
<SettingsHeader> Reorder Quests </SettingsHeader>
<SettingsDescription> Sort Quests by their status when the Questify sort option is selected on the Quests page. </SettingsDescription>
<SettingsSubheader> Status Order </SettingsSubheader>
<SettingsRow>
{questOrder.map((status, index) => (
<SettingsRowItem key={index}>
<SettingsSelect
label={`${positionLabels[index]}:`}
options={questStatusManaOptions}
value={status}
selectionMode="single"
disabled={disabled}
fullWidth={true}
maxOptionsVisible={questStatusManaOptions.length}
onSelectionChange={value => updateQuestOrder(index, value)}
tooltip={{
position: "top",
text: "Each status can only appear once. Selecting a status already used in another position swaps the two positions."
}}
/>
</SettingsRowItem>
))}
</SettingsRow>
<SettingsSubheader> Subsorts </SettingsSubheader>
<SettingsRow>
<SettingsRowItem>
<SettingsSelect
label="Unclaimed Subsort:"
options={unclaimedSubsortManaOptions}
value={reorderQuests.unclaimedSubsort}
selectionMode="single"
disabled={disabled}
fullWidth={true}
maxOptionsVisible={unclaimedSubsortManaOptions.length}
onSelectionChange={value => updateSubsort("unclaimedSubsort", value)}
tooltip={{
position: "top",
text: subsortTooltips.unclaimedSubsort
}}
/>
</SettingsRowItem>
<SettingsRowItem>
<SettingsSelect
label="Claimed Subsort:"
options={claimedSubsortManaOptions}
value={reorderQuests.claimedSubsort}
selectionMode="single"
disabled={disabled}
fullWidth={true}
maxOptionsVisible={claimedSubsortManaOptions.length}
onSelectionChange={value => updateSubsort("claimedSubsort", value)}
tooltip={{
position: "top",
text: subsortTooltips.claimedSubsort
}}
/>
</SettingsRowItem>
</SettingsRow>
<SettingsRow>
<SettingsRowItem>
<SettingsSelect
label="Ignored Subsort:"
options={ignoredSubsortManaOptions}
value={reorderQuests.ignoredSubsort}
selectionMode="single"
disabled={disabled}
fullWidth={true}
maxOptionsVisible={ignoredSubsortManaOptions.length}
onSelectionChange={value => updateSubsort("ignoredSubsort", value)}
tooltip={{
position: "top",
text: subsortTooltips.ignoredSubsort
}}
/>
</SettingsRowItem>
<SettingsRowItem>
<SettingsSelect
label="Expired Subsort:"
options={expiredSubsortManaOptions}
value={reorderQuests.expiredSubsort}
selectionMode="single"
disabled={disabled}
fullWidth={true}
maxOptionsVisible={expiredSubsortManaOptions.length}
onSelectionChange={value => updateSubsort("expiredSubsort", value)}
tooltip={{
position: "top",
text: subsortTooltips.expiredSubsort
}}
/>
</SettingsRowItem>
</SettingsRow>
<SettingsSubheader> Quest Page Memory </SettingsSubheader>
<SettingsSubtleSwitch
checked={reorderQuests.rememberQuestPageSort}
disabled={disabled}
label="Remember the selected Quest page sort:"
onChange={checked => updateRememberSetting("rememberQuestPageSort", checked)}
bottomSpacing="5"
tooltip={{
position: "top",
text: "When disabled, the Quests page opens with the Questify sort option each time."
}}
/>
<SettingsSubtleSwitch
checked={reorderQuests.rememberQuestPageFilters}
disabled={disabled}
label="Remember the selected Quest page filters:"
onChange={checked => updateRememberSetting("rememberQuestPageFilters", checked)}
tooltip={{
position: "top",
text: "When disabled, the Quests page opens without task or reward filters each time."
}}
/>
</SettingsCard>
);
}

View file

@ -0,0 +1,32 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import PluginModal from "@components/settings/tabs/plugins/PluginModal";
import { openModal } from "@utils/modal";
import type { Plugin } from "@utils/types";
import { promptToRestartIfDirty } from "../settings/restartTracking";
import { setSettingsModalOpen } from "../state";
export function openQuestifySettingsModal(plugin: Plugin): void {
setSettingsModalOpen(true);
openModal(
modalProps => (
<PluginModal
{...modalProps}
plugin={plugin}
onRestartNeeded={() => { }}
/>
),
{
onCloseCallback: () => {
setSettingsModalOpen(false);
promptToRestartIfDirty();
}
}
);
}

View file

@ -0,0 +1,379 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { Card } from "@components/Card";
import { Heading } from "@components/Heading";
import { Paragraph } from "@components/Paragraph";
import { findComponentByCodeLazy } from "@webpack";
import { ColorPicker, Slider } from "@webpack/common";
import type { ComponentProps, ComponentType, JSX, ReactNode } from "react";
import { q } from "../utils/ui";
export function SettingsCard({ children }: { children: ReactNode; }): JSX.Element {
return (
<Card variant="primary" className={q("setting")}>
{children}
</Card>
);
}
export function SettingsHeader({ children }: { children: ReactNode; }): JSX.Element {
return (
<Heading className={q("setting-header")}>
{children}
</Heading>
);
}
export function SettingsSubheader({ children, className }: { children: ReactNode; className?: string | string[]; }): JSX.Element {
return (
<Heading className={q("setting-subheader", className)}>
{children}
</Heading>
);
}
export function SettingsDescription({ children }: { children: ReactNode; }): JSX.Element {
return (
<Paragraph className={q("setting-description")}>
{children}
</Paragraph>
);
}
export function SettingsParagraph({ children, className }: { children: ReactNode; className?: string | string[]; }): JSX.Element {
return (
<Paragraph className={q("setting-paragraph", className)}>
{children}
</Paragraph>
);
}
function withDimmedClass(className: string | string[] | undefined, dimmed: boolean): string | string[] | undefined {
if (!dimmed) return className;
return [
...(Array.isArray(className) ? className : [className]),
"dimmed-settings-item",
].filter(c => c !== undefined);
}
export function SettingsNotice({ children, className }: { children: ReactNode; className?: string | string[]; }): JSX.Element {
return (
<Paragraph className={q("notice-card", className)}>
{children}
</Paragraph>
);
}
export interface SettingsRowProps {
children: ReactNode;
className?: string | string[];
}
export function SettingsRow({ children, className }: SettingsRowProps): JSX.Element {
return (
<div className={q("settings-row", className)}>
{children}
</div>
);
}
export interface SettingsRowItemProps {
children: ReactNode;
className?: string | string[];
width?: "fill" | "content";
}
export function SettingsRowItem({
children,
className,
width = "fill",
}: SettingsRowItemProps): JSX.Element {
return (
<div className={q(
"settings-row-item",
width === "content" ? "settings-row-item-content" : undefined,
className,
)}>
{children}
</div>
);
}
export interface ManaSelectOption {
id: string;
value: string;
label: string;
disabled?: boolean;
}
export interface ManaSelectFormattedOption extends ManaSelectOption {
description?: ReactNode;
leading?: ReactNode;
trailing?: ReactNode;
}
export interface ManaSelectProps {
options: ManaSelectOption[];
value?: string | string[] | null;
onSelectionChange?: (value: string | string[] | null) => void;
selectionMode?: "single" | "multiple";
placeholder?: string;
disabled?: boolean;
readOnly?: boolean;
clearable?: boolean;
fullWidth?: boolean;
autoFocus?: boolean;
closeOnSelect?: boolean;
shouldFocusWrap?: boolean;
maxOptionsVisible?: number;
wrapTags?: boolean;
formatOption?: (option: ManaSelectOption) => ManaSelectFormattedOption;
name?: string;
form?: string;
autoComplete?: string;
label?: string;
required?: boolean;
}
export const ManaSelect = findComponentByCodeLazy('"data-mana-component":"select"') as React.ComponentType<ManaSelectProps>;
export interface SettingsSelectProps extends Omit<ManaSelectProps, "label"> {
label: ReactNode;
className?: string | string[];
labelClassName?: string | string[];
selectClassName?: string | string[];
tooltip?: { position: "top" | "bottom", text: string; wider?: boolean; };
}
export function SettingsSelect({
label,
className,
labelClassName,
selectClassName,
tooltip,
...props
}: SettingsSelectProps): JSX.Element {
const selectElement = (
<div className={q("settings-select", className)}>
<SettingsParagraph className={withDimmedClass(labelClassName, !!props.disabled)}>{label}</SettingsParagraph>
<div className={q(selectClassName)}>
<ManaSelect {...props} />
</div>
</div>
);
if (tooltip) {
return (
<SettingsTooltip text={tooltip.text} position={tooltip.position} wider={tooltip.wider}>
{selectElement}
</SettingsTooltip>
);
}
return selectElement;
}
type ColorPickerWithOnCloseProps = ComponentProps<typeof ColorPicker> & {
onClose?: () => void;
};
function ColorPickerWithOnClose(props: ColorPickerWithOnCloseProps): JSX.Element {
const LiveColorPicker = ColorPicker as ComponentType<ColorPickerWithOnCloseProps>;
return <LiveColorPicker {...props} />;
}
export interface SettingsColorPickerProps extends ColorPickerWithOnCloseProps {
className?: string | string[];
label?: ReactNode;
labelClassName?: string | string[];
}
export function SettingsColorPicker({
className,
label,
labelClassName,
...props
}: SettingsColorPickerProps): JSX.Element {
return (
<>
{label != null && <SettingsParagraph className={withDimmedClass(labelClassName, !!props.disabled)}>{label}</SettingsParagraph>}
<div className={q("settings-color-picker", className)}>
<ColorPickerWithOnClose {...props} />
</div>
</>
);
}
export interface SettingsSliderProps {
className?: string | string[];
disabled?: boolean;
label: ReactNode;
labelClassName?: string | string[];
maxValue?: number;
minValue?: number;
onChange: (value: number) => void;
sliderClassName?: string | string[];
value: number;
}
export function SettingsSlider({
className,
disabled,
label,
labelClassName,
maxValue = 100,
minValue = 0,
onChange,
sliderClassName,
value,
}: SettingsSliderProps): JSX.Element {
return (
<div className={q("settings-slider", className)}>
<SettingsParagraph className={withDimmedClass(labelClassName, !!disabled)}>{label}</SettingsParagraph>
<div className={q("settings-slider-control-container")}>
<Slider
minValue={minValue}
maxValue={maxValue}
initialValue={value}
onValueChange={onChange}
className={q("settings-slider-control", sliderClassName)}
disabled={disabled}
/>
</div>
</div>
);
}
interface SwitchWithLabelProps {
checked: boolean;
onChange?: (checked: boolean) => void;
disabled?: boolean;
label?: string;
description?: string;
}
interface SettingsSwitchLayoutProps extends SwitchWithLabelProps {
bottomSpacing?: "5" | "10" | "15";
topSpacing?: "5" | "10" | "15";
className?: string | string[];
tooltip?: { position: "top" | "bottom", text: string; };
}
const SwitchWithLabel = findComponentByCodeLazy('"data-toggleable-component":"switch"') as React.ComponentType<SwitchWithLabelProps>;
export type SettingsSubtleSwitchProps = Omit<SettingsSwitchLayoutProps, "description">;
export function SettingsSubtleSwitch(props: SettingsSubtleSwitchProps): JSX.Element {
const switchElement = (
<div className={q(
"setting-subtle-switch",
props.topSpacing ? `margin-top-${props.topSpacing}` : undefined,
props.bottomSpacing ? `margin-bottom-${props.bottomSpacing}` : undefined,
withDimmedClass(props.className, !!props.disabled)
)}>
<SwitchWithLabel {...props} />
</div>
);
if (props.tooltip) {
return (
<SettingsTooltip text={props.tooltip.text} aria-label={props.tooltip.text} position={props.tooltip.position}>
{switchElement}
</SettingsTooltip>
);
}
return switchElement;
}
export const TooltipPositions = ["top", "bottom", "left", "right"] as const;
export type TooltipPosition = typeof TooltipPositions[number];
export const TooltipAligns = ["start", "center", "end"] as const;
export type TooltipAlign = typeof TooltipAligns[number];
export const TooltipColors = ["primary", "grey", "brand", "green", "red"] as const;
export type TooltipColor = typeof TooltipColors[number];
export interface ManaTooltipProps {
text: string | (() => React.ReactNode);
position?: TooltipPosition;
align?: TooltipAlign;
color?: TooltipColor;
spacing?: number;
hideOnClick?: boolean;
delay?: number;
forceOpen?: boolean;
shouldShow?: boolean;
allowOverflow?: boolean;
overflowOnly?: boolean;
clickableOnMobile?: boolean;
disableTooltipPointerEvents?: boolean;
targetElementRef?: React.RefObject<HTMLElement>;
tooltipClassName?: string;
tooltipStyle?: React.CSSProperties;
tooltipContentClassName?: string;
tooltipPointerClassName?: string;
positionKeyStemOverride?: string;
onTooltipShow?: () => void;
onTooltipHide?: () => void;
onAnimationRest?: () => void;
"aria-label"?: string | false;
children: (props: Record<string, any>) => React.ReactNode;
}
export const ManaTooltip = findComponentByCodeLazy("VoidTooltip cannot find DOM node") as React.ComponentType<ManaTooltipProps>;
function SettingsTooltip({
children,
position,
text,
wider,
className
}: {
children: ReactNode;
position: "top" | "bottom";
text: string;
wider?: boolean;
className?: string | string[];
}): JSX.Element {
return (
<div className={q("settings-tooltip-wrapper", className)}>
<ManaTooltip
text={text}
position={position}
color="brand"
tooltipStyle={{ maxWidth: wider ? "602px" : "350px" }}
tooltipContentClassName={q("settings-tooltip-content")}
delay={50}
>
{tooltipProps => <div {...tooltipProps}>{children}</div>}
</ManaTooltip>
</div>
);
}
export const ManaButtonVariants = ["primary", "secondary", "critical-primary", "critical-secondary", "active", "overlay-primary", "overlay-secondary", "expressive"] as const;
export type ManaButtonVariant = typeof ManaButtonVariants[number];
export const ManaButtonSizes = ["xs", "sm", "md"] as const;
export type ManaButtonSize = typeof ManaButtonSizes[number];
export interface ManaButtonProps {
text?: string;
variant?: ManaButtonVariant;
size?: ManaButtonSize;
disabled?: boolean;
fullWidth?: boolean;
onClick?: (e: React.MouseEvent) => void;
style?: React.CSSProperties;
}
export const ManaButton = findComponentByCodeLazy('"data-mana-component":"button"') as React.ComponentType<ManaButtonProps>;

File diff suppressed because it is too large Load diff

View file

@ -1,50 +1,83 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { IpcMainInvokeEvent } from "electron";
import { BrowserWindow, type IpcMainInvokeEvent } from "electron";
export async function complete(_: IpcMainInvokeEvent, appID: string, authCode: string, questTarget: number): Promise<{ success: boolean; error: string | null; }> {
const result = await authorize(appID, authCode);
export function canOpenDevTools(event: IpcMainInvokeEvent): boolean {
return !event.sender.isDestroyed();
}
if (result.error || !result.token) {
return { success: false, error: JSON.stringify(result.error) };
export function openDevTools(event: IpcMainInvokeEvent): boolean {
if (!canOpenDevTools(event)) {
return false;
}
const success = await progress(appID, result.token, questTarget);
const window = BrowserWindow.fromWebContents(event.sender);
if (success.error || !success.success) {
return { success: false, error: JSON.stringify(success.error) };
if (event.sender.isDevToolsOpened()) {
window?.focus();
return true;
}
event.sender.openDevTools();
return true;
}
export async function complete(_: IpcMainInvokeEvent, appId: string, authCode: string, questTarget: number): Promise<{ success: boolean; error: string | null; }> {
const authorization = await authorize(appId, authCode);
if (authorization.error || !authorization.token) {
return { success: false, error: JSON.stringify(authorization.error) };
}
const progressResult = await progress(appId, authorization.token, questTarget);
if (progressResult.error || !progressResult.success) {
return { success: false, error: JSON.stringify(progressResult.error) };
}
return { success: true, error: null };
}
async function authorize(appID: string, authCode: string): Promise<{ token: string | false; error: any; }> {
let error = null;
async function authorize(appId: string, authCode: string): Promise<{ token: string | false; error: unknown; }> {
let error: unknown = null;
const token = await fetch(`https://${appID}.discordsays.com/.proxy/acf/authorize`, {
const token = await fetch(`https://${appId}.discordsays.com/.proxy/acf/authorize`, {
body: JSON.stringify({ code: authCode }),
method: "POST",
mode: "cors",
credentials: "include"
}).then(res => res.json()).then(data => data.token).catch(e => { error = e; return ""; });
credentials: "include",
})
.then(res => res.json())
.then(data => data.token)
.catch(e => {
error = e;
return false;
});
return { token, error };
}
async function progress(appID: string, token: string, questTarget: number): Promise<{ success: boolean; error: any; }> {
let error = null;
async function progress(appId: string, token: string, questTarget: number): Promise<{ success: boolean; error: unknown; }> {
let error: unknown = null;
const success = await fetch(`https://${appID}.discordsays.com/.proxy/acf/quest/progress`, {
const success = await fetch(`https://${appId}.discordsays.com/.proxy/acf/quest/progress`, {
headers: { "x-auth-token": token },
body: JSON.stringify({ progress: questTarget }),
method: "POST",
mode: "cors",
credentials: "include"
}).then(res => res.ok).catch(e => { error = e; return false; });
credentials: "include",
})
.then(res => res.ok)
.catch(e => {
error = e;
return false;
});
return { success, error };
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,29 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { Settings, useSettings } from "@api/Settings";
import { UserStore } from "@webpack/common";
export const QUESTIFY_PLUGIN_NAME = "Questify";
interface QuestifySettingsOverrides {
ignoredQuestIDs: Record<string, string[]>;
resumeQuestIDs: Record<string, { timestamp: number, questIDs: string[]; }>;
}
type QuestifySettings = Settings["plugins"][typeof QUESTIFY_PLUGIN_NAME] & QuestifySettingsOverrides;
export function getQuestifySettings(): QuestifySettings {
return Settings.plugins[QUESTIFY_PLUGIN_NAME] as QuestifySettings;
}
export function useQuestifySettings<K extends keyof QuestifySettings & string>(keys: readonly K[]): Pick<QuestifySettings, K> {
return useSettings(keys.map(key => `plugins.${QUESTIFY_PLUGIN_NAME}.${key}`) as any).plugins[QUESTIFY_PLUGIN_NAME] as unknown as Pick<QuestifySettings, K>;
}
export function getCurrentUserId(userId?: string): string | null {
return userId ?? UserStore.getCurrentUser()?.id ?? null;
}

View file

@ -0,0 +1,128 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { QuestRewardType, QuestTaskType } from "@vencord/discord-types/enums";
export type QuestButtonDisplayMode = "always" | "unclaimed" | "never";
export type QuestButtonIndicatorMode = "pill" | "badge" | "both" | "none";
export type QuestButtonAction = "open-quests" | "context-menu" | "plugin-settings" | "nothing";
export type QuestTileGradient = "intense" | "default" | "black" | "hide";
export type QuestOrderStatus = "UNCLAIMED" | "CLAIMED" | "IGNORED" | "EXPIRED";
export type QuestSubsort = "Recent ASC" | "Recent DESC" | "Expiring ASC" | "Expiring DESC" | "Claimed ASC" | "Claimed DESC";
export interface QuestTileColorSetting {
enabled: boolean;
color: number;
}
export const defaultQuestTileUnclaimedColor = 2842239;
export const defaultQuestTileClaimedColor = 6105983;
export const defaultQuestTileIgnoredColor = 8334124;
export const defaultQuestTileExpiredColor = 2368553;
export const defaultQuestTileGradient: QuestTileGradient = "intense";
export const defaultQuestTilePreload = true;
export const defaultQuestTileUnclaimedColorSetting: QuestTileColorSetting = { enabled: true, color: defaultQuestTileUnclaimedColor };
export const defaultQuestTileClaimedColorSetting: QuestTileColorSetting = { enabled: true, color: defaultQuestTileClaimedColor };
export const defaultQuestTileIgnoredColorSetting: QuestTileColorSetting = { enabled: true, color: defaultQuestTileIgnoredColor };
export const defaultQuestTileExpiredColorSetting: QuestTileColorSetting = { enabled: true, color: defaultQuestTileExpiredColor };
export const defaultQuestOrder = ["UNCLAIMED", "CLAIMED", "IGNORED", "EXPIRED"] as const satisfies readonly QuestOrderStatus[];
export const defaultQuestButtonBadgeColor = defaultQuestTileUnclaimedColor;
export const defaultQuestButtonDisplay: QuestButtonDisplayMode = "always";
export const defaultQuestButtonIndicator: QuestButtonIndicatorMode = "both";
export const defaultLeftClickAction: QuestButtonAction = "open-quests";
export const defaultMiddleClickAction: QuestButtonAction = "plugin-settings";
export const defaultRightClickAction: QuestButtonAction = "context-menu";
export const defaultDisableQuestsEverything = false;
export const defaultDisableRelocationNotices = true;
export const defaultDisableSponsoredBanner = false;
export const defaultDisableAccountPanelPromo = true;
export const defaultDisableAccountPanelQuestProgress = false;
export const defaultDisableOrbsAndQuestsBadges = false;
export const defaultDisableFriendsListPromo = true;
export const defaultDisableMembersListPromo = true;
export const defaultResumeInterruptedQuests = false;
export const defaultAllowChangingDangerousSettings = false; // true -> Risky
export const defaultMakeMobileVideoQuestsDesktopCompatible = false; // true -> Risky
export const defaultCompleteVideoQuestsQuicker = false; // true -> Risky
export const defaultAutoCompleteQuestsSimultaneously = false; // true -> Risky
export const defaultNotifyOnQuestComplete = true;
export const defaultNotifyOnNewQuests = true;
export const defaultNotifyOnNewExcludedQuests = false;
export const defaultQuestCompletedAlertSound = "bop_message1";
export const defaultQuestCompletedAlertVolume = 100;
const questTaskTypes = [
QuestTaskType.WATCH_VIDEO,
QuestTaskType.WATCH_VIDEO_ON_MOBILE,
QuestTaskType.ACHIEVEMENT_IN_ACTIVITY,
QuestTaskType.ACHIEVEMENT_IN_GAME,
QuestTaskType.PLAY_ACTIVITY,
QuestTaskType.PLAY_ON_DESKTOP,
QuestTaskType.PLAY_ON_DESKTOP_V2,
QuestTaskType.STREAM_ON_DESKTOP,
QuestTaskType.PLAY_ON_PLAYSTATION,
QuestTaskType.PLAY_ON_XBOX,
] as const satisfies readonly QuestTaskType[];
export const autoCompleteQuestTaskTypes = [
QuestTaskType.PLAY_ON_DESKTOP,
QuestTaskType.PLAY_ON_XBOX,
QuestTaskType.PLAY_ON_PLAYSTATION,
QuestTaskType.PLAY_ACTIVITY,
QuestTaskType.WATCH_VIDEO,
QuestTaskType.WATCH_VIDEO_ON_MOBILE,
QuestTaskType.ACHIEVEMENT_IN_ACTIVITY,
] as const satisfies readonly QuestTaskType[];
const desktopOnlyAutoCompleteQuestTypes = new Set<QuestTaskType>([
QuestTaskType.PLAY_ON_DESKTOP,
QuestTaskType.PLAY_ON_PLAYSTATION,
QuestTaskType.PLAY_ON_XBOX,
QuestTaskType.PLAY_ACTIVITY,
]);
export function isDesktopCompatible(questType: QuestTaskType): boolean {
return IS_DISCORD_DESKTOP || !desktopOnlyAutoCompleteQuestTypes.has(questType);
}
export type AutoCompleteQuestTypes = Partial<Record<QuestTaskType, boolean>>;
export const defaultAutoCompleteQuestTypes = Object.fromEntries(
autoCompleteQuestTaskTypes.map(questType => [questType, false])
) as AutoCompleteQuestTypes;
export type QuestButtonIncludedTypes = Record<QuestTaskType | QuestRewardType, boolean>;
export const defaultQuestButtonIncludedTypes: QuestButtonIncludedTypes = {
...Object.fromEntries(questTaskTypes.map(questType => [questType, true])),
[QuestRewardType.REWARD_CODE]: true,
[QuestRewardType.IN_GAME]: true,
[QuestRewardType.COLLECTIBLE]: true,
[QuestRewardType.VIRTUAL_CURRENCY]: true,
[QuestRewardType.FRACTIONAL_PREMIUM]: true,
} as QuestButtonIncludedTypes;
export const defaultQuestButtonBadgeCount = 0;
export const defaultQuestFetchInterval = 2700;
export const defaultNewQuestAlertSound = "discodo";
export const defaultNewQuestAlertVolume = 100;
export const defaultNewExcludedQuestAlertSound = null;
export const defaultNewExcludedQuestAlertVolume = 100;
export const defaultUnclaimedSubsort: QuestSubsort = "Expiring ASC";
export const defaultClaimedSubsort: QuestSubsort = "Claimed DESC";
export const defaultIgnoredSubsort: QuestSubsort = "Recent DESC";
export const defaultExpiredSubsort: QuestSubsort = "Expiring DESC";
export const defaultIsOnQuestsPage = false;
export const defaultRememberQuestPageSort = true;
export const defaultRememberQuestPageFilters = true;
export const defaultLastQuestPageSort = "questify";
export const defaultLastQuestPageFilters = {} as Record<string, { group: string, filter: string; }>;
export const ignoredQuestIDsKey = "questIDs";
export const defaultIgnoredQuestIDs = { [ignoredQuestIDsKey]: [] } as Record<typeof ignoredQuestIDsKey, string[]>;
export const defaultResumeQuestIDs = {} as Record<string, // key: UserID
{ timestamp: number, questIDs: string[]; }>;

View file

@ -0,0 +1,97 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import type { Quest } from "@vencord/discord-types";
import { fetchAndAlertQuests } from "../utils/fetching";
import { QL } from "../utils/logging";
import { getCurrentUserId, getQuestifySettings } from "./access";
let autoFetchInterval: null | ReturnType<typeof setInterval> = null;
const minimumAutoFetchIntervalValue = 30 * 60; // 30 minutes
const maximumAutoFetchIntervalValue = 12 * 60 * 60; // 12 hours
export function autoFetchCompatible(): boolean {
const settings = getQuestifySettings();
const {
newExcludedQuestAlertSound,
newQuestAlertSound,
questFetchInterval,
notifyOnNewExcludedQuests,
notifyOnNewQuests,
questButtonDisplay: displayMode,
questButtonIndicator: indicatorMode
} = settings;
const fetching = !settings.disableQuestsEverything && questFetchInterval > 0;
const notificationsCompatible = notifyOnNewQuests || notifyOnNewExcludedQuests || Boolean(newQuestAlertSound) || Boolean(newExcludedQuestAlertSound);
let buttonCompatible = false;
if (displayMode === "always") {
buttonCompatible = ["pill", "badge", "both"].includes(indicatorMode);
} else if (displayMode === "unclaimed") {
buttonCompatible = true;
}
const compatible = fetching && (buttonCompatible || notificationsCompatible);
return compatible;
}
export function startAutoFetchingQuests(force: boolean = false): void {
if (!autoFetchCompatible()) {
stopAutoFetchingQuests();
return;
}
if (autoFetchInterval) {
if (!force) {
return;
}
stopAutoFetchingQuests();
}
const { questFetchInterval } = getQuestifySettings();
const interval = Math.min(Math.max(questFetchInterval, minimumAutoFetchIntervalValue), maximumAutoFetchIntervalValue);
autoFetchInterval = setInterval(() => { void fetchAndAlertQuests("AUTO_FETCH"); }, interval * 1000);
QL.info("START_AUTO_FETCHING_QUESTS", { autoFetchIntervalID: autoFetchInterval, questFetchInterval, questFetchIntervalClamped: interval });
}
export function stopAutoFetchingQuests(): void {
if (autoFetchInterval) {
clearInterval(autoFetchInterval);
autoFetchInterval = null;
QL.info("STOP_AUTO_FETCHING_QUESTS");
}
}
export function resetQuestsToResume(quest?: Quest, userId?: string): void {
const { resumeQuestIDs } = getQuestifySettings();
const key = getCurrentUserId(userId);
if (!key) {
QL.warn("RESET_QUESTS_TO_RESUME_NO_USER");
return;
}
const resumeState = resumeQuestIDs[key];
if (!resumeState) {
return;
}
if (!quest) {
delete resumeQuestIDs[key];
return;
}
resumeQuestIDs[key] = {
timestamp: resumeState.timestamp,
questIDs: resumeState.questIDs.filter(id => id !== quest.id),
};
}

View file

@ -0,0 +1,87 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import type { Quest } from "@vencord/discord-types";
import { QuestStore } from "@webpack/common";
import type { QuestIncludedTypes } from "../utils/filtering";
import { countIncludedUnclaimedQuests, getQuestStatus, QuestStatus } from "../utils/questState";
import { getQuestifySettings } from "./access";
import { ignoredQuestIDsKey } from "./def";
import { rerenderQuests } from "./rerender";
function validateQuestBadgeCount(): void {
const settings = getQuestifySettings();
const questButtonIncludedTypes = settings.questButtonIncludedTypes as QuestIncludedTypes;
const quests = Array.from(QuestStore.quests.values());
const ignoredQuestIds = getIgnoredQuestIDs();
const count = countIncludedUnclaimedQuests(quests, ignoredQuestIds, questButtonIncludedTypes);
settings.questButtonBadgeCount = count;
}
export function getIgnoredQuestIDs(): string[] {
const { ignoredQuestIDs } = getQuestifySettings();
const ignoredQuestIDsForKey = Array.from(ignoredQuestIDs[ignoredQuestIDsKey] ?? []);
return ignoredQuestIDsForKey;
}
function setIgnoredQuestIDs(questIDs: string[]): void {
getQuestifySettings().ignoredQuestIDs[ignoredQuestIDsKey] = questIDs;
}
export function validateIgnoredQuests(qs?: Quest[]): void {
const currentlyIgnoredQuests = getIgnoredQuestIDs();
const quests = qs ?? Array.from(QuestStore.quests.values());
const excludedQuests = Array.from(QuestStore.excludedQuests.values());
const validIgnored = Array.from(new Set<string>(currentlyIgnoredQuests.filter(id => quests.some(quest => quest.id === id) || excludedQuests.some(quest => quest.id === id))));
setIgnoredQuestIDs(validIgnored);
validateQuestBadgeCount();
rerenderQuests();
}
export function resetIgnoredQuests(): void {
setIgnoredQuestIDs([]);
validateIgnoredQuests();
}
export function addIgnoredQuest(questId: string): void {
setIgnoredQuestIDs(Array.from(new Set([...getIgnoredQuestIDs(), questId])));
validateIgnoredQuests();
}
export function removeIgnoredQuest(questId: string): void {
setIgnoredQuestIDs(getIgnoredQuestIDs().filter(id => id !== questId));
validateIgnoredQuests();
}
export function questIsIgnored(questId: string): boolean {
return getIgnoredQuestIDs().includes(questId);
}
export function ignoreAllQuests(): void {
const currentlyIgnored = new Set(getIgnoredQuestIDs());
const ignoredQuests = new Set<string>();
for (const quest of QuestStore.quests.values()) {
if (
currentlyIgnored.has(quest.id)
|| getQuestStatus(quest, Array.from(currentlyIgnored), false) === QuestStatus.Unclaimed
) {
ignoredQuests.add(quest.id);
}
}
for (const quest of QuestStore.excludedQuests.values()) {
if (currentlyIgnored.has(quest.id)) {
ignoredQuests.add(quest.id);
}
}
setIgnoredQuestIDs(Array.from(ignoredQuests));
validateIgnoredQuests();
}

View file

@ -0,0 +1,29 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { React } from "@webpack/common";
let questRerenderVersion = 0;
const listeners = new Set<() => void>();
function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
function getSnapshot(): number {
return questRerenderVersion;
}
export function rerenderQuests(): void {
questRerenderVersion++;
listeners.forEach(listener => listener());
}
export function useQuestRerender(): number {
return React.useSyncExternalStore(subscribe, getSnapshot);
}

View file

@ -0,0 +1,65 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { SettingsStore } from "@api/Settings";
import type { DefinedSettings, SettingsDefinition } from "@utils/types";
import { Alerts } from "../utils/ui";
type RestartTrackingSettings = Pick<DefinedSettings<SettingsDefinition>, "def" | "pluginName">;
let restartDirty = false;
let didAttachRestartListeners = false;
const restartListenerCleanups: (() => void)[] = [];
function getRestartSettingPaths(settings: RestartTrackingSettings): string[] {
return Object.entries(settings.def)
.filter(([, definition]) => definition.restartNeeded)
.map(([key]) => `plugins.${settings.pluginName}.${key}`);
}
export function initializeRestartTracking(settings: RestartTrackingSettings): void {
if (didAttachRestartListeners) {
return;
}
didAttachRestartListeners = true;
for (const path of getRestartSettingPaths(settings)) {
const markRestartDirty = () => { restartDirty = true; };
SettingsStore.addChangeListener(path, markRestartDirty);
restartListenerCleanups.push(() => SettingsStore.removeChangeListener(path, markRestartDirty));
}
}
export function disposeRestartTracking(): void {
for (const cleanup of restartListenerCleanups.splice(0)) {
cleanup();
}
didAttachRestartListeners = false;
}
export function isRestartDirty(): boolean {
return restartDirty;
}
export function promptToRestartIfDirty(): boolean {
if (!restartDirty) {
return false;
}
Alerts.show({
title: "Restart Required",
body: "A change you've made to Questify's settings requires a restart.",
confirmText: "Restart",
cancelText: "Later",
onConfirm: () => location.reload(),
});
return true;
}

View file

@ -0,0 +1,381 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { definePluginSettings, PlainSettings, SettingsStore } from "@api/Settings";
import ErrorBoundary from "@components/ErrorBoundary";
import { OptionType } from "@utils/types";
import { QuestButtonSetting } from "../components/questButtonSettings";
import { QuestFeaturesSetting } from "../components/questFeaturesSetting";
import { QuestNotificationsSetting } from "../components/questNotificationsSetting";
import { QuestTilesSetting } from "../components/questTilesSetting";
import { ReorderQuestsSetting } from "../components/reorderQuestsSetting";
import { defaultAllowChangingDangerousSettings, defaultAutoCompleteQuestsSimultaneously, defaultAutoCompleteQuestTypes, defaultClaimedSubsort, defaultCompleteVideoQuestsQuicker, defaultDisableAccountPanelPromo, defaultDisableAccountPanelQuestProgress, defaultDisableFriendsListPromo, defaultDisableMembersListPromo, defaultDisableOrbsAndQuestsBadges, defaultDisableQuestsEverything, defaultDisableRelocationNotices, defaultDisableSponsoredBanner, defaultExpiredSubsort, defaultIgnoredQuestIDs, defaultIgnoredSubsort, defaultIsOnQuestsPage, defaultLastQuestPageFilters, defaultLastQuestPageSort, defaultLeftClickAction, defaultMakeMobileVideoQuestsDesktopCompatible, defaultMiddleClickAction, defaultNewExcludedQuestAlertSound, defaultNewExcludedQuestAlertVolume, defaultNewQuestAlertSound, defaultNewQuestAlertVolume, defaultNotifyOnNewExcludedQuests, defaultNotifyOnNewQuests, defaultNotifyOnQuestComplete, defaultQuestButtonBadgeColor, defaultQuestButtonBadgeCount, defaultQuestButtonDisplay, defaultQuestButtonIncludedTypes, defaultQuestButtonIndicator, defaultQuestCompletedAlertSound, defaultQuestCompletedAlertVolume, defaultQuestFetchInterval, defaultQuestOrder, defaultQuestTileClaimedColorSetting, defaultQuestTileExpiredColorSetting, defaultQuestTileGradient, defaultQuestTileIgnoredColorSetting, defaultQuestTilePreload, defaultQuestTileUnclaimedColorSetting, defaultRememberQuestPageFilters, defaultRememberQuestPageSort, defaultResumeInterruptedQuests, defaultResumeQuestIDs, defaultRightClickAction, defaultUnclaimedSubsort, type QuestButtonAction, type QuestButtonDisplayMode, type QuestButtonIncludedTypes, type QuestButtonIndicatorMode, type QuestOrderStatus } from "./def";
const MIGRATION_TARGET = 1;
const CURRENT_SETTINGS = PlainSettings.plugins.Questify;
if (CURRENT_SETTINGS) {
let migrationVersion = CURRENT_SETTINGS.migrationVersion ?? 0;
// 0 -> 1: Reset Settings
if (migrationVersion === 0) {
PlainSettings.plugins.Questify = { enabled: CURRENT_SETTINGS.enabled, migrationVersion: 1 };
migrationVersion = 1;
}
if (migrationVersion !== CURRENT_SETTINGS.migrationVersion) {
SettingsStore.markAsChanged();
}
}
export const settings = definePluginSettings({
migrationVersion: {
type: OptionType.NUMBER,
description: "The current migration version of the settings.",
default: MIGRATION_TARGET,
hidden: true,
},
questFeatures: {
type: OptionType.COMPONENT,
component: ErrorBoundary.wrap(QuestFeaturesSetting) as any,
description: "Select which Quest features to disable.",
},
disableQuestsEverything: {
type: OptionType.BOOLEAN,
description: "Disable all Quest features.",
default: defaultDisableQuestsEverything,
restartNeeded: true,
hidden: true,
},
disableRelocationNotices: {
type: OptionType.BOOLEAN,
description: "Disable Quest relocation notices in the Discovery page.",
default: defaultDisableRelocationNotices,
restartNeeded: true,
hidden: true,
},
disableSponsoredBanner: {
type: OptionType.BOOLEAN,
description: "Disable the sponsored banner on the Quest page.",
default: defaultDisableSponsoredBanner,
restartNeeded: true,
hidden: true,
},
disableAccountPanelPromo: {
type: OptionType.BOOLEAN,
description: "Disable the promoted Quest popup above your account panel.",
default: defaultDisableAccountPanelPromo,
restartNeeded: true,
hidden: true,
},
disableAccountPanelQuestProgress: {
type: OptionType.BOOLEAN,
description: "Disable active and completed Quest progress above your account panel.",
default: defaultDisableAccountPanelQuestProgress,
restartNeeded: true,
hidden: true,
},
disableOrbsAndQuestsBadges: {
type: OptionType.BOOLEAN,
description: "Disable the Quest badge on user profiles.",
default: defaultDisableOrbsAndQuestsBadges,
restartNeeded: true,
hidden: true,
},
disableFriendsListPromo: {
type: OptionType.BOOLEAN,
description: "Disable the promotion of Quests for games played by friends.",
default: defaultDisableFriendsListPromo,
restartNeeded: true,
hidden: true,
},
disableMembersListPromo: {
type: OptionType.BOOLEAN,
description: "Disable the actively playing icon in members list items.",
default: defaultDisableMembersListPromo,
restartNeeded: true,
hidden: true,
},
allowChangingDangerousSettings: {
type: OptionType.BOOLEAN,
description: "Allow changing dangerous settings.",
default: defaultAllowChangingDangerousSettings,
hidden: true,
},
resumeInterruptedQuests: {
type: OptionType.BOOLEAN,
description: "Resume auto-completing Quests after a reload or restart.",
default: defaultResumeInterruptedQuests,
restartNeeded: true,
hidden: true,
},
makeMobileVideoQuestsDesktopCompatible: {
type: OptionType.BOOLEAN,
description: "Make mobile-only Video Quests compatible with desktop.",
default: defaultMakeMobileVideoQuestsDesktopCompatible,
restartNeeded: true,
hidden: true,
},
autoCompleteQuestsSimultaneously: {
type: OptionType.BOOLEAN,
description: "Complete Quests simultaneously rather than sequentially.",
default: defaultAutoCompleteQuestsSimultaneously,
restartNeeded: true,
hidden: true,
},
completeVideoQuestsQuicker: {
type: OptionType.BOOLEAN,
description: "Use Discord's progress leeway and elapsed enrollment time for Video Quest auto-completion.",
default: defaultCompleteVideoQuestsQuicker,
restartNeeded: true,
hidden: true,
},
autoCompleteQuestTypes: {
type: OptionType.CUSTOM,
description: "Which types of Quests to auto-complete in the background.",
default: defaultAutoCompleteQuestTypes,
restartNeeded: true,
hidden: true,
},
questButton: {
type: OptionType.COMPONENT,
component: ErrorBoundary.wrap(QuestButtonSetting) as any,
description: "Customize the Quest button in the server list.",
},
questButtonDisplay: {
type: OptionType.CUSTOM,
description: "Which display type to use for the Quest button in the server list.",
default: defaultQuestButtonDisplay as QuestButtonDisplayMode,
restartNeeded: true,
hidden: true,
},
questButtonIncludedTypes: {
type: OptionType.CUSTOM,
description: "Which reward types and Quest types to include when displaying Quest counts on the Quest button.",
default: defaultQuestButtonIncludedTypes as QuestButtonIncludedTypes,
hidden: true,
},
questButtonIndicator: {
type: OptionType.CUSTOM,
description: "Which display type to use for the unclaimed indicator on the Quest button in the server list.",
default: defaultQuestButtonIndicator as QuestButtonIndicatorMode,
hidden: true,
},
questButtonBadgeCount: {
type: OptionType.NUMBER,
description: "The current number of relevant unclaimed Quests.",
default: defaultQuestButtonBadgeCount,
hidden: true,
},
questButtonBadgeColor: {
type: OptionType.NUMBER | OptionType.CUSTOM,
description: "The color of the Quest button badge in the server list.",
default: defaultQuestButtonBadgeColor as number | null,
hidden: true,
},
questButtonLeftClickAction: {
type: OptionType.CUSTOM,
description: "The action to perform when left-clicking the Quest button in the server list.",
default: defaultLeftClickAction as QuestButtonAction,
hidden: true,
},
questButtonMiddleClickAction: {
type: OptionType.CUSTOM,
description: "The action to perform when middle-clicking the Quest button in the server list.",
default: defaultMiddleClickAction as QuestButtonAction,
hidden: true,
},
questButtonRightClickAction: {
type: OptionType.CUSTOM,
description: "The action to perform when right-clicking the Quest button in the server list.",
default: defaultRightClickAction as QuestButtonAction,
hidden: true,
},
questNotifications: {
type: OptionType.COMPONENT,
component: ErrorBoundary.wrap(QuestNotificationsSetting) as any,
description: "Configure Quest completed and new Quest detected notifications.",
},
notifyOnQuestComplete: {
type: OptionType.BOOLEAN,
description: "Show a notification when a Quest is completed.",
default: defaultNotifyOnQuestComplete,
hidden: true,
},
notifyOnNewQuests: {
type: OptionType.BOOLEAN,
description: "Show a notification when new Quests are detected.",
default: defaultNotifyOnNewQuests,
hidden: true,
},
notifyOnNewExcludedQuests: {
type: OptionType.BOOLEAN,
description: "Show a notification when new excluded Quests are detected.",
default: defaultNotifyOnNewExcludedQuests,
hidden: true,
},
questCompletedAlertSound: {
type: OptionType.STRING | OptionType.CUSTOM,
description: "The sound to play when a Quest is completed.",
default: defaultQuestCompletedAlertSound as string | null,
hidden: true,
},
questCompletedAlertVolume: {
type: OptionType.NUMBER,
description: "The volume for the Quest completed alert sound.",
default: defaultQuestCompletedAlertVolume,
hidden: true,
},
questFetchInterval: {
type: OptionType.NUMBER,
description: "The interval in seconds to fetch Quests from Discord.",
default: defaultQuestFetchInterval,
hidden: true,
},
newQuestAlertSound: {
type: OptionType.STRING | OptionType.CUSTOM,
description: "The sound to play when new Quests are detected.",
default: defaultNewQuestAlertSound as string | null,
hidden: true,
},
newQuestAlertVolume: {
type: OptionType.NUMBER,
description: "The volume for the new Quest alert sound.",
default: defaultNewQuestAlertVolume,
hidden: true,
},
newExcludedQuestAlertSound: {
type: OptionType.STRING | OptionType.CUSTOM,
description: "The sound to play when new excluded Quests are detected.",
default: defaultNewExcludedQuestAlertSound as string | null,
hidden: true,
},
newExcludedQuestAlertVolume: {
type: OptionType.NUMBER,
description: "The volume for the new excluded Quest alert sound.",
default: defaultNewExcludedQuestAlertVolume,
hidden: true,
},
questTiles: {
type: OptionType.COMPONENT,
component: ErrorBoundary.wrap(QuestTilesSetting) as any,
description: "Customize the appearance of Quest tiles in the Quests page.",
},
questTileUnclaimedColor: {
type: OptionType.CUSTOM,
description: "The color of unclaimed Quest tiles in the Quests page.",
default: { ...defaultQuestTileUnclaimedColorSetting },
hidden: true,
},
questTileClaimedColor: {
type: OptionType.CUSTOM,
description: "The color of claimed Quest tiles in the Quests page.",
default: { ...defaultQuestTileClaimedColorSetting },
hidden: true,
},
questTileIgnoredColor: {
type: OptionType.CUSTOM,
description: "The color of ignored Quest tiles in the Quests page.",
default: { ...defaultQuestTileIgnoredColorSetting },
hidden: true,
},
questTileExpiredColor: {
type: OptionType.CUSTOM,
description: "The color of expired Quest tiles in the Quests page.",
default: { ...defaultQuestTileExpiredColorSetting },
hidden: true,
},
questTileGradient: {
type: OptionType.STRING,
description: "Style of the gradient used in the Quest tiles.",
default: defaultQuestTileGradient,
hidden: true,
},
questTilePreload: {
type: OptionType.BOOLEAN,
description: "Attempt to preload the assets for the Quest tiles.",
default: defaultQuestTilePreload,
hidden: true,
},
reorderQuests: {
type: OptionType.COMPONENT,
description: "Sort Quests by their status.",
component: ErrorBoundary.wrap(ReorderQuestsSetting) as any,
},
questOrder: {
type: OptionType.CUSTOM,
description: "Sort order for Quest status groups.",
default: Array.from(defaultQuestOrder) as QuestOrderStatus[],
hidden: true,
},
unclaimedSubsort: {
type: OptionType.STRING,
description: "Subsort method for unclaimed Quests.",
default: defaultUnclaimedSubsort,
hidden: true,
},
claimedSubsort: {
type: OptionType.STRING,
description: "Subsort method for claimed Quests.",
default: defaultClaimedSubsort,
hidden: true,
},
ignoredSubsort: {
type: OptionType.STRING,
description: "Subsort method for ignored Quests.",
default: defaultIgnoredSubsort,
hidden: true,
},
expiredSubsort: {
type: OptionType.STRING,
description: "Subsort method for expired Quests.",
default: defaultExpiredSubsort,
hidden: true,
},
isOnQuestsPage: {
type: OptionType.BOOLEAN,
description: "Whether the user is currently on the Quests page.",
default: defaultIsOnQuestsPage,
hidden: true,
},
rememberQuestPageSort: {
type: OptionType.BOOLEAN,
description: "Remember the last used sort on the Quests page.",
default: defaultRememberQuestPageSort,
hidden: true,
},
rememberQuestPageFilters: {
type: OptionType.BOOLEAN,
description: "Remember the last used filters on the Quests page.",
default: defaultRememberQuestPageFilters,
hidden: true,
},
lastQuestPageSort: {
type: OptionType.STRING,
description: "Remember the last used sort on the Quests page.",
default: defaultLastQuestPageSort,
hidden: true,
},
lastQuestPageFilters: {
type: OptionType.CUSTOM,
description: "Remember the last used filters on the Quests page.",
default: defaultLastQuestPageFilters,
hidden: true,
},
ignoredQuestIDs: {
type: OptionType.CUSTOM,
description: "An array of Quest IDs that are ignored.",
default: defaultIgnoredQuestIDs,
hidden: true,
},
resumeQuestIDs: {
type: OptionType.CUSTOM,
description: "An array of Quest IDs that are being or are queued to be auto-completed in the background.",
default: defaultResumeQuestIDs,
hidden: true,
},
});

View file

@ -0,0 +1,20 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
export let initialQuestDataFetched = false;
let settingsModalOpen = false;
export function setInitialQuestDataFetched(fetched: boolean): void {
initialQuestDataFetched = fetched;
}
export function setSettingsModalOpen(open: boolean): void {
settingsModalOpen = open;
}
export function getSettingsModalOpen(): boolean {
return settingsModalOpen;
}

View file

@ -1,209 +1,550 @@
/* Plugin Settings */
/* Miscellaneous */
/* Fix to overwrite bad CSS in callTimer/alignedChatInputFix.css */
[class*="panels"] [class*="questProgressWrapper"] [class*="outer"] > [class*="inner"] {
height: 100% !important;
}
/* Fix to overwrite bad CSS in callTimer/alignedChatInputFix.css */
[class*="panels"] [class*="questProgressWrapper"] > div {
opacity: 1 !important;
}
/* Fix oversized confetti blocking Quest buttons. */
[class*="rewardDescriptionContainer"] [class*="progressWrapper"] > [class*="confetti"] {
pointer-events: none;
}
/* Fix Questify button text clipping. */
.questify-quest-item-restyle button [class*="buttonChildren"],
.questify-quest-item-restyle button span {
overflow: visible;
}
/* Settings */
.questify-setting {
border-radius: 3px;
border-left: 3px solid var(--text-strong);
padding: 8px;
user-select: text;
}
.questify-settings-divider-small {
margin: 10px 0;
}
.questify-settings-divider-medium {
margin: 15px 0;
}
.questify-settings-divider-large {
margin: 20px 0;
}
.questify-dimmed-settings-item {
filter: brightness(0.5);
}
.questify-setting-header {
font-size: 19px;
font-weight: 600;
margin-bottom: 10px;
}
.questify-setting-subheader {
font-size: 16px;
font-weight: 500;
margin: 15px 0 5px;
}
.questify-setting-paragraph {
font-size: 14px;
font-weight: 400;
margin: 5px 0;
line-height: normal;
}
.questify-setting-description {
margin-bottom: 15px;
line-height: normal;
}
.questify-setting-description-multi-paragraph {
line-height: 0.2em;
}
[class*="clickTrapContainer"] > div > div:has(.questify-settings-tooltip-content) {
margin-right: 4px;
}
.questify-settings-tooltip-content {
text-align: justify;
white-space: pre-line;
}
.questify-setting-switch {
margin-bottom: 5px;
}
.questify-setting-switch [class*="container_"] [class*="labelContainer_"] label {
font-size: 16px;
font-weight: 500;
}
.questify-setting-subtle-switch [class*="container_"] [class*="labelContainer_"] label,
.questify-setting-switch [class*="container_"] [class*="labelContainer_"] div {
font-size: 14px;
font-weight: 400;
}
.questify-notice-card {
margin: 12px 0;
padding: 12px;
border-radius: 4px;
line-height: 0.2em;
}
.questify-notice-card-red {
border-left: 3px solid var(--status-danger);
background-color: color-mix(in srgb, var(--status-danger) 12%, transparent);
}
.questify-notice-card-blue {
border-left: 3px solid var(--background-brand);
background-color: color-mix(in srgb, var(--background-brand) 12%, transparent);
}
.questify-notice-card-solo {
margin: 12px 0 0;
}
.questify-settings-button > button[data-mana-component="button"] {
border-radius: 3px;
}
.questify-settings-button > button[data-mana-component="button"] span {
font-size: 14px;
}
.questify-setting [data-floating-ui-focusable][class*="selectDropdown"],
.questify-setting [data-mana-component="select-input-field"] > [class*="selectField"],
.questify-setting [data-mana-component="select-input-field"] [class*="tagList"] > div {
border-radius: 3px;
}
.questify-inline-volume-slider {
margin-top: 0;
}
.questify-sound-preview-button {
color: var(--interactive-normal);
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: 0;
border-radius: 4px;
background: transparent;
cursor: pointer;
flex: 0 0 auto;
}
.questify-sound-preview-button:hover,
.questify-sound-preview-button.questify-playing-audio {
background: var(--background-modifier-hover);
}
.questify-sound-preview-button:hover {
color: var(--interactive-active);
}
.questify-sound-preview-button.questify-playing-audio {
color: var(--red-500);
}
.questify-sound-preview-button:disabled {
cursor: default;
}
.questify-sound-select [class*="selectedIcon"] {
display: none;
}
.questify-no-top-margin,
.questify-margin-top-0 {
margin-top: 0;
}
.questify-margin-top-1 {
margin-top: 1px;
}
.questify-margin-top-2 {
margin-top: 2px;
}
.questify-margin-top-3 {
margin-top: 3px;
}
.questify-margin-top-4 {
margin-top: 4px;
}
.questify-margin-top-5 {
margin-top: 5px;
}
.questify-margin-top-6 {
margin-top: 6px;
}
.questify-margin-top-7 {
margin-top: 7px;
}
.questify-margin-top-8 {
margin-top: 8px;
}
.questify-margin-top-9 {
margin-top: 9px;
}
.questify-margin-top-10 {
margin-top: 10px;
}
.questify-margin-top-11 {
margin-top: 11px;
}
.questify-margin-top-12 {
margin-top: 12px;
}
.questify-margin-top-13 {
margin-top: 13px;
}
.questify-margin-top-14 {
margin-top: 14px;
}
.questify-margin-top-15 {
margin-top: 15px;
}
.questify-no-bottom-margin,
.questify-margin-bottom-0 {
margin-bottom: 0;
}
.questify-margin-bottom-1 {
margin-bottom: 1px;
}
.questify-margin-bottom-2 {
margin-bottom: 2px;
}
.questify-margin-bottom-3 {
margin-bottom: 3px;
}
.questify-margin-bottom-4 {
margin-bottom: 4px;
}
.questify-margin-bottom-5 {
margin-bottom: 5px;
}
.questify-margin-bottom-6 {
margin-bottom: 6px;
}
.questify-margin-bottom-7 {
margin-bottom: 7px;
}
.questify-margin-bottom-8 {
margin-bottom: 8px;
}
.questify-setting-divider {
margin: 0;
.questify-margin-bottom-9 {
margin-bottom: 9px;
}
.questify-setting {
border-left: 3px solid var(--text-strong);
padding: 3px 0 3px 8px;
border-radius: 3px;
user-select: text;
.questify-margin-bottom-10 {
margin-bottom: 10px;
}
.questify-main-inline-group {
.questify-margin-bottom-11 {
margin-bottom: 11px;
}
.questify-margin-bottom-12 {
margin-bottom: 12px;
}
.questify-margin-bottom-13 {
margin-bottom: 13px;
}
.questify-margin-bottom-14 {
margin-bottom: 14px;
}
.questify-margin-bottom-15 {
margin-bottom: 15px;
}
.questify-settings-row {
margin-top: 4px;
display: flex;
gap: 12px;
gap: 4px;
align-items: flex-start;
}
.questify-sub-inline-group {
display: flex;
gap: 8px;
.questify-settings-row-item {
flex: 1 1 0;
min-width: 0;
}
.questify-inline-group-item {
flex: 1;
.questify-settings-row-item-content {
flex: 0 0 auto;
}
.questify-form-title {
font-size: 18px;
font-weight: 700;
margin-bottom: 8px;
}
.questify-form-subtitle {
font-size: 14px;
font-weight: 500;
margin-top: 4px;
margin-bottom: 4px;
}
.questify-form-subtitle-spacier {
margin-top: 8px;
}
.questify-form-description {
font-size: 14px;
}
.questify-form-warning {
margin: 12px 0 8px;
padding: 10px 12px;
border-left: 3px solid var(--status-danger);
border-radius: 4px;
background-color: color-mix(in srgb, var(--status-danger) 12%, transparent);
}
.questify-select > div {
border-radius: 3px;
min-height: 43px;
border: 1px solid var(--border-normal);
}
.questify-affixless-select-popout-option {
grid-template-areas: "content selectedIcon" !important;
grid-template-columns: 1fr auto !important;
}
.questify-setting [class*="comboBoxInputContainer"] {
flex-wrap: wrap;
}
.questify-volume-slider-container {
margin: 8px 0 0;
.questify-settings-slider-control-container {
margin: 4px 0 0;
background-color: var(--input-background-default, var(--input-background));
padding: 7px 12px 7px 7px;
padding: 0 12px 0 4px;
border-radius: 3px;
border: 1px solid var(--border-normal);
}
.questify-button,
.questify-sub-inline-group [class*="swatch"] {
height: 38px;
min-height: 38px;
width: 80px;
min-width: 80px;
padding: 2px;
border-radius: 3px;
}
.questify-alert-icon {
flex: 0;
place-content: center center;
background-color: var(--input-background-default, var(--input-background));
border-radius: 3px;
padding: 0 10px;
color: var(--text-default);
border: 1px solid var(--border-normal);
min-height: 43px;
min-height: 40px;
box-sizing: border-box;
display: flex;
align-items: center;
transition: color 0.2s ease-in-out;
}
.questify-alert-icon.questify-playing-audio {
color: var(--red-500);
transition: color 0.2s ease-in-out;
}
.questify-button [class*="contents"] {
margin: 0 8px;
}
.questify-button-red {
background-color: var(--red-500) !important;
}
.questify-button-red:hover {
filter: brightness(0.75);
transition: filter 0.2s ease-in-out;
}
.questify-button-blue {
background-color: var(--blue-500) !important;
}
.questify-button-blue:hover {
filter: brightness(0.75);
transition: filter 0.2s ease-in-out;
}
#questify-dummy-quest-button {
border-left: 5px solid var(--text-strong);
border-radius: 4px;
}
.questify-text-input,
.questify-text-area {
margin-top: 4px;
.questify-settings-slider-control {
margin: 0 4px;
width: 100%;
border-radius: 3px;
}
.questify-text-area {
height: 150px;
.questify-sound-select-row-item {
flex: 0 1 67%;
}
.questify-color-picker-container {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-top: 8px;
.questify-volume-slider-row-item {
flex: 0 1 33%;
}
.questify-color-picker-group {
/* Quest Tiles */
.questify-quest-tile-gradient-row-item {
flex: 0 1 42%;
}
.questify-quest-tile-preload-row-item {
flex: 0 1 58%;
}
.questify-quest-tile-color-row {
align-items: stretch;
}
.questify-quest-tile-color-row-item {
display: flex;
flex-direction: column;
}
.questify-color-picker-with-buttons {
background-color: var(--input-background-default, var(--input-background));
border: 1px solid var(--border-normal);
border-radius: 3px;
padding: 8px;
.questify-settings-color-picker.questify-quest-tile-color-picker {
display: flex;
height: 38px;
min-height: 38px;
min-width: 0;
margin-top: 5px;
}
.questify-color-picker-with-buttons [class*="swatch"],
.questify-color-picker-with-buttons [class*="colorSwatch"] {
border-radius: 3px;
.questify-settings-color-picker.questify-quest-tile-color-picker > [class*="colorSwatch"] {
display: flex;
width: 100%;
margin-bottom: 4px;
height: 100%;
min-height: 38px;
}
.questify-wide-button {
.questify-settings-color-picker.questify-quest-tile-color-picker [class*="swatch"],
.questify-settings-color-picker.questify-quest-tile-color-picker [class*="Swatch"] {
width: 100%;
flex: 1;
height: 100%;
min-height: 38px;
border-radius: 3px;
}
.questify-flex-65 {
flex: 6;
.questify-settings-color-picker.questify-disabled-color-picker {
filter: brightness(0.5);
pointer-events: none;
}
.questify-flex-35 {
flex: 4;
.questify-quest-tile-color-button {
margin-top: 6px;
}
.questify-inline-code-block {
display: inline-block;
padding: 0 4px;
border-radius: 4px;
background-color: var(--background-code);
border: 1px solid var(--border-normal);
font-family: var(--font-code);
font-size: 90%;
.questify-dummy-quest-preview {
cursor: default;
margin-top: 12px;
user-select: none;
}
.questify-dummy-quest {
margin-top: 8px;
}
.questify-dummy-quest:active {
/* Background of Quest tile. */
.questify-quest-item-restyle > :where(div) {
background-color: var(--questify-color) !important;
}
/* Normal button text + SVG button text color. */
.questify-quest-item-restyle > :nth-child(2) button > *,
.questify-quest-item-restyle > :nth-child(2) button > * > * > span {
color: var(--questify-quest-name);
}
/* Watch again button SVG color. */
.questify-quest-item-restyle > :nth-child(2) button > div > div > svg path {
fill: var(--questify-quest-name);
}
/* Allows glowing reward previews which aren't confined to their own square video frame to overflow. */
.questify-quest-item-restyle > div > div[class*="rewardDescriptionContainer"] {
overflow: visible;
}
/* Quest name color. */
.questify-quest-item-restyle > div > div[class*="rewardDescriptionContainer"] > div[class*="textContainer"] > [class*="eyebrow"] {
color: var(--questify-quest-name) !important;
}
/* Reward title + Orbs reward title text color. */
.questify-quest-item-restyle > div > div[class*="rewardDescriptionContainer"] > div[class*="textContainer"] > div[class*="text-md"],
.questify-quest-item-restyle > div > div[class*="rewardDescriptionContainer"] > div[class*="textContainer"] > div[class*="text-md"] > span {
color: var(--questify-reward-title) !important;
}
/* Quest description text color. */
.questify-quest-item-restyle > div > div[class*="rewardDescriptionContainer"] > div[class*="textContainer"] > div[class*="text-sm"] {
color: var(--questify-reward-description) !important;
}
.questify-quest-item-restyle [class*="placeholderElement"] {
background-color: transparent;
}
/* Promoted by XYZ text color. */
.questify-quest-item-restyle > div[class*="container"] > div[class*="positionContentOverBackground"] > div[class*="contents"] > div[class*="bottomRow"] > div[class*="text-sm"] {
color: var(--questify-reward-description) !important;
}
.questify-quest-item-contrast-logo > div[class*="container"] > div[class*="positionContentOverBackground"] > div[class*="contents"] > div[class*="partnerBranding"] > img {
filter: drop-shadow(0 0 10px rgb(0 0 0 / 80%));
}
.questify-quest-item-intense-gradient > :nth-child(1) [class*="overlay"] {
background: linear-gradient(to top,
var(--questify-color) 0,
hsl(from var(--questify-color) h s l / 100%) 20.5%,
hsl(from var(--questify-color) h s l / 99.5%) 23.33%,
hsl(from var(--questify-color) h s l / 98%) 26.66%,
hsl(from var(--questify-color) h s l / 97%) 30%,
hsl(from var(--questify-color) h s l / 90%) 40%,
hsl(from var(--questify-color) h s l / 77%) 50%,
hsl(from var(--questify-color) h s l / 0%) 100%);
}
.questify-quest-item-default-gradient > :nth-child(1) [class*="overlay"] {
background: linear-gradient(to top,
var(--questify-color) 0,
hsl(from var(--questify-color) h s l / 95%) 20.31%,
hsl(from var(--questify-color) h s l / 0%) 100%);
}
.questify-quest-item-black-gradient > :nth-child(1) [class*="overlay"] {
background: linear-gradient(to top,
hsl(0deg 0% 0% / 80%) 10%,
hsl(0deg 0% 0% / 65%) 20%,
hsl(0deg 0% 0% / 50%) 50%,
hsl(0deg 0% 0% / 10%) 75%,
hsl(0deg 0% 0% / 0%) 90%);
}
.questify-quest-item-hide-gradient > :nth-child(1) [class*="overlay"] {
display: none;
}
.questify-quest-item-restyle > :nth-child(2) [class*="textOverflowBlur"] {
background: linear-gradient(transparent 50%, var(--questify-color) 100%);
filter: brightness(0.9);
}
.questify-quest-item-restyle > :nth-child(2) > div button {
background-color: var(--questify-button-normal) !important;
}
.questify-quest-item-restyle > :nth-child(2) > div button:not([disabled]):hover {
background-color: var(--questify-button-hover) !important;
transition: background-color 0.2s ease-in-out;
}
.questify-quest-item-restyle > :nth-child(1) [class*="container"] {
border-top-left-radius: 8px;
border-top-right-radius: 8px;
}
.questify-quest-item-restyle > :nth-child(2) [class*="container"] {
border-bottom-left-radius: 8px;
border-bottom-right-radius: 8px;
}
div[id^="quest-tile-"] div[class^="confetti__"] {
pointer-events: none;
user-select: none;
}
/* Quest Button */
#questify-dummy-quest-button {
margin-right: -12px;
margin-left: 5px;
border-left: 5px solid var(--text-strong);
border-radius: 4px;
}
.questify-settings-color-picker.questify-quest-button-color-picker {
display: flex;
height: 38px;
min-height: 38px;
min-width: 0;
margin-top: 5px;
}
.questify-settings-color-picker.questify-quest-button-color-picker > [class*="colorSwatch"] {
display: flex;
width: 100%;
height: 100%;
min-height: 38px;
}
.questify-settings-color-picker.questify-quest-button-color-picker [class*="swatch"],
.questify-settings-color-picker.questify-quest-button-color-picker [class*="Swatch"] {
width: 100%;
height: 100%;
min-height: 38px;
border-radius: 3px;
}
.questify-quest-button-pill {
@ -245,126 +586,3 @@
.questify-quest-button-container {
position: relative;
}
/* Background of Quest tile. */
.questify-quest-item-restyle > div {
background-color: var(--questify-color) !important;
}
/* Normal button text + SVG button text color. */
.questify-quest-item-restyle > :nth-child(2) button > div,
.questify-quest-item-restyle > :nth-child(2) button > div > div > span {
color: var(--questify-quest-name);
}
/* Watch again button SVG color. */
.questify-quest-item-restyle > :nth-child(2) button > div > div > svg path {
fill: var(--questify-quest-name);
}
/* Allows glowing reward previews which aren't confined to their own
square video frame to overflow so the glow doesn't get cut off. */
.questify-quest-item-restyle > div > div[class*="rewardDescriptionContainer"] {
overflow: visible;
}
/* Quest name color. */
.questify-quest-item-restyle > div > div[class*="rewardDescriptionContainer"] > div[class*="textContainer"] > [class*="eyebrow"] {
color: var(--questify-quest-name) !important;
}
/* Reward title + Orbs reward title text color. */
.questify-quest-item-restyle > div > div[class*="rewardDescriptionContainer"] > div[class*="textContainer"] > div[class*="text-md"],
.questify-quest-item-restyle > div > div[class*="rewardDescriptionContainer"] > div[class*="textContainer"] > div[class*="text-md"] > span {
color: var(--questify-reward-title) !important;
}
/* Quest description text color. */
.questify-quest-item-restyle > div > div[class*="rewardDescriptionContainer"] > div[class*="textContainer"] > div[class*="text-sm"] {
color: var(--questify-reward-description) !important;
}
/* Hides the gray placeholder elements which distract from the restyles. */
.questify-quest-item-restyle [class*="placeholderElement"] {
background-color: transparent;
}
/* Promoted by XYZ text color. */
.questify-quest-item-restyle > div[class*="container"] > div[class*="positionContentOverBackground"] > div[class*="contents"] > div[class*="bottomRow"] > div[class*="text-sm"] {
color: var(--questify-reward-description) !important;
}
/* Drop shadow for >87.5% luminance (near-white) restyle gradients so logos are legible. */
.questify-quest-item-contrast-logo > div[class*="container"] > div[class*="positionContentOverBackground"] > div[class*="contents"] > div[class*="partnerBranding"] > img {
filter: drop-shadow(0 0 10px rgb(0 0 0 / 80%));
}
/* Increased gradient intensity with restyle color. */
.questify-quest-item-intense-gradient > :nth-child(1) [class*="overlay"] {
background: linear-gradient(to top,
var(--questify-color) 0,
hsl(from var(--questify-color) h s l / 100%) 20.5%,
hsl(from var(--questify-color) h s l / 99.5%) 23.33%,
hsl(from var(--questify-color) h s l / 98%) 26.66%,
hsl(from var(--questify-color) h s l / 97%) 30%,
hsl(from var(--questify-color) h s l / 90%) 40%,
hsl(from var(--questify-color) h s l / 77%) 50%,
hsl(from var(--questify-color) h s l / 0%) 100%);
}
/* Default gradient intensity with restyle color. */
.questify-quest-item-default-gradient > :nth-child(1) [class*="overlay"] {
background: linear-gradient(to top,
var(--questify-color) 0,
hsl(from var(--questify-color) h s l / 95%) 20.31%,
hsl(from var(--questify-color) h s l / 0%) 100%);
}
/* Subtle black gradient. */
.questify-quest-item-black-gradient > :nth-child(1) [class*="overlay"] {
background: linear-gradient(to top,
hsl(0deg 0% 0% / 80%) 10%,
hsl(0deg 0% 0% / 65%) 20%,
hsl(0deg 0% 0% / 50%) 50%,
hsl(0deg 0% 0% / 10%) 75%,
hsl(0deg 0% 0% / 0%) 90%);
}
/* Hides the gradient altogether. */
.questify-quest-item-hide-gradient > :nth-child(1) [class*="overlay"] {
display: none;
}
/* Matches the text overlay which darkens the text of long
descriptions until hovered with the chosen restyle color. */
.questify-quest-item-restyle > :nth-child(2) [class*="textOverflowBlur"] {
background: linear-gradient(transparent 50%, var(--questify-color) 100%);
filter: brightness(0.9);
}
/* Quest tile button color. */
.questify-quest-item-restyle > :nth-child(2) > div button {
background-color: var(--questify-button-normal) !important;
}
.questify-quest-item-restyle > :nth-child(2) > div button:not([disabled]):hover {
background-color: var(--questify-button-hover) !important;
transition: background-color 0.2s ease-in-out;
}
/* Quest tile border radius. */
.questify-quest-item-restyle > :nth-child(1) [class*="container"] {
border-top-left-radius: 8px;
border-top-right-radius: 8px;
}
.questify-quest-item-restyle > :nth-child(2) [class*="container"] {
border-bottom-left-radius: 8px;
border-bottom-right-radius: 8px;
}
/* Prevent completion confetti from covering quest buttons */
div[id^="quest-tile-"] div[class^="confetti__"] {
pointer-events: none;
}

File diff suppressed because it is too large Load diff

View file

@ -1,381 +0,0 @@
/*
* 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 { findComponentByCodeLazy } from "@webpack";
import { SearchableSelect, useState } from "@webpack/common";
import { JSX } from "react";
import { settings } from "../settings";
import { formatLowerBadge } from "./misc";
// Requires GuildlessServerListItemComponent export patch in index.tsx
// GuildlessServerListItem's built-in pill does not support unread state.
export const GuildlessServerListItemComponent = findComponentByCodeLazy("tooltip:", "lowerBadgeSize:");
export const GuildedServerListItemPillComponent = findComponentByCodeLazy('"pill":"empty"');
export const ServerListItemLowerBadgeComponent = findComponentByCodeLazy("BADGE_NOTIFICATION_BACKGROUND", "let{count:");
export const ServerListItemUpperBadgeComponent = findComponentByCodeLazy("{icon:", ".ROUND,disableColor");
export const RadioGroup = findComponentByCodeLazy(",{label:", ",description:", ",required:", ",errorMessage:", ",children:");
export const QuestTile = findComponentByCodeLazy(".rowIndex,trackGuildAndChannelMetadata");
export class ActiveQuestIntervalsMap extends Map<string, { progressTimeout: NodeJS.Timeout; rerenderTimeout: NodeJS.Timeout; progress: number; type: string; }> {
set(key: string, value: { progressTimeout: NodeJS.Timeout; rerenderTimeout: NodeJS.Timeout; progress: number; type: string; }): this {
const { resumeQuestIDs } = settings.store;
settings.store.resumeQuestIDs = {
watch: resumeQuestIDs.watch.filter((id: string) => id !== key),
play: resumeQuestIDs.play.filter((id: string) => id !== key),
achievement: resumeQuestIDs.achievement.filter((id: string) => id !== key),
};
settings.store.resumeQuestIDs[value.type].push(key);
return super.set(key, value);
}
delete(key: string): boolean {
const { resumeQuestIDs } = settings.store;
settings.store.resumeQuestIDs = {
watch: resumeQuestIDs.watch.filter((id: string) => id !== key),
play: resumeQuestIDs.play.filter((id: string) => id !== key),
achievement: resumeQuestIDs.achievement.filter((id: string) => id !== key),
};
return super.delete(key);
}
}
export enum QuestRewardType {
UNKNOWN = 0,
REWARD_CODE = 1,
IN_GAME = 2,
COLLECTIBLE = 3,
VIRTUAL_CURRENCY = 4,
FRACTIONAL_PREMIUM = 5
}
export enum QuestStatus {
Claimed = "CLAIMED",
Unclaimed = "UNCLAIMED",
Ignored = "IGNORED",
Expired = "EXPIRED",
Unknown = "UNKNOWN"
}
export interface FetchingQuestsSettingProps {
interval: number | null;
alert: string | null;
}
export interface RadioOption {
name: string;
value: string;
}
export interface SelectOption {
label: string;
value: string | number;
}
export interface DynamicDropdownSettingOption {
label: string;
value: string;
selected: boolean;
type?: string;
}
export interface RGB {
r: number;
g: number;
b: number;
}
export type ExcludedQuestMap = Map<string, ExcludedQuest>;
export type QuestMap = Map<string, Quest>;
export interface ExcludedQuest {
id: string;
}
export enum QuestTaskType {
WATCH_VIDEO = "WATCH_VIDEO",
WATCH_VIDEO_ON_MOBILE = "WATCH_VIDEO_ON_MOBILE",
PLAY_ON_DESKTOP = "PLAY_ON_DESKTOP",
PLAY_ON_XBOX = "PLAY_ON_XBOX",
PLAY_ON_PLAYSTATION = "PLAY_ON_PLAYSTATION",
PLAY_ACTIVITY = "PLAY_ACTIVITY",
ACHIEVEMENT_IN_ACTIVITY = "ACHIEVEMENT_IN_ACTIVITY"
}
export interface QuestTask {
type: QuestTaskType;
target: number;
}
export interface Quest {
questifyNumber: number;
id: string;
config: {
application: {
id: string;
name: string;
},
startsAt: string;
expiresAt: string;
messages: {
questName: string;
};
rewardsConfig: {
rewards: {
type: QuestRewardType;
orbQuantity?: number;
messages?: {
nameWithArticle: string;
};
}[];
},
taskConfigV2: {
tasks: {
WATCH_VIDEO?: {
type: QuestTaskType.WATCH_VIDEO;
target: number;
},
WATCH_VIDEO_ON_MOBILE?: {
type: QuestTaskType.WATCH_VIDEO_ON_MOBILE;
target: number;
},
PLAY_ON_DESKTOP?: {
type: QuestTaskType.PLAY_ON_DESKTOP;
target: number;
},
PLAY_ON_XBOX?: {
type: QuestTaskType.PLAY_ON_XBOX;
target: number;
},
PLAY_ON_PLAYSTATION?: {
type: QuestTaskType.PLAY_ON_PLAYSTATION;
target: number;
},
PLAY_ACTIVITY?: {
type: QuestTaskType.PLAY_ACTIVITY;
target: number;
};
ACHIEVEMENT_IN_ACTIVITY?: {
type: QuestTaskType.ACHIEVEMENT_IN_ACTIVITY;
target: number;
eventName: "progress";
applications: {
id: string;
}[];
};
};
};
},
userStatus: null | {
userId: string;
claimedAt: string | null;
completedAt: string | null;
enrolledAt: string | null;
progress: null | {
WATCH_VIDEO?: {
eventName: QuestTaskType.WATCH_VIDEO;
value: number;
},
WATCH_VIDEO_ON_MOBILE?: {
eventName: QuestTaskType.WATCH_VIDEO_ON_MOBILE;
value: number;
},
PLAY_ON_DESKTOP?: {
eventName: QuestTaskType.PLAY_ON_DESKTOP;
value: number;
},
PLAY_ON_XBOX?: {
eventName: QuestTaskType.PLAY_ON_XBOX;
value: number;
},
PLAY_ON_PLAYSTATION?: {
eventName: QuestTaskType.PLAY_ON_PLAYSTATION;
value: number;
},
PLAY_ACTIVITY?: {
eventName: QuestTaskType.PLAY_ACTIVITY;
value: number;
},
};
},
dummyColor?: number | null;
}
export function SoundIcon(height: number, width: number, className?: string): JSX.Element {
return (
<svg
viewBox="0 0 24 24"
height={height}
width={width}
fill="none"
className={className}
>
<path fill="currentColor" d="M12 3a1 1 0 0 0-1-1h-.06a1 1 0 0 0-.74.32L5.92 7H3a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h2.92l4.28 4.68a1 1 0 0 0 .74.32H11a1 1 0 0 0 1-1V3ZM15.1 20.75c-.58.14-1.1-.33-1.1-.92v-.03c0-.5.37-.92.85-1.05a7 7 0 0 0 0-13.5A1.11 1.11 0 0 1 14 4.2v-.03c0-.6.52-1.06 1.1-.92a9 9 0 0 1 0 17.5Z" className=""></path><path fill="currentColor" d="M15.16 16.51c-.57.28-1.16-.2-1.16-.83v-.14c0-.43.28-.8.63-1.02a3 3 0 0 0 0-5.04c-.35-.23-.63-.6-.63-1.02v-.14c0-.63.59-1.1 1.16-.83a5 5 0 0 1 0 9.02Z"></path>
</svg>
);
}
export function QuestIcon(height: number, width: number, className?: string): JSX.Element {
return (
<svg
viewBox="0 0 24 24"
height={height}
width={width}
fill="none"
className={className}
>
<path fill="currentColor" d="M7.5 21.7a8.95 8.95 0 0 1 9 0 1 1 0 0 0 1-1.73c-.6-.35-1.24-.64-1.9-.87.54-.3 1.05-.65 1.52-1.07a3.98 3.98 0 0 0 5.49-1.8.77.77 0 0 0-.24-.95 3.98 3.98 0 0 0-2.02-.76A4 4 0 0 0 23 10.47a.76.76 0 0 0-.71-.71 4.06 4.06 0 0 0-1.6.22 3.99 3.99 0 0 0 .54-5.35.77.77 0 0 0-.95-.24c-.75.36-1.37.95-1.77 1.67V6a4 4 0 0 0-4.9-3.9.77.77 0 0 0-.6.72 4 4 0 0 0 3.7 4.17c.89 1.3 1.3 2.95 1.3 4.51 0 3.66-2.75 6.5-6 6.5s-6-2.84-6-6.5c0-1.56.41-3.21 1.3-4.51A4 4 0 0 0 11 2.82a.77.77 0 0 0-.6-.72 4.01 4.01 0 0 0-4.9 3.96A4.02 4.02 0 0 0 3.73 4.4a.77.77 0 0 0-.95.24 3.98 3.98 0 0 0 .55 5.35 4 4 0 0 0-1.6-.22.76.76 0 0 0-.72.71l-.01.28a4 4 0 0 0 2.65 3.77c-.75.06-1.45.33-2.02.76-.3.22-.4.62-.24.95a4 4 0 0 0 5.49 1.8c.47.42.98.78 1.53 1.07-.67.23-1.3.52-1.91.87a1 1 0 1 0 1 1.73Z" ></path>
</svg>
);
}
export interface ServerListItemUpperBadgeProps {
icon?: (() => JSX.Element),
color?: string;
className?: string;
}
export function ServerListItemUpperBadge(props: ServerListItemUpperBadgeProps): JSX.Element {
return (
<ServerListItemUpperBadgeComponent {...props} />
);
}
export interface ServerListItemLowerBadgeProps {
count: number;
className?: string;
color?: string;
style?: React.CSSProperties;
maxDigits?: number;
}
export function ServerListItemLowerBadge(props: ServerListItemLowerBadgeProps): JSX.Element {
return (
<ServerListItemLowerBadgeComponent
{...props}
/>
);
}
export interface GuildlessServerListItemProps {
id?: string;
className?: string;
icon?: JSX.Element;
tooltip?: string;
showPill?: boolean;
isVisible?: boolean;
isSelected?: boolean;
hasUnread?: boolean;
lowerBadgeProps?: ServerListItemLowerBadgeProps;
upperBadgeProps?: ServerListItemUpperBadgeProps;
onClick?: ((e: React.MouseEvent) => void);
onContextMenu?: ((e: React.MouseEvent) => void);
onMouseDown?: ((e: React.MouseEvent) => void);
}
export function GuildlessServerListItem({
id,
className = "vc-server-list-item",
icon,
tooltip,
showPill = true,
isVisible,
isSelected,
hasUnread,
lowerBadgeProps,
upperBadgeProps,
onClick,
onContextMenu,
onMouseDown
}: GuildlessServerListItemProps): JSX.Element {
const baseClasses = className.split(" ") || ["vc-server-list-item"];
const mainContainerClass = `${baseClasses.join(" ")}-container`;
const iconContainerClass = `${baseClasses.join(" ")}-icon-container`;
const buttonContainerClass = `${baseClasses.join(" ")}-server-list-button-container`;
const pillContainerClass = `${baseClasses.join(" ")}-pill-container`;
const buttonClass = `${baseClasses.join(" ")}-button`;
const pillClass = `${baseClasses.join(" ")}-pill`;
const pillClassSelected = `${pillClass} selected`;
const pillClassHovered = `${pillClass} hovered`;
const lowerBadgeClass = `${baseClasses.join(" ")}-lower-badge`;
const upperBadgeClass = `${baseClasses.join(" ")}-upper-badge`;
const [hovered, setHovered] = useState(false);
const visible = isVisible ?? true;
const selected = isSelected ?? false;
const unread = hasUnread ?? false;
const lowerBadgeData = lowerBadgeProps ? { ...lowerBadgeProps, className: lowerBadgeProps.className ?? lowerBadgeClass } : null;
const lowerBadgeSize = lowerBadgeData ? { width: formatLowerBadge(lowerBadgeData.count, lowerBadgeData.maxDigits)[1] } : null;
const lowerBadge = !lowerBadgeData || lowerBadgeData.count === 0 ? null : ServerListItemLowerBadge({ ...lowerBadgeData, style: { ...(lowerBadgeData.style || {}), ...(lowerBadgeSize || {}) } });
const upperBadgeData = upperBadgeProps ? { ...upperBadgeProps, className: upperBadgeProps.className ?? upperBadgeClass } : null;
const upperBadge = upperBadgeData ? ServerListItemUpperBadge(upperBadgeData) : null;
const wrappedIcon = icon ? (
<div className={iconContainerClass}>
{icon}
</div>
) : undefined;
const componentProps: Record<string, any> = {
...(wrappedIcon && { icon: () => wrappedIcon }),
...(tooltip !== undefined && { tooltip }),
...(onClick !== undefined && { onClick }),
...(onMouseDown !== undefined && { onMouseDown }),
...(onContextMenu !== undefined && { onContextMenu }),
// This one styles the backdrop of the badge while the one passed as a prop to the badge styles the badge itself.
...(lowerBadge && lowerBadgeSize ? { lowerBadgeSize: lowerBadgeSize } : {}),
...(lowerBadge ? { lowerBadge: lowerBadge } : {}),
...(upperBadge ? { upperBadge: upperBadge } : {}),
};
return (
<ErrorBoundary>
{visible && (
<div
{...(id !== undefined ? { id } : {})}
className={mainContainerClass}
>
<div className={pillContainerClass} >
<GuildedServerListItemPillComponent
unread={unread && showPill}
selected={selected && showPill}
hovered={hovered && showPill}
className={selected ? pillClassSelected : hovered ? pillClassHovered : pillClass}
/>
</div>
<div
className={buttonContainerClass}
>
<GuildlessServerListItemComponent
showPill={false}
selected={selected}
className={buttonClass}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
{...componentProps}
/>
</div>
</div>
)}
</ErrorBoundary>
);
}
export interface DynamicDropdownProps extends React.ComponentProps<typeof SearchableSelect> {
feedback?: string;
maxOptionsVisible?: number;
}
export const DynamicDropdown: React.FC<DynamicDropdownProps> = props => {
return <SearchableSelect {...props} />;
};

View file

@ -0,0 +1,218 @@
/*
* 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 { showNotification } from "@api/Notifications";
import { sleep } from "@utils/misc";
import type { PluginNative } from "@utils/types";
import type { Quest } from "@vencord/discord-types";
import { findByCodeLazy, findStoreLazy } from "@webpack";
import { QuestStore, RestAPI } from "@webpack/common";
import { NavigationRouter } from "@webpack/common/utils";
import { getQuestifySettings } from "../settings/access";
import { questIsIgnored } from "../settings/ignoredQuests";
import { getNewQuests, normalizeQuestName, type QuestIncludedTypes, questMatchesIncludedTypes } from "./filtering";
import { QL } from "./logging";
import { QUEST_PAGE } from "./ui";
export const AuthorizedAppsStore = findStoreLazy("AuthorizedAppsStore");
const QuestifyNative = VencordNative?.pluginHelpers?.Questify as PluginNative<typeof import("../native")> | undefined;
export function snakeToCamel(obj: any): any {
if (Array.isArray(obj)) {
return obj.map(snakeToCamel);
}
if (obj && typeof obj === "object") {
return Object.fromEntries(
Object.entries(obj).map(([key, value]) => [
key.replace(/_([a-z])/g, (_, c) => c.toUpperCase()),
snakeToCamel(value),
]),
);
}
return obj;
}
export const fetchAndDispatchQuests = findByCodeLazy("QUESTS_FETCH_CURRENT_QUESTS_BEGIN");
const parseQuestConfig = findByCodeLazy("config).with({config_version:");
const formatQuestData = findByCodeLazy("config),userStatus:null==");
async function fetchQuestById(questId: string): Promise<Quest | null> {
try {
const { body } = await RestAPI.get({ url: `/quests/${questId}`, retries: 3 });
const valid = !!parseQuestConfig({ config: body });
if (!valid) {
QL.warn("FETCH_QUEST_BY_ID_INVALID_BODY", { questId, body });
return null;
}
return formatQuestData(body);
} catch (error: unknown) {
QL.warn("FETCH_QUEST_BY_ID_FAILED", { questId, error });
return null;
}
}
async function fetchExcludedQuestConfigs(questIds: string[]): Promise<Quest[]> {
const quests: Quest[] = [];
for (const [index, questId] of questIds.entries()) {
if (index > 0) {
await sleep(1000);
}
const quest = await fetchQuestById(questId);
if (quest) {
quests.push(quest);
}
}
return quests;
}
export function canOpenDevToolsWindow(): boolean {
return typeof QuestifyNative?.canOpenDevTools === "function"
&& typeof QuestifyNative?.openDevTools === "function";
}
export async function openDevToolsWindow(): Promise<boolean> {
if (!canOpenDevToolsWindow()) {
return false;
}
const native = QuestifyNative!;
return await native.canOpenDevTools()
? native.openDevTools()
: false;
}
function getQuestNotificationText(quests: Quest[], excluded: boolean): { title: string; body: string; } {
const firstQuest = quests[0];
const firstQuestName = normalizeQuestName(firstQuest);
if (quests.length === 1) {
return {
title: excluded ? "New Excluded Quest Detected!" : "New Quest Detected!",
body: excluded
? `The excluded ${firstQuestName} Quest was detected. ID: ${firstQuest.id}`
: `The ${firstQuestName} Quest is now available.`
};
}
return {
title: excluded ? "New Excluded Quests Detected!" : "New Quests Detected!",
body: excluded
? `${quests.length} new excluded Quests were detected. Check the console for their Quest IDs.`
: `${quests.length} new Quests are now available.`
};
}
function notifyNewQuests(quests: Quest[], excluded: boolean): void {
if (quests.length === 0) return;
const firstQuest = quests[0];
const { title, body } = getQuestNotificationText(quests, excluded);
const onClick = excluded
? canOpenDevToolsWindow() ? openDevToolsWindow : undefined
: () => NavigationRouter.transitionTo(`${QUEST_PAGE}#${firstQuest.id}`);
showNotification({
title,
body,
dismissOnClick: true,
onClick
});
QL.log("NOTIFY_NEW_QUESTS", { excluded, quests });
}
export async function fetchAndAlertQuests(source: string): Promise<Quest[] | null> {
const settings = getQuestifySettings();
const alertSound = settings.newQuestAlertSound;
const alertVolume = settings.newQuestAlertVolume;
const excludedAlertSound = settings.newExcludedQuestAlertSound;
const excludedAlertVolume = settings.newExcludedQuestAlertVolume;
const shouldFetchExcludedQuests = settings.notifyOnNewExcludedQuests || Boolean(excludedAlertSound);
const currentQuests = Array.from(QuestStore.quests.values());
const currentExcludedQuestIds = new Set(Array.from(QuestStore.excludedQuests.values()).map(quest => quest.id));
const includedTypes = settings.questButtonIncludedTypes as QuestIncludedTypes;
await fetchAndDispatchQuests();
await sleep(1000);
const nextQuests = Array.from(QuestStore.quests.values());
if (!nextQuests || currentQuests.length === 0) {
return nextQuests;
}
const newQuests = getNewQuests(currentQuests, nextQuests);
const newExcludedQuestIds = shouldFetchExcludedQuests
? Array.from(QuestStore.excludedQuests.values())
.map(quest => quest.id)
.filter(questId => !currentExcludedQuestIds.has(questId))
: [];
if (newQuests.length === 0 && newExcludedQuestIds.length === 0) {
return nextQuests;
}
const newExcludedQuests = newExcludedQuestIds.length > 0 ? await fetchExcludedQuestConfigs(newExcludedQuestIds) : [];
const newIncludedQuests = newQuests.filter(quest => questMatchesIncludedTypes(quest, includedTypes) && !questIsIgnored(quest.id));
const newIncludedExcludedQuests = newExcludedQuests.filter(quest => questMatchesIncludedTypes(quest, includedTypes) && !questIsIgnored(quest.id));
const shouldAlert = Boolean(alertSound) && newIncludedQuests.length > 0;
const shouldAlertExcluded = Boolean(excludedAlertSound) && newIncludedExcludedQuests.length > 0;
const shouldNotify = settings.notifyOnNewQuests && newIncludedQuests.length > 0;
const shouldNotifyExcluded = settings.notifyOnNewExcludedQuests && newIncludedExcludedQuests.length > 0;
QL.info("FETCH_AND_ALERT_QUESTS_NEW_QUESTS", {
source,
newQuestCount: newQuests.length,
newQuests: newIncludedQuests,
matchedQuestCount: newIncludedQuests.length + newIncludedExcludedQuests.length,
...(shouldFetchExcludedQuests ? {
newExcludedQuestCount: newExcludedQuestIds.length,
newExcludedQuests: newIncludedExcludedQuests,
matchedExcludedQuestCount: newIncludedExcludedQuests.length,
} : {}),
shouldAlert,
shouldAlertExcluded,
shouldNotify,
shouldNotifyExcluded,
});
if (shouldAlert) {
playAudio(
alertSound!,
{ volume: Math.max(0, Math.min(100, alertVolume)) }
);
}
if (shouldAlertExcluded) {
playAudio(
excludedAlertSound!,
{ volume: Math.max(0, Math.min(100, excludedAlertVolume)) }
);
}
if (shouldNotify) {
notifyNewQuests(newIncludedQuests, false);
}
if (shouldNotifyExcluded) {
notifyNewQuests(newIncludedExcludedQuests, true);
}
return nextQuests;
}

View file

@ -0,0 +1,28 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import type { Quest } from "@vencord/discord-types";
import { QuestRewardType, QuestTaskType } from "@vencord/discord-types/enums";
export type QuestIncludedTypes = Record<QuestTaskType | QuestRewardType, boolean>;
export function getNewQuests(currentQuests: Quest[], nextQuests: Quest[]): Quest[] {
const currentQuestIds = new Set(currentQuests.map(quest => quest.id));
return nextQuests.filter(quest => !currentQuestIds.has(quest.id));
}
export function questMatchesIncludedTypes(quest: Quest, includedTypes: QuestIncludedTypes): boolean {
const rewardTypeAllowed = quest.config.rewardsConfig.rewards.some(reward => Boolean(includedTypes[reward.type]));
const taskTypeAllowed = Object.values(quest.config.taskConfigV2.tasks).some(task => Boolean(includedTypes[task.type]));
return rewardTypeAllowed && taskTypeAllowed;
}
export function normalizeQuestName(quest: Quest): string {
const normalized = quest.config.messages.questName.trim().toUpperCase();
return normalized.endsWith("QUEST") ? normalized.slice(0, -5).trim() : normalized;
}

View file

@ -0,0 +1,79 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { showNotification } from "@api/Notifications";
import { Logger } from "@utils/Logger";
import type { Quest } from "@vencord/discord-types";
import { NavigationRouter } from "@webpack/common/utils";
import { normalizeQuestName } from "./filtering";
import { QUEST_PAGE } from "./ui";
const LOG = new Logger("Questify");
export function getFormattedNow(): string {
return new Date().toLocaleString(undefined, {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: true,
}).replace(",", "");
}
function getQuestifyLogPrefix(source: string): string {
return `[${getFormattedNow()}] [${source}]\n`;
}
function emitQuestifyLog(level: string, source: string, ...args: unknown[]): void {
const prefix = args.length ? getQuestifyLogPrefix(source) : getQuestifyLogPrefix(source).trim();
switch (level) {
case "debug":
LOG.debug(prefix, ...args);
return;
case "error":
LOG.error(prefix, ...args);
return;
case "info":
LOG.info(prefix, ...args);
return;
case "warn":
LOG.warn(prefix, ...args);
return;
default:
LOG.log(prefix, ...args);
return;
}
}
export const QL = {
log(source: string, ...args: unknown[]): void { emitQuestifyLog("log", source, ...args); },
info(source: string, ...args: unknown[]): void { emitQuestifyLog("info", source, ...args); },
warn(source: string, ...args: unknown[]): void { emitQuestifyLog("warn", source, ...args); },
error(source: string, ...args: unknown[]): void { emitQuestifyLog("error", source, ...args); },
debug(source: string, ...args: unknown[]): void { emitQuestifyLog("debug", source, ...args); },
};
export function notifyQuestCompletion(quest?: Quest): void {
if (!quest) return;
showNotification({
title: "Quest Completed!",
body: `The ${normalizeQuestName(quest)} Quest has completed.`,
dismissOnClick: true,
onClick: () => NavigationRouter.transitionTo(`${QUEST_PAGE}#${quest.id}`)
});
QL.log("NOTIFY_QUEST_COMPLETION", { quest });
}

View file

@ -1,490 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { classNameFactory } from "@utils/css";
import { Logger } from "@utils/Logger";
import { findByCodeLazy, findByPropsLazy } from "@webpack";
import { FluxDispatcher, RestAPI, UserStore } from "@webpack/common";
import { activeQuestIntervals } from "..";
import { questIsIgnored, settings } from "../settings";
import { Quest, QuestStatus, QuestTask, QuestTaskType, RGB } from "./components";
export const q = classNameFactory("questify-");
export const QuestifyLogger = new Logger("Questify");
export const QuestsStore = findByPropsLazy("getQuest");
export const reportProgress = findByCodeLazy(".QUESTS_VIDEO_PROGRESS(");
export const sendHeartbeat = findByCodeLazy(".QUESTS_HEARTBEAT(");
export const questPath = "/quest-home";
export const videoQuestLeeway = 24;
export const leftClick = 0;
export const middleClick = 1;
export const rightClick = 2;
type HeartbeatDispatchResult =
| { type: "success"; userStatus: Quest["userStatus"]; }
| { type: "failure"; error: unknown; }
| { type: "timeout"; };
export function setIgnoredQuestIDs(questIDs: string[], userId?: string): void {
const currentUserID = userId ?? UserStore.getCurrentUser()?.id;
if (!currentUserID) return;
const { ignoredQuestProfile } = settings.store;
const key = ignoredQuestProfile === "shared" ? "shared" : currentUserID;
settings.store.ignoredQuestIDs[key] = questIDs;
}
export function getIgnoredQuestIDs(userId?: string): string[] {
const currentUserID = userId ?? UserStore.getCurrentUser()?.id;
if (!currentUserID) return [];
const { ignoredQuestIDs, ignoredQuestProfile } = settings.store;
const key = ignoredQuestProfile === "shared" ? "shared" : currentUserID;
ignoredQuestIDs[key] ??= [];
return ignoredQuestIDs[key];
}
export function getQuestTask(quest: Quest): QuestTask {
const tasks = quest.config.taskConfigV2?.tasks;
return (tasks.PLAY_ON_DESKTOP ||
tasks.PLAY_ON_XBOX ||
tasks.PLAY_ON_PLAYSTATION ||
tasks.PLAY_ACTIVITY ||
tasks.WATCH_VIDEO ||
tasks.WATCH_VIDEO_ON_MOBILE ||
tasks.ACHIEVEMENT_IN_ACTIVITY) as QuestTask;
}
export function getQuestProgress(quest: Quest, task: QuestTask) {
const intervalData = activeQuestIntervals.get(quest.id);
if (intervalData) { return intervalData.progress; }
const progressMap = quest.userStatus?.progress;
if (!progressMap) { return null; }
if (task.type === QuestTaskType.WATCH_VIDEO || task.type === QuestTaskType.WATCH_VIDEO_ON_MOBILE) {
const watchProgress = progressMap.WATCH_VIDEO?.value;
const mobileProgress = progressMap.WATCH_VIDEO_ON_MOBILE?.value;
if (watchProgress !== undefined || mobileProgress !== undefined) {
return Math.max(watchProgress ?? 0, mobileProgress ?? 0);
}
return null;
}
return progressMap[task.type]?.value ?? null;
}
export function getQuestTarget(task: QuestTask) {
const isWatch = task.type === QuestTaskType.WATCH_VIDEO || task.type === QuestTaskType.WATCH_VIDEO_ON_MOBILE;
const { completeVideoQuestsQuicker } = settings.store;
const raw = task.target;
const adjusted = Math.max(0, raw - (isWatch && completeVideoQuestsQuicker ? videoQuestLeeway : 0));
return { raw, adjusted };
}
export function getQuestStatus(quest: Quest, checkIgnored: boolean = true): QuestStatus {
const completedQuest = quest.userStatus?.completedAt;
const claimedQuest = quest.userStatus?.claimedAt;
const expiredQuest = new Date(quest.config.expiresAt) < new Date();
const questIgnored = questIsIgnored(quest.id);
if (claimedQuest) {
return QuestStatus.Claimed;
} else if (checkIgnored && questIgnored && (!expiredQuest || completedQuest)) {
return QuestStatus.Ignored;
} else if (completedQuest || !expiredQuest) {
return QuestStatus.Unclaimed;
} else if (expiredQuest) {
return QuestStatus.Expired;
}
return QuestStatus.Unknown;
}
export function refreshQuest(quest: Quest): Quest {
return QuestsStore.getQuest(quest.id) as Quest ?? quest;
}
export function isSoundAllowed(url: string): Promise<boolean> {
return VencordNative.csp.isDomainAllowed(url, ["img-src"]);
}
export function snakeToCamel(obj: any): any {
if (Array.isArray(obj)) {
return obj.map(snakeToCamel);
} else if (obj && typeof obj === "object") {
return Object.fromEntries(
Object.entries(obj).map(([key, value]) => [
key.replace(/_([a-z])/g, (_, c) => c.toUpperCase()),
snakeToCamel(value)
])
);
}
return obj;
}
export function getFormattedNow(): string {
return new Date().toLocaleString(undefined, {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: true
}).replace(",", "");
}
export function decimalToRGB(decimal: number): RGB {
const r = (decimal >> 16) & 0xFF;
const g = (decimal >> 8) & 0xFF;
const b = decimal & 0xFF;
return { r, g, b };
}
export function adjustRGB(rgb: RGB, shift: number): RGB {
const color = { ...rgb };
color.r = Math.max(0, Math.min(255, color.r + shift));
color.g = Math.max(0, Math.min(255, color.g + shift));
color.b = Math.max(0, Math.min(255, color.b + shift));
return color;
}
export function isDarkish(rgb: RGB, threshold: number = 0.5): boolean {
const luminance = (0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b) / 255;
return luminance < threshold;
}
export function validCommaSeparatedList(
value: string,
allowedTerms: string[] = [],
allowEmpty: boolean = true,
mustIncludeAll: boolean = false,
noDuplicates: boolean = true,
caseSensitive: boolean = false
): boolean {
const trimmedValue = value.trim();
if (!trimmedValue) {
return allowEmpty;
}
function normalize(str: string): string {
return caseSensitive ? str.trim() : str.trim().toLowerCase();
}
const allowed = new Set(allowedTerms.map(term => normalize(term)));
const seen = new Set<string>();
for (let term of trimmedValue.split(",")) {
term = normalize(term);
if (!term || !allowed.has(term)) {
return false;
}
if (noDuplicates && seen.has(term)) {
return false;
}
seen.add(term);
}
return !mustIncludeAll || (allowed.size === seen.size);
}
export function normalizeQuestName(name: string): string {
const normalized = name.trim().toUpperCase();
return normalized.endsWith("QUEST") ? normalized.slice(0, -5).trim() : normalized;
}
export async function fetchAndDispatchQuests(source?: string, logger?: Logger): Promise<Quest[] | null> {
try {
const { body } = snakeToCamel(await RestAPI.get({ url: "/quests/@me", retries: 3 }));
FluxDispatcher.dispatch({
type: "QUESTS_FETCH_CURRENT_QUESTS_SUCCESS",
excludedQuests: body.excludedQuests,
questEnrollmentBlockedUntil: body.questEnrollmentBlockedUntil,
quests: body.quests,
...(source !== undefined && { source }),
});
return body.quests;
} catch (e) {
logger?.warn(`[${getFormattedNow()}] Failed to fetch and dispatch Quests:`, e);
return null;
}
}
export async function waitUntilEnrolled(quest: Quest, timeout: number = 30000, interval: number = 500, logger?: Logger): Promise<boolean> {
const questName = normalizeQuestName(quest.config.messages.questName);
const start = Date.now();
if (quest.userStatus?.enrolledAt) {
logger?.info(`[${getFormattedNow()}] Quest ${questName} is already enrolled.`);
return true;
} else if (quest.userStatus?.completedAt) {
logger?.warn(`[${getFormattedNow()}] Cannot enroll in completed Quest ${questName}.`);
return false;
} else {
logger?.info(`[${getFormattedNow()}] Waiting for enrollment in Quest ${questName}...`);
}
while (!quest.userStatus?.enrolledAt && (Date.now() - start) < timeout) {
await new Promise(resolve => setTimeout(resolve, interval));
quest = refreshQuest(quest);
}
if (quest.userStatus?.enrolledAt) {
logger?.info(`[${getFormattedNow()}] Successfully waited for enrollment in Quest ${questName}.`);
return true;
} else {
logger?.warn(`[${getFormattedNow()}] Timeout waiting for enrollment in Quest ${questName}.`);
return false;
}
}
export async function reportVideoQuestProgress(quest: Quest, currentProgress: number, logger?: Logger, options?: { attempts?: number, delay?: number; }): Promise<boolean> {
const questName = normalizeQuestName(quest.config.messages.questName);
if (!quest.userStatus?.enrolledAt) {
logger?.warn(`[${getFormattedNow()}] Cannot report progress for unenrolled Quest ${questName}.`);
return false;
} else if (quest.userStatus?.completedAt) {
return true;
}
const maxAttempts = options?.attempts ?? 1;
const delay = options?.delay ?? 2500;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
await reportProgress(quest.id, currentProgress);
logger?.info(`[${getFormattedNow()}] Quest ${questName} progress reported: ${currentProgress.toFixed(6)} seconds.`);
return true;
} catch (error) {
logger?.error(`[${getFormattedNow()}] Failed to report progress for Quest ${questName} on attempt ${attempt}/${maxAttempts}:`, error);
if (attempt < maxAttempts) {
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
logger?.warn(`[${getFormattedNow()}] Exhausted all ${maxAttempts} attempts to report progress for Quest ${questName}.`);
return false;
}
function getPlayGameProgressValue(userStatus: Quest["userStatus"]): number | null {
const progressMap = userStatus?.progress;
if (!progressMap) {
return null;
}
const progressPlayType = progressMap.PLAY_ON_DESKTOP?.value
?? progressMap.PLAY_ON_XBOX?.value
?? progressMap.PLAY_ON_PLAYSTATION?.value
?? progressMap.PLAY_ACTIVITY?.value;
return progressPlayType ?? null;
}
function waitForHeartbeatDispatchResult(questId: string, timeoutMs: number): Promise<HeartbeatDispatchResult> {
return new Promise(resolve => {
const successEvent = "QUESTS_SEND_HEARTBEAT_SUCCESS";
const failureEvent = "QUESTS_SEND_HEARTBEAT_FAILURE";
const timeoutId = setTimeout(() => {
cleanup();
resolve({ type: "timeout" });
}, timeoutMs);
function cleanup() {
clearTimeout(timeoutId);
FluxDispatcher.unsubscribe(successEvent, onSuccess);
FluxDispatcher.unsubscribe(failureEvent, onFailure);
}
function onSuccess(data: { questId?: string; userStatus?: Quest["userStatus"]; }) {
if (data.questId !== questId) {
return;
}
cleanup();
resolve({ type: "success", userStatus: data.userStatus ?? null });
}
function onFailure(data: { questId?: string; error?: unknown; }) {
if (data.questId !== questId) {
return;
}
cleanup();
resolve({ type: "failure", error: data.error });
}
FluxDispatcher.subscribe(successEvent, onSuccess);
FluxDispatcher.subscribe(failureEvent, onFailure);
});
}
export async function reportPlayGameQuestProgress(quest: Quest, terminal: boolean, logger?: Logger, options?: { attempts?: number, delay?: number; timeout?: number; applicationId?: string; streamKey?: string; }): Promise<{ progress: number | null; completed: boolean; }> {
const questName = normalizeQuestName(quest.config.messages.questName);
if (!quest.userStatus?.enrolledAt) {
logger?.warn(`[${getFormattedNow()}] Cannot send heartbeat for unenrolled Quest ${questName}.`);
return { progress: null, completed: false };
} else if (quest.userStatus?.completedAt && !terminal) {
return { progress: null, completed: true };
}
const maxAttempts = options?.attempts ?? 1;
const delay = options?.delay ?? 2500;
const timeout = options?.timeout ?? 10000;
const streamKey = options?.streamKey;
const applicationId = options?.applicationId ?? quest.config.application.id;
const questPlayType = quest.config.taskConfigV2?.tasks.PLAY_ON_DESKTOP || quest.config.taskConfigV2?.tasks.PLAY_ON_XBOX || quest.config.taskConfigV2?.tasks.PLAY_ON_PLAYSTATION || quest.config.taskConfigV2?.tasks.PLAY_ACTIVITY;
if (!questPlayType) {
logger?.warn(`[${getFormattedNow()}] Could not recognize the Quest type for Quest ${questName}.`);
return { progress: null, completed: false };
}
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const dispatchResultPromise = waitForHeartbeatDispatchResult(quest.id, timeout);
await sendHeartbeat({
questId: quest.id,
streamKey,
applicationId,
terminal,
});
const dispatchResult = await dispatchResultPromise;
if (dispatchResult.type === "failure") {
logger?.error(`[${getFormattedNow()}] Failed to send heartbeat for Quest ${questName} on attempt ${attempt}/${maxAttempts}:`, dispatchResult.error);
if (attempt < maxAttempts) {
await new Promise(resolve => setTimeout(resolve, delay));
}
continue;
}
if (dispatchResult.type === "timeout") {
logger?.warn(`[${getFormattedNow()}] Timed out waiting for heartbeat dispatch for Quest ${questName} on attempt ${attempt}/${maxAttempts}.`);
if (attempt < maxAttempts) {
await new Promise(resolve => setTimeout(resolve, delay));
}
continue;
}
const updatedQuest = refreshQuest(quest);
const progress = getPlayGameProgressValue(dispatchResult.userStatus) ?? getQuestProgress(updatedQuest, questPlayType) ?? 0;
const completed = !!dispatchResult.userStatus?.completedAt || !!updatedQuest.userStatus?.completedAt;
logger?.info(`[${getFormattedNow()}] Heartbeat sent for Quest ${questName} with progress: ${progress}/${questPlayType.target}.`);
return { progress, completed };
} catch (error) {
logger?.error(`[${getFormattedNow()}] Failed to send heartbeat for Quest ${questName} on attempt ${attempt}/${maxAttempts}:`, error);
if (attempt < maxAttempts) {
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
logger?.warn(`[${getFormattedNow()}] Exhausted all ${maxAttempts} attempts to send heartbeat for Quest ${questName}.`);
return { progress: null, completed: false };
}
export function getBadgeSize(value: string, negative: boolean): number {
const numChars = value.length;
const subtract = negative ? 3 : 0;
if (numChars === 1) {
return 16;
} else if (numChars === 2) {
return 21;
} else {
return (21 + (numChars - 2) * 8) - subtract;
}
}
export function formatLowerBadge(value: number, maxDigits: number = 4): [string, number] {
const isNegative = value < 0;
const absValue = Math.abs(value);
if (maxDigits <= 0) {
const str = isNegative ? `-${absValue}` : `${absValue}`;
return [str, getBadgeSize(str, isNegative)];
}
const absStr = String(absValue);
const numDigits = absStr.length;
if (numDigits <= maxDigits) {
const str = isNegative ? `-${absValue}` : `${absValue}`;
return [str, getBadgeSize(str, isNegative)];
}
if (absValue < 1000) {
if (maxDigits === 1) {
if (absValue < 100) {
const str = isNegative ? "<-9" : "9+";
return [str, getBadgeSize(str, isNegative)];
} else {
const str = isNegative ? "<-99" : "99+";
return [str, getBadgeSize(str, isNegative)];
}
} else if (maxDigits === 2) {
const str = isNegative ? "<-99" : "99+";
return [str, getBadgeSize(str, isNegative)];
}
}
let num = absValue;
const units = ["K", "M", "B"];
let unit = "";
for (let i = 0; i < units.length; i++) {
const nextValue = Math.floor(num / 1000);
if (nextValue === 0) {
break;
}
if (i === units.length - 1 && nextValue >= 1000) {
num = 999;
unit = units[i];
break;
}
num = nextValue;
unit = units[i];
}
if (isNegative) {
const str = `<-${num}${unit}`;
return [str, getBadgeSize(str, true)];
} else {
const str = `${num}${unit}+`;
return [str, getBadgeSize(str, false)];
}
}

View file

@ -0,0 +1,323 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import type { Quest } from "@vencord/discord-types";
import { QuestTaskType } from "@vencord/discord-types/enums";
import { QuestStore } from "@webpack/common";
import { getQuestifySettings, useQuestifySettings } from "../settings/access";
import { ignoredQuestIDsKey } from "../settings/def";
import { getActiveAutoCompletes, getAutoCompleteQuestTarget, getQuestAutoCompleteEntry } from "./completion";
import { type QuestIncludedTypes, questMatchesIncludedTypes } from "./filtering";
export interface QuestTask {
type: QuestTaskType;
target: number;
applications?: { id: string; }[];
}
export enum QuestStatus {
Claimed = "CLAIMED",
Unclaimed = "UNCLAIMED",
Ignored = "IGNORED",
Expired = "EXPIRED",
Unknown = "UNKNOWN"
}
const showcaseSwitchLeewaySeconds = 3;
let showcasedAutoCompleteQuestId: string | null = null;
const questProgressTaskPriority = [
QuestTaskType.WATCH_VIDEO,
QuestTaskType.WATCH_VIDEO_ON_MOBILE,
QuestTaskType.ACHIEVEMENT_IN_ACTIVITY,
QuestTaskType.ACHIEVEMENT_IN_GAME,
QuestTaskType.PLAY_ACTIVITY,
QuestTaskType.PLAY_ON_DESKTOP,
QuestTaskType.PLAY_ON_DESKTOP_V2,
QuestTaskType.STREAM_ON_DESKTOP,
QuestTaskType.PLAY_ON_PLAYSTATION,
QuestTaskType.PLAY_ON_XBOX,
] as const satisfies readonly QuestTaskType[];
interface QuestPanelPercentCompleteOptions {
quest?: Quest | null;
percentCompleteText?: string;
}
interface QuestPanelPercentCompleteResult {
percentComplete: number;
percentCompleteText?: string;
}
interface QuestProgressEntry {
eventName?: QuestTaskType;
heartbeat?: { lastBeatAt?: string | null; } | null;
updatedAt?: string | null;
}
export function refreshQuest(quest: Quest): Quest {
return QuestStore.getQuest(quest.id) ?? quest;
}
export function isVideoQuestTask(taskType: QuestTaskType): boolean {
return taskType === QuestTaskType.WATCH_VIDEO || taskType === QuestTaskType.WATCH_VIDEO_ON_MOBILE;
}
function getQuestTaskByType(quest: Quest, taskType: QuestTaskType): QuestTask | null {
const task = quest.config.taskConfigV2?.tasks[taskType] as QuestTask | undefined;
return task ?? null;
}
function getVideoQuestTask(quest: Quest): QuestTask | null {
return getQuestTaskByType(quest, QuestTaskType.WATCH_VIDEO)
?? getQuestTaskByType(quest, QuestTaskType.WATCH_VIDEO_ON_MOBILE);
}
function getProgressTimestamp(progress: QuestProgressEntry): number {
const timestamp = progress.heartbeat?.lastBeatAt ?? progress.updatedAt;
const time = timestamp ? new Date(timestamp).getTime() : 0;
return Number.isFinite(time) ? time : 0;
}
function getLatestProgressTask(quest: Quest): QuestTask | null {
const progressEntries = Object.entries(quest.userStatus?.progress ?? {}) as [QuestTaskType, QuestProgressEntry][];
progressEntries.sort(([, a], [, b]) => getProgressTimestamp(b) - getProgressTimestamp(a));
for (const [fallbackTaskType, progress] of progressEntries) {
const task = getQuestTaskByType(quest, progress.eventName ?? fallbackTaskType);
if (task) {
return task;
}
}
return null;
}
function getQuestProgressTask(quest: Quest): QuestTask | null {
if (!quest.config.taskConfigV2?.tasks) {
return null;
}
const progressTask = getLatestProgressTask(quest) ?? getVideoQuestTask(quest);
if (progressTask) {
return progressTask;
}
for (const taskType of questProgressTaskPriority) {
const task = getQuestTaskByType(quest, taskType);
if (task) {
return task;
}
}
return null;
}
export function getQuestStoredProgress(quest: Quest, task: QuestTask): number | null {
if (quest.userStatus?.completedAt) {
return task.target;
}
const progressMap = quest.userStatus?.progress;
if (!progressMap) {
return null;
}
if (isVideoQuestTask(task.type)) {
const watchProgress = progressMap.WATCH_VIDEO?.value;
const mobileProgress = progressMap.WATCH_VIDEO_ON_MOBILE?.value;
return watchProgress !== undefined || mobileProgress !== undefined
? Math.max(watchProgress ?? 0, mobileProgress ?? 0)
: null;
}
return progressMap[task.type]?.value ?? null;
}
function getCurrentIgnoredQuestIds(): string[] {
return Array.from(getQuestifySettings().ignoredQuestIDs[ignoredQuestIDsKey] ?? []);
}
function getAutoCompleteShowcaseQuest(): Quest | null {
let bestQuest: Quest | null = null;
let bestTimeRemaining = Infinity;
let currentQuest: Quest | null = null;
let currentTimeRemaining = Infinity;
for (const entry of getActiveAutoCompletes()) {
const quest = QuestStore.getQuest(entry.questId);
if (!quest) {
continue;
}
const progress = entry.progress ?? 0;
const { adjusted: target } = getAutoCompleteQuestTarget(entry.task);
const timeRemaining = Math.max(0, target - progress);
if (entry.questId === showcasedAutoCompleteQuestId) {
currentQuest = quest;
currentTimeRemaining = timeRemaining;
}
if (timeRemaining < bestTimeRemaining) {
bestQuest = quest;
bestTimeRemaining = timeRemaining;
}
}
if (!bestQuest) {
showcasedAutoCompleteQuestId = null;
return null;
}
if (currentQuest && bestQuest.id !== currentQuest.id && bestTimeRemaining >= currentTimeRemaining - showcaseSwitchLeewaySeconds) {
return currentQuest;
}
showcasedAutoCompleteQuestId = bestQuest.id;
return bestQuest;
}
function getMostRecentlyCompletedUnclaimedQuest(): Quest | null {
return Array.from(QuestStore.quests.values())
.filter(quest => (
Boolean(quest.userStatus?.completedAt)
&& getQuestStatus(quest, getCurrentIgnoredQuestIds()) === QuestStatus.Unclaimed
))
.sort((a, b) => {
const aTime = new Date(a.userStatus?.completedAt ?? 0).getTime();
const bTime = new Date(b.userStatus?.completedAt ?? 0).getTime();
return bTime - aTime;
})[0] ?? null;
}
export function getQuestPanelOverride(quest: Quest | null): Quest | null {
const panelState = useQuestifySettings(["disableQuestsEverything", "disableAccountPanelPromo", "disableAccountPanelQuestProgress"]);
if (panelState.disableQuestsEverything) {
return null;
}
if (panelState.disableAccountPanelPromo && panelState.disableAccountPanelQuestProgress) {
return null;
}
if (panelState.disableAccountPanelQuestProgress) {
return quest;
}
const nextQuest = getAutoCompleteShowcaseQuest() ?? getMostRecentlyCompletedUnclaimedQuest();
return nextQuest ?? (panelState.disableAccountPanelPromo ? null : quest);
}
export function shouldForceQuestPanelVisible(quest: Quest | null): boolean {
const settings = getQuestifySettings();
if (!quest || settings.disableQuestsEverything || settings.disableAccountPanelQuestProgress) {
return false;
}
return getQuestAutoCompleteEntry(refreshQuest(quest)) !== null;
}
export function getQuestPanelPercentComplete({
quest,
percentCompleteText,
}: QuestPanelPercentCompleteOptions): QuestPanelPercentCompleteResult | null {
if (!quest) {
return null;
}
const refreshedQuest = refreshQuest(quest);
const activeAutoComplete = getQuestAutoCompleteEntry(refreshedQuest);
const task: QuestTask | null = activeAutoComplete?.task ?? getQuestProgressTask(refreshedQuest);
if (!task) {
return null;
}
const questTarget = activeAutoComplete
? getAutoCompleteQuestTarget(task)
.adjusted
: task.target;
const questProgress = activeAutoComplete?.progress ?? getQuestStoredProgress(refreshedQuest, task);
if (!questTarget || questProgress === null) {
return null;
}
const percentComplete = Math.min(1, questProgress / questTarget);
if (!percentCompleteText) {
return { percentComplete };
}
return {
percentComplete,
percentCompleteText: `${Math.floor(percentComplete * 100)}%`,
};
}
export function getQuestStatus(
quest: Quest,
ignoredQuestIds: ReadonlyArray<string>,
checkIgnored: boolean = true,
): QuestStatus {
const completedQuest = quest.userStatus?.completedAt;
const claimedQuest = quest.userStatus?.claimedAt;
const expiredQuest = new Date(quest.config.expiresAt) < new Date();
const questIgnored = ignoredQuestIds.includes(quest.id);
if (claimedQuest) {
return QuestStatus.Claimed;
}
if (checkIgnored && questIgnored && (!expiredQuest || completedQuest)) {
return QuestStatus.Ignored;
}
if (completedQuest || !expiredQuest) {
return QuestStatus.Unclaimed;
}
if (expiredQuest) {
return QuestStatus.Expired;
}
return QuestStatus.Unknown;
}
export function countIncludedUnclaimedQuests(
quests: Quest[],
ignoredQuestIds: ReadonlyArray<string>,
includedTypes: QuestIncludedTypes,
): number {
let count = 0;
for (const quest of quests) {
const questStatus = getQuestStatus(quest, ignoredQuestIds);
if (questMatchesIncludedTypes(quest, includedTypes) && questStatus === QuestStatus.Unclaimed) {
count++;
}
}
return count;
}

View file

@ -0,0 +1,367 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import type { Quest, QuestTaskWatchVideo, QuestTaskWatchVideoOnMobile } from "@vencord/discord-types";
import { QuestTaskType } from "@vencord/discord-types/enums";
import { getQuestifySettings, useQuestifySettings } from "../settings/access";
import { defaultClaimedSubsort, defaultExpiredSubsort, defaultIgnoredSubsort, defaultQuestOrder, defaultUnclaimedSubsort, type QuestOrderStatus, type QuestSubsort, type QuestTileColorSetting, type QuestTileGradient } from "../settings/def";
import { getIgnoredQuestIDs } from "../settings/ignoredQuests";
import { getQuestStatus, QuestStatus } from "./questState";
import { adjustRGB, decimalToRGB, isDarkish, q, type RGB } from "./ui";
type QuestGroupKey = "claimed" | "expired" | "ignored" | "unclaimed" | "unknown";
export const desktopVideoCompatibilityQuestIds = new Set<string>();
interface QuestTileColorSettings {
questTileUnclaimedColor: QuestTileColorSetting;
questTileClaimedColor: QuestTileColorSetting;
questTileIgnoredColor: QuestTileColorSetting;
questTileExpiredColor: QuestTileColorSetting;
}
const customQuestTileClasses = [
q("quest-item-restyle"),
q("quest-item-intense-gradient"),
q("quest-item-default-gradient"),
q("quest-item-black-gradient"),
q("quest-item-hide-gradient"),
q("quest-item-contrast-logo"),
];
const sortFallbacks = {
unclaimed: defaultUnclaimedSubsort,
claimed: defaultClaimedSubsort,
ignored: defaultIgnoredSubsort,
expired: defaultExpiredSubsort,
} as const satisfies Record<string, QuestSubsort>;
const validSubsorts = new Set<QuestSubsort>([
"Recent ASC",
"Recent DESC",
"Expiring ASC",
"Expiring DESC",
"Claimed ASC",
"Claimed DESC",
]);
function getTileColorSetting(status: QuestStatus, colors: QuestTileColorSettings): QuestTileColorSetting | null {
switch (status) {
case QuestStatus.Claimed:
return colors.questTileClaimedColor;
case QuestStatus.Unclaimed:
return colors.questTileUnclaimedColor;
case QuestStatus.Ignored:
return colors.questTileIgnoredColor;
case QuestStatus.Expired:
return colors.questTileExpiredColor;
default:
return null;
}
}
function getQuestTileColor(quest: Quest & { dummyColor?: QuestTileColorSetting; }, colors: QuestTileColorSettings): number | null {
const questStatus = getQuestStatus(quest, getIgnoredQuestIDs());
const setting = quest.dummyColor ?? getTileColorSetting(questStatus, colors);
if (!setting?.enabled) {
return null;
}
return setting.color;
}
function getGradientClass(gradient: QuestTileGradient): string | null {
if (gradient === "black") return q("quest-item-black-gradient");
if (gradient === "hide") return q("quest-item-hide-gradient");
if (gradient === "default") return q("quest-item-default-gradient");
return q("quest-item-intense-gradient");
}
export function getQuestTileClasses(originalClasses: string, quest: Quest & { dummyColor?: QuestTileColorSetting; }, gradientOverride?: QuestTileGradient): string {
const questTiles = useQuestifySettings([
"disableQuestsEverything",
"ignoredQuestIDs",
"questTileUnclaimedColor",
"questTileClaimedColor",
"questTileIgnoredColor",
"questTileExpiredColor",
"questTileGradient",
]);
const baseClasses = originalClasses
.split(" ")
.filter(cls => cls && !customQuestTileClasses.includes(cls));
const colors: QuestTileColorSettings = {
questTileUnclaimedColor: questTiles.questTileUnclaimedColor as QuestTileColorSetting,
questTileClaimedColor: questTiles.questTileClaimedColor as QuestTileColorSetting,
questTileIgnoredColor: questTiles.questTileIgnoredColor as QuestTileColorSetting,
questTileExpiredColor: questTiles.questTileExpiredColor as QuestTileColorSetting,
};
const color = !questTiles.disableQuestsEverything
? getQuestTileColor(quest, colors)
: null;
if (color == null) {
return baseClasses.join(" ");
}
const returnClasses = [...baseClasses, q("quest-item-restyle")];
const gradient = gradientOverride ?? questTiles.questTileGradient as QuestTileGradient;
const gradientClass = getGradientClass(gradient);
if (gradientClass != null) {
returnClasses.push(gradientClass);
}
if (gradient !== "black" && gradient !== "hide" && !isDarkish(decimalToRGB(color), 0.875)) {
returnClasses.push(q("quest-item-contrast-logo"));
}
return returnClasses.join(" ");
}
export function getQuestTileStyle(quest: (Quest & { dummyColor?: QuestTileColorSetting; }) | null): Record<string, string> {
const questTiles = useQuestifySettings([
"disableQuestsEverything",
"ignoredQuestIDs",
"questTileUnclaimedColor",
"questTileClaimedColor",
"questTileIgnoredColor",
"questTileExpiredColor",
]);
const style: Record<string, string> = {};
const colors: QuestTileColorSettings = {
questTileUnclaimedColor: questTiles.questTileUnclaimedColor as QuestTileColorSetting,
questTileClaimedColor: questTiles.questTileClaimedColor as QuestTileColorSetting,
questTileIgnoredColor: questTiles.questTileIgnoredColor as QuestTileColorSetting,
questTileExpiredColor: questTiles.questTileExpiredColor as QuestTileColorSetting,
};
const themeColor = quest && !questTiles.disableQuestsEverything
? getQuestTileColor(quest, colors)
: null;
if (themeColor == null) return style;
const rgb = decimalToRGB(themeColor);
const darkish = isDarkish(rgb);
const sign = darkish ? 1 : -1;
const questNameColor = adjustRGB(rgb, 200 * sign);
const rewardTitleColor = adjustRGB(rgb, 150 * sign);
const rewardDescriptionColor = adjustRGB(rgb, 100 * sign);
const buttonNormalColor = adjustRGB(rgb, 50 * sign);
const buttonHoverColor = adjustRGB(rgb, 75 * sign);
function toRGB(value: RGB): string {
return `rgb(${value.r}, ${value.g}, ${value.b})`;
}
style["--questify-color"] = toRGB(rgb);
style["--questify-quest-name"] = toRGB(questNameColor);
style["--questify-reward-title"] = toRGB(rewardTitleColor);
style["--questify-reward-description"] = toRGB(rewardDescriptionColor);
style["--questify-button-normal"] = toRGB(buttonNormalColor);
style["--questify-button-hover"] = toRGB(buttonHoverColor);
return style;
}
function createSortFunction(subsort: QuestSubsort): (a: Quest, b: Quest) => number {
switch (subsort) {
case "Recent ASC":
return (a, b) => new Date(a.config.startsAt).getTime() - new Date(b.config.startsAt).getTime();
case "Recent DESC":
return (a, b) => new Date(b.config.startsAt).getTime() - new Date(a.config.startsAt).getTime();
case "Expiring ASC":
return (a, b) => new Date(a.config.expiresAt).getTime() - new Date(b.config.expiresAt).getTime();
case "Expiring DESC":
return (a, b) => new Date(b.config.expiresAt).getTime() - new Date(a.config.expiresAt).getTime();
case "Claimed ASC":
return (a, b) => new Date(a.userStatus?.claimedAt || 0).getTime() - new Date(b.userStatus?.claimedAt || 0).getTime();
case "Claimed DESC":
return (a, b) => new Date(b.userStatus?.claimedAt || 0).getTime() - new Date(a.userStatus?.claimedAt || 0).getTime();
}
}
function getValidSubsort(value: unknown, fallback: QuestSubsort): QuestSubsort {
return validSubsorts.has(value as QuestSubsort) ? value as QuestSubsort : fallback;
}
function getValidQuestOrder(value: unknown): QuestOrderStatus[] {
const validStatuses = new Set<QuestOrderStatus>(defaultQuestOrder);
const configuredOrder = Array.isArray(value)
? value
: defaultQuestOrder;
const order = configuredOrder.filter((status): status is QuestOrderStatus => validStatuses.has(status as QuestOrderStatus));
for (const status of defaultQuestOrder) {
if (!order.includes(status)) {
order.push(status);
}
}
return order;
}
function injectDesktopVideoQuestTasks(quests: Quest[]): void {
for (const quest of quests) {
const tasks = quest.config.taskConfigV2?.tasks;
const mobileVideoTask = tasks?.[QuestTaskType.WATCH_VIDEO_ON_MOBILE] as QuestTaskWatchVideoOnMobile | undefined;
if (!tasks || !mobileVideoTask || tasks[QuestTaskType.WATCH_VIDEO]) {
continue;
}
const desktopVideoTask: QuestTaskWatchVideo = {
...mobileVideoTask,
type: QuestTaskType.WATCH_VIDEO,
};
const reorderedTasks: typeof tasks = {};
for (const [taskType, task] of Object.entries(tasks) as [QuestTaskType, typeof tasks[QuestTaskType]][]) {
if (taskType === QuestTaskType.WATCH_VIDEO_ON_MOBILE) {
reorderedTasks[QuestTaskType.WATCH_VIDEO] = desktopVideoTask;
}
reorderedTasks[taskType] = task;
}
quest.config.taskConfigV2.tasks = reorderedTasks;
desktopVideoCompatibilityQuestIds.add(quest.id);
}
}
export function hasInjectedDesktopVideoCompatibility(quest?: Quest | string | null): boolean {
return !quest ? false : desktopVideoCompatibilityQuestIds.has(typeof quest === "string" ? quest : quest.id);
}
export function sortQuests(quests: Quest[], skip?: boolean): Quest[] {
const questSorting = useQuestifySettings([
"disableQuestsEverything",
"ignoredQuestIDs",
"makeMobileVideoQuestsDesktopCompatible",
"completeVideoQuestsQuicker",
"questOrder",
"unclaimedSubsort",
"claimedSubsort",
"ignoredSubsort",
"expiredSubsort",
"autoCompleteQuestTypes",
]);
if (questSorting.disableQuestsEverything) {
return quests;
}
if (questSorting.makeMobileVideoQuestsDesktopCompatible || !!questSorting.autoCompleteQuestTypes.WATCH_VIDEO_ON_MOBILE) {
injectDesktopVideoQuestTasks(quests);
}
if (skip) {
return quests;
}
const ignoredQuestIds = getIgnoredQuestIDs();
const questGroups: Record<QuestGroupKey, Quest[]> = {
claimed: [],
expired: [],
ignored: [],
unclaimed: [],
unknown: [],
};
for (const quest of quests) {
switch (getQuestStatus(quest, ignoredQuestIds)) {
case QuestStatus.Claimed:
questGroups.claimed.push(quest);
break;
case QuestStatus.Unclaimed:
questGroups.unclaimed.push(quest);
break;
case QuestStatus.Expired:
questGroups.expired.push(quest);
break;
case QuestStatus.Ignored:
questGroups.ignored.push(quest);
break;
default:
questGroups.unknown.push(quest);
break;
}
}
const unclaimedSortFunction = createSortFunction(getValidSubsort(questSorting.unclaimedSubsort, sortFallbacks.unclaimed));
questGroups.unclaimed.sort((a, b) => {
const aCompleted = !!a.userStatus?.completedAt;
const bCompleted = !!b.userStatus?.completedAt;
if (aCompleted !== bCompleted) {
return aCompleted ? 1 : -1;
}
return unclaimedSortFunction(a, b);
});
questGroups.claimed.sort(createSortFunction(getValidSubsort(questSorting.claimedSubsort, sortFallbacks.claimed)));
questGroups.ignored.sort(createSortFunction(getValidSubsort(questSorting.ignoredSubsort, sortFallbacks.ignored)));
questGroups.expired.sort(createSortFunction(getValidSubsort(questSorting.expiredSubsort, sortFallbacks.expired)));
return [
...getValidQuestOrder(questSorting.questOrder).flatMap(status => questGroups[status.toLowerCase() as QuestGroupKey]),
...questGroups.unknown,
];
}
export function shouldPreloadQuestAssets(): boolean {
const settings = getQuestifySettings();
return !settings.disableQuestsEverything && settings.questTilePreload;
}
export function getLastSortChoice(): string | null {
const { rememberQuestPageSort, lastQuestPageSort } = getQuestifySettings();
return rememberQuestPageSort ? lastQuestPageSort : "questify";
}
export function setLastSortChoice(sort: string): void {
getQuestifySettings().lastQuestPageSort = sort || "questify";
}
function getFilterChoiceKey({ group, filter }: { group: string; filter: string; }): string {
return JSON.stringify([group, filter]);
}
export function getLastFilterChoices(): { group: string, filter: string; }[] | null {
const { rememberQuestPageFilters, lastQuestPageFilters } = getQuestifySettings();
return rememberQuestPageFilters
? Object.values(lastQuestPageFilters).map(item => JSON.parse(JSON.stringify(item)))
: null;
}
export function setLastFilterChoices(filters: { group: string, filter: string; }[] | null): void {
if (!filters?.length) {
getQuestifySettings().lastQuestPageFilters = {};
return;
}
if (!filters.every(filter => filter?.group && filter?.filter)) {
return;
}
getQuestifySettings().lastQuestPageFilters = JSON.parse(JSON.stringify(filters)).reduce((acc, item) => {
acc[getFilterChoiceKey(item)] = item;
return acc;
}, {} as Record<string, { group: string, filter: string; }>);
}

View file

@ -0,0 +1,158 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { classNameFactory } from "@utils/css";
import { findByPropsLazy } from "@webpack";
import type { QuestButtonDisplayMode, QuestButtonIndicatorMode } from "../settings/def";
export interface RGB {
r: number;
g: number;
b: number;
}
export const QUEST_PAGE = "/quest-home";
export const q = classNameFactory("questify-");
export const leftClick = 0;
export const middleClick = 1;
export const rightClick = 2;
export function decimalToRGB(decimal: number): RGB {
return {
r: (decimal >> 16) & 0xff,
g: (decimal >> 8) & 0xff,
b: decimal & 0xff,
};
}
export function adjustRGB(rgb: RGB, shift: number): RGB {
return {
r: Math.max(0, Math.min(255, rgb.r + shift)),
g: Math.max(0, Math.min(255, rgb.g + shift)),
b: Math.max(0, Math.min(255, rgb.b + shift)),
};
}
export function isDarkish(rgb: RGB, threshold: number = 0.5): boolean {
const luminance = (0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b) / 255;
return luminance < threshold;
}
export function canShowBadge(mode: QuestButtonIndicatorMode): boolean {
return mode === "badge" || mode === "both";
}
export function canShowPill(mode: QuestButtonIndicatorMode): boolean {
return mode === "pill" || mode === "both";
}
export function canShowButton(mode: QuestButtonDisplayMode): boolean {
return mode === "always" || mode === "unclaimed";
}
function getBadgeSize(value: string, negative: boolean): number {
const numChars = value.length;
const subtract = negative ? 3 : 0;
if (numChars === 1) {
return 16;
}
if (numChars === 2) {
return 21;
}
return (21 + (numChars - 2) * 8) - subtract;
}
export function formatLowerBadge(value: number, maxDigits: number = 4): [string, number] {
const isNegative = value < 0;
const absValue = Math.abs(value);
if (maxDigits <= 0) {
const formatted = isNegative ? `-${absValue}` : `${absValue}`;
return [formatted, getBadgeSize(formatted, isNegative)];
}
const absStr = String(absValue);
if (absStr.length <= maxDigits) {
const formatted = isNegative ? `-${absValue}` : `${absValue}`;
return [formatted, getBadgeSize(formatted, isNegative)];
}
if (absValue < 1000) {
if (maxDigits === 1) {
const formatted = absValue < 100
? (isNegative ? "<-9" : "9+")
: (isNegative ? "<-99" : "99+");
return [formatted, getBadgeSize(formatted, isNegative)];
}
if (maxDigits === 2) {
const formatted = isNegative ? "<-99" : "99+";
return [formatted, getBadgeSize(formatted, isNegative)];
}
}
let reducedValue = absValue;
let unit = "";
const units = ["K", "M", "B"];
for (let index = 0; index < units.length; index++) {
const nextValue = Math.floor(reducedValue / 1000);
if (nextValue === 0) {
break;
}
if (index === units.length - 1 && nextValue >= 1000) {
reducedValue = 999;
unit = units[index];
break;
}
reducedValue = nextValue;
unit = units[index];
}
const formatted = isNegative ? `<-${reducedValue}${unit}` : `${reducedValue}${unit}+`;
return [formatted, getBadgeSize(formatted, isNegative)];
}
export interface AlertOptions {
title: string;
body: React.ReactNode;
confirmText?: string;
cancelText?: string;
confirmVariant?: "primary" | "critical-primary" | "expressive";
onConfirm?: () => void;
onCancel?: () => void;
onCloseCallback?: () => void;
contextKey?: string;
}
export interface AlertsType {
show: (options: AlertOptions) => void;
close: () => void;
confirm: (options: AlertOptions) => Promise<boolean>;
}
const AlertsModule = findByPropsLazy("show", "close", "confirm");
export const Alerts: AlertsType = {
show: options => AlertsModule.show(options),
close: () => AlertsModule.close(),
confirm: options => AlertsModule.confirm(options),
};

File diff suppressed because one or more lines are too long

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