diff --git a/src/equicordplugins/channelTabs/components/ChannelTab.tsx b/src/equicordplugins/channelTabs/components/ChannelTab.tsx
index 5660eec76..5bec9dc1a 100644
--- a/src/equicordplugins/channelTabs/components/ChannelTab.tsx
+++ b/src/equicordplugins/channelTabs/components/ChannelTab.tsx
@@ -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,20 +467,19 @@ 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
setIsHovered(true)}
diff --git a/src/equicordplugins/questify/components/questButton.tsx b/src/equicordplugins/questify/components/questButton.tsx
new file mode 100644
index 000000000..460ec03ab
--- /dev/null
+++ b/src/equicordplugins/questify/components/questButton.tsx
@@ -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 { initialQuestDataFetched } from "..";
+import type { QuestButtonAction, QuestButtonDisplayMode, QuestButtonIndicatorMode } from "../settings/def";
+import { getIgnoredQuestIDs, ignoreAllQuests, resetIgnoredQuests } from "../settings/ignoredQuests";
+import { settings } from "../settings/store";
+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
) => void;
+}
+
+export function QuestIcon(height: number, width: number, className?: string): JSX.Element {
+ return (
+
+ );
+}
+
+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 ;
+}
+
+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
+ : (
+
+ );
+ const icon = (
+
+ {QuestIcon(26, 26)}
+
+ );
+
+ return (
+
+ {visible && (
+
+
+
+
+
+ icon}
+ tooltip="Quests"
+ showPill={false}
+ selected={selected}
+ className={buttonClass}
+ onClick={onPress}
+ onMouseDown={onPress}
+ onContextMenu={onPress}
+ onMouseEnter={() => setHovered(true)}
+ onMouseLeave={() => setHovered(false)}
+ {...(lowerBadge ? { lowerBadge, lowerBadgeSize } : {})}
+ />
+
+
+ )}
+
+ );
+}
+
+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 = settings.use(["questButtonDisplay", "questButtonBadgeCount", "isOnQuestsPage"]);
+
+ if (!showQuestButton(buttonSettings.questButtonDisplay, buttonSettings.questButtonBadgeCount, buttonSettings.isOnQuestsPage)) {
+ return false;
+ }
+
+ return pathname === QUEST_PAGE;
+}
+
+function resolveQuestButtonAction(
+ event: MouseEvent,
+ 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 || settings.store.questButtonBadgeCount <= 0;
+ const resetIgnoredDisabled = dummy || getIgnoredQuestIDs().length <= 0;
+ const fetchQuestsDisabled = dummy;
+
+ return (
+
+
+
+ { void fetchAndAlertQuests("MANUAL_FETCH"); }}
+ disabled={fetchQuestsDisabled}
+ />
+
+ );
+}
+
+export function QuestButton(): JSX.Element {
+ const {
+ isOnQuestsPage,
+ questButtonBadgeColor,
+ questButtonDisplay,
+ questButtonBadgeCount,
+ questButtonLeftClickAction,
+ questButtonMiddleClickAction,
+ questButtonRightClickAction,
+ questButtonIndicator,
+ } = settings.use([
+ "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): 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, () => );
+ }
+ }
+
+ return (
+ 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): 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, () => );
+ }
+ }
+
+ return (
+
+ );
+}
diff --git a/src/equicordplugins/questify/components/questButtonSettings.tsx b/src/equicordplugins/questify/components/questButtonSettings.tsx
new file mode 100644
index 000000000..f53ff07bb
--- /dev/null
+++ b/src/equicordplugins/questify/components/questButtonSettings.tsx
@@ -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 { type QuestButtonAction, type QuestButtonDisplayMode, type QuestButtonIncludedTypes, type QuestButtonIndicatorMode } from "../settings/def";
+import { startAutoFetchingQuests } from "../settings/fetching";
+import { validateIgnoredQuests } from "../settings/ignoredQuests";
+import { settings } from "../settings/store";
+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(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 = settings.use([
+ "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;
+
+ settings.store.questButtonDisplay = value as QuestButtonDisplayMode;
+ startAutoFetchingQuests(true);
+ }
+
+ function updateQuestButtonIndicator(value: string | string[] | null) {
+ if (typeof value !== "string") return;
+
+ settings.store.questButtonIndicator = value as QuestButtonIndicatorMode;
+ startAutoFetchingQuests(true);
+ }
+
+ function updateQuestButtonAction(key: "questButtonLeftClickAction" | "questButtonMiddleClickAction" | "questButtonRightClickAction", value: string | string[] | null) {
+ if (typeof value !== "string") return;
+
+ settings.store[key] = value as QuestButtonAction;
+ }
+
+ function updateBadgeColor(value: number | null) {
+ settings.store.questButtonBadgeColor = value;
+ }
+
+ function updateIncludedTypes(options: readonly QuestButtonIncludedTypeOption[], value: string | string[] | null) {
+ const selectedValues = new Set(Array.isArray(value) ? value : value ? [value] : []);
+ const nextIncludedTypes = { ...settings.store.questButtonIncludedTypes };
+
+ for (const option of options) {
+ nextIncludedTypes[option.value] = selectedValues.has(String(option.value));
+ }
+
+ settings.store.questButtonIncludedTypes = nextIncludedTypes;
+ validateIgnoredQuests();
+ }
+
+ return (
+
+
+
+ Quest Button
+ Show a Quest Button in the server list with an optional indicator for unclaimed and unignored Quests.
+
+
+
+
+
+ Button Behavior
+
+
+ updateQuestButtonAction("questButtonLeftClickAction", value)}
+ />
+
+
+ updateQuestButtonAction("questButtonMiddleClickAction", value)}
+ />
+
+
+ updateQuestButtonAction("questButtonRightClickAction", value)}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Quest Relevancy
+ 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."
+ }}
+ />
+ 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."
+ }}
+ />
+
+ );
+}
diff --git a/src/equicordplugins/questify/components/questFeaturesSetting.tsx b/src/equicordplugins/questify/components/questFeaturesSetting.tsx
new file mode 100644
index 000000000..a388c1c86
--- /dev/null
+++ b/src/equicordplugins/questify/components/questFeaturesSetting.tsx
@@ -0,0 +1,364 @@
+/*
+ * 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 { autoCompleteQuestTaskTypes, defaultAllowChangingDangerousSettings, defaultAutoCompleteQuestsSimultaneously, defaultAutoCompleteQuestTypes, defaultCompleteVideoQuestsQuicker, defaultMakeMobileVideoQuestsDesktopCompatible, defaultResumeInterruptedQuests, isDesktopCompatible } from "../settings/def";
+import { settings } from "../settings/store";
+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;
+
+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 (
+
+
+
+ );
+}
+
+export function QuestFeaturesSetting(): JSX.Element {
+ const questFeatures = settings.use([
+ "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) {
+ settings.store[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;
+ }
+
+ settings.store.autoCompleteQuestTypes = nextAutoCompleteQuestTypes;
+ }
+
+ function updateDisableEverything(checked: boolean) {
+ function setDisableEverything() {
+ settings.store.disableQuestsEverything = checked;
+ }
+
+ if (checked) {
+ confirmDangerousSettingChange(
+ "This will completely disable Quest functionality.",
+ () => {
+ resetDangerousSettings();
+ setDisableEverything();
+ }
+ );
+ } else {
+ setDisableEverything();
+ }
+ }
+
+ function resetDangerousSettings() {
+ settings.store.allowChangingDangerousSettings = defaultAllowChangingDangerousSettings;
+ settings.store.autoCompleteQuestsSimultaneously = defaultAutoCompleteQuestsSimultaneously;
+ settings.store.completeVideoQuestsQuicker = defaultCompleteVideoQuestsQuicker;
+ settings.store.makeMobileVideoQuestsDesktopCompatible = defaultMakeMobileVideoQuestsDesktopCompatible;
+ settings.store.resumeInterruptedQuests = defaultResumeInterruptedQuests;
+ settings.store.autoCompleteQuestTypes = { ...defaultAutoCompleteQuestTypes };
+ }
+
+ function updateDangerousAccess(checked: boolean) {
+ function setDangerousAccess() {
+ settings.store.allowChangingDangerousSettings = checked;
+ }
+
+ if (checked) {
+ confirmDangerousSettingChange(
+ "This will allow changing dangerous settings.",
+ setDangerousAccess
+ );
+ } else {
+ resetDangerousSettings();
+ setDangerousAccess();
+ }
+ }
+
+ function updateModifyValue(key: QuestModifySettingKey, checked: boolean) {
+ settings.store[key] = checked;
+ }
+
+ return (
+
+ Quest Features
+ Modify how Quests behave to enhance or remove functionality.
+ Disable Features
+
+
+ Modify Features
+ c !== undefined)}>
+
+ Discord has began issuing warnings to users of scripts or plugins that modify how Quests are completed, which is against their Terms of Service.
+
+
+
+ The warnings appear limited to threat of loss of access to Quests or their rewards, but Discord may escalate at any time.
+
+
+
+ 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.
+
+
+
+ Use the following toggle to access potentially dangerous settings at your own risk.
+
+ updateDangerousAccess(!questFeatures.allowChangingDangerousSettings)}
+ />
+ {questFeatures.allowChangingDangerousSettings && <>
+ 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."
+ }}
+ />
+ 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."
+ }}
+ />
+ 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."
+ }}
+ />
+ 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."
+ }}
+ />
+
+ >}
+
+
+ );
+}
diff --git a/src/equicordplugins/questify/components/questNotificationsSetting.tsx b/src/equicordplugins/questify/components/questNotificationsSetting.tsx
new file mode 100644
index 000000000..b52581945
--- /dev/null
+++ b/src/equicordplugins/questify/components/questNotificationsSetting.tsx
@@ -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 { startAutoFetchingQuests } from "../settings/fetching";
+import { settings } from "../settings/store";
+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 (
+
+ );
+}
+
+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) {
+ event.preventDefault();
+ event.stopPropagation();
+ }
+
+ function handlePreviewClick(event: MouseEvent) {
+ event.preventDefault();
+ event.stopPropagation();
+
+ if (sound && !disabled) {
+ onPreview(sound);
+ }
+ }
+
+ return {
+ ...option,
+ trailing: sound
+ ? (
+
+ )
+ : undefined,
+ };
+ }
+
+ return (
+ {
+ if (nextValue != null && typeof nextValue !== "string") return;
+
+ onChange(nextValue ?? null);
+ }}
+ />
+ );
+}
+
+export function QuestNotificationsSetting(): JSX.Element {
+ const questNotifications = settings.use([
+ "newExcludedQuestAlertSound",
+ "newExcludedQuestAlertVolume",
+ "newQuestAlertSound",
+ "newQuestAlertVolume",
+ "questFetchInterval",
+ "disableQuestsEverything",
+ "notifyOnNewExcludedQuests",
+ "notifyOnNewQuests",
+ "notifyOnQuestComplete",
+ "questCompletedAlertSound",
+ "questCompletedAlertVolume",
+ ]);
+
+ const soundOptions = useMemo(getSoundOptions, []);
+ const activePlayer = useRef(null);
+ const [playingSound, setPlayingSound] = useState(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);
+ settings.store.questFetchInterval = interval;
+ startAutoFetchingQuests(true);
+ }
+
+ return (
+
+ Quest Notifications
+ Configure Quest completed and new Quest detected notifications and alerts.
+ Quest Completed
+ { settings.store.notifyOnQuestComplete = checked; }}
+ bottomSpacing="5"
+ />
+
+
+ previewSound(sound, questNotifications.questCompletedAlertVolume)}
+ onChange={value => { settings.store.questCompletedAlertSound = value; }}
+ />
+
+
+ { settings.store.questCompletedAlertVolume = value; }}
+ />
+
+
+ New Quests Detected
+ {
+ settings.store.notifyOnNewQuests = checked;
+ startAutoFetchingQuests(true);
+ }}
+ />
+
+
+ previewSound(sound, questNotifications.newQuestAlertVolume)}
+ onChange={value => {
+ settings.store.newQuestAlertSound = value;
+ startAutoFetchingQuests(true);
+ }}
+ />
+
+
+ { settings.store.newQuestAlertVolume = value; }}
+ />
+
+
+ {
+ settings.store.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."
+ }}
+ />
+
+
+ previewSound(sound, questNotifications.newExcludedQuestAlertVolume)}
+ onChange={value => {
+ settings.store.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."
+ }}
+ />
+
+
+ { settings.store.newExcludedQuestAlertVolume = value; }}
+ />
+
+
+
+
+ 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."
+ }}
+ />
+
+
+
+ );
+}
diff --git a/src/equicordplugins/questify/components/questTileContextMenu.tsx b/src/equicordplugins/questify/components/questTileContextMenu.tsx
new file mode 100644
index 000000000..ec1cb048f
--- /dev/null
+++ b/src/equicordplugins/questify/components/questTileContextMenu.tsx
@@ -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((
+
+ {!isClaimedMenu && (!isIgnored ? (
+ addIgnoredQuest(quest.id)}
+ />
+ ) : (
+ removeIgnoredQuest(quest.id)}
+ />
+ ))}
+ {isAutoCompleting ? (
+ {
+ stopQuestAutoComplete(quest, {
+ manual: true,
+ preserveResume: false,
+ terminalHeartbeat: true,
+ });
+ rerenderQuests();
+ }}
+ />
+ ) : canStartAutoComplete ? (
+ {
+ processQuestForAutoComplete(quest, {
+ force: true,
+ source: "manual",
+ });
+ rerenderQuests();
+ }}
+ />
+ ) : null}
+ copyToClipboard(quest.id)}
+ />
+
+ ));
+}
diff --git a/src/equicordplugins/questify/components/questTilesSetting.tsx b/src/equicordplugins/questify/components/questTilesSetting.tsx
new file mode 100644
index 000000000..ec6e98e62
--- /dev/null
+++ b/src/equicordplugins/questify/components/questTilesSetting.tsx
@@ -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 { defaultQuestTileClaimedColorSetting, defaultQuestTileExpiredColorSetting, defaultQuestTileIgnoredColorSetting, defaultQuestTileUnclaimedColorSetting, type QuestTileColorSetting, type QuestTileGradient } from "../settings/def";
+import { rerenderQuests } from "../settings/rerender";
+import { settings } from "../settings/store";
+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(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 (
+
+
+
+ );
+}
+
+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 (
+
+ );
+}
+
+export function QuestTilesSetting(): JSX.Element {
+ const questTiles = settings.use([
+ "disableQuestsEverything",
+ "questTileUnclaimedColor",
+ "questTileClaimedColor",
+ "questTileIgnoredColor",
+ "questTileExpiredColor",
+ "questTileGradient",
+ "questTilePreload",
+ ]);
+
+ const [previewColorKey, setPreviewColorKey] = useState(defaultPreviewColorKey);
+
+ const disabled = questTiles.disableQuestsEverything;
+ const previewColor = questTiles[previewColorKey] as QuestTileColorSetting;
+
+ function updateColor(key: QuestTileColorKey, nextColor: QuestTileColorSetting): void {
+ setPreviewColorKey(key);
+ settings.store[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;
+
+ settings.store.questTileGradient = value as QuestTileGradient;
+ }
+
+ function updatePreload(value: string | string[] | null): void {
+ if (typeof value !== "string") return;
+
+ const preload = value === "true";
+ settings.store.questTilePreload = preload;
+ rerenderQuests();
+ }
+
+ return (
+
+ Quest Tiles
+ Highlight Quests with optional theme colors for visibility.
+ Tile Behavior
+
+
+
+
+
+
+
+
+ Tile Colors
+
+ {colorOptions.map(({ key, label }) => {
+ const setting = questTiles[key] as QuestTileColorSetting;
+
+ return (
+
+ setPreviewColorKey(key)}
+ onPointerDownCapture={() => setPreviewColorKey(key)}
+ >
+ updateColorValue(key, setting, value)}
+ showEyeDropper={true}
+ />
+
+
+ updateColorEnabled(key, setting, !setting.enabled)}
+ />
+
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/src/equicordplugins/questify/components/reorderQuestsSetting.tsx b/src/equicordplugins/questify/components/reorderQuestsSetting.tsx
new file mode 100644
index 000000000..a39c618ee
--- /dev/null
+++ b/src/equicordplugins/questify/components/reorderQuestsSetting.tsx
@@ -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 { defaultQuestOrder, type QuestOrderStatus, type QuestSubsort } from "../settings/def";
+import { rerenderQuests } from "../settings/rerender";
+import { settings } from "../settings/store";
+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(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 = settings.use([
+ "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;
+ settings.store.questOrder = nextOrder;
+ rerenderQuests();
+ }
+
+ function updateSubsort(key: "unclaimedSubsort" | "claimedSubsort" | "ignoredSubsort" | "expiredSubsort", value: string | string[] | null): void {
+ if (typeof value !== "string") return;
+
+ settings.store[key] = value;
+ rerenderQuests();
+ }
+
+ function updateRememberSetting(key: "rememberQuestPageSort" | "rememberQuestPageFilters", checked: boolean): void {
+ settings.store[key] = checked;
+ }
+
+ return (
+
+ Reorder Quests
+ Sort Quests by their status when the Questify sort option is selected on the Quests page.
+ Status Order
+
+ {questOrder.map((status, index) => (
+
+ 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."
+ }}
+ />
+
+ ))}
+
+ Subsorts
+
+
+ updateSubsort("unclaimedSubsort", value)}
+ tooltip={{
+ position: "top",
+ text: subsortTooltips.unclaimedSubsort
+ }}
+ />
+
+
+ updateSubsort("claimedSubsort", value)}
+ tooltip={{
+ position: "top",
+ text: subsortTooltips.claimedSubsort
+ }}
+ />
+
+
+
+
+ updateSubsort("ignoredSubsort", value)}
+ tooltip={{
+ position: "top",
+ text: subsortTooltips.ignoredSubsort
+ }}
+ />
+
+
+ updateSubsort("expiredSubsort", value)}
+ tooltip={{
+ position: "top",
+ text: subsortTooltips.expiredSubsort
+ }}
+ />
+
+
+ Quest Page Memory
+ updateRememberSetting("rememberQuestPageSort", checked)}
+ bottomSpacing="5"
+ tooltip={{
+ position: "top",
+ text: "When disabled, the Quests page opens with the Questify sort option each time."
+ }}
+ />
+ updateRememberSetting("rememberQuestPageFilters", checked)}
+ tooltip={{
+ position: "top",
+ text: "When disabled, the Quests page opens without task or reward filters each time."
+ }}
+ />
+
+ );
+}
diff --git a/src/equicordplugins/questify/components/settingsModal.tsx b/src/equicordplugins/questify/components/settingsModal.tsx
new file mode 100644
index 000000000..8fc8ce838
--- /dev/null
+++ b/src/equicordplugins/questify/components/settingsModal.tsx
@@ -0,0 +1,32 @@
+/*
+ * Vencord, a Discord client mod
+ * Copyright (c) 2026 Vendicated and contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+import PluginModal from "@components/settings/tabs/plugins/PluginModal";
+import { openModal } from "@utils/modal";
+import type { Plugin } from "@utils/types";
+
+import { setSettingsModalOpen } from "..";
+import { promptToRestartIfDirty } from "../settings/restartTracking";
+
+export function openQuestifySettingsModal(plugin: Plugin): void {
+ setSettingsModalOpen(true);
+
+ openModal(
+ modalProps => (
+ { }}
+ />
+ ),
+ {
+ onCloseCallback: () => {
+ setSettingsModalOpen(false);
+ promptToRestartIfDirty();
+ }
+ }
+ );
+}
diff --git a/src/equicordplugins/questify/components/shared.tsx b/src/equicordplugins/questify/components/shared.tsx
new file mode 100644
index 000000000..dbdedd0eb
--- /dev/null
+++ b/src/equicordplugins/questify/components/shared.tsx
@@ -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 (
+
+ {children}
+
+ );
+}
+
+export function SettingsHeader({ children }: { children: ReactNode; }): JSX.Element {
+ return (
+
+ {children}
+
+ );
+}
+
+export function SettingsSubheader({ children, className }: { children: ReactNode; className?: string | string[]; }): JSX.Element {
+ return (
+
+ {children}
+
+ );
+}
+
+export function SettingsDescription({ children }: { children: ReactNode; }): JSX.Element {
+ return (
+
+ {children}
+
+ );
+}
+
+export function SettingsParagraph({ children, className }: { children: ReactNode; className?: string | string[]; }): JSX.Element {
+ return (
+
+ {children}
+
+ );
+}
+
+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 (
+
+ {children}
+
+ );
+}
+
+export interface SettingsRowProps {
+ children: ReactNode;
+ className?: string | string[];
+}
+
+export function SettingsRow({ children, className }: SettingsRowProps): JSX.Element {
+ return (
+
+ {children}
+
+ );
+}
+
+export interface SettingsRowItemProps {
+ children: ReactNode;
+ className?: string | string[];
+ width?: "fill" | "content";
+}
+
+export function SettingsRowItem({
+ children,
+ className,
+ width = "fill",
+}: SettingsRowItemProps): JSX.Element {
+ return (
+
+ {children}
+
+ );
+}
+
+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;
+
+export interface SettingsSelectProps extends Omit {
+ 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 = (
+
+ );
+
+ if (tooltip) {
+ return (
+
+ {selectElement}
+
+ );
+ }
+
+ return selectElement;
+}
+
+type ColorPickerWithOnCloseProps = ComponentProps & {
+ onClose?: () => void;
+};
+
+function ColorPickerWithOnClose(props: ColorPickerWithOnCloseProps): JSX.Element {
+ const LiveColorPicker = ColorPicker as ComponentType;
+
+ return ;
+}
+
+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 && {label}}
+
+
+
+ >
+ );
+}
+
+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 (
+
+ );
+}
+
+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;
+
+export type SettingsSubtleSwitchProps = Omit;
+
+export function SettingsSubtleSwitch(props: SettingsSubtleSwitchProps): JSX.Element {
+ const switchElement = (
+
+
+
+ );
+
+ if (props.tooltip) {
+ return (
+
+ {switchElement}
+
+ );
+ }
+
+ 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;
+ tooltipClassName?: string;
+ tooltipStyle?: React.CSSProperties;
+ tooltipContentClassName?: string;
+ tooltipPointerClassName?: string;
+ positionKeyStemOverride?: string;
+ onTooltipShow?: () => void;
+ onTooltipHide?: () => void;
+ onAnimationRest?: () => void;
+ "aria-label"?: string | false;
+ children: (props: Record) => React.ReactNode;
+}
+
+export const ManaTooltip = findComponentByCodeLazy("VoidTooltip cannot find DOM node") as React.ComponentType;
+
+function SettingsTooltip({
+ children,
+ position,
+ text,
+ wider,
+ className
+}: {
+ children: ReactNode;
+ position: "top" | "bottom";
+ text: string;
+ wider?: boolean;
+ className?: string | string[];
+}): JSX.Element {
+ return (
+
+
+ {tooltipProps => {children}
}
+
+
+ );
+}
+
+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;
diff --git a/src/equicordplugins/questify/index.tsx b/src/equicordplugins/questify/index.tsx
index 70d86f715..2593ebd23 100644
--- a/src/equicordplugins/questify/index.tsx
+++ b/src/equicordplugins/questify/index.tsx
@@ -1,1428 +1,216 @@
/*
* 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 "./styles.css";
-import { showNotification } from "@api/Notifications";
-import { plugins } from "@api/PluginManager";
+import { playAudio } from "@api/AudioPlayer";
import { addServerListElement, removeServerListElement, ServerListRenderPosition } from "@api/ServerList";
-import { migratePluginToSettings, Settings } from "@api/Settings";
-import ErrorBoundary from "@components/ErrorBoundary";
-import { openPluginModal } from "@components/settings";
+import { Settings } from "@api/Settings";
+import { ErrorBoundary } from "@components/index";
import { EquicordDevs } from "@utils/constants";
-import { copyToClipboard } from "@utils/index";
-import definePlugin, { PluginNative, StartAt } from "@utils/types";
-import { findStoreLazy, onceReady } from "@webpack";
-import { ContextMenuApi, Menu, NavigationRouter, RestAPI, useEffect, useState } from "@webpack/common";
-import { JSX } from "react";
+import definePlugin, { StartAt } from "@utils/types";
+import type { Quest, QuestUserStatus } from "@vencord/discord-types";
+import { findComponentByCodeLazy, onceReady } from "@webpack";
+import { QuestStore } from "@webpack/common";
+import type { JSX } from "react";
-import { addIgnoredQuest, addRerenderCallback, autoFetchCompatible, fetchAndAlertQuests, maximumAutoFetchIntervalValue, minimumAutoFetchIntervalValue, questIsIgnored, removeIgnoredQuest, rerenderQuests, settings, startAutoFetchingQuests, stopAutoFetchingQuests, validateAndOverwriteIgnoredQuests } from "./settings";
-import { ActiveQuestIntervalsMap, ExcludedQuestMap, GuildlessServerListItem, Quest, QuestIcon, QuestMap, QuestStatus, QuestTaskType, RGB } from "./utils/components";
-import { adjustRGB, decimalToRGB, fetchAndDispatchQuests, formatLowerBadge, getFormattedNow, getIgnoredQuestIDs, getQuestProgress, getQuestStatus, getQuestTarget, getQuestTask, isDarkish, leftClick, middleClick, normalizeQuestName, q, QuestifyLogger, questPath, QuestsStore, refreshQuest, reportPlayGameQuestProgress, reportVideoQuestProgress, rightClick, setIgnoredQuestIDs, waitUntilEnrolled } from "./utils/misc";
+import { disguiseHomeButton, QuestButton, showQuestButton } from "./components/questButton";
+import { QuestTileContextMenu } from "./components/questTileContextMenu";
+import { resetQuestsToResume, startAutoFetchingQuests, stopAutoFetchingQuests } from "./settings/fetching";
+import { validateIgnoredQuests } from "./settings/ignoredQuests";
+import { rerenderQuests, useQuestRerender } from "./settings/rerender";
+import { disposeRestartTracking, initializeRestartTracking, promptToRestartIfDirty } from "./settings/restartTracking";
+import { settings } from "./settings/store";
+import { canAutoCompleteQuest, getActiveAutoCompletes, getQuestAutoCompleteProgress, getQuestButtonProps, getQuestPanelSubtitleText, hasEnabledAutoCompleteQuestTypes, processQuestForAutoComplete, resumeInterruptedAutoCompletes, setHeartbeatStackTracePatchSucceeded, setVideoProgressStackTracePatchSucceeded, stopAllAutoCompletes, stopAutoCompletesForRunningGames, stopQuestAutoComplete } from "./utils/completion";
+import { canOpenDevToolsWindow, fetchAndDispatchQuests, openDevToolsWindow, snakeToCamel } from "./utils/fetching";
+import { normalizeQuestName } from "./utils/filtering";
+import { notifyQuestCompletion, QL } from "./utils/logging";
+import { getQuestPanelOverride, getQuestPanelPercentComplete, shouldForceQuestPanelVisible } from "./utils/questState";
+import { getLastFilterChoices, getLastSortChoice, getQuestTileClasses, getQuestTileStyle, setLastFilterChoices, setLastSortChoice, shouldPreloadQuestAssets, sortQuests } from "./utils/questTiles";
+import { formatLowerBadge, QUEST_PAGE } from "./utils/ui";
-const AuthorizedAppsStore = findStoreLazy("AuthorizedAppsStore");
-let initialQuestDataFetched = false;
-const QuestifyNative = VencordNative.pluginHelpers.Questify as PluginNative;
-const patchedMobileQuests = new Set();
-const manuallyStoppedQuestIDs = new Set();
-export const activeQuestIntervals = new ActiveQuestIntervalsMap();
+let settingsModalOpen = false;
-function questMenuUnignoreAllClicked(): void {
- validateAndOverwriteIgnoredQuests([]);
+export function setSettingsModalOpen(open: boolean): void {
+ settingsModalOpen = open;
}
-function questMenuIgnoreAllClicked(): void {
- const quests = (QuestsStore.quests as QuestMap);
- const excludedQuests = (QuestsStore.excludedQuests as ExcludedQuestMap);
- const ignoredQuestsSet = new Set();
- const ignoredQuestIDs = getIgnoredQuestIDs();
-
- for (const quest of quests.values()) {
- const questID = quest.id;
- const questStatus = getQuestStatus(quest, false);
-
- if (questStatus === QuestStatus.Unclaimed || ignoredQuestIDs.includes(questID)) {
- ignoredQuestsSet.add(questID);
- }
- }
-
- for (const quest of excludedQuests.values()) {
- if (ignoredQuestIDs.includes(quest.id)) {
- ignoredQuestsSet.add(quest.id);
- }
- }
-
- setIgnoredQuestIDs(Array.from(ignoredQuestsSet));
- settings.store.unclaimedUnignoredQuests = 0;
+function getSettingsModalOpen(): boolean {
+ return settingsModalOpen;
}
-function showQuestsButton(questButtonDisplay: string, unclaimedUnignoredQuests: number, onQuestsPage: boolean): boolean {
- const canShow = questButtonDisplay !== "never";
- const alwaysShow = questButtonDisplay === "always";
- return canShow && (alwaysShow || !!unclaimedUnignoredQuests || onQuestsPage);
+export let initialQuestDataFetched = false;
+let isSwitchingAccount = false;
+let didAttemptAutoCompleteResume = false;
+const notifiedCompletedQuests = new Set();
+
+function setOnQuestsPage(force?: boolean): void {
+ settings.store.isOnQuestsPage = force ?? (window.location.pathname === QUEST_PAGE);
}
-export function QuestButton(): JSX.Element {
- const { questButtonDisplay, questButtonUnclaimed, questButtonBadgeColor, unclaimedUnignoredQuests, onQuestsPage } = settings.use(["questButtonDisplay", "questButtonUnclaimed", "questButtonBadgeColor", "unclaimedUnignoredQuests", "onQuestsPage"]);
- const questButtonBadgeColorRGB = questButtonBadgeColor === null ? null : decimalToRGB(questButtonBadgeColor);
+function startPerAccountTasks(source: string): void {
+ const startedAt = Date.now();
- function handleClick(event: React.MouseEvent) {
- // ListItem does not support onAuxClick, so we have to listen for mousedown events.
- // Ignore left and right clicks sent via mousedown events to prevent double events.
- if (event.type === "mousedown" && event.button !== middleClick) {
- return;
- }
+ setOnQuestsPage();
+ startAutoFetchingQuests();
+ resumeAutoCompletesIfReady();
+ fetchAndDispatchQuests();
- event.preventDefault();
- event.stopPropagation();
+ QL.info(`START_TASKS-${source.toUpperCase()}`, { startedAt });
+}
- let todo: string | null = null;
+function stopPerAccountTasks(source: string, preserveResume: boolean = true): void {
+ const stoppedAt = Date.now();
- if (event.button === middleClick) {
- todo = settings.store.questButtonMiddleClickAction;
- } else if (event.button === rightClick) {
- todo = settings.store.questButtonRightClickAction;
- } else if (event.button === leftClick) {
- todo = settings.store.questButtonLeftClickAction;
- }
+ setOnQuestsPage();
+ stopAutoFetchingQuests();
+ notifiedCompletedQuests.clear();
+ stopAllAutoCompletes({ manual: false, preserveResume, terminalHeartbeat: true });
- if (todo === "open-quests") {
- NavigationRouter.transitionTo(questPath);
- } else if (todo === "plugin-settings") {
- openPluginModal(plugins.Questify);
- } else if (todo === "context-menu") {
- ContextMenuApi.openContextMenu(event, () => (
-
-
-
- fetchAndAlertQuests("Questify-ManualFetch", QuestifyLogger)}
- />
-
- ));
- }
+ QL.info(`STOP_TASKS-${source.toUpperCase()}`, { stoppedAt });
+}
+
+function resumeAutoCompletesIfReady(): void {
+ if (didAttemptAutoCompleteResume || !initialQuestDataFetched) {
+ return;
}
- const lowerBadgeProps = {
- count: !["badge", "both"].includes(questButtonUnclaimed) ? 0 : unclaimedUnignoredQuests,
- maxDigits: 2,
- ...(questButtonBadgeColorRGB ? { color: `rgb(${questButtonBadgeColorRGB.r}, ${questButtonBadgeColorRGB.g}, ${questButtonBadgeColorRGB.b})` } : {}),
- ...(questButtonBadgeColorRGB ? { style: { color: isDarkish(questButtonBadgeColorRGB) ? "white" : "black" } } : {})
- };
+ didAttemptAutoCompleteResume = true;
+ resumeInterruptedAutoCompletes();
+}
+
+const Button = findComponentByCodeLazy("BUTTON_LOADING_STARTED_LABEL)),");
+
+function enrolledIncompleteButton(args: { quest: Quest, size: string; }): JSX.Element | null {
+ const props = getQuestButtonProps({ quest: args.quest });
+
+ if (!props) {
+ return null;
+ }
return (
-
+
+
+
);
}
-function shouldHideGiftInventoryRelocationNotice(): boolean {
- const {
- disableQuestsGiftInventoryRelocationNotice,
- disableQuestsEverything
- } = settings.use([
- "disableQuestsGiftInventoryRelocationNotice",
- "disableQuestsEverything"
- ]);
-
- return disableQuestsGiftInventoryRelocationNotice || disableQuestsEverything;
-}
-
-function shouldHideDiscoveryTab(): boolean {
- const {
- disableQuestsDiscoveryTab,
- disableQuestsEverything
- } = settings.use([
- "disableQuestsDiscoveryTab",
- "disableQuestsEverything"
- ]);
-
- return disableQuestsDiscoveryTab || disableQuestsEverything;
-}
-
-function shouldHideDirectMessagesTab(): boolean {
- const {
- disableQuestsDirectMessagesTab,
- disableQuestsEverything
- } = settings.use([
- "disableQuestsDirectMessagesTab",
- "disableQuestsEverything"
- ]);
-
- return disableQuestsDirectMessagesTab || disableQuestsEverything;
-}
-
-function shouldHideSponsoredQuestBanner(): boolean {
- const {
- disableQuestsPageSponsoredBanner,
- disableQuestsEverything
- } = settings.use([
- "disableQuestsPageSponsoredBanner",
- "disableQuestsEverything"
- ]);
-
- return disableQuestsPageSponsoredBanner || disableQuestsEverything;
-}
-
-function shouldHideBadgeOnUserProfiles(): boolean {
- const {
- disableQuestsBadgeOnUserProfiles,
- disableQuestsEverything
- } = settings.use([
- "disableQuestsBadgeOnUserProfiles",
- "disableQuestsEverything"
- ]);
-
- return disableQuestsBadgeOnUserProfiles || disableQuestsEverything;
-}
-
-function shouldHideQuestPopup(quest: Quest | null): boolean {
- const {
- disableQuestsPopupAboveAccountPanel,
- disableQuestsEverything
- } = settings.use([
- "disableQuestsPopupAboveAccountPanel",
- "disableQuestsEverything",
- "triggerQuestsRerender"
- ]);
-
- const noProgress = !quest?.userStatus?.progress || Object.keys(quest?.userStatus?.progress || {}).length === 0;
- return !quest || ((disableQuestsPopupAboveAccountPanel || disableQuestsEverything) && noProgress);
-}
-
-function shouldPreventFetchingQuests(): boolean {
- return settings.store.disableQuestsFetchingQuests || settings.store.disableQuestsEverything;
-}
-
-function shouldHideFriendsListActiveNowPromotion(): boolean {
- const {
- disableFriendsListActiveNowPromotion,
- disableQuestsEverything
- } = settings.use([
- "disableFriendsListActiveNowPromotion",
- "disableQuestsEverything"
- ]);
-
- return disableFriendsListActiveNowPromotion || disableQuestsEverything;
-}
-
-function shouldHideMembersListActivelyPlayingIcon(): boolean {
- const {
- disableMembersListActivelyPlayingIcon,
- disableQuestsEverything
- } = settings.use([
- "disableMembersListActivelyPlayingIcon",
- "disableQuestsEverything"
- ]);
-
- return disableMembersListActivelyPlayingIcon || disableQuestsEverything;
-}
-
-function QuestTileContextMenu(children: React.ReactNode[], props: { quest: any; }, isClaimedMenu: boolean = false): void {
- const isIgnored = questIsIgnored(props.quest.id);
- const isEnrolled = !!props.quest.userStatus?.enrolledAt;
- const canAutoComplete = !isClaimedMenu && isEnrolled && canQuestAutoComplete(props.quest);
-
- children.unshift((
-
- {!isClaimedMenu && (!isIgnored ? (
- { addIgnoredQuest(props.quest.id); }}
- />
- ) : (
- { removeIgnoredQuest(props.quest.id); }}
- />
- ))}
- {activeQuestIntervals.has(props.quest.id) ? (
- {
- const interval = activeQuestIntervals.get(props.quest.id);
-
- if (interval) {
- manuallyStoppedQuestIDs.add(props.quest.id);
- QuestifyLogger.info(`[${getFormattedNow()}] Auto-Complete for Quest ${normalizeQuestName(props.quest.config.messages.questName)} stopped via context menu.`);
- clearInterval(interval.progressTimeout);
- clearTimeout(interval.rerenderTimeout);
- activeQuestIntervals.delete(props.quest.id);
-
- if (interval.type === "play") {
- void reportPlayGameQuestProgress(refreshQuest(props.quest), true, QuestifyLogger, {
- attempts: 3,
- delay: 2500,
- });
- }
-
- resetQuestsToResume(props.quest);
- rerenderQuests();
- }
- }}
- />) : canAutoComplete ? (
- { processQuestForAutoComplete(props.quest, true); }}
- />) : null
- }
- { copyToClipboard(props.quest.id); }}
- />
-
- ));
-}
-
-export function getQuestTileClasses(originalClasses: string, quest: Quest, color: number | null | undefined, gradient: string | undefined): string {
- const {
- restyleQuestsUnclaimed,
- restyleQuestsClaimed,
- restyleQuestsIgnored,
- restyleQuestsExpired,
- restyleQuestsGradient
- } = settings.use([
- "ignoredQuestIDs",
- "ignoredQuestProfile",
- "restyleQuestsUnclaimed",
- "restyleQuestsClaimed",
- "restyleQuestsIgnored",
- "restyleQuestsExpired",
- "restyleQuestsGradient"
- ]);
-
- const customClasses = [
- 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")
- ];
-
- if (originalClasses.includes(q("dummy-quest"))) {
- return originalClasses;
- }
-
- const questStatus = getQuestStatus(quest);
- const baseClasses = originalClasses.split(" ").filter(cls => cls && !customClasses.includes(cls));
- const returnClasses: string[] = [...baseClasses];
- const hasColorOverride = color !== undefined;
- const skipColorCheck = hasColorOverride && color === null;
- let isRestyledAndDarkish: any = null;
-
- if (!skipColorCheck) {
- if (questStatus === QuestStatus.Claimed && (color || restyleQuestsClaimed !== null)) {
- returnClasses.push(q("quest-item-restyle"));
- isRestyledAndDarkish = isDarkish(decimalToRGB(color ?? restyleQuestsClaimed), 0.875);
- } else if (questStatus === QuestStatus.Unclaimed && (color || restyleQuestsUnclaimed !== null)) {
- returnClasses.push(q("quest-item-restyle"));
- isRestyledAndDarkish = isDarkish(decimalToRGB(color ?? restyleQuestsUnclaimed), 0.875);
- } else if (questStatus === QuestStatus.Expired && (color || restyleQuestsExpired !== null)) {
- returnClasses.push(q("quest-item-restyle"));
- isRestyledAndDarkish = isDarkish(decimalToRGB(color ?? restyleQuestsExpired), 0.875);
- } else if (questStatus === QuestStatus.Ignored && (color || restyleQuestsIgnored !== null)) {
- returnClasses.push(q("quest-item-restyle"));
- isRestyledAndDarkish = isDarkish(decimalToRGB(color ?? restyleQuestsIgnored), 0.875);
- }
- }
-
- if (isRestyledAndDarkish !== null) {
- if ((gradient || restyleQuestsGradient) === "black") {
- returnClasses.push(q("quest-item-black-gradient"));
- } else if ((gradient || restyleQuestsGradient) === "hide") {
- returnClasses.push(q("quest-item-hide-gradient"));
- } else {
- if ((gradient || restyleQuestsGradient) === "default") {
- returnClasses.push(q("quest-item-default-gradient"));
- } else {
- returnClasses.push(q("quest-item-intense-gradient"));
- }
-
- if (!isRestyledAndDarkish) {
- returnClasses.push(q("quest-item-contrast-logo"));
- }
- }
- }
-
- return returnClasses.join(" ");
-}
-
-function makeDesktopCompatible(quests: Quest[]): void {
- const { makeMobileQuestsDesktopCompatible } = settings.use(["makeMobileQuestsDesktopCompatible", "triggerQuestsRerender"]);
-
- if (makeMobileQuestsDesktopCompatible) {
- quests.forEach(quest => {
- const config = quest.config?.taskConfigV2;
- const tasks = config?.tasks;
-
- if (tasks?.WATCH_VIDEO_ON_MOBILE && (!tasks?.WATCH_VIDEO || patchedMobileQuests.has(quest.id))) {
- patchedMobileQuests.add(quest.id);
-
- tasks.WATCH_VIDEO = {
- ...tasks.WATCH_VIDEO_ON_MOBILE,
- type: QuestTaskType.WATCH_VIDEO
- };
- }
- });
- } else if (patchedMobileQuests.size > 0) {
- patchedMobileQuests.forEach(questId => {
- const quest = quests.find(q => q.id === questId);
- const config = quest?.config?.taskConfigV2;
-
- if (config) {
- delete config.tasks.WATCH_VIDEO;
- }
- });
-
- patchedMobileQuests.clear();
- }
-}
-
-function sortQuests(quests: Quest[], skip?: boolean): Quest[] {
- const {
- reorderQuests,
- unclaimedSubsort,
- claimedSubsort,
- ignoredSubsort,
- expiredSubsort,
- } = settings.use([
- "ignoredQuestIDs",
- "ignoredQuestProfile",
- "reorderQuests",
- "unclaimedSubsort",
- "claimedSubsort",
- "ignoredSubsort",
- "expiredSubsort",
- "completeVideoQuestsInBackground",
- "completeGameQuestsInBackground",
- "completeAchievementQuestsInBackground",
- "triggerQuestsRerender"
- ]);
-
- makeDesktopCompatible(quests);
-
- if (skip || !reorderQuests?.trim()) {
- return quests;
- }
-
- const orderList = reorderQuests.split(",").map(q => q.trim().toLowerCase());
-
- const questGroups: { [key: string]: Quest[]; } = {
- claimed: [],
- expired: [],
- ignored: [],
- unclaimed: [],
- unknown: []
- };
-
- quests.forEach(quest => {
- const questStatus = getQuestStatus(quest);
-
- if (questStatus === QuestStatus.Claimed) {
- questGroups.claimed.push(quest);
- } else if (questStatus === QuestStatus.Unclaimed) {
- questGroups.unclaimed.push(quest);
- } else if (questStatus === QuestStatus.Expired) {
- questGroups.expired.push(quest);
- } else if (questStatus === QuestStatus.Ignored) {
- questGroups.ignored.push(quest);
- } else {
- questGroups.unknown.push(quest);
- }
- });
-
- const createSortFunction = (subsort: string) => {
- switch (subsort) {
- case "Recent ASC":
- return (a: Quest, b: Quest) => new Date(a.config.startsAt).getTime() - new Date(b.config.startsAt).getTime();
- case "Recent DESC":
- return (a: Quest, b: Quest) => new Date(b.config.startsAt).getTime() - new Date(a.config.startsAt).getTime();
- case "Expiring ASC":
- return (a: Quest, b: Quest) => new Date(a.config.expiresAt).getTime() - new Date(b.config.expiresAt).getTime();
- case "Expiring DESC":
- return (a: Quest, b: Quest) => new Date(b.config.expiresAt).getTime() - new Date(a.config.expiresAt).getTime();
- case "Claimed ASC":
- return (a: Quest, b: Quest) => new Date(a.userStatus?.claimedAt || 0).getTime() - new Date(b.userStatus?.claimedAt || 0).getTime();
- case "Claimed DESC":
- return (a: Quest, b: Quest) => new Date(b.userStatus?.claimedAt || 0).getTime() - new Date(a.userStatus?.claimedAt || 0).getTime();
- default:
- return (a: Quest, b: Quest) => new Date(b.config.startsAt).getTime() - new Date(a.config.startsAt).getTime();
- }
- };
-
- const unclaimedSortFunction = createSortFunction(unclaimedSubsort || "Recent DESC");
-
- // Divide unclaimed Quests by completion status before applying subsort.
- questGroups.unclaimed.sort((a: Quest, b: Quest) => {
- 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(claimedSubsort || "Claimed DESC"));
- questGroups.ignored.sort(createSortFunction(ignoredSubsort || "Recent DESC"));
- questGroups.expired.sort(createSortFunction(expiredSubsort || "Expiring DESC"));
-
- const sortedQuests: Quest[] = [];
-
- orderList.forEach(status => {
- if (questGroups[status]) {
- sortedQuests.push(...questGroups[status]);
- }
- });
-
- Object.keys(questGroups).forEach(status => {
- if (!orderList.includes(status)) {
- sortedQuests.push(...questGroups[status]);
- }
- });
-
- return sortedQuests;
-}
-
-export function getQuestTileStyle(quest: Quest | null): Record {
- settings.use([
- "restyleQuests",
- "ignoredQuestIDs",
- "ignoredQuestProfile"
- ]);
-
- const style: Record = {};
- let themeColor: RGB | null = null;
-
- const restyleUnclaimed = settings.store.restyleQuestsUnclaimed;
- const restyleClaimed = settings.store.restyleQuestsClaimed;
- const restyleIgnored = settings.store.restyleQuestsIgnored;
- const restyleExpired = settings.store.restyleQuestsExpired;
-
- const claimedColor = restyleClaimed !== null ? decimalToRGB(restyleClaimed) : "";
- const unclaimedColor = restyleUnclaimed !== null ? decimalToRGB(restyleUnclaimed) : "";
- const ignoredColor = restyleIgnored !== null ? decimalToRGB(restyleIgnored) : "";
- const expiredColor = restyleExpired !== null ? decimalToRGB(restyleExpired) : "";
- const dummyProvided = quest?.dummyColor !== undefined;
- const dummyColor = (quest?.dummyColor !== undefined && quest?.dummyColor !== null) ? decimalToRGB(quest.dummyColor) : null;
- const questStatus = quest ? getQuestStatus(quest) : null;
-
- if (questStatus === QuestStatus.Claimed) {
- themeColor = dummyProvided ? dummyColor : claimedColor || null;
- } else if (questStatus === QuestStatus.Unclaimed) {
- themeColor = dummyProvided ? dummyColor : unclaimedColor || null;
- } else if (questStatus === QuestStatus.Expired) {
- themeColor = dummyProvided ? dummyColor : expiredColor || null;
- } else if (questStatus === QuestStatus.Ignored) {
- themeColor = dummyProvided ? dummyColor : ignoredColor || null;
- }
-
- if (!themeColor) return style;
-
- const darkish = isDarkish(themeColor);
- const sign = darkish ? 1 : -1;
- const questNameColor = adjustRGB(themeColor, 200 * sign);
- const rewardTitleColor = adjustRGB(themeColor, 150 * sign);
- const rewardDescriptionColor = adjustRGB(themeColor, 100 * sign);
- const buttonNormalColor = adjustRGB(themeColor, 50 * sign);
- const buttonHoverColor = adjustRGB(themeColor, 75 * sign);
-
- style["--questify-color"] = `rgb(${themeColor.r}, ${themeColor.g}, ${themeColor.b})`;
- style["--questify-quest-name"] = `rgb(${questNameColor.r}, ${questNameColor.g}, ${questNameColor.b})`;
- style["--questify-reward-title"] = `rgb(${rewardTitleColor.r}, ${rewardTitleColor.g}, ${rewardTitleColor.b})`;
- style["--questify-reward-description"] = `rgb(${rewardDescriptionColor.r}, ${rewardDescriptionColor.g}, ${rewardDescriptionColor.b})`;
- style["--questify-button-normal"] = `rgb(${buttonNormalColor.r}, ${buttonNormalColor.g}, ${buttonNormalColor.b})`;
- style["--questify-button-hover"] = `rgb(${buttonHoverColor.r}, ${buttonHoverColor.g}, ${buttonHoverColor.b})`;
-
- return style;
-}
-
-function shouldPreloadQuestAssets(): boolean {
- const { restyleQuestsPreload } = settings.use(["restyleQuestsPreload"]);
- return restyleQuestsPreload;
-}
-
-async function startVideoProgressTracking(quest: Quest, target: { raw: number; adjusted: number; }): Promise {
- quest = refreshQuest(quest);
- const questName = normalizeQuestName(quest.config.messages.questName);
- const task = getQuestTask(quest);
- const { completeVideoQuestsQuicker } = settings.store;
- const questEnrolledAt = quest.userStatus?.enrolledAt ? new Date(quest.userStatus.enrolledAt) : null;
- const initialProgress = completeVideoQuestsQuicker
- ? Number(Math.max(1, ((new Date()).getTime() - (questEnrolledAt ?? new Date()).getTime()) / 1000).toFixed(6))
- : Math.max(0, getQuestProgress(quest, task) || 0);
- activeQuestIntervals.set(quest.id, { progressTimeout: null as any, rerenderTimeout: null as any, progress: initialProgress, type: "watch" });
- // Max up to ~25 seconds into the future can be reported.
- const { raw: reportTarget, adjusted: questTargetWithLeeway } = target;
- let currentProgress = initialProgress;
- let currentProgressScaled = initialProgress;
- const timeRemaining = Math.max(0, questTargetWithLeeway - currentProgressScaled);
-
- QuestifyLogger.info(`[${getFormattedNow()}] Quest ${questName} will be completed in the background in ${timeRemaining} seconds.`);
-
- if (!questEnrolledAt) {
- const enrollmentTimeout = 60000;
- const enrolled = await waitUntilEnrolled(quest, enrollmentTimeout, 15, QuestifyLogger);
- quest = refreshQuest(quest);
-
- if (!enrolled) {
- QuestifyLogger.warn(`[${getFormattedNow()}] Quest ${questName} not enrolled within ${enrollmentTimeout / 1000} seconds.`);
- activeQuestIntervals.delete(quest.id);
- return;
- }
- }
-
- if (!activeQuestIntervals.has(quest.id)) {
- return;
- }
-
- const playbackTickMs = 250;
- const minimumReportDelta = 6;
- const maximumReportDelta = 8;
- const floatPrecision = 6;
- let progressIntervalId: NodeJS.Timeout | null = null;
- let renderIntervalId: NodeJS.Timeout | null = null;
- let maximumPlaybackTimestamp = Math.floor(initialProgress);
- let nextReportThreshold = 0;
- let hasReportedInitialProgress = currentProgressScaled <= 0;
- let lastPlaybackTickAt = performance.now();
-
- function clampFloat(value: number): number {
- return Number(value.toFixed(floatPrecision));
- }
-
- function randomBetween(min: number, max: number): number {
- return clampFloat(min + Math.random() * (max - min));
- }
-
- function getNextReportThreshold(currentPlaybackPosition: number): number {
- return clampFloat(currentPlaybackPosition + randomBetween(minimumReportDelta, maximumReportDelta));
- }
-
- function getProgressToReport(currentPlaybackPosition: number): number {
- const cappedProgress = clampFloat(Math.min(reportTarget, currentPlaybackPosition));
- const lowerBound = nextReportThreshold > 0
- ? nextReportThreshold
- : clampFloat(Math.min(cappedProgress, 0.05));
- const overshootAvailable = clampFloat(Math.max(0, cappedProgress - lowerBound));
-
- return overshootAvailable > 0
- ? clampFloat(lowerBound + randomBetween(0, overshootAvailable))
- : lowerBound;
- }
-
- function updateQuestIntervalProgress(progress: number): void {
- const intervalData = activeQuestIntervals.get(quest.id);
-
- if (intervalData) {
- intervalData.progress = Math.floor(progress);
- }
- }
-
- function clearTrackingTimers(): void {
- if (progressIntervalId) {
- clearTimeout(progressIntervalId);
- }
-
- if (renderIntervalId) {
- clearInterval(renderIntervalId);
- }
- }
-
- async function handleSendComplete() {
- clearTrackingTimers();
- const success = await reportVideoQuestProgress(quest, maximumPlaybackTimestamp, QuestifyLogger);
- activeQuestIntervals.delete(quest.id);
- resetQuestsToResume(quest);
-
- if (success) {
- QuestifyLogger.info(`[${getFormattedNow()}] Quest ${questName} completed.`);
-
- if (settings.store.notifyOnQuestComplete) {
- showNotification({
- title: "Quest Completed!",
- body: `The ${questName} Quest has completed.`,
- dismissOnClick: true,
- onClick: () => NavigationRouter.transitionTo(`${questPath}#${quest.id}`)
- });
- }
- } else {
- QuestifyLogger.error(`[${getFormattedNow()}] Failed to complete Quest ${questName}.`);
- }
- }
-
- const simulatedProgressToCover = reportTarget - currentProgressScaled;
- const speedFactor = timeRemaining > 0 ? simulatedProgressToCover / timeRemaining : 0;
-
- async function scheduleNextPlaybackTick(): Promise {
- progressIntervalId = setTimeout(async () => {
- if (!activeQuestIntervals.has(quest.id)) {
- return;
- }
-
- const now = performance.now();
- const playbackElapsedSeconds = clampFloat((now - lastPlaybackTickAt) / 1000);
- lastPlaybackTickAt = now;
-
- currentProgress = clampFloat(Math.min(questTargetWithLeeway, currentProgress + playbackElapsedSeconds));
- currentProgressScaled = clampFloat(Math.min(reportTarget, currentProgressScaled + playbackElapsedSeconds * speedFactor));
- maximumPlaybackTimestamp = Math.max(maximumPlaybackTimestamp, Math.floor(currentProgressScaled));
- updateQuestIntervalProgress(currentProgress);
-
- if (hasReportedInitialProgress && currentProgressScaled >= nextReportThreshold && currentProgress < questTargetWithLeeway) {
- const progressToReport = getProgressToReport(currentProgressScaled);
- const reported = await reportVideoQuestProgress(quest, progressToReport, QuestifyLogger);
-
- if (!reported) {
- clearTrackingTimers();
- activeQuestIntervals.delete(quest.id);
- QuestifyLogger.error(`[${getFormattedNow()}] Failed to report progress for Quest ${questName}.`);
- return;
- }
-
- nextReportThreshold = getNextReportThreshold(progressToReport);
- }
-
- if (currentProgress >= questTargetWithLeeway) {
- await handleSendComplete();
- return;
- }
-
- await scheduleNextPlaybackTick();
- }, playbackTickMs);
-
- const intervalData = activeQuestIntervals.get(quest.id);
-
- if (intervalData && progressIntervalId) {
- intervalData.progressTimeout = progressIntervalId;
- }
- }
-
- updateQuestIntervalProgress(currentProgress);
- renderIntervalId = setInterval(() => rerenderQuests(), 1000);
-
- const intervalData = activeQuestIntervals.get(quest.id);
-
- if (intervalData) {
- intervalData.rerenderTimeout = renderIntervalId;
- }
-
- if (timeRemaining <= 0) {
- await handleSendComplete();
- return;
- }
-
- if (currentProgressScaled > 0) {
- const initialProgressToReport = clampFloat(Math.min(reportTarget, currentProgressScaled));
-
- void (async () => {
- const reported = await reportVideoQuestProgress(quest, initialProgressToReport, QuestifyLogger);
-
- if (!activeQuestIntervals.has(quest.id)) {
- return;
- }
-
- if (!reported) {
- clearTrackingTimers();
- activeQuestIntervals.delete(quest.id);
- QuestifyLogger.error(`[${getFormattedNow()}] Failed to report initial progress for Quest ${questName}.`);
- return;
- }
-
- hasReportedInitialProgress = true;
- nextReportThreshold = getNextReportThreshold(initialProgressToReport);
- })();
- }
-
- lastPlaybackTickAt = performance.now();
-
- await scheduleNextPlaybackTick();
-}
-
-async function startPlayGameProgressTracking(quest: Quest, target: { raw: number; adjusted: number; }): Promise {
- quest = refreshQuest(quest);
- const questName = normalizeQuestName(quest.config.messages.questName);
- const questEnrolledAt = quest.userStatus?.enrolledAt ? new Date(quest.userStatus.enrolledAt) : null;
- const task = getQuestTask(quest);
- const questTarget = target.adjusted;
- const initialProgress = getQuestProgress(quest, task) || 0;
- const remaining = Math.max(0, questTarget - initialProgress);
- const maximumHeartbeatDurationMs = 60 * 1000;
- const heartbeatBufferMs = 1 * 1000;
- activeQuestIntervals.set(quest.id, { progressTimeout: null as any, rerenderTimeout: null as any, progress: initialProgress, type: "play" });
-
- QuestifyLogger.info(`[${getFormattedNow()}] Quest ${questName} will be completed in the background in ${remaining} seconds.`);
-
- if (!questEnrolledAt) {
- const enrollmentTimeout = 60000;
- const enrolled = await waitUntilEnrolled(quest, enrollmentTimeout, 500, QuestifyLogger);
- quest = refreshQuest(quest);
-
- if (!enrolled) {
- QuestifyLogger.warn(`[${getFormattedNow()}] Quest ${questName} not enrolled after waiting for ${enrollmentTimeout / 1000} seconds.`);
- activeQuestIntervals.delete(quest.id);
- return;
- }
- }
-
- if (!activeQuestIntervals.has(quest.id)) {
- return;
- }
-
- let progressTimeoutId: NodeJS.Timeout | null = null;
- let renderIntervalId: NodeJS.Timeout | null = null;
-
- function getHeartbeatDurationMs(progress: number): number {
- const remainingMs = Math.max(0, (questTarget - progress) * 1000);
-
- return remainingMs <= maximumHeartbeatDurationMs
- ? remainingMs + heartbeatBufferMs
- : maximumHeartbeatDurationMs;
- }
-
- function clearTrackingTimers(): void {
- if (progressTimeoutId) {
- clearTimeout(progressTimeoutId);
- }
-
- if (renderIntervalId) {
- clearTimeout(renderIntervalId);
- }
- }
-
- async function sendTerminalHeartbeat(): Promise {
- await reportPlayGameQuestProgress(refreshQuest(quest), true, QuestifyLogger, {
- attempts: 3,
- delay: 2500,
- });
- }
-
- async function handleQuestComplete(): Promise {
- clearTrackingTimers();
- activeQuestIntervals.delete(quest.id);
-
- await sendTerminalHeartbeat();
- QuestifyLogger.info(`[${getFormattedNow()}] Quest ${questName} completed.`);
-
- if (settings.store.notifyOnQuestComplete) {
- showNotification({
- title: "Quest Completed!",
- body: `The ${questName} Quest has completed.`,
- dismissOnClick: true,
- onClick: () => NavigationRouter.transitionTo(`${questPath}#${quest.id}`),
- });
- }
- }
-
- async function scheduleNextHeartbeat(progress: number): Promise {
- const heartbeatDurationMs = getHeartbeatDurationMs(progress);
-
- progressTimeoutId = setTimeout(async () => {
- const result = await reportPlayGameQuestProgress(quest, false, QuestifyLogger, { attempts: 3, delay: 2500 });
-
- if (result.progress === null) {
- clearTrackingTimers();
- activeQuestIntervals.delete(quest.id);
- QuestifyLogger.error(`[${getFormattedNow()}] Failed to send heartbeat for Quest ${questName}.`);
- return;
- }
-
- const intervalData = activeQuestIntervals.get(quest.id);
-
- if (intervalData) {
- intervalData.progress = result.progress;
- }
-
- if (result.completed || result.progress >= questTarget) {
- await handleQuestComplete();
- return;
- }
-
- await scheduleNextHeartbeat(result.progress);
- }, heartbeatDurationMs);
-
- const intervalData = activeQuestIntervals.get(quest.id);
-
- if (intervalData && progressTimeoutId) {
- intervalData.progressTimeout = progressTimeoutId;
- }
- }
-
- const initial = await reportPlayGameQuestProgress(quest, false, QuestifyLogger, { attempts: 3, delay: 2500 });
-
- if (initial.progress === null) {
- activeQuestIntervals.delete(quest.id);
- QuestifyLogger.error(`[${getFormattedNow()}] Failed to send heartbeat for Quest ${questName}.`);
- return;
- }
-
- renderIntervalId = setInterval(() => {
- const intervalData = activeQuestIntervals.get(quest.id);
-
- if (!!intervalData) {
- intervalData.progress += 1;
- } else {
- if (renderIntervalId) {
- clearInterval(renderIntervalId);
- }
- }
-
- rerenderQuests();
- }, 1000);
-
- const intervalData = activeQuestIntervals.get(quest.id);
-
- if (intervalData) {
- intervalData.progress = initial.progress || initialProgress;
- intervalData.rerenderTimeout = renderIntervalId;
- }
-
- if (initial.completed || initial.progress >= questTarget) {
- await handleQuestComplete();
- return;
- }
-
- await scheduleNextHeartbeat(initial.progress);
-}
-
-async function startAchievementActivityProgressTracking(quest: Quest, target: { raw: number; adjusted: number; }): Promise {
- const questName = normalizeQuestName(quest.config.messages.questName);
- const questEnrolledAt = quest.userStatus?.enrolledAt ? new Date(quest.userStatus.enrolledAt) : null;
- const achievementType = quest.config.taskConfigV2?.tasks.ACHIEVEMENT_IN_ACTIVITY;
- activeQuestIntervals.set(quest.id, { progressTimeout: null as any, rerenderTimeout: null as any, progress: 0, type: "achievement" });
-
- QuestifyLogger.info(`[${getFormattedNow()}] Quest ${questName} will be completed in the background.`);
-
- if (!questEnrolledAt) {
- const enrollmentTimeout = 60000;
- const enrolled = await waitUntilEnrolled(quest, enrollmentTimeout, 15, QuestifyLogger);
- quest = refreshQuest(quest);
-
- if (!enrolled) {
- QuestifyLogger.warn(`[${getFormattedNow()}] Quest ${questName} not enrolled within ${enrollmentTimeout / 1000} seconds.`);
- activeQuestIntervals.delete(quest.id);
- return;
- }
- }
-
- if (!activeQuestIntervals.has(quest.id)) {
- return;
- }
-
- const appID = achievementType?.applications?.[0].id;
-
- if (!appID) {
- QuestifyLogger.warn(`[${getFormattedNow()}] Could not find application ID for Quest ${questName}.`);
- activeQuestIntervals.delete(quest.id);
- return;
- }
-
- let authCode: string | null = null;
-
- try {
- const response = await RestAPI.post({
- url: `/oauth2/authorize?client_id=${appID}&response_type=code&scope=identify%20applications.entitlements&state=`,
- body: { authorize: true }
- });
-
- const location = response?.body?.location;
-
- if (location) {
- authCode = new URL(location).searchParams.get("code");
- }
-
- if (!authCode) {
- QuestifyLogger.warn(`[${getFormattedNow()}] Auth code not found for Quest ${questName}.`);
- activeQuestIntervals.delete(quest.id);
- return;
- }
- } catch (error) {
- QuestifyLogger.error(`[${getFormattedNow()}] Failed to retrieve auth code for Quest ${questName}:`, error);
- activeQuestIntervals.delete(quest.id);
- return;
- }
-
- const result = await QuestifyNative.complete(appID, authCode!, target.adjusted);
- activeQuestIntervals.delete(quest.id);
-
- if (!result.success) {
- const errorReason = result.error || "An error occurred while completing the Quest.";
- QuestifyLogger.error(`[${getFormattedNow()}] Failed to complete Quest ${questName}:`, errorReason);
- } else {
- QuestifyLogger.info(`[${getFormattedNow()}] Quest ${questName} completed.`);
-
- if (settings.store.notifyOnQuestComplete) {
- showNotification({
- title: "Quest Completed!",
- body: `The ${questName} Quest has completed.`,
- dismissOnClick: true,
- onClick: () => NavigationRouter.transitionTo(`${questPath}#${quest.id}`),
- });
- }
- }
-
- try {
- const deauthToken = AuthorizedAppsStore.getNewestTokenForApplication(appID)?.id;
-
- if (!deauthToken) {
- throw new Error("Deauthorization token not found.");
- }
-
- await RestAPI.del({
- url: `/oauth2/tokens/${deauthToken}/`,
- });
- } catch (error) {
- QuestifyLogger.error(`[${getFormattedNow()}] Failed to deauthorize application for Quest ${questName}:`, error);
- }
-}
-
-function canQuestAutoComplete(quest: Quest): boolean {
- const { completeVideoQuestsInBackground, completeGameQuestsInBackground, completeAchievementQuestsInBackground } = settings.store;
-
- const task = getQuestTask(quest);
-
- if (!task) { return false; }
-
- const questStatus = getQuestStatus(quest);
- const questCompleted = !!quest.userStatus?.completedAt;
-
- if (questStatus !== QuestStatus.Unclaimed || questCompleted) {
- return false;
- }
-
- const isWatch = task.type === QuestTaskType.WATCH_VIDEO || task.type === QuestTaskType.WATCH_VIDEO_ON_MOBILE;
- const isPlay = task.type === QuestTaskType.PLAY_ON_DESKTOP || task.type === QuestTaskType.PLAY_ON_XBOX || task.type === QuestTaskType.PLAY_ON_PLAYSTATION || task.type === QuestTaskType.PLAY_ACTIVITY;
- const isAchievement = task.type === QuestTaskType.ACHIEVEMENT_IN_ACTIVITY;
-
- const watchTypeCompatible = isWatch && completeVideoQuestsInBackground;
- const playTypeCompatible = isPlay && completeGameQuestsInBackground && IS_DISCORD_DESKTOP;
- const achievementTypeCompatible = isAchievement && completeAchievementQuestsInBackground && IS_DISCORD_DESKTOP;
-
- if (watchTypeCompatible || playTypeCompatible || achievementTypeCompatible) {
- return true;
- }
-
- return false;
-}
-
-function processQuestForAutoComplete(quest: Quest, force: boolean = false): boolean {
- const questName = normalizeQuestName(quest.config.messages.questName);
-
- const task = getQuestTask(quest);
- const questTarget = getQuestTarget(task);
- const existingInterval = activeQuestIntervals.get(quest.id);
-
- const isWatch = task?.type === QuestTaskType.WATCH_VIDEO || task?.type === QuestTaskType.WATCH_VIDEO_ON_MOBILE;
- const isPlay = task?.type === QuestTaskType.PLAY_ON_DESKTOP || task?.type === QuestTaskType.PLAY_ON_XBOX || task?.type === QuestTaskType.PLAY_ON_PLAYSTATION || task?.type === QuestTaskType.PLAY_ACTIVITY;
- const isAchievement = task?.type === QuestTaskType.ACHIEVEMENT_IN_ACTIVITY;
- const canAutoComplete = canQuestAutoComplete(quest);
-
- if (force) {
- manuallyStoppedQuestIDs.delete(quest.id);
- } else if (manuallyStoppedQuestIDs.has(quest.id)) {
- return false;
- }
-
- if (quest.userStatus?.completedAt || existingInterval) {
- return false;
- } else if (!task) {
- QuestifyLogger.warn(`[${getFormattedNow()}] Could not recognize the Quest type for ${questName}.`);
- return false;
- } else if (!canAutoComplete) {
- return false;
- } else if (isWatch) {
- startVideoProgressTracking(quest, questTarget);
- return true;
- } else if (isPlay) {
- startPlayGameProgressTracking(quest, questTarget);
- return true;
- } else if (isAchievement) {
- startAchievementActivityProgressTracking(quest, questTarget);
- return true;
- }
-
- return false;
-}
-
-function shouldDisableQuestAcceptedButton(quest: Quest): boolean | null {
- const { completeGameQuestsInBackground } = settings.store;
-
- if (activeQuestIntervals.has(quest.id)) {
- return true;
- } else if (completeGameQuestsInBackground) {
- if (IS_DISCORD_DESKTOP) {
- return false;
- }
- }
-
- return null;
-}
-
-function getQuestUnacceptedButtonText(quest: Quest): string | null {
- const { completeGameQuestsInBackground, completeVideoQuestsInBackground, completeAchievementQuestsInBackground } = settings.store;
-
- if (activeQuestIntervals.has(quest.id) || !!quest.userStatus?.enrolledAt) {
- return null;
- }
-
- const task = getQuestTask(quest);
- const { adjusted: target } = getQuestTarget(task);
- const targetFormatted = `${String(Math.floor(target / 60)).padStart(2, "0")}:${String(target % 60).padStart(2, "0")}`;
-
- const isWatch = task?.type === QuestTaskType.WATCH_VIDEO || task?.type === QuestTaskType.WATCH_VIDEO_ON_MOBILE;
- const isPlay = task?.type === QuestTaskType.PLAY_ON_DESKTOP || task?.type === QuestTaskType.PLAY_ON_XBOX || task?.type === QuestTaskType.PLAY_ON_PLAYSTATION || task?.type === QuestTaskType.PLAY_ACTIVITY;
- const isAchievement = task?.type === QuestTaskType.ACHIEVEMENT_IN_ACTIVITY;
-
- if ((isPlay && completeGameQuestsInBackground && IS_DISCORD_DESKTOP) || (isWatch && completeVideoQuestsInBackground && target > 0)) {
- return `Complete (${targetFormatted})`;
- } else if ((isAchievement && completeAchievementQuestsInBackground) || (isWatch && completeVideoQuestsInBackground && target === 0)) {
- return "Complete (Immediate)";
- }
-
- return null;
-}
-
-function getQuestAcceptedButtonText(quest: Quest, prepositional: boolean = false): string | null {
- const { completeVideoQuestsQuicker, completeGameQuestsInBackground, completeVideoQuestsInBackground, completeAchievementQuestsInBackground } = settings.store;
-
- quest = refreshQuest(quest);
- const task = getQuestTask(quest);
- const intervalData = activeQuestIntervals.get(quest.id);
- const questEnrolledAt = quest.userStatus?.enrolledAt ? new Date(quest.userStatus.enrolledAt) : null;
-
- const isWatch = task?.type === QuestTaskType.WATCH_VIDEO || task?.type === QuestTaskType.WATCH_VIDEO_ON_MOBILE;
- const isPlay = task?.type === QuestTaskType.PLAY_ON_DESKTOP || task?.type === QuestTaskType.PLAY_ON_XBOX || task?.type === QuestTaskType.PLAY_ON_PLAYSTATION || task?.type === QuestTaskType.PLAY_ACTIVITY;
- const isAchievement = task?.type === QuestTaskType.ACHIEVEMENT_IN_ACTIVITY;
-
- if (questEnrolledAt) {
- if (((isPlay && completeGameQuestsInBackground && IS_DISCORD_DESKTOP) || (isWatch && completeVideoQuestsInBackground))) {
- const { adjusted: durationWithLeeway } = getQuestTarget(task);
- const currentProgress = getQuestProgress(quest, task) ?? 0;
- const progress = Math.min(currentProgress, durationWithLeeway);
- const timeRemaining = Math.max(0, durationWithLeeway - progress);
- const canCompleteImmediately = isWatch && (
- completeVideoQuestsQuicker
- ? !!questEnrolledAt && ((new Date().getTime() - questEnrolledAt.getTime()) / 1000) >= durationWithLeeway
- : !timeRemaining
- );
- const progressFormatted = `${String(Math.floor(timeRemaining / 60)).padStart(2, "0")}:${String(timeRemaining % 60).padStart(2, "0")}`;
- const progressFormattedAsPreposition = timeRemaining >= 60
- ? `${Math.floor(timeRemaining / 60)}m ${timeRemaining % 60}s`
- : `${timeRemaining % 60}s`;
-
- if (!!intervalData) {
- return canCompleteImmediately || !timeRemaining
- ? "Completing..."
- : `Completing ${prepositional ? `in ${progressFormattedAsPreposition}` : `(${progressFormatted})`}`;
- } else if (isWatch || isPlay) {
- return canCompleteImmediately ? "Complete (Immediate)" : timeRemaining === durationWithLeeway ? `Complete (${progressFormatted})` : `Resume (${progressFormatted})`;
- }
- } else if (isAchievement && completeAchievementQuestsInBackground) {
- if (!!intervalData) {
- return "Completing...";
- } else {
- return "Complete (Immediate)";
- }
- }
- }
-
- return null;
-}
-
-function getQuestPanelPercentComplete({ quest, percentCompleteText }: { quest: Quest; percentCompleteText?: string; }): { percentComplete: number; } | { percentComplete: number; percentCompleteText: string; } | null {
- if (!quest) { return null; }
-
- quest = refreshQuest(quest);
- const task = getQuestTask(quest);
-
- if (!task) { return null; }
-
- const { adjusted: questTarget } = getQuestTarget(task);
- const questProgress = getQuestProgress(quest, task);
-
- if (!questTarget || questProgress === null) { return null; }
-
- const decimal = Math.min(1, questProgress / questTarget);
-
- return percentCompleteText ? {
- percentComplete: decimal,
- percentCompleteText: `${Math.floor(decimal * 100)}%`
- } : {
- percentComplete: decimal
- };
-}
-
-function getQuestPanelSubtitleText(quest: Quest): string | null {
- const questStatus = getQuestStatus(quest);
- const questCompleted = !!quest.userStatus?.completedAt && questStatus === QuestStatus.Unclaimed;
- const intervalData = activeQuestIntervals.get(quest.id);
- const rewardItem = quest.config.rewardsConfig.rewards[0] || null;
- const completingText = getQuestAcceptedButtonText(quest, true) || "Completing";
- const completedText = questCompleted ? "Completed" : null;
-
- if (!intervalData && !completedText) {
- return null;
- }
-
- if (rewardItem?.orbQuantity) {
- return `${completedText || completingText} for ${rewardItem.orbQuantity} Orbs.`;
- } else if (rewardItem?.messages?.nameWithArticle) {
- return `${completedText || completingText} for ${rewardItem.messages.nameWithArticle}.`;
- } else {
- return `${completedText || completingText} for an unrecognized reward.`;
- }
-}
-
-function getQuestPanelTitleText(quest: Quest): string | null {
- return normalizeQuestName(quest.config.messages.questName);
-}
-
-function getQuestPanelOverride(): Quest | null {
- settings.use(["triggerQuestsRerender"]);
- let closestQuest: Quest | null = null;
- let closestTimeRemaining = Infinity;
-
- activeQuestIntervals.forEach((interval, questId) => {
- const quest = QuestsStore.getQuest(questId);
-
- if (!quest) {
- return;
- }
-
- const task = getQuestTask(quest);
- const { adjusted: duration } = getQuestTarget(task);
-
- if (!duration) {
- return;
- }
-
- const timeRemaining = duration - interval.progress;
-
- // 3 second buffer to account for per-second rerendering
- // which could cause flickering if multiple Quests were
- // started at the same time.
- if (timeRemaining < (closestTimeRemaining - 3)) {
- closestTimeRemaining = timeRemaining;
- closestQuest = quest;
- }
- });
-
- if (!closestQuest) {
- const completedUnclaimedQuests = (Array.from(QuestsStore.quests.values()) as Quest[])
- .filter(q => q.userStatus?.completedAt && getQuestStatus(q) === QuestStatus.Unclaimed)
- .sort((a, b) => {
- const aTime = new Date(a.userStatus?.completedAt as string).getTime();
- const bTime = new Date(b.userStatus?.completedAt as string).getTime();
- return bTime - aTime;
- });
-
- closestQuest = completedUnclaimedQuests[0] ?? null;
- }
-
- return closestQuest;
-}
-
-function disguiseHomeButton(location: string): boolean {
- const { questButtonDisplay, unclaimedUnignoredQuests, onQuestsPage } = settings.use(["questButtonDisplay", "unclaimedUnignoredQuests", "onQuestsPage"]);
-
- if (!showQuestsButton(questButtonDisplay, unclaimedUnignoredQuests, onQuestsPage)) {
- return false;
- }
-
- return location === questPath;
-}
-
-function useQuestRerender(): number {
- settings.use(["triggerQuestsRerender"]);
- const [renderTrigger, setRenderTrigger] = useState(0);
- useEffect(() => addRerenderCallback(() => setRenderTrigger(prev => prev + 1)), []);
- return renderTrigger;
-}
-
-function getLastSortChoice(): string | null {
- const { rememberQuestPageSort, lastQuestPageSort } = settings.store;
- return rememberQuestPageSort ? lastQuestPageSort : "questify";
-}
-
-function getLastFilterChoices(): { group: string; filter: string; }[] | null {
- const { rememberQuestPageFilters, lastQuestPageFilters } = settings.store;
- return rememberQuestPageFilters ? Object.values(lastQuestPageFilters).map(item => JSON.parse(JSON.stringify(item))) : null;
-}
-
-function setLastSortChoice(sort: string): void {
- if (!sort) sort = "questify";
- settings.store.lastQuestPageSort = sort;
-}
-
-function setLastFilterChoices(filters: { group: string; filter: string; }[] | null): void {
- if (!filters || filters.length === 0) {
- settings.store.lastQuestPageFilters = {};
- return;
- }
-
- if (!filters.every(f => f && f.group && f.filter)) {
- return;
- }
-
- settings.store.lastQuestPageFilters = (JSON.parse(JSON.stringify(filters)) as { group: string; filter: string; }[]).reduce((acc, item) => {
- acc[item.filter] = item;
- return acc;
- }, {});
-}
-
-function getQuestAcceptedButtonProps(quest: Quest, text: string, disabled: boolean, onClick?: () => void) {
- const validTasks = [
- QuestTaskType.WATCH_VIDEO,
- QuestTaskType.WATCH_VIDEO_ON_MOBILE,
- QuestTaskType.PLAY_ON_DESKTOP,
- QuestTaskType.PLAY_ON_XBOX,
- QuestTaskType.PLAY_ON_PLAYSTATION,
- QuestTaskType.PLAY_ACTIVITY,
- QuestTaskType.ACHIEVEMENT_IN_ACTIVITY
- ];
-
- const validTask = Array.from(validTasks).some(taskType => Object.values(quest.config.taskConfigV2?.tasks || {}).some(
- task => task.type === taskType && (task.type !== QuestTaskType.WATCH_VIDEO_ON_MOBILE || settings.store.makeMobileQuestsDesktopCompatible)
- ));
-
- if (!validTask) {
- return {
- disabled: disabled,
- text: text,
- onClick: onClick,
- icon: () => { }
- };
- }
-
- return {
- disabled: shouldDisableQuestAcceptedButton(quest) ?? disabled,
- text: getQuestAcceptedButtonText(quest) ?? text,
- onClick: () => { const startingAutocomplete = processQuestForAutoComplete(quest, true); !startingAutocomplete && onClick ? onClick() : null; },
- icon: () => { }
- };
-}
-
-function resetQuestsToResume(quest?: Quest): void {
- if (quest) {
- settings.store.resumeQuestIDs.play = settings.store.resumeQuestIDs.play.filter(id => id !== quest.id);
- settings.store.resumeQuestIDs.watch = settings.store.resumeQuestIDs.watch.filter(id => id !== quest.id);
- settings.store.resumeQuestIDs.achievement = settings.store.resumeQuestIDs.achievement.filter(id => id !== quest.id);
- } else {
- settings.store.resumeQuestIDs = {
- watch: [],
- play: [],
- achievement: [],
- };
- }
-}
-
-// Drop support for QuestCompleter and migrate to Questify settings.
-migratePluginToSettings(true, "Questify", "QuestCompleter", "completeVideoQuestsInBackground", "completeGameQuestsInBackground", "completeAchievementQuestsInBackground");
-
export default definePlugin({
name: "Questify",
- description: "Enhance your Quest experience with a suite of features, or disable them entirely if they're not your thing.",
+ description: "Enhance specific Quest features, disable annoyances, or completely remove Quests.",
tags: ["Appearance", "Customisation", "Privacy", "Utility"],
authors: [EquicordDevs.Etorix],
dependencies: ["AudioPlayerAPI", "ServerListAPI"],
startAt: StartAt.Init, // Needed in order to beat Read All Messages to inserting above the server list.
settings,
- sortQuests,
- formatLowerBadge,
- getQuestTileStyle,
- getQuestTileClasses,
- makeDesktopCompatible,
- shouldHideQuestPopup,
- shouldHideDiscoveryTab,
- shouldPreloadQuestAssets,
- shouldHideDirectMessagesTab,
- shouldPreventFetchingQuests,
- shouldHideBadgeOnUserProfiles,
- shouldHideSponsoredQuestBanner,
- shouldHideGiftInventoryRelocationNotice,
- shouldHideFriendsListActiveNowPromotion,
- shouldHideMembersListActivelyPlayingIcon,
- getQuestUnacceptedButtonText,
- getQuestPanelPercentComplete,
- processQuestForAutoComplete,
- getQuestAcceptedButtonProps,
- getQuestAcceptedButtonText,
- getQuestPanelSubtitleText,
- getQuestPanelTitleText,
- getQuestPanelOverride,
- setLastFilterChoices,
- getLastFilterChoices,
- activeQuestIntervals,
+ canOpenDevToolsWindow,
+ canAutoCompleteQuest,
disguiseHomeButton,
+ enrolledIncompleteButton,
+ formatLowerBadge,
+ getActiveAutoCompletes,
+ getLastFilterChoices,
getLastSortChoice,
+ getQuestAutoCompleteProgress,
+ getQuestButtonProps,
+ getQuestPanelOverride,
+ getQuestPanelPercentComplete,
+ getQuestPanelSubtitleText,
+ getQuestTileClasses,
+ getQuestTileStyle,
+ getSettingsModalOpen,
+ hasEnabledAutoCompleteQuestTypes,
+ normalizeQuestName,
+ openDevToolsWindow,
+ processQuestForAutoComplete,
+ rerenderQuests,
+ setHeartbeatStackTracePatchSucceeded,
+ setLastFilterChoices,
setLastSortChoice,
+ setVideoProgressStackTracePatchSucceeded,
+ shouldForceQuestPanelVisible,
+ shouldPreloadQuestAssets,
+ sortQuests,
+ stopQuestAutoComplete,
useQuestRerender,
patches: [
{
- // Needed for GuildlessServerListItemComponent component in components.tsx
+ // Prevent color picker modal and dummy Quest button context menu modal
+ // from force scrolling back up to the top of the settings when closed.
+ find: ",NodeFilter.SHOW_ELEMENT,{acceptNode:function(",
+ replacement: {
+ match: /\.focus\(\)/g,
+ replace: ".focus({preventScroll:$self.getSettingsModalOpen()?!0:undefined})"
+ }
+ },
+ {
+ // Exports the guildless server list item component used by the Quest button.
find: '="DOWNLOAD_APPS";function',
replacement: {
- match: /(?=\i:\(\)=>\i.*?asContainer:!\i.{0,50};let (\i)=\i.forwardRef\(function)/,
+ match: /(?=\i:\(\)=>\i.{0,30000}?asContainer:!\i.{0,50};let (\i)=\i.forwardRef\(function)/,
replace: "GuildlessServerListItemComponent:()=>$1,"
}
},
+ {
+ // Prevents the DMs Quests tab from counting as part of the
+ // DM button highlight logic while the Quest button is visible.
+ find: "GLOBAL_DISCOVERY),",
+ predicate: () => !settings.store.disableQuestsEverything && showQuestButton(settings.store.questButtonDisplay, 1, true),
+ replacement: {
+ match: /(pathname:(\i)}.{0,400}?return )/,
+ replace: "$1$self.disguiseHomeButton($2)?false:"
+ }
+ },
+ {
+ // Hides the Quest icon on members list nameplates.
+ find: '("ActivityStatus"),',
+ predicate: () => settings.store.disableQuestsEverything || settings.store.disableMembersListPromo,
+ replacement: {
+ match: /(,hasQuest:)(?=\i=!1)/,
+ replace: ",questifyInvalid1:"
+ }
+ },
+ {
+ // Hides the Friends List "Active Now" promotion.
+ find: "`application-stream-",
+ predicate: () => settings.store.disableQuestsEverything || settings.store.disableFriendsListPromo,
+ replacement: [
+ {
+ match: /(?<=let{party:\i,onChannelContextMenu:\i,)quest:(\i)/,
+ replace: "questifyInvalid2:$1=null"
+ }
+ ]
+ },
{
// Hides Quests tab in the Discovery page.
find: "GLOBAL_DISCOVERY_SIDEBAR},",
+ predicate: () => settings.store.disableQuestsEverything || settings.store.disableRelocationNotices,
replacement: [
{
match: /(GLOBAL_DISCOVERY_TABS).map/,
- replace: '$1.filter(tab=>!(tab==="quests"&&$self.shouldHideDiscoveryTab())).map'
+ replace: '$1.filter(tab=>tab!=="quests").map'
}
]
},
{
// Hides Quests tab in the DMs tab list.
find: ".QUEST_HOME):",
+ predicate: () => settings.store.disableQuestsEverything,
replacement: [
{
match: /(?<="family-center"\):null,)/,
- replace: "$self.shouldHideDirectMessagesTab()||"
+ replace: "null&&"
}
]
},
@@ -1430,187 +218,75 @@ export default definePlugin({
// Hides the sponsored banner on the Quests page.
find: "resetSortingFiltering(),requestAnimationFrame",
group: true,
+ predicate: () => !settings.store.disableQuestsEverything && settings.store.disableSponsoredBanner,
replacement: [
- {
- match: /(?=let{topLevelRoute)/,
- replace: "const shouldHideSponsoredQuestBanner=$self.shouldHideSponsoredQuestBanner();"
- },
- {
- match: /(?<=\{onAssetLoad:\i,onQuestCtaClick:\i)(?=\}\),)/,
- replace: ",shouldHideSponsoredQuestBanner"
- },
{
match: /(?<=(\i),isLoading:(\i)}=\(0,\i.\i\)\(\);)/,
- replace: "if(arguments[0].shouldHideSponsoredQuestBanner){$1=null;$2=false;};"
+ replace: "if(true){$1=null;$2=false;};"
}
]
},
{
- // Hides the Quest icon from members list items when
- // a user is playing a game tied to an active Quest.
- find: '"ActivityStatus")',
- group: true,
- replacement: [
- {
- match: /(?<=null&&"xs"===\i;)/,
- replace: "const shouldHideMembersListActivelyPlayingIcon=$self.shouldHideMembersListActivelyPlayingIcon();"
- },
- {
- match: /(?<=\i\(\),\i&&)/,
- replace: "!shouldHideMembersListActivelyPlayingIcon&&"
- }
- ]
- },
- {
- // Hides the Quest badge on user profiles.
+ // Hides the Quest & Orbs badges on user profiles.
find: ".MODAL]:26",
group: true,
+ predicate: () => !settings.store.disableQuestsEverything && settings.store.disableOrbsAndQuestsBadges,
replacement: [
- {
- match: /(return 0===\i.length\?null:\(0,)/,
- replace: "const shouldHideBadgeOnUserProfiles=$self.shouldHideBadgeOnUserProfiles();$1"
- },
{
match: /(badges:\i)/,
- replace: '$1.filter(badge=>!(["quest_completed","orb_profile_badge"].includes(badge.id)&&shouldHideBadgeOnUserProfiles))',
+ replace: '$1.filter(badge=>!["quest_completed","orb_profile_badge"].includes(badge.id))',
}
]
},
{
- // Hides the new Quest popup above the account panel.
- // Allows in-progress Quests to still show.
+ // Overrides the account panel Quest popup and progress display.
find: "QUESTS_BAR,questId",
+ predicate: () => settings.store.disableAccountPanelPromo || !settings.store.disableAccountPanelQuestProgress,
replacement: {
- match: /(?<=function\(\){let (\i)=\(0,\i.\i\)\(\);)(return)/,
- replace: "const hidePopup=$self.shouldHideQuestPopup($1);$2 hidePopup||"
+ match: /(?<=function\(\){)(let (\i)=\(0,\i.\i\)\(\);)/,
+ replace: "void $self.useQuestRerender();$1$2=$self.getQuestPanelOverride($2);"
}
},
{
- // Fixes the progress tracking for auto-completing Quests.
+ // Prevents fetching Quests.
+ find: 'type:"QUESTS_FETCH_CURRENT_QUESTS_BEGIN"',
+ group: true,
+ predicate: () => settings.store.disableQuestsEverything,
+ replacement: [
+ {
+ // QUESTS_FETCH_CURRENT_QUESTS_BEGIN
+ match: /(?=if\(!\i.\i.isFetchingCurrentQuests\))/,
+ replace: "return;"
+ },
+ {
+ // QUESTS_FETCH_QUEST_TO_DELIVER_BEGIN
+ match: /(?=let.{0,150}QUESTS_FETCH_QUEST_TO_DELIVER_BEGIN)/,
+ replace: "return;"
+ }
+ ]
+ },
+ {
+ // Fixes the progress tracking for Quests.
find: ",{progressTextAnimation:",
+ predicate: () => !settings.store.disableQuestsEverything,
replacement: {
match: /(let{percentComplete:.{0,115}?children:\i}=)(\i)/,
replace: "const questifyProgress=$self.getQuestPanelPercentComplete({...$2,quest:$2.children?.props?.quest});$1Object.assign({},$2,questifyProgress??{})"
}
},
{
- // Overrides the title and subtitle to provide more
- // useful information for Quests being auto-completed.
+ // Overrides the title and subtitle to provide more useful information for Quests being completed.
find: '"progress-title"',
+ predicate: () => !settings.store.disableQuestsEverything,
replacement: {
match: /(?<={quest:(\i).{0,250}?return.{0,150}?,percentComplete:\i.{0,280}?"progress-title",children.{0,115}?children:)(\i.{0,50}"progress-subtitle",isTextTransition:!0,children.{0,115}?children:)/,
- replace: "$self.getQuestPanelTitleText($1)??$2$self.getQuestPanelSubtitleText($1)??"
- }
- },
- {
- // Replaces the default displayed Quest with the soonest to
- // be completed Quest which is actively being auto-completed.
- find: '"useQuestBarQuest"})',
- replacement: {
- match: /(?<=null\);return )(\i\?\i:\i)/,
- replace: "$self.getQuestPanelOverride()??($1)"
- }
- },
- {
- // Hides the Friends List "Active Now" promotion.
- find: "`application-stream-",
- group: true,
- replacement: [
- {
- match: /(let{party:\i,onChannelContextMenu)/,
- replace: "const shouldHideFriendsListActiveNowPromotion=$self.shouldHideFriendsListActiveNowPromotion();$1"
- },
- {
- match: /(null!=(\i)&&null!=\i&&)/,
- replace: "!shouldHideFriendsListActiveNowPromotion&&$1"
- }
- ]
- },
- {
- // Prevents fetching Quests.
- find: 'type:"QUESTS_FETCH_CURRENT_QUESTS_BEGIN"',
- group: true,
- replacement: [
- {
- // QUESTS_FETCH_CURRENT_QUESTS_BEGIN
- match: /(if\(!\i.\i.isFetchingCurrentQuests\))/,
- replace: "if($self.shouldPreventFetchingQuests())return;$1"
- },
- {
- // QUESTS_FETCH_QUEST_TO_DELIVER_BEGIN
- match: /(?=let.{0,150}QUESTS_FETCH_QUEST_TO_DELIVER_BEGIN)/,
- replace: "if($self.shouldPreventFetchingQuests())return;"
- }
- ]
- },
- {
- // MARK: TODO 1
- // - Cleanup once Discord rolls out the new mana select completely.
- // - Also see anywhere DynamicDropdown is used for refactoring.
- //
- // Various patches to the SearchableSelect component.
- find: ".popoutLayerContext,renderPopout:",
- group: true,
- replacement: [
- {
- // Extracts a custom feedback prop before the variable is overwritten.
- match: /(?<=forwardRef\(function\((\i),\i\){)/,
- replace: "const vcDynamicDropdownFeedback=$1.feedback;"
- },
- {
- // Passes the custom prop to the dropdown's invalid handler.
- match: /((\i);return\(0,\i.\i\)\(\i,{)(loading:\i,)/,
- replace: "$1feedback:vcDynamicDropdownFeedback,$3"
- },
- {
- // Makes use of the custom prop if provided, otherwise assume default behavior.
- match: /(\i.intl.string\(\i.\i#{intl::NO_RESULTS_FOUND}\))/,
- replace: "arguments[0]?.feedback??$1"
- },
- {
- // Prevent SearchableSelect from force-scrolling into view, causing the dropdown to close.
- match: /(&&\i.current\?\.scrollIntoView\(\))/,
- replace: ""
- },
- {
- // Passes a popoutClassName and optionClassName to the popout handler.
- match: /(?<=renderOptionPrefix:\i,renderOptionSuffix:\i)/,
- replace: ",popoutClassName:arguments[0]?.popoutClassName,optionClassName:arguments[0]?.optionClassName"
- },
- {
- // Makes use of the custom popoutClassName prop if provided.
- match: /"aria-busy":!0,className:\i\(\)\(/,
- replace: "$&arguments[0]?.popoutClassName,"
- },
- {
- // Passes the custom optionClassName prop to the row renderer.
- match: /(?<="aria-posinset":\i,"aria-setsize":\i.length,)/,
- replace: "optionClassName:arguments[0]?.optionClassName,"
- },
- {
- // Makes use of the custom optionClassName prop if provided.
- match: /(?<=focusProps:{enabled:!1},className:\i\(\)\()/,
- replace: "arguments[0]?.optionClassName,"
- },
- {
- // Pass the unused props to the new mana select being
- // used by dev://experiment/2025-09-mana-desktop-select
- match: /(?<=closeOnSelect:\i)(?=})/,
- replace: ",...arguments[0]"
- }
- ]
- },
- {
- // Prevent the new version of SearchableSelect from force-scrolling into view.
- find: '"data-mana-component":"combobox",',
- replacement: {
- match: /\i.current\?\.scrollIntoView\({.{0,50}?}\)/,
- replace: ""
+ replace: "$self.normalizeQuestName($1)??$2$self.getQuestPanelSubtitleText($1)??"
}
},
{
// Formats the Orbs balance on the Quests page with locale string formatting.
find: '("BalanceCounter")',
+ predicate: () => !settings.store.disableQuestsEverything,
replacement: [
{
match: /(`\${(\i).toFixed\(0\)}`.length)/,
@@ -1622,6 +298,223 @@ export default definePlugin({
}
]
},
+ {
+ // Removes stack traces from Quest auto-complete network actions and marks both patches as healthy.
+ find: "NetworkActionNames.QUEST_VIDEO_PROGRESS,",
+ group: true,
+ predicate: () => !settings.store.disableQuestsEverything && hasEnabledAutoCompleteQuestTypes(),
+ replacement: [
+ {
+ match: /(async function \i\(\i,\i\)\{await \i\.\i\.post\(\{url:\i\.\i\.QUESTS_VIDEO_PROGRESS.{0,200}?stack_trace:)Error\(\)\.stack\?\?""/,
+ replace: '$self.setVideoProgressStackTracePatchSucceeded();$1""'
+ },
+ {
+ match: /(async function \i\(\i\)\{let\{questId:\i,streamKey:\i.{0,350}?stack_trace:)Error\(\)\.stack\?\?""/,
+ replace: '$self.setHeartbeatStackTracePatchSucceeded();$1""'
+ }
+ ]
+ },
+ {
+ find: "resetSortingFiltering(),requestAnimationFrame",
+ group: true,
+ predicate: () => !settings.store.disableQuestsEverything,
+ replacement: [
+ {
+ // Subscribes the Quest page sort/filter state to Questify rerenders.
+ match: /(let \i,\i,\i,\i,\i=\i\.useRef\(null\),)/,
+ replace: "$1questRerenderTrigger=$self.useQuestRerender(),"
+ },
+ {
+ // Set the initial sort method.
+ match: /(\i.\i.SUGGESTED)/,
+ replace: "$self.getLastSortChoice()??$1"
+ },
+ {
+ // Set the initial filters and update the filters and sort method when they change.
+ match: /(get\(\i\)\)\?\?)(\i,\[)(\i)(\]\),\i=\i.useCallback\((\i)=>{)(.{0,60}?useCallback\((\i)=>{)/,
+ replace: "$1$self.getLastFilterChoices()??$2$3,questRerenderTrigger$4$self.setLastSortChoice($5);$6$self.setLastFilterChoices($7);$self.rerenderQuests();"
+ },
+ {
+ // Update the last used sort and filter choices when the toggle setting for either is changed.
+ match: /(?<=ALL,\i.useMemo\(\(\)=>\()({sortMethod:(\i),filters:(\i))/,
+ replace: "$self.setLastSortChoice($2),$self.setLastFilterChoices($3),$1"
+ },
+ {
+ // Makes the reset action return to the Questify sort option.
+ match: /(?<=resetSortingFiltering:\(\)=>{\i\(\),\i\()\i.\i.SUGGESTED/,
+ replace: '"questify"'
+ }
+ ]
+ },
+ {
+ find: "config.taskConfigV2.tasks).length)return",
+ group: true,
+ predicate: () => !settings.store.disableQuestsEverything && hasEnabledAutoCompleteQuestTypes(),
+ replacement: [
+ {
+ // Overwrite button props for UNENROLLED Quests.
+ match: /(?<=onClick:(\(\)=>{.[^}]+}),text:(\i),icon:\i,fullWidth:!0)/,
+ replace: ",...($self.getQuestButtonProps(arguments[0])??{})"
+ },
+ {
+ // Overwrite button props for ENROLLED/INCOMPLETE Quests.
+ match: /(?<=let{quest:\i,taskType:\i,surface:\i.{0,150}?size:\i}=\i;return)(.{0,300}?taskType:\i,size:\i,analyticsCtxQuestContent:\i,analyticsCtxSourceQuestContent:\i}\))/,
+ replace: " $self.enrolledIncompleteButton(arguments[0])||($1)"
+ }
+ ]
+ },
+ {
+ // Overwrite button props for Quest bar.
+ find: "QUESTS_BAR,questId",
+ predicate: () => !settings.store.disableQuestsEverything && hasEnabledAutoCompleteQuestTypes(),
+ replacement: {
+ match: /(?<=SELECT&&!\i&&!\i,(\i)=null;)(return )(\i\?\i=\(0,\i.\i\)\(\i,{quest:(\i))/,
+ replace: "const questifyButton=$self.enrolledIncompleteButton({quest:$4});$2questifyButton?$1=questifyButton:$3"
+ }
+ },
+ {
+ // Keeps Questify completion progress visible when Discord marks the native Quest bar dismissed.
+ find: "prevIsQuestAccepted:",
+ predicate: () => !settings.store.disableQuestsEverything && !settings.store.disableAccountPanelQuestProgress,
+ replacement: {
+ match: /(?<=isLoading:\i}=\(0,\i.\i\)\(\),\i=\i\.useContext\(\i\.\i\)\|\|\i&&)(\i)/,
+ replace: "($1||$self.shouldForceQuestPanelVisible(arguments[0].quest))"
+ }
+ },
+ {
+ find: ".rowIndex,trackGuildAndChannelMetadata",
+ group: true,
+ predicate: () => !settings.store.disableQuestsEverything,
+ replacement: [
+ {
+ // Prevent the platform selector if the Quest is auto-completable.
+ match: /(?<=ACCEPTED,\i=)(?=\i&&)/,
+ replace: "!$self.canAutoCompleteQuest(arguments[0].quest)&&"
+ },
+ {
+ // Prevent the platform selector if the Quest is auto-completable.
+ match: /(?<=SELECT,\i=)(?=\i&&)/,
+ replace: "!$self.canAutoCompleteQuest(arguments[0].quest)&&"
+ },
+ // If this group becomes unruly due to Discord refactoring and is unfixable,
+ // the 2nd, 3rd, and 4th can be commented out in favor of just the 1st at the
+ // expense of not seeing CTA buttons on completed but unclaimed Quests.
+ {
+ // Always expose the CTA button when available instead of only for videos and activities.
+ match: /(?<=wrap:!1,children:\[)\i&&[^?]+/,
+ replace: "!!arguments[0].quest.config.ctaConfig"
+ },
+ {
+ // Let completed/claimed expired Quests with CTAs use the CTA-aware completed branch.
+ match: /(?<=return\()(?=\i.enabled&&\i===\i\.\i\.EXPIRED_CLAIMABLE&&\i\.\i\.has\(\i\))/,
+ replace: "!arguments[0].quest.config.ctaConfig&&"
+ },
+ {
+ // Let completed/claimed expired Quests with CTAs use the CTA-aware completed branch.
+ match: /(?<=\):\i\?\i=)(\i)(?=\?\(0,\i\.jsx\)\(\i,\{quest:\i,sourceQuestContent:\i,onClick:\i,text:\i\}\):\(0,\i\.\i\)\(\i\)\?)/,
+ replace: "arguments[0].quest.config.ctaConfig||$1"
+ },
+ {
+ // Force the CTA-aware complete branch.
+ match: /(?<=analyticsCtxQuestContentRowIndex:\i}\)}\):\i&&\i)(.{0,200}?fullWidth:!0}\)}\):)(\i.enabled)(.{0,50}?CLAIMED\))&&\i.\i.has\(\i\)(\?\i=)\i/,
+ replace: "&&false$1arguments[0].quest.config.ctaConfig&&arguments[0].quest.userStatus?.completedAt&&($2||true)$3$4true"
+ }
+ ]
+ },
+ {
+ find: ".rowIndex,trackGuildAndChannelMetadata",
+ group: true,
+ predicate: () => !settings.store.disableQuestsEverything,
+ replacement: [
+ {
+ // Subscribes each Quest tile to Questify's manual rerender trigger.
+ match: /(?=return\(0,\i\.\i\)\("div",\{id:`quest-tile-\$\{\i\.id\}`)/,
+ replace: "void $self.useQuestRerender();"
+ },
+ {
+ // Adds Questify tile classes and inline CSS variables.
+ match: /(?<=className:)(\i\(\)\(\i\.\i,\i\))(?=,onMouseEnter)/,
+ replace: "$self.getQuestTileClasses($1,arguments[0].quest),style:$self.getQuestTileStyle(arguments[0].quest)"
+ },
+ {
+ // Skips the reward placeholder when assets are preloaded.
+ match: /(?<=showPlaceholder:)(!\i)(?=,width)/g,
+ replace: "$self.shouldPreloadQuestAssets()?!1:$1"
+ },
+ {
+ // Disables lazy loading for Quest art when preloading is enabled.
+ match: /(?<=onLoadComplete:\i,lazyLoad:)!0/g,
+ replace: "$self.shouldPreloadQuestAssets()?!1:!0"
+ },
+ {
+ // Treats the banner & reward content as visible so it loads immediately when preloading.
+ match: /(?<=isVisibleInViewport:)(\i)(?=,sourceQuestContent:\i\}\))/g,
+ replace: "$self.shouldPreloadQuestAssets()?true:$1"
+ }
+ ]
+ },
+ {
+ // Adds the Questify sort option to Discord's Quest sort enum.
+ find: "SUGGESTED=\"suggested\",",
+ predicate: () => !settings.store.disableQuestsEverything,
+ replacement: {
+ match: /(?<=\(\((\i)=\{\}\))(?=\.SUGGESTED="suggested",)/,
+ replace: ".QUESTIFY=\"questify\",$1"
+ }
+ },
+ {
+ // Labels the injected Questify sort option in the dropdown.
+ find: "has no rewards configured`",
+ predicate: () => !settings.store.disableQuestsEverything,
+ replacement: {
+ match: /(?=case (\i\.\i)\.SUGGESTED)/,
+ replace: "case $1.QUESTIFY:return\"Questify\";"
+ },
+ },
+ {
+ find: "CLAIMED=\"claimed\",",
+ group: true,
+ predicate: () => !settings.store.disableQuestsEverything,
+ replacement: [
+ {
+ // Runs Questify sorting in the hook-safe Quest list path and tracks manual rerenders.
+ match: /,(\i)=new Map\((\i)\.map/,
+ replace: ";const questRerenderTrigger=$self.useQuestRerender();const questifySorted=$self.sortQuests($2,arguments[1]?.sortMethod!==\"questify\");let $1=new Map($2.map"
+ },
+ {
+ // Replaces Discord's filtered Quest list with Questify's order only when selected.
+ match: /(?=if\(0===(\i)\.length\)return\[\];if\(\i\.current\.length>0)/,
+ replace: "if(arguments[1]?.sortMethod===\"questify\"){$1=questifySorted;};"
+ },
+ {
+ // Bypasses Discord's memo cache while the Questify sort is active.
+ match: /(if\(\i\.current\.length>0&&\i\.current===\i\.length&&\i\.current===\i\.sortMethod&&\i\.current===\i\.filters&&\i\.current===\i)(\)return \i\.current;)/,
+ replace: "$1&&arguments[1]?.sortMethod!==\"questify\"$2"
+ },
+ {
+ // If we already applied Questify's sort, skip further sorting.
+ match: /(?<=\{sortMethod:(\i).{0,750}?return )((\i).sort)/,
+ replace: "$1===\"questify\"?$3:$2"
+ },
+ {
+ // Recomputes Discord's Quest list memo when Questify settings or rerenders change.
+ match: /(?<=\.id\);return \i\.current=\i,\i\.current=\i\.sortMethod,\i\.current=\i\.filters,\i\.current=\i\.length,\i\.current=\i,\i\},\[)(\i,\i,\i)(?=\]\)\))/,
+ replace: "$1,questRerenderTrigger,questifySorted"
+ }
+ ]
+ },
+ {
+ // Sorts the "Claimed Quests" tabs.
+ find: ".ALL)}):(",
+ group: true,
+ predicate: () => !settings.store.disableQuestsEverything,
+ replacement: [
+ {
+ match: /(return \i&&0===\i.length.{0,150}?children:)\[\.\.\.(\i).{0,100}?claimedAt\?\?""\)\)/,
+ replace: "const questifySorted=$self.sortQuests($2);$1questifySorted"
+ },
+ ]
+ },
{
// Adds a maxDigits prop to the LowerBadge component which allows for not truncating, or for truncating at a specific threshold.
find: ".INTERACTIVE_TEXT_ACTIVE.css,shape",
@@ -1638,386 +531,127 @@ export default definePlugin({
replace: "$1,maxDigits"
},
{
- // Makes use of the custom prop if provided by using custom logic for negatives and truncation.
- // If the prop is not provided, assume default behavior for native badges or other plugins not
- // utilizing the custom prop.
+ // Makes use of the custom prop if provided by using custom logic for negatives and
+ // truncation. If the prop is not provided, assume default behavior for native badges.
match: /(?<=function \i\((\i))(\){return )(\i<1e3.{0,60}?k\+`)/,
replace: ",maxDigits$2maxDigits===undefined?($3):$self.formatLowerBadge($1,maxDigits)[0]"
}
]
},
- {
- find: "id:`quest-tile-",
- group: true,
- replacement: [
- {
- // Restyles Quest tiles with colors.
- match: /(?<=\i.current=\i},className:)(\i\(\)\(\i.\i,\i\)),/,
- replace: "$self.getQuestTileClasses($1,arguments[0].quest),style:$self.getQuestTileStyle(arguments[0].quest),"
- },
- {
- // Encourages banners to load quicker if the setting is enabled.
- match: /(hideAssets:)(!\i)/,
- replace: "$1$self.shouldPreloadQuestAssets()?!1:$2"
- },
- {
- // Encourages banners to load quicker if the setting is enabled.
- match: /(showPlaceholder:)(!\i)/,
- replace: "$1$self.shouldPreloadQuestAssets()?!1:$2"
- },
- {
- // Encourages reward icons to load quicker if the setting is enabled.
- match: /(onReceiveErrorHints:\i,)isVisibleInViewport:(\i)/,
- replace: "$1isVisibleInViewport:$self.shouldPreloadQuestAssets()?true:$2"
- },
- ]
- },
- {
- // Sorts the "Claimed Quests" tabs.
- find: ".ALL)}):(",
- group: true,
- replacement: [
- {
- match: /(claimedQuests:(\i).{0,50}?;)/,
- replace: "$1$2=$self.sortQuests($2);"
- },
- ]
- },
- {
- // Adds the "Questify" sort option to the sort enum.
- find: "SUGGESTED=\"suggested\",",
- replacement: {
- match: /(\(\((\i)=\{\}\))(.SUGGESTED="suggested",)/,
- replace: "$1.QUESTIFY=\"questify\",$2$3"
- }
- },
- {
- // Adds the "Questify" sort option to the sort dropdown.
- find: "has no rewards configured`",
- replacement: {
- match: /(?=case (\i.\i).SUGGESTED)/,
- replace: "case $1.QUESTIFY:return \"Questify\";"
- },
- },
- {
- find: "CLAIMED=\"claimed\",",
- group: true,
- replacement: [
- {
- // Run Questify's sort function every time due to hook requirements but return
- // early if not applicable. If the sort method is set to "Questify", replace the
- // Quests with the sorted ones. Also, setup a trigger to rerender the memo.
- match: /(?<=quests:(\i).{0,150}"use_filtered_quests".{0,25}\i\.id,\i\]\)\)),/,
- replace: ";const questRerenderTrigger=$self.useQuestRerender();const questifySorted=$self.sortQuests($1,arguments[1].sortMethod!==\"questify\");let "
- },
- {
- match: /(?=if\(0===(\i).length\).{0,100}\.sortMethod&&\i\.current)/,
- replace: "if(arguments[1].sortMethod===\"questify\"){$1=questifySorted;};"
- },
- {
- // Account for Quest status changes.
- match: /return (\i).current;/,
- replace: "null;"
- },
- {
- // If we already applied Questify's sort, skip further sorting.
- match: /(?<=\{sortMethod:(\i).*?return )((\i).sort)/,
- replace: "$1===\"questify\"?$3:$2"
- },
- {
- // Add the trigger to the memo for rerendering Quests order due to progress changes, etc.
- match: /(?<=id\);.{0,100}?,\i},\[\i,\i)/,
- replace: ",questRerenderTrigger,questifySorted"
- }
- ]
- },
- {
- // Loads the last used sort method and filter choices.
- // Defaults to sorting by "Questify" and no filters.
- find: "({resetSortingFiltering:()",
- group: true,
- replacement: [
- {
- // Set the initial sort method.
- match: /(\i.\i.SUGGESTED)/,
- replace: "$self.getLastSortChoice()??$1"
- },
- {
- // Set the initial filters and update the filters and sort method when they change.
- match: /(get\(\i\)\)\?\?)(\i,\[\i\]\),\i=\i.useCallback\((\i)=>{)(.{0,60}?useCallback\((\i)=>{)/,
- replace: "$1$self.getLastFilterChoices()??$2$self.setLastSortChoice($3);$4$self.setLastFilterChoices($5);"
- },
- {
- // Update the last used sort and filter choices when the toggle setting for either is changed.
- match: /(?<=ALL,\i.useMemo\(\(\)=>\()({sortMethod:(\i),filters:(\i))/,
- replace: "$self.setLastSortChoice($2),$self.setLastFilterChoices($3),$1"
- },
- {
- match: /(?<=resetSortingFiltering:\(\)=>{\i\(\),\i\()\i.\i.SUGGESTED/,
- replace: '"questify"'
- }
- ]
- },
- {
- // Whether preloading assets is enabled or not, the placeholders loading
- // before the assets causes a lot of element shifting, whereas if
- // the elements load immediately instead, it doesn't.
- find: ".QUEST_HOME_TILE_HEADER_WATCH_VIDEO})},",
- replacement: {
- match: /showPlaceholder:!\i/,
- replace: "showPlaceholder:false"
- }
- },
- // MARK: TODO 2 START
- // - Cleanup once Discord rolls out the new quest CTA refactor completely.
- {
- // Sets intervals to progress Play Game Quests in the background and patches some common click handlers.
- find: "IN_PROGRESS:if(",
- group: true,
- replacement: [
- {
- // Resume Video Quest.
- match: /(tooltipText:\i.intl.string\(\i.\i.\i\),onClick:\(\)=>)(\(0,\i.\i\)\({quest)/,
- replace: "$1!$self.processQuestForAutoComplete(arguments[0].quest,true)&&$2"
- },
- {
- // Start Play Game and Play Activity Quests.
- // Video Quests are handled in the next patch group.
- // Also set the unaccepted button text to "Complete".
- match: /(\i,tooltipText:null,onClick:async\(\)=>{)/,
- replace: "$self.getQuestUnacceptedButtonText(arguments[0].quest)??$1const startingAutoComplete=arguments[0].isVideoQuest?false:$self.processQuestForAutoComplete(arguments[0].quest,true);"
- },
- {
- // Set the accepted button text to "Complete", "Completing", or "Resume" based on progress.
- match: /(if\(\i\)return{text:)/,
- replace: "$1$self.getQuestAcceptedButtonText(arguments[0].quest)??",
- },
- {
- // Setup a trigger to rerender the memo.
- match: /(?=return \i.useMemo)/,
- replace: "const questRerenderTrigger=$self.useQuestRerender();"
- },
- {
- // Add the trigger to the memo for rerendering the progress label.
- match: /(\i\.intl\.string\(\i\.\i#{intl::QUESTS_SEE_CODE}\)\}\)\}\},\[|\)\}\}\},\[)/,
- replace: "$1questRerenderTrigger,"
- },
- {
- // Stop Play Activity Quests from launching the activity on first click.
- match: /(?<=,)(\i\(\))(\)}};)/,
- replace: "!startingAutoComplete&&$1$2"
- }
- ]
- },
- {
- // Same thing as above, maybe? Different location though.
- find: ".ACCEPT_QUEST),",
- replacement: [
- {
- match: /(?=let{quest:)/g,
- replace: "const questifyText=$self.getQuestUnacceptedButtonText(arguments[0].quest)??$self.getQuestAcceptedButtonText(arguments[0].quest);"
- },
- {
- match: /(?<=,text:)(\i)/g,
- replace: "questifyText??$1"
- },
- {
- match: /(?<="primary",onClick:)(\i)/,
- replace: "()=>{!$self.processQuestForAutoComplete(arguments[0].quest,true)&&$1()}"
- },
- {
- match: /(?<=}\),)(\i\?\.\(\))/,
- replace: "!$self.processQuestForAutoComplete(arguments[0].quest,true)&&($1)"
- },
- ]
- },
- {
- // Sets intervals to progress Video Quests in the background.
- find: "questContentRowIndex});",
- replacement: {
- match: /(?<=SUCCESS:)(\i\({)/,
- replace: "!$self.processQuestForAutoComplete(arguments[0])&&$1"
- }
- },
- {
- // Sets intervals to progress Play Game Quests in the background.
- // Triggers if a Quest has already been started but was interrupted, such as by a reload.
- find: "),handleOpenExternalLink:",
- group: true,
- replacement: [
- {
- // Initial and subsequent select drop down for picking or changing a platform.
- match: /(select:)(\i)(,serialize:\i=>{)/g,
- replace: "$1(platform)=>{$self.processQuestForAutoComplete(arguments[0].quest,true),$2(platform)}$3"
- },
- {
- // The Quest Accepted button is disabled by default. If the user reloads the client, they need a way
- // to resume the automatic completion, so patch in optionally enabling it if the feature is enabled.
- // The "Quest Accepted" text is changed to "Resume" if the Quest is in progress but not active.
- // Then, when the Quest Accepted button is clicked, resume the automatic completion of the
- // Quest and disable the button again.
- match: /(?<=secondary",)disabled:(!0),text:(\i\.intl\.string\(\i\.\i#{intl::QUEST_ACCEPTED}\)),/,
- replace: "...$self.getQuestAcceptedButtonProps(arguments[0].quest,$2,$1,undefined),"
- },
- {
- // Does the above for resuming Play Activity Quests.
- match: /(?<=icon:.{0,35}?onClick:(.{0,20}?),text:(\i),fullWidth:!0)/,
- replace: ",...$self.getQuestAcceptedButtonProps(arguments[0].quest,$2,false,$1)"
- }
- ]
- },
- // MARK: TODO 2 END
- {
- // Prevents the new Quests location from counting as part of the
- // DM button highlight logic while the Quest button is visible.
- find: "GLOBAL_DISCOVERY),",
- replacement: {
- match: /(pathname:(\i)}.{0,400}?return )/,
- replace: "$1$self.disguiseHomeButton($2)?false:"
- }
- }
],
- contextMenus: {
- "quests-entry": QuestTileContextMenu
- },
-
flux: {
- CHANNEL_SELECT(data) {
- settings.store.onQuestsPage = (window.location.pathname === questPath);
- },
+ CHANNEL_SELECT() { setOnQuestsPage(); },
- QUESTS_FETCH_CURRENT_QUESTS_SUCCESS(data) {
+ QUESTS_FETCH_CURRENT_QUESTS_SUCCESS(data: { quests: Quest[]; }): void {
initialQuestDataFetched = true;
- const source = data.source ? ` [${data.source}]` : "";
- QuestifyLogger.info(`[${getFormattedNow()}] [QUESTS_FETCH_CURRENT_QUESTS_SUCCESS]${source}\n`, data);
- validateAndOverwriteIgnoredQuests(undefined, data.quests);
+ QL.log("QUESTS_FETCH_CURRENT_QUESTS_SUCCESS", data);
+ validateIgnoredQuests(data.quests);
+ resumeAutoCompletesIfReady();
},
- QUESTS_ENROLL_SUCCESS(data) {
- QuestifyLogger.info(`[${getFormattedNow()}] [QUESTS_ENROLL_SUCCESS]\n`, data);
- fetchAndDispatchQuests("Questify", QuestifyLogger);
- validateAndOverwriteIgnoredQuests();
+ QUESTS_ENROLL_SUCCESS(data: any): void {
+ QL.log("QUESTS_ENROLL_SUCCESS", data);
+ validateIgnoredQuests();
},
- QUESTS_CLAIM_REWARD_SUCCESS(data) {
- QuestifyLogger.info(`[${getFormattedNow()}] [QUESTS_CLAIM_REWARD_SUCCESS]\n`, data);
- fetchAndDispatchQuests("Questify", QuestifyLogger);
- validateAndOverwriteIgnoredQuests();
+ QUESTS_CLAIM_REWARD_SUCCESS(data: any): void {
+ QL.log("QUESTS_CLAIM_REWARD_SUCCESS", data);
+ validateIgnoredQuests();
},
- QUESTS_USER_STATUS_UPDATE(data) {
- QuestifyLogger.info(`[${getFormattedNow()}] [QUESTS_USER_STATUS_UPDATE]\n`, data);
- validateAndOverwriteIgnoredQuests();
- },
+ QUESTS_USER_STATUS_UPDATE(data: any): void {
+ QL.log("QUESTS_USER_STATUS_UPDATE", data);
- // Stops any Game Quest background completion intervals for running games to prevent duplicate heartbeats.
- // This will also update the button text back to "Quest Accepted" from "Resume" if the Quest is in progress.
- RUNNING_GAMES_CHANGE(data) {
- const gameIDs: string[] = data.games.map(game => game.id);
- let shouldRerenderQuests = false;
+ const userStatus = snakeToCamel(data).userStatus as QuestUserStatus | undefined;
+ const claimedAt = !!userStatus?.claimedAt;
+ const completedRecently = userStatus?.completedAt
+ ? Date.now() - new Date(userStatus.completedAt).getTime() <= 5000
+ : false;
- (Array.from(QuestsStore.quests.values()) as Quest[]).forEach(quest => {
- const questName = normalizeQuestName(quest.config.messages.questName);
- const questAppID = quest.config.application.id;
+ validateIgnoredQuests();
- if (gameIDs.includes(questAppID) && activeQuestIntervals.has(quest.id)) {
- const intervalData = activeQuestIntervals.get(quest.id);
- clearInterval(intervalData?.progressTimeout);
- clearTimeout(intervalData?.rerenderTimeout);
- activeQuestIntervals.delete(quest.id);
- QuestifyLogger.info(`[${getFormattedNow()}] Application for Quest ${questName} that was being completed in the background has been launched. Stopping background completion to prevent duplicate heartbeats.`);
- shouldRerenderQuests = true;
+ if (completedRecently && !claimedAt && !notifiedCompletedQuests.has(userStatus!.questId)) {
+ notifiedCompletedQuests.add(userStatus!.questId);
+
+ if (settings.store.notifyOnQuestComplete) {
+ notifyQuestCompletion(QuestStore.getQuest(userStatus!.questId));
}
- });
- if (shouldRerenderQuests) {
- rerenderQuests();
+ if (settings.store.questCompletedAlertSound) {
+ playAudio(
+ settings.store.questCompletedAlertSound,
+ { volume: Math.max(0, Math.min(100, settings.store.questCompletedAlertVolume)) }
+ );
+ }
}
},
- LOGOUT(data) {
- settings.store.unclaimedUnignoredQuests = 0;
- settings.store.onQuestsPage = false;
+ USER_SETTINGS_MODAL_OPEN(): void {
+ setSettingsModalOpen(true);
},
- LOGIN_SUCCESS(data) {
- onceReady.then(() => {
- fetchAndDispatchQuests("Questify", QuestifyLogger);
- });
+ USER_SETTINGS_MODAL_CLOSE(): void {
+ setSettingsModalOpen(false);
+ promptToRestartIfDirty();
+ },
+
+ LOGIN_SUCCESS(): void {
+ if (!isSwitchingAccount || settings.store.disableQuestsEverything) {
+ return;
+ } else {
+ isSwitchingAccount = false;
+ }
+
+ initialQuestDataFetched = false;
+ didAttemptAutoCompleteResume = false;
+ startPerAccountTasks("LOGIN_SUCCESS");
+ },
+
+ LOGOUT(data: { isSwitchingAccount?: boolean; }): void {
+ if (!data.isSwitchingAccount) {
+ return;
+ } else {
+ isSwitchingAccount = true;
+ }
+
+ initialQuestDataFetched = false;
+ stopPerAccountTasks("LOGOUT");
+ },
+
+ RUNNING_GAMES_CHANGE(data: { games: { id: string; }[]; }): void {
+ stopAutoCompletesForRunningGames(data.games.map(game => game.id));
}
},
+ contextMenus: {
+ "quests-entry": QuestTileContextMenu,
+ },
+
renderQuestifyButton: ErrorBoundary.wrap(QuestButton, { noop: true }),
start() {
+ initializeRestartTracking(settings);
addServerListElement(ServerListRenderPosition.Above, this.renderQuestifyButton);
- const interval = settings.store.fetchingQuestsInterval;
- const intervalValid = interval >= minimumAutoFetchIntervalValue && interval <= maximumAutoFetchIntervalValue;
-
- if (!!intervalValid && autoFetchCompatible()) {
- startAutoFetchingQuests();
- }
-
- const wasReload = window?.navigation?.activation?.navigationType === "reload";
- const maybeResumable = !(settings.store.disableQuestsEverything || settings.store.disableQuestsFetchingQuests);
-
- if (!wasReload || !maybeResumable) {
- resetQuestsToResume();
- return;
- }
onceReady.then(() => {
- const interval = setInterval(() => {
- if (initialQuestDataFetched) {
- clearInterval(interval);
-
- const playResume = settings.store.resumeQuestIDs.play.map(id => QuestsStore.getQuest(id)).filter(Boolean);
- const watchResume = settings.store.resumeQuestIDs.watch.map(id => QuestsStore.getQuest(id)).filter(Boolean);
- const achievementResume = settings.store.resumeQuestIDs.achievement.map(id => QuestsStore.getQuest(id)).filter(Boolean);
-
- settings.store.resumeQuestIDs.play = playResume.map(quest => quest!.id);
- settings.store.resumeQuestIDs.watch = watchResume.map(quest => quest!.id);
- settings.store.resumeQuestIDs.achievement = achievementResume.map(quest => quest!.id);
-
- if (!playResume.length && !watchResume.length && !achievementResume.length) {
- return;
- }
-
- QuestifyLogger.info(`[${getFormattedNow()}] Resuming background completion for Quests:`, {
- play: playResume.map(q => q.config.messages.questName),
- watch: watchResume.map(q => q.config.messages.questName),
- achievement: achievementResume.map(q => q.config.messages.questName)
- });
-
- [...playResume, ...watchResume, ...achievementResume].forEach(quest => quest && processQuestForAutoComplete(quest));
- }
- }, 100);
+ if (!settings.store.disableQuestsEverything) {
+ startPerAccountTasks("PLUGIN_START");
+ } else {
+ removeServerListElement(ServerListRenderPosition.Above, this.renderQuestifyButton);
+ }
});
},
stop() {
- removeServerListElement(ServerListRenderPosition.Above, this.renderQuestifyButton);
- stopAutoFetchingQuests();
+ const pluginEnabled = Settings.plugins.Questify?.enabled;
- if (!Settings.plugins.Questify.enabled) {
+ disposeRestartTracking();
+ removeServerListElement(ServerListRenderPosition.Above, this.renderQuestifyButton);
+ stopPerAccountTasks("PLUGIN_STOP", pluginEnabled);
+
+ if (!pluginEnabled) {
resetQuestsToResume();
}
-
- activeQuestIntervals.forEach((intervalData, questId) => {
- const quest = QuestsStore.getQuest(questId);
-
- if (intervalData.type === "play" && quest) {
- void reportPlayGameQuestProgress(refreshQuest(quest), true, QuestifyLogger, {
- attempts: 3,
- delay: 2500,
- });
- }
-
- clearInterval(intervalData.progressTimeout);
- clearTimeout(intervalData.rerenderTimeout);
- });
-
- activeQuestIntervals.clear();
}
});
diff --git a/src/equicordplugins/questify/native.ts b/src/equicordplugins/questify/native.ts
index 93eed6818..1a852d895 100644
--- a/src/equicordplugins/questify/native.ts
+++ b/src/equicordplugins/questify/native.ts
@@ -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 };
}
diff --git a/src/equicordplugins/questify/settings.tsx b/src/equicordplugins/questify/settings.tsx
deleted file mode 100644
index e50b43cd3..000000000
--- a/src/equicordplugins/questify/settings.tsx
+++ /dev/null
@@ -1,2002 +0,0 @@
-/*
- * Vencord, a Discord client mod
- * Copyright (c) 2025 Vendicated and contributors
- * SPDX-License-Identifier: GPL-3.0-or-later
- */
-
-import { defaultAudioNames, playAudio } from "@api/AudioPlayer";
-import { definePluginSettings } from "@api/Settings";
-import { Divider } from "@components/Divider";
-import ErrorBoundary from "@components/ErrorBoundary";
-import { Heading } from "@components/Heading";
-import { Paragraph } from "@components/Paragraph";
-import { Logger } from "@utils/Logger";
-import { makeRange, OptionType } from "@utils/types";
-import { Button, ColorPicker, ContextMenuApi, Menu, Select, Slider, TextInput, useEffect, useRef, useState } from "@webpack/common";
-import { JSX } from "react";
-
-import { activeQuestIntervals, getQuestTileClasses, getQuestTileStyle } from "./index";
-import { DynamicDropdown, DynamicDropdownSettingOption, ExcludedQuest, GuildlessServerListItem, Quest, QuestIcon, QuestRewardType, QuestStatus, QuestTile, RadioGroup, RadioOption, SelectOption, SoundIcon } from "./utils/components";
-import { decimalToRGB, fetchAndDispatchQuests, getFormattedNow, getIgnoredQuestIDs, getQuestStatus, isDarkish, isSoundAllowed, leftClick, middleClick, q, QuestifyLogger, QuestsStore, rightClick, setIgnoredQuestIDs, validCommaSeparatedList } from "./utils/misc";
-
-let autoFetchInterval: null | ReturnType = null;
-const defaultLeftClickAction = "open-quests";
-const defaultMiddleClickAction = "plugin-settings";
-const defaultRightClickAction = "context-menu";
-const defaultQuestButtonDisplay = "always";
-const defaultQuestButtonUnclaimed = "both";
-const defaultQuestOrder = "UNCLAIMED, CLAIMED, IGNORED, EXPIRED";
-const defaultUnclaimedColor = 2842239;
-const defaultClaimedColor = 6105983;
-const defaultIgnoredColor = 8334124;
-const defaultExpiredColor = 2368553;
-const defaultRestyleQuestsGradient = "intense";
-const defaultFetchQuestsAlert = "discodo";
-export const minimumAutoFetchIntervalValue = 30 * 60;
-export const maximumAutoFetchIntervalValue = 12 * 60 * 60;
-
-const rerenderCallbacks = new Set<() => void>();
-
-export function addRerenderCallback(callback: () => void): () => void {
- rerenderCallbacks.add(callback);
- return () => rerenderCallbacks.delete(callback);
-}
-
-export function rerenderQuests(): void {
- settings.store.triggerQuestsRerender = !settings.store.triggerQuestsRerender;
- rerenderCallbacks.forEach(callback => callback());
-}
-
-export function fetchAndAlertQuests(source: string, logger: Logger): void {
- const { questRewardIncludeRewardCode, questRewardIncludeNitroCode, questRewardIncludeCollectibles, questRewardIncludeInGame, questRewardIncludeOrbs } = settings.store;
-
- const rewardTypeToSettingMap = {
- [QuestRewardType.REWARD_CODE]: questRewardIncludeRewardCode,
- [QuestRewardType.IN_GAME]: questRewardIncludeInGame,
- [QuestRewardType.COLLECTIBLE]: questRewardIncludeCollectibles,
- [QuestRewardType.VIRTUAL_CURRENCY]: questRewardIncludeOrbs,
- [QuestRewardType.FRACTIONAL_PREMIUM]: questRewardIncludeNitroCode
- };
-
- const currentQuests = Array.from(QuestsStore.quests.values()) as Quest[];
-
- fetchAndDispatchQuests(source, logger).then(newQuests => {
- if (newQuests !== null && Array.isArray(newQuests) && currentQuests.length > 0) {
- const currentIds = new Set(currentQuests.map((q: Quest) => q.id));
- const newOnly = newQuests.filter((q: Quest) => !currentIds.has(q.id));
-
- const newOnlyFiltered = newOnly.filter(quest => {
- const rewardType = getQuestRewardType(quest);
- return rewardType in rewardTypeToSettingMap && rewardTypeToSettingMap[rewardType];
- });
-
- if (newOnly.length > 0) {
- const shouldAlert = settings.store.fetchingQuestsAlert;
-
- if (shouldAlert && newOnlyFiltered.length > 0) {
- logger.info(`[${getFormattedNow()}] New Quests detected. Playing alert sound.`);
- playAudio(shouldAlert, { volume: settings.store.fetchingQuestsAlertVolume });
- } else {
- logger.info(`[${getFormattedNow()}] New Quests detected.`);
- }
- }
- }
- });
-}
-
-function checkAutoFetchInterval(interval: number | null): void {
- if (!!interval && autoFetchCompatible()) {
- startAutoFetchingQuests();
- } else {
- stopAutoFetchingQuests();
- }
-}
-
-export function startAutoFetchingQuests(seconds?: number): void {
- if (autoFetchInterval) {
- clearInterval(autoFetchInterval);
- autoFetchInterval = null;
- }
-
- const interval = seconds ? seconds * 1000 : settings.store.fetchingQuestsInterval * 1000;
- QuestifyLogger.info(`[${getFormattedNow()}] Starting AutoFetch of Quests every ${(interval / 60000).toFixed(2)} minutes.`);
- autoFetchInterval = setInterval(() => { fetchAndAlertQuests("Questify-AutoFetch", QuestifyLogger); }, interval);
-}
-
-export function stopAutoFetchingQuests(): void {
- if (autoFetchInterval) {
- QuestifyLogger.info(`[${getFormattedNow()}] Stopping AutoFetch of Quests.`);
- clearInterval(autoFetchInterval);
- autoFetchInterval = null;
- }
-}
-
-export function autoFetchCompatible(): boolean {
- const display = settings.store.questButtonDisplay;
- const unclaimed = settings.store.questButtonUnclaimed;
- const fetching = !settings.store.disableQuestsEverything && !settings.store.disableQuestsFetchingQuests;
-
- if (display === "always") {
- return fetching && ["pill", "badge", "both"].includes(unclaimed);
- } else if (display === "unclaimed") {
- return fetching;
- } else {
- return false;
- }
-}
-
-export const intervalScales = {
- second: {
- singular: "Second",
- plural: "Seconds",
- multiplier: 1
- },
- minute: {
- singular: "Minute",
- plural: "Minutes",
- multiplier: 60
- },
- hour: {
- singular: "Hour",
- plural: "Hours",
- multiplier: 60 * 60
- },
- day: {
- singular: "Day",
- plural: "Days",
- multiplier: 24 * 60 * 60
- },
- week: {
- singular: "Week",
- plural: "Weeks",
- multiplier: 7 * 24 * 60 * 60
- }
-};
-
-export function removeIgnoredQuest(questID: string): void {
- const ignoredQuests = getIgnoredQuestIDs();
- const newIgnoredQuests = ignoredQuests.filter(id => id !== questID);
- validateAndOverwriteIgnoredQuests(newIgnoredQuests);
-}
-
-export function addIgnoredQuest(questID: string): void {
- const ignoredQuests = getIgnoredQuestIDs();
- const newIgnoredQuests = ignoredQuests.concat(questID);
- validateAndOverwriteIgnoredQuests(newIgnoredQuests);
-}
-
-export function questIsIgnored(questID: string): boolean {
- const ignoredQuests = getIgnoredQuestIDs();
- return ignoredQuests.includes(questID);
-}
-
-function getQuestRewardType(quest: Quest): QuestRewardType {
- const reward = quest.config.rewardsConfig.rewards[0];
- if (!(reward.type in QuestRewardType)) return QuestRewardType.UNKNOWN;
- return reward.type as QuestRewardType;
-}
-
-export function validateIgnoredQuests(ignoredQuests?: string[], questsData?: Quest[]): [string[], number] {
- const { questRewardIncludeRewardCode, questRewardIncludeNitroCode, questRewardIncludeCollectibles, questRewardIncludeInGame, questRewardIncludeOrbs } = settings.store;
-
- const rewardTypeToSettingMap = {
- [QuestRewardType.REWARD_CODE]: questRewardIncludeRewardCode,
- [QuestRewardType.IN_GAME]: questRewardIncludeInGame,
- [QuestRewardType.COLLECTIBLE]: questRewardIncludeCollectibles,
- [QuestRewardType.VIRTUAL_CURRENCY]: questRewardIncludeOrbs,
- [QuestRewardType.FRACTIONAL_PREMIUM]: questRewardIncludeNitroCode
- };
-
- const quests = questsData ?? Array.from(QuestsStore.quests.values()) as Quest[];
- const excludedQuests = Array.from(QuestsStore.excludedQuests.values()) as ExcludedQuest[];
- const currentlyIgnored = ignoredQuests ? new Set(ignoredQuests) : new Set(getIgnoredQuestIDs(questsData?.[0]?.userStatus?.userId));
- const validIgnored = new Set();
- let numUnclaimedUnignoredQuests = 0;
-
- for (const quest of quests) {
- const questStatus = getQuestStatus(quest, false);
-
- if (currentlyIgnored.has(quest.id)) {
- validIgnored.add(quest.id);
- } else if (questStatus === QuestStatus.Unclaimed) {
- const rewardType = getQuestRewardType(quest);
-
- if (rewardTypeToSettingMap[rewardType]) {
- numUnclaimedUnignoredQuests++;
- }
- }
- }
-
- for (const quest of excludedQuests) {
- if (currentlyIgnored.has(quest.id)) {
- validIgnored.add(quest.id);
- }
- }
-
- return [Array.from(validIgnored), numUnclaimedUnignoredQuests];
-}
-
-export function validateAndOverwriteIgnoredQuests(ignoredQuests?: string[], questsData?: Quest[]): string[] {
- const [validIgnored, numUnclaimedUnignoredQuests] = validateIgnoredQuests(ignoredQuests, questsData);
- settings.store.unclaimedUnignoredQuests = numUnclaimedUnignoredQuests;
- setIgnoredQuestIDs(validIgnored, questsData?.[0]?.userStatus?.userId);
- rerenderQuests();
- return validIgnored;
-}
-
-interface DummyQuestButtonProps {
- visible: boolean;
- selected: boolean;
- showPill: boolean;
- showBadge: boolean;
- badgeColor: number | null;
- leftClickAction: string;
- middleClickAction: string;
- rightClickAction: string;
- onSelectedChange: (selected: boolean) => void;
-}
-
-function DummyQuestButton({
- visible,
- selected,
- showPill,
- showBadge,
- badgeColor,
- leftClickAction,
- middleClickAction,
- rightClickAction,
- onSelectedChange
-}: DummyQuestButtonProps): JSX.Element {
- function handleClick(event: React.MouseEvent) {
- // ListItem does not support onAuxClick, so we have to listen for mousedown events.
- // Ignore left and right clicks sent via mousedown events to prevent double events.
- if (event.type === "mousedown" && event.button !== middleClick) {
- return;
- }
-
- event.preventDefault();
- event.stopPropagation();
-
- let todo: string | null = null;
-
- if (event.button === middleClick) {
- todo = middleClickAction;
- } else if (event.button === rightClick) {
- todo = rightClickAction;
- } else if (event.button === leftClick) {
- todo = leftClickAction;
- }
-
- if (todo === "open-quests") {
- onSelectedChange(!selected);
- } else if (todo === "plugin-settings") {
- } else if (todo === "context-menu") {
- ContextMenuApi.openContextMenu(event, () => (
-
-
-
-
-
- ));
- }
- }
-
- const dummyBadgeColorRGB = badgeColor ? decimalToRGB(badgeColor) : null;
- const lowerBadgeProps = {
- count: showBadge ? 3 : 0,
- maxDigits: 2,
- ...(dummyBadgeColorRGB ? { color: `rgb(${dummyBadgeColorRGB.r},${dummyBadgeColorRGB.g},${dummyBadgeColorRGB.b})` } : {}),
- ...(dummyBadgeColorRGB ? { style: { color: isDarkish(dummyBadgeColorRGB) ? "white" : "black" } } : {})
- };
-
- return (
-
- );
-}
-
-function validateQuestButtonSetting() {
- const {
- questButtonDisplay,
- questButtonUnclaimed,
- questButtonBadgeColor,
- questButtonLeftClickAction,
- questButtonMiddleClickAction,
- questButtonRightClickAction
- } = settings.use([
- "questButtonDisplay",
- "questButtonUnclaimed",
- "questButtonBadgeColor",
- "questButtonLeftClickAction",
- "questButtonMiddleClickAction",
- "questButtonRightClickAction"
- ]);
-
- if (!["always", "unclaimed", "never"].includes(questButtonDisplay)) {
- settings.store.questButtonDisplay = "always";
- }
-
- if (!["pill", "badge", "both", "none"].includes(questButtonUnclaimed)) {
- settings.store.questButtonUnclaimed = "both";
- }
-
- if (typeof questButtonBadgeColor !== "number" && questButtonBadgeColor !== null) {
- settings.store.questButtonBadgeColor = defaultUnclaimedColor;
- }
-
- if (!["open-quests", "context-menu", "plugin-settings", "nothing"].includes(questButtonLeftClickAction)) {
- settings.store.questButtonLeftClickAction = defaultLeftClickAction;
- }
-
- if (!["open-quests", "context-menu", "plugin-settings", "nothing"].includes(questButtonMiddleClickAction)) {
- settings.store.questButtonMiddleClickAction = defaultMiddleClickAction;
- }
-
- if (!["open-quests", "context-menu", "plugin-settings", "nothing"].includes(questButtonRightClickAction)) {
- settings.store.questButtonRightClickAction = defaultRightClickAction;
- }
-}
-
-function validateDisableQuestSetting() {
- const {
- disableQuestsDiscoveryTab,
- disableQuestsFetchingQuests,
- disableQuestsDirectMessagesTab,
- disableQuestsPageSponsoredBanner,
- disableQuestsPopupAboveAccountPanel,
- disableQuestsBadgeOnUserProfiles,
- disableQuestsGiftInventoryRelocationNotice,
- disableFriendsListActiveNowPromotion,
- disableMembersListActivelyPlayingIcon
- } = settings.use([
- "disableQuestsEverything",
- "disableQuestsDiscoveryTab",
- "disableQuestsFetchingQuests",
- "disableQuestsDirectMessagesTab",
- "disableQuestsPageSponsoredBanner",
- "disableQuestsPopupAboveAccountPanel",
- "disableQuestsBadgeOnUserProfiles",
- "disableQuestsGiftInventoryRelocationNotice",
- "disableFriendsListActiveNowPromotion",
- "disableMembersListActivelyPlayingIcon"
- ]);
-
- if (disableQuestsDiscoveryTab || disableQuestsDirectMessagesTab || disableQuestsPageSponsoredBanner || disableQuestsFetchingQuests || disableQuestsPopupAboveAccountPanel || disableQuestsBadgeOnUserProfiles || disableQuestsGiftInventoryRelocationNotice || disableFriendsListActiveNowPromotion || disableMembersListActivelyPlayingIcon) {
- settings.store.disableQuestsEverything = false;
- }
-}
-
-function QuestButtonSettings(): JSX.Element {
- validateQuestButtonSetting();
-
- const {
- questButtonDisplay,
- questRewardIncludeRewardCode,
- questRewardIncludeNitroCode,
- questRewardIncludeCollectibles,
- questRewardIncludeInGame,
- questRewardIncludeOrbs,
- questButtonUnclaimed,
- questButtonBadgeColor,
- questButtonLeftClickAction,
- questButtonMiddleClickAction,
- questButtonRightClickAction
- } = settings.use([
- "questButtonDisplay",
- "questRewardIncludeRewardCode",
- "questRewardIncludeNitroCode",
- "questRewardIncludeCollectibles",
- "questRewardIncludeInGame",
- "questRewardIncludeOrbs",
- "questButtonUnclaimed",
- "questButtonBadgeColor",
- "questButtonLeftClickAction",
- "questButtonMiddleClickAction",
- "questButtonRightClickAction"
- ]);
-
- const questButtonDisplayOptions: RadioOption[] = [
- { name: "Always", value: "always" },
- { name: "Unclaimed", value: "unclaimed" },
- { name: "Never", value: "never" }
- ];
-
- const questButtonUnclaimedOptions: RadioOption[] = [
- { name: "Pill", value: "pill" },
- { name: "Badge", value: "badge" },
- { name: "Both", value: "both" },
- { name: "None", value: "none" }
- ];
-
- const questButtonClickOptions: SelectOption[] = [
- { label: "Open Quests", value: "open-quests" },
- { label: "Context Menu", value: "context-menu" },
- { label: "Plugin Settings", value: "plugin-settings" },
- { label: "Nothing", value: "nothing" }
- ];
-
- const questButtonRewardDisplayOptions: DynamicDropdownSettingOption[] = [
- { label: "Orbs", value: "orbs", selected: questRewardIncludeOrbs },
- { label: "Nitro Codes", value: "nitro-code", selected: questRewardIncludeNitroCode },
- { label: "Reward Codes", value: "reward-code", selected: questRewardIncludeRewardCode },
- { label: "In Game Items", value: "in-game", selected: questRewardIncludeInGame },
- { label: "Profile Collectibles", value: "collectibles", selected: questRewardIncludeCollectibles },
- ];
-
- const [currentRewardsOptions, setCurrentRewardsOptions] = useState(questButtonRewardDisplayOptions.filter(option => option.selected).map(option => option.value));
- const [currentQuestButtonDisplay, setCurrentQuestButtonDisplay] = useState((questButtonDisplayOptions.find(option => option.value === questButtonDisplay) as RadioOption));
- const [currentQuestButtonUnclaimed, setCurrentQuestButtonUnclaimed] = useState((questButtonUnclaimedOptions.find(option => option.value === questButtonUnclaimed) as RadioOption));
- const [currentQuestButtonLeftClickAction, setCurrentQuestButtonLeftClickAction] = useState<"open-quests" | "plugin-settings" | "context-menu" | "nothing">(questButtonLeftClickAction as "open-quests" | "plugin-settings" | "context-menu" | "nothing");
- const [currentQuestButtonMiddleClickAction, setCurrentQuestButtonMiddleClickAction] = useState<"open-quests" | "plugin-settings" | "context-menu" | "nothing">(questButtonMiddleClickAction as "open-quests" | "plugin-settings" | "context-menu" | "nothing");
- const [currentQuestButtonRightClickAction, setCurrentQuestButtonRightClickAction] = useState<"open-quests" | "plugin-settings" | "context-menu" | "nothing">(questButtonRightClickAction as "open-quests" | "plugin-settings" | "context-menu" | "nothing");
- const [currentBadgeColor, setCurrentBadgeColor] = useState((questButtonBadgeColor as number | null));
- const [dummySelected, setDummySelected] = useState(false);
-
- function updateSettingsTruthy(enabled: string[]) {
- questButtonRewardDisplayOptions.forEach(option => {
- option.selected = enabled.includes(option.value);
- });
-
- settings.store.questRewardIncludeRewardCode = enabled.includes("reward-code");
- settings.store.questRewardIncludeNitroCode = enabled.includes("nitro-code");
- settings.store.questRewardIncludeCollectibles = enabled.includes("collectibles");
- settings.store.questRewardIncludeInGame = enabled.includes("in-game");
- settings.store.questRewardIncludeOrbs = enabled.includes("orbs");
-
- setCurrentRewardsOptions(enabled);
- validateAndOverwriteIgnoredQuests();
- }
-
- function handleQuestRewardDisplayChange(values: Array) {
- const parsedValues = values.map(v => typeof v === "string" ? questButtonRewardDisplayOptions.find(option => option.value === v) as DynamicDropdownSettingOption : v);
-
- if (values.length === 0) {
- updateSettingsTruthy([]);
- return;
- }
-
- const selectedOption = values.length > currentRewardsOptions.length ? values[values.length - 1] as string : currentRewardsOptions.find(v => !values.includes(v)) as string;
- const stringlessValues = parsedValues.filter(v => v.value !== selectedOption);
- const option = questButtonRewardDisplayOptions.find(option => option.value === selectedOption) as DynamicDropdownSettingOption;
-
- if (option.selected) {
- updateSettingsTruthy(stringlessValues.filter(v => v.value !== selectedOption).map(v => v.value));
- } else {
- updateSettingsTruthy([...stringlessValues.map(v => v.value), option.value]);
- }
- }
-
- function handleQuestButtonDisplayChange(value: RadioOption) {
- setCurrentQuestButtonDisplay(value);
- settings.store.questButtonDisplay = value.value;
- checkAutoFetchInterval(settings.store.fetchingQuestsInterval);
- }
-
- function handleQuestButtonUnclaimedChange(value: RadioOption) {
- setCurrentQuestButtonUnclaimed(value);
- settings.store.questButtonUnclaimed = value.value;
- checkAutoFetchInterval(settings.store.fetchingQuestsInterval);
- }
-
- function handleBadgeColorChange(value: number | null) {
- setCurrentBadgeColor(value);
- settings.store.questButtonBadgeColor = value as any;
- }
-
- function handleLeftClickActionChange(value: "open-quests" | "context-menu" | "plugin-settings" | "nothing") {
- setCurrentQuestButtonLeftClickAction(value);
- settings.store.questButtonLeftClickAction = value;
- }
-
- function handleMiddleClickActionChange(value: "open-quests" | "context-menu" | "plugin-settings" | "nothing") {
- setCurrentQuestButtonMiddleClickAction(value);
- settings.store.questButtonMiddleClickAction = value;
- }
-
- function handleRightClickActionChange(value: "open-quests" | "context-menu" | "plugin-settings" | "nothing") {
- setCurrentQuestButtonRightClickAction(value);
- settings.store.questButtonRightClickAction = value;
- }
-
- return (
-
-
-
-
-
-
-
- Quest Button
-
-
- Show a Quest button in the server list with an optional indicator for unclaimed Quests.
-
-
-
-
-
-
-
-
-
- Left Click Action
-
-
-
-
- Middle Click Action
-
-
-
-
- Right Click Action
-
-
-
-
-
-
- Button Visibility
-
-
-
- Badge Color
-
-
-
-
-
-
-
-
-
- Unclaimed Indicator
-
-
-
-
-
-
-
- Included Reward Types
-
-
- Only count Quests with these reward types as unclaimed when determining button
- visibility, badge count, and when playing the alert sound.
-
-
-
-
-
-
-
-
- );
-}
-
-function DisableQuestsSetting(): JSX.Element {
- validateDisableQuestSetting();
-
- const {
- disableQuestsEverything,
- disableQuestsDiscoveryTab,
- disableQuestsFetchingQuests,
- disableQuestsDirectMessagesTab,
- disableQuestsPageSponsoredBanner,
- disableQuestsPopupAboveAccountPanel,
- disableQuestsBadgeOnUserProfiles,
- disableQuestsGiftInventoryRelocationNotice,
- disableFriendsListActiveNowPromotion,
- disableMembersListActivelyPlayingIcon,
- makeMobileQuestsDesktopCompatible,
- completeVideoQuestsQuicker,
- completeVideoQuestsInBackground,
- completeGameQuestsInBackground,
- completeAchievementQuestsInBackground,
- notifyOnQuestComplete
- } = settings.use([
- "disableQuestsEverything",
- "disableQuestsDiscoveryTab",
- "disableQuestsFetchingQuests",
- "disableQuestsDirectMessagesTab",
- "disableQuestsPageSponsoredBanner",
- "disableQuestsPopupAboveAccountPanel",
- "disableQuestsBadgeOnUserProfiles",
- "disableQuestsGiftInventoryRelocationNotice",
- "disableFriendsListActiveNowPromotion",
- "disableMembersListActivelyPlayingIcon",
- "makeMobileQuestsDesktopCompatible",
- "completeVideoQuestsQuicker",
- "completeVideoQuestsInBackground",
- "completeGameQuestsInBackground",
- "completeAchievementQuestsInBackground",
- "notifyOnQuestComplete",
- ]);
-
- const options: DynamicDropdownSettingOption[] = [
- { label: "Disable Everything", value: "everything", selected: disableQuestsEverything, type: "disable" },
- { label: "Disable Fetching Quests", value: "fetching", selected: disableQuestsFetchingQuests, type: "disable" },
- { label: "Disable Quests Tab in DMs", value: "dms", selected: disableQuestsDirectMessagesTab, type: "disable" },
- { label: "Disable Badge on User Profiles", value: "badge", selected: disableQuestsBadgeOnUserProfiles, type: "disable" },
- { label: "Disable Popup Above User Panel", value: "popup", selected: disableQuestsPopupAboveAccountPanel, type: "disable" },
- { label: "Disable Discovery Tab Relocation Notice", value: "discovery", selected: disableQuestsDiscoveryTab, type: "disable" },
- { label: "Disable Gift Inventory Relocation Notice", value: "inventory", selected: disableQuestsGiftInventoryRelocationNotice, type: "disable" },
- { label: "Disable Sponsored Banner on Quests Page", value: "sponsored-banner", selected: disableQuestsPageSponsoredBanner, type: "disable" },
- { label: "Disable Friends List Active Now Promotion", value: "friends-list", selected: disableFriendsListActiveNowPromotion, type: "disable" },
- { label: "Disable Members List Actively Playing Icon", value: "members-list", selected: disableMembersListActivelyPlayingIcon, type: "disable" },
- { label: "Complete Watch Video Quests in Background", value: "video-quests-background", selected: completeVideoQuestsInBackground, type: "modification" },
- { label: "Complete Play Game/Activity Quests in Background", value: "game-quests-background", selected: completeGameQuestsInBackground, type: "modification" },
- { label: "Complete Task in Activity Quests in Background", value: "achievement-quests-background", selected: completeAchievementQuestsInBackground, type: "modification" },
- { label: "Complete Watch Video Quests Quicker", value: "video-quests-quicker", selected: completeVideoQuestsQuicker, type: "modification" },
- { label: "Make Mobile Quests Desktop Compatible", value: "mobile-desktop-compatible", selected: makeMobileQuestsDesktopCompatible, type: "modification" },
- { label: "Notify on Auto-Complete", value: "notify-on-complete", selected: notifyOnQuestComplete, type: "modification" },
- ];
-
- const disableOptions = options.filter(option => option.type === "disable");
- const modificationOptions = options.filter(option => option.type === "modification");
- const everythingOnly = options.find(option => option.value === "everything") as DynamicDropdownSettingOption;
- const [currentValue, setCurrentValue] = useState(options.filter(option => option.selected).map(option => option.value));
-
- function updateSettingsTruthy(enabled: string[]) {
- options.forEach(option => {
- option.selected = enabled.includes(option.value);
- });
-
- const redoAutoFetch = (
- settings.store.disableQuestsEverything !== enabled.includes("everything") ||
- settings.store.disableQuestsFetchingQuests !== enabled.includes("fetching")
- );
-
- settings.store.disableQuestsEverything = enabled.includes("everything");
- settings.store.disableQuestsDiscoveryTab = enabled.includes("discovery");
- settings.store.disableQuestsFetchingQuests = enabled.includes("fetching");
- settings.store.disableQuestsDirectMessagesTab = enabled.includes("dms");
- settings.store.disableQuestsPageSponsoredBanner = enabled.includes("sponsored-banner");
- settings.store.disableQuestsPopupAboveAccountPanel = enabled.includes("popup");
- settings.store.disableQuestsBadgeOnUserProfiles = enabled.includes("badge");
- settings.store.disableQuestsGiftInventoryRelocationNotice = enabled.includes("inventory");
- settings.store.disableFriendsListActiveNowPromotion = enabled.includes("friends-list");
- settings.store.disableMembersListActivelyPlayingIcon = enabled.includes("members-list");
- settings.store.makeMobileQuestsDesktopCompatible = enabled.includes("mobile-desktop-compatible");
- settings.store.completeVideoQuestsQuicker = enabled.includes("video-quests-quicker");
- settings.store.completeVideoQuestsInBackground = enabled.includes("video-quests-background");
- settings.store.completeGameQuestsInBackground = enabled.includes("game-quests-background");
- settings.store.completeAchievementQuestsInBackground = enabled.includes("achievement-quests-background");
- settings.store.notifyOnQuestComplete = enabled.includes("notify-on-complete");
-
- redoAutoFetch ? checkAutoFetchInterval(settings.store.fetchingQuestsInterval) : null;
- setCurrentValue(enabled.map(value => typeof value === "string" ? value : (value as any).value));
- }
-
- function handleChange(values: Array) {
- const parsedValues = values.map(v => typeof v === "string" ? options.find(option => option.value === v) as DynamicDropdownSettingOption : v);
-
- if (values.length === 0) {
- updateSettingsTruthy([]);
- return;
- }
-
- const selectedOption = values.length > currentValue.length ? values[values.length - 1] as string : currentValue.find(v => !values.includes(v)) as string;
- const stringlessValues = parsedValues.filter(v => v.value !== selectedOption);
-
- if (selectedOption === "everything") {
- if (everythingOnly.selected) {
- // If was already selected when clicked.
- updateSettingsTruthy(stringlessValues.filter(option => option.value !== selectedOption).map(option => option.value));
- } else {
- updateSettingsTruthy([...stringlessValues.filter(option => option.type !== "disable").map(option => option.value), selectedOption]);
- }
- } else if (disableOptions.some(option => option.value === selectedOption)) {
- const option = disableOptions.find(option => option.value === selectedOption) as DynamicDropdownSettingOption;
-
- if (option.selected) {
- // If was already selected when clicked.
- updateSettingsTruthy(stringlessValues.filter(option => option.value !== selectedOption).map(option => option.value));
- } else {
- updateSettingsTruthy([...stringlessValues.map(option => option.value), selectedOption]);
- }
- } else if (modificationOptions.some(option => option.value === selectedOption)) {
- const option = modificationOptions.find(option => option.value === selectedOption) as DynamicDropdownSettingOption;
-
- if (option.selected) {
- // If was already selected when clicked.
- updateSettingsTruthy(stringlessValues.map(option => option.value));
- } else {
- updateSettingsTruthy([...stringlessValues.map(option => option.value), selectedOption]);
- }
- }
- }
-
- return (
-
-
-
-
-
- Quest Features
-
-
- Modify specific Quest features.
-
- The Disable Quest Popup Above Account Panel option
- will be ignored for completed Quests and Quest progress tracking.
-
- The Complete Video Quests in Background option
- will wait for the duration of the Video Quest and mark it as completed automatically.
-
- Similarly, the Complete Play Game/Activity Quests in Background option
- will wait for the duration of the Game Quest and mark it as completed automatically. This option is only supported
- on the official desktop client.
-
- The Complete Task in Activity Quests in Background option
- will immediately mark Task in Activity Quests as completed when started.
-
- The Complete Video Quests Quicker option
- uses Discord's progress leeway and elapsed enrollment time for Video Quests. When disabled, Video
- Quests wait for the full duration at 1x speed, which may appear less suspicious to their systems.
-
- You still must start the Quests manually. The first click will start the Quests in the background.
- For Video Quests, subsequent clicks will open the video modal as normal. For Activity Quests, subsequent clicks
- will open the activity as normal. To abort the Quests, you can open the context menu on the Quest tile and
- select Stop Auto-Complete.
-
-
- Using any of the Auto-Complete options is against Discord's TOS. Discord has been known to warn users
- that automate Quest completion with threat of loss of access to Quests. There has been no reports of account
- limitations or bans, but Discord may change their stance at any time. Use at your own risk.
-
-
-
-
-
-
- );
-}
-
-const DummyQuestPreview = ({ quest, dummyColor, dummyGradient }: { quest: Quest; dummyColor: number | null; dummyGradient: string; }) => {
- const classes = getQuestTileClasses("", quest, dummyColor, dummyGradient);
-
- return (
-
- );
-};
-
-function RestyleQuestsSetting() {
- const {
- restyleQuestsUnclaimed,
- restyleQuestsClaimed,
- restyleQuestsIgnored,
- restyleQuestsExpired,
- restyleQuestsGradient,
- restyleQuestsPreload
- } = settings.use([
- "restyleQuestsUnclaimed",
- "restyleQuestsClaimed",
- "restyleQuestsIgnored",
- "restyleQuestsExpired",
- "restyleQuestsGradient",
- "restyleQuestsPreload",
- ]);
-
- const [unclaimedColor, setUnclaimedColor] = useState(restyleQuestsUnclaimed);
- const [claimedColor, setClaimedColor] = useState(restyleQuestsClaimed);
- const [ignoredColor, setIgnoredColor] = useState(restyleQuestsIgnored);
- const [expiredColor, setExpiredColor] = useState(restyleQuestsExpired);
- const [restyleQuestsGradientValue, setRestyleQuestsGradientValue] = useState(restyleQuestsGradient);
- const [restyleQuestsPreloadValue, setRestyleQuestsPreloadValue] = useState(restyleQuestsPreload);
- const [dummyColor, setDummyColor] = useState(restyleQuestsUnclaimed);
- const [dummyGradient, setDummyGradient] = useState(restyleQuestsGradient);
-
- const [hasQuests, setHasQuests] = useState(false);
- const [dummyQuest, setDummyQuest] = useState(null);
- const dummyQuestStyle = getQuestTileStyle(dummyQuest);
-
- useEffect(() => {
- const handleChange = () => {
- if (QuestsStore.quests.size > 0) {
- if (!dummyQuest) {
- const questArray = Array.from(QuestsStore.quests.values()) as Quest[];
- const questIndex = Math.floor(Math.random() * questArray.length);
- const questData = structuredClone(questArray[questIndex]);
- questData.dummyColor = dummyColor as any;
- setDummyQuest(questData);
- } else if (dummyQuest) {
- const updatedQuest = structuredClone(dummyQuest);
- updatedQuest.dummyColor = dummyColor as any;
- setDummyQuest(updatedQuest);
- }
-
- setHasQuests(true);
- } else {
- setHasQuests(false);
- setDummyQuest(null);
- }
- };
-
- QuestsStore.addChangeListener(handleChange);
- handleChange();
-
- return () => QuestsStore.removeChangeListener(handleChange);
- }, [dummyColor]);
-
- function handleRestyleChange(colorIndex: number, newColorValue: number | null) {
- if (colorIndex === 0) setUnclaimedColor(newColorValue);
- if (colorIndex === 1) setClaimedColor(newColorValue);
- if (colorIndex === 2) setIgnoredColor(newColorValue);
- if (colorIndex === 3) setExpiredColor(newColorValue);
- setDummyColor(newColorValue);
-
- settings.store.restyleQuestsUnclaimed = colorIndex === 0 ? newColorValue : unclaimedColor as any;
- settings.store.restyleQuestsClaimed = colorIndex === 1 ? newColorValue : claimedColor as any;
- settings.store.restyleQuestsIgnored = colorIndex === 2 ? newColorValue : ignoredColor as any;
- settings.store.restyleQuestsExpired = colorIndex === 3 ? newColorValue : expiredColor as any;
- }
-
- function handleGradientChange(value: "intense" | "default" | "black" | "hide") {
- setDummyGradient(value);
- setRestyleQuestsGradientValue(value);
- settings.store.restyleQuestsGradient = value;
- }
-
- function handlePreloadChange(value: boolean) {
- setRestyleQuestsPreloadValue(value);
- settings.store.restyleQuestsPreload = value;
- }
-
- const colorPickers = [
- {
- label: "Unclaimed",
- idx: 0,
- defaultValue: defaultUnclaimedColor,
- value: unclaimedColor
- },
- {
- label: "Claimed",
- idx: 1,
- defaultValue: defaultClaimedColor,
- value: claimedColor
- },
- {
- label: "Ignored",
- idx: 2,
- defaultValue: defaultIgnoredColor,
- value: ignoredColor
- },
- {
- label: "Expired",
- idx: 3,
- defaultValue: defaultExpiredColor,
- value: expiredColor
- }
- ];
-
- 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" }
- ];
-
- const preloadOptions = [
- { label: "Load All Quest Assets On Page Load", value: true },
- { label: "Load Quest Assets During Page Scroll", value: false }
- ];
-
- return (
-
-
-
-
-
-
- Restyle Quests
-
-
- Highlight Quests with optional theme colors for visibility.
-
- Claimed and Expired Quest styles will take precedence even if a Quest is ignored.
-
-
-
-
-
- Gradient Style
-
-
-
-
- Asset Preload
-
-
-
-
- {colorPickers.map(({ label, idx, defaultValue, value }) => (
-
-
- {label}
-
-
-
handleRestyleChange(idx, newValue)}
- showEyeDropper={true}
- />
-
-
-
-
-
-
- ))}
-
-
- {hasQuests && dummyQuest && (
-
- )}
-
-
-
-
- );
-}
-
-function ReorderQuestsSetting(): JSX.Element {
- const [reorderQuests, setReorderQuests] = useState(settings.store.reorderQuests);
-
- const {
- unclaimedSubsort,
- claimedSubsort,
- ignoredSubsort,
- expiredSubsort,
- ignoredQuestProfile,
- rememberQuestPageSort,
- rememberQuestPageFilters
- } = settings.use([
- "unclaimedSubsort",
- "claimedSubsort",
- "ignoredSubsort",
- "expiredSubsort",
- "ignoredQuestProfile",
- "rememberQuestPageSort",
- "rememberQuestPageFilters"
- ]);
-
- const getSubsortOptions = (source: string): SelectOption[] => {
- const baseOptions = [
- { label: "Added (Newest)", value: "Recent DESC" },
- { label: "Added (Oldest)", value: "Recent ASC" }
- ];
-
- if (source === "expired") {
- baseOptions.push(
- { label: "Expired (Most Recent)", value: "Expiring DESC" },
- { label: "Expired (Least Recent)", value: "Expiring ASC" }
- );
- } else if (source !== "claimed") {
- baseOptions.push(
- { label: "Expiring (Soonest)", value: "Expiring ASC" },
- { label: "Expiring (Latest)", value: "Expiring DESC" }
- );
- }
-
- if (source === "claimed") {
- baseOptions.push(
- { label: "Claimed (Most Recent)", value: "Claimed DESC" },
- { label: "Claimed (Least Recent)", value: "Claimed ASC" }
- );
- }
-
- return baseOptions;
- };
-
- return (
-
-
-
-
-
-
- Reorder Quests
-
-
- Sort Quests by their status. Applied when the "Questify" sort option is selected on the Quest page.
-
- Comma-separated list must contain all of: UNCLAIMED, CLAIMED, IGNORED, EXPIRED.
-
-
-
- {
- const trimmedValue = newValue.toUpperCase();
- const isValid = validCommaSeparatedList(trimmedValue, ["UNCLAIMED", "CLAIMED", "IGNORED", "EXPIRED"], true, true, true, false);
- setReorderQuests(trimmedValue);
-
- if (isValid) {
- const cleaned = trimmedValue
- .split(",")
- .map(item => item.trim())
- .join(", ");
- settings.store.reorderQuests = cleaned;
- }
- }}
- placeholder="You must include all of UNCLAIMED, CLAIMED, IGNORED, EXPIRED"
- error={
- validCommaSeparatedList(reorderQuests, ["UNCLAIMED", "CLAIMED", "IGNORED", "EXPIRED"], false, true, true, false)
- ? undefined
- : "Invalid format."
- }
- >
-
-
-
-
-
- Unclaimed Subsort
-
-
-
-
- Claimed Subsort
-
-
-
-
-
-
- Ignored Subsort
-
-
-
-
- Expired Subsort
-
-
-
-
-
-
- Ignored Quest Profile
-
-
-
-
-
-
- Remember Sort Choice
-
-
-
-
- Remember Filter Choice
-
-
-
-
-
- This sort and filter choice refers to the built-in sort and filter options on the Quest page.
- The custom sorting above is only applied when the "Questify" sort option is selected on the Quest page.
- If remembering is disabled, the sort or filter options will be reset each time you open the Quest page.
-
-
-
-
-
- );
-}
-
-function FetchingQuestsSetting(): JSX.Element {
- const {
- fetchingQuestsInterval,
- fetchingQuestsAlert
- } = settings.use([
- "fetchingQuestsInterval",
- "fetchingQuestsAlert"
- ]);
-
- const allowedScales: Record = {
- minutes: { singular: "Minute", plural: "Minutes", multiplier: 60 },
- hours: { singular: "Hour", plural: "Hours", multiplier: 60 * 60 },
- };
-
- const resolvedIntervals: SelectOption[] = [
- { value: 0, label: "Disabled" },
- { value: 60 * 30, label: "30 Minutes" },
- { value: 60 * 60, label: "1 Hour" },
- { value: 60 * 60 * 2, label: "3 Hours" },
- { value: 60 * 60 * 4, label: "6 Hours" },
- { value: 60 * 60 * 6, label: "12 Hours" },
- ];
-
- const resolvedSounds: SelectOption[] = defaultAudioNames().map(sound => {
- const label = sound.toUpperCase().replace(/_/g, " ").replace(/(\d+)/g, " $1");
- return { value: sound, label };
- });
-
- function createIntervalSelectOptionFromValue(value: number): SelectOption {
- const existingOption = resolvedIntervals.find(option => option.value === value);
- if (existingOption) { return existingOption; }
-
- const relevantScales = Object.entries(allowedScales).filter(([_, scale]) => { return value >= scale.multiplier; });
- const largestScale = relevantScales[relevantScales.length - 1]?.[1];
- const valueInScale = Math.ceil((value / largestScale.multiplier) * 100) / 100;
- const label = valueInScale === 1 ? largestScale.singular : largestScale.plural;
-
- return {
- value: value,
- label: `${valueInScale} ${label}`
- };
- }
-
- function createAlertSelectOptionFromValue(value: string): SelectOption | null {
- if (value === null) {
- return null;
- }
-
- const existingOption = resolvedSounds.find(option => option.value === value);
-
- if (existingOption) {
- return existingOption;
- }
-
- const filename = value
- .split("/")
- .pop()
- ?.split("?")[0]
- ?.split(".")[0]
- ?.replace(/_/g, " ")
- .replace(/\w\S*/g, word =>
- word.toUpperCase()
- ) || "CUSTOM SOUND";
-
- return {
- value: value,
- label: filename
- };
- }
-
- function getAllIntervalOptions(currentValue: number) {
- const currentOption = createIntervalSelectOptionFromValue(currentValue);
- const otherOptions = resolvedIntervals.filter(option => option.value !== currentValue);
-
- return [
- currentOption,
- ...otherOptions
- ].sort((a, b) => Number(a.value) - Number(b.value));
- }
-
- function getAllAlertOptions(currentValue: string | null) {
- const otherOptions = currentValue ? resolvedSounds.filter(option => option.value !== currentValue) : resolvedSounds;
-
- if (!currentValue) {
- return otherOptions.sort((a, b) => a.label.localeCompare(b.label));
- }
-
- const currentOption = createAlertSelectOptionFromValue(currentValue);
-
- return [
- currentOption!,
- ...otherOptions
- ].sort((a, b) => a.label.localeCompare(b.label));
- }
-
- function handleScaleSearchChange(searchValue: string) {
- if (!searchValue.trim()) {
- // Show all options for empty searches.
- setCurrentIntervalOptions(getAllIntervalOptions(currentIntervalSelection));
- return;
- }
-
- const isDisabledTerm = resolvedIntervals.find(option => option.value === 0 && option.label.toLowerCase().startsWith(searchValue.toLowerCase()));
-
- if (isDisabledTerm) {
- // Show the dedicated "disabled" option if it matches the search term.
- setCurrentIntervalOptions([isDisabledTerm]);
- return;
- }
-
- const match = searchValue.match(/^\s*(\d+\.?(?:\d+)?)\s*([a-zA-Z]+)?\s*$/i);
-
- if (!match) {
- // If the input can't be parsed, show feedback message.
- setCurrentIntervalOptions([]);
- return;
- }
-
- const num = Number(match[1]);
- const scaleName = match[2]?.toLowerCase();
- const resolvedScale = Object.keys(allowedScales).filter(key => allowedScales[key].singular.toLowerCase().startsWith(scaleName) || allowedScales[key].plural.toLowerCase().startsWith(scaleName))[0];
-
- if (isNaN(num)) {
- // If the input isn't valid, show feedback message.
- setCurrentIntervalOptions([]);
- return;
- }
-
- const options: SelectOption[] = [];
-
- if (!resolvedScale) {
- for (const scale of Object.values(intervalScales)) {
- // Try each allowed scale to see if the value fits within the min/max range.
- const valueInSeconds = Math.ceil((num * scale.multiplier) * 100) / 100;
-
- if (valueInSeconds >= minimumAutoFetchIntervalValue && valueInSeconds <= maximumAutoFetchIntervalValue) {
- options.push(createIntervalSelectOptionFromValue(valueInSeconds));
- }
- }
- } else {
- // If a specific scale was provided, use it to create the option.
- const scale = allowedScales[resolvedScale];
- const valueInSeconds = Math.ceil((num * scale.multiplier) * 100) / 100;
-
- if (valueInSeconds >= minimumAutoFetchIntervalValue && valueInSeconds <= maximumAutoFetchIntervalValue) {
- options.push(createIntervalSelectOptionFromValue(valueInSeconds));
- }
- }
-
- setCurrentIntervalOptions(options);
- }
-
- function handleAlertSearchChange(searchValue: string) {
- if (!searchValue.trim()) {
- // Show all options for empty searches.
- setCurrentAlertOptions(getAllAlertOptions(currentAlertSelection));
- return;
- }
-
- const isCustomSound = searchValue.startsWith("http://") || searchValue.startsWith("https://");
- const customSoundOption = createAlertSelectOptionFromValue(searchValue);
-
- if (isCustomSound) {
- isSoundAllowed(searchValue).then(allowed => {
- if (allowed) {
- // If the input is a custom sound URL, show it as the only option.
- setCurrentAlertOptions([customSoundOption as SelectOption]);
- } else {
- // If the input is a custom sound URL but not allowed, show feedback message.
- setCurrentAlertOptions([]);
- }
- });
-
- return;
- }
-
- const matches = resolvedSounds.filter(option => option.label.toLowerCase().replace(/\s+/, "").includes(searchValue.toLowerCase().replace(/\s+/, "")));
-
- if (matches) {
- // If a matching sound is found, show it as the only option.
- setCurrentAlertOptions(matches);
- return;
- }
-
- // If no match is found, show feedback message.
- setCurrentAlertOptions([]);
- }
-
- const resolvedIntervalValue = fetchingQuestsInterval;
- const [currentIntervalSelection, setCurrentSelection] = useState(resolvedIntervalValue);
- const [currentIntervalOptions, setCurrentIntervalOptions] = useState(getAllIntervalOptions(currentIntervalSelection));
- const resolvedAlertValue = fetchingQuestsAlert;
- const [currentAlertSelection, setCurrentAlertSelection] = useState(resolvedAlertValue);
- const [currentAlertOptions, setCurrentAlertOptions] = useState(getAllAlertOptions(currentAlertSelection));
- // Needed to update the playing state of the preview button.
- const [isPlaying, setIsPlaying] = useState(false);
- // Needed to stop audio output on settings close mid-preview.
- const activePlayer = useRef(null);
-
- function clearActivePlayer() {
- activePlayer.current?.stop();
- activePlayer.current = null;
- setIsPlaying(false);
- }
-
- useEffect(() => {
- return () => {
- clearActivePlayer();
- };
- }, []);
-
- return (
-
-
-
-
-
-
- Fetching Quests
-
-
- Configure how often to fetch Quests from Discord and set up alerts for new Quests.
-
- By default, Discord only fetches Quests on load and when visiting the Quests page.
- This means that without a fetch interval defined below, this plugin will become unaware
- of new Quests added throughout the day.
-
- This relies on the Quest Button being enabled and set to either Unclaimed, or set to Always with
- unclaimed Pill, Badge, or Both indicators enabled. Otherwise, there is no reason to periodically fetch Quests.
-
- Also, if Fetching Quests is blocked in the Quest Features setting, this will not work.
-
-
-
-
-
- Fetch Interval
-
-
-
- options}
- placeholder="Select or type an interval between 30 minutes and 12 hours."
- feedback="Intervals must be between 30 minutes and 12 hours."
- className={q("select", "titled-select")}
- maxVisibleItems={resolvedIntervals.length + 1}
- maxOptionsVisible={resolvedIntervals.length + 1}
- clearable={false}
- multi={false}
- value={currentIntervalSelection as any}
- options={currentIntervalOptions}
- closeOnSelect={true}
- onSearchChange={handleScaleSearchChange}
- onClose={() => { setCurrentIntervalOptions(getAllIntervalOptions(currentIntervalSelection)); }}
- onChange={value => {
- const val = value as number;
- settings.store.fetchingQuestsInterval = val;
-
- setCurrentSelection(val);
- setCurrentIntervalOptions(getAllIntervalOptions(val));
- checkAutoFetchInterval(val);
- }}
- />
-
-
-
-
-
- Alert Sound
-
-
-
-
- options}
- placeholder="Select a sound or provide a custom sound URL."
- feedback="Sound not found, or URL is not from a supported domain."
- className={q("select", "titled-select")}
- clearable={true}
- multi={false}
- maxVisibleItems={7}
- value={currentAlertSelection as any}
- options={currentAlertOptions}
- closeOnSelect={false}
- onSearchChange={handleAlertSearchChange}
- onClose={() => { setCurrentAlertOptions(getAllAlertOptions(currentAlertSelection)); }}
- onChange={value => {
- const val = value as string | null;
- settings.store.fetchingQuestsAlert = val as any;
-
- setCurrentAlertSelection(val);
- setCurrentAlertOptions(getAllAlertOptions(val));
- }}
- />
-
-
{
- if (currentAlertSelection) {
- if (activePlayer.current) {
- clearActivePlayer();
- } else {
- activePlayer.current = playAudio(currentAlertSelection, { onEnded: clearActivePlayer, volume: settings.store.fetchingQuestsAlertVolume });
- setIsPlaying(true);
- }
- }
- }}
- style={{ cursor: currentAlertSelection ? "pointer" : "default" }}
- >
- {SoundIcon(24, 24)}
-
-
-
-
- { settings.store.fetchingQuestsAlertVolume = val; }}
- className={q("volume-slider")}
- />
-
-
-
-
-
-
- );
-}
-
-export const settings = definePluginSettings({
- disableQuests: {
- type: OptionType.COMPONENT,
- component: DisableQuestsSetting,
- description: "Select which Quest features to disable."
- },
- disableQuestsEverything: {
- type: OptionType.BOOLEAN,
- description: "Disable all Quest features.",
- default: false,
- hidden: true
- },
- disableQuestsFetchingQuests: {
- type: OptionType.BOOLEAN,
- description: "Disable fetching Quests from Discord.",
- default: false,
- hidden: true
- },
- disableQuestsDiscoveryTab: {
- type: OptionType.BOOLEAN,
- description: "Disable Quest tab in the Discovery page.",
- default: false,
- hidden: true
- },
- disableQuestsDirectMessagesTab: {
- type: OptionType.BOOLEAN,
- description: "Disable Quest tab in Direct Messages.",
- default: false,
- hidden: true
- },
- disableQuestsPageSponsoredBanner: {
- type: OptionType.BOOLEAN,
- description: "Disable the sponsored banner on the Quest page.",
- default: false,
- hidden: true
- },
- disableQuestsPopupAboveAccountPanel: {
- type: OptionType.BOOLEAN,
- description: "Disable the Quest popup above your account panel.",
- default: true,
- hidden: true
- },
- disableQuestsBadgeOnUserProfiles: {
- type: OptionType.BOOLEAN,
- description: "Disable the Quest badge on user profiles.",
- default: false,
- hidden: true
- },
- disableQuestsGiftInventoryRelocationNotice: {
- type: OptionType.BOOLEAN,
- description: "Disable the gift inventory Quest relocation notice.",
- default: true,
- hidden: true
- },
- disableFriendsListActiveNowPromotion: {
- type: OptionType.BOOLEAN,
- description: "Disable the promotion of Quests for games played by friends.",
- default: true,
- hidden: true
- },
- disableMembersListActivelyPlayingIcon: {
- type: OptionType.BOOLEAN,
- description: "Disable the actively playing icon in members list items.",
- default: true,
- hidden: true
- },
- makeMobileQuestsDesktopCompatible: {
- type: OptionType.BOOLEAN,
- description: "Make mobile-only Quests compatible with desktop.",
- default: true,
- hidden: true
- },
- completeVideoQuestsQuicker: {
- type: OptionType.BOOLEAN,
- description: "Use Discord's progress leeway and elapsed enrollment time for Video Quest auto-completion.",
- default: false,
- hidden: true,
- onChange: () => {
- rerenderQuests();
- }
- },
- completeVideoQuestsInBackground: {
- type: OptionType.BOOLEAN,
- description: "Complete Video Quests in the background after the video duration has passed.",
- default: false,
- hidden: true,
- onChange: (value: boolean) => {
- if (!value) {
- activeQuestIntervals.forEach((interval, questId) => {
- if (interval.type === "watch") {
- clearTimeout(interval.progressTimeout);
- clearTimeout(interval.rerenderTimeout);
- activeQuestIntervals.delete(questId);
- }
- });
- }
-
- rerenderQuests();
- }
- },
- completeGameQuestsInBackground: {
- type: OptionType.BOOLEAN,
- description: "Complete Game Quests in the background after the game duration has passed.",
- default: false,
- hidden: true,
- onChange: (value: boolean) => {
- if (!value) {
- activeQuestIntervals.forEach((interval, questId) => {
- if (interval.type === "play") {
- clearTimeout(interval.progressTimeout);
- clearTimeout(interval.rerenderTimeout);
- activeQuestIntervals.delete(questId);
- }
- });
- }
-
- rerenderQuests();
- },
- },
- completeAchievementQuestsInBackground: {
- type: OptionType.BOOLEAN,
- description: "Complete Achievement in Activity Quests in the background.",
- default: false,
- hidden: true,
- onChange: (value: boolean) => {
- if (!value) {
- activeQuestIntervals.forEach((interval, questId) => {
- if (interval.type === "achievement") {
- clearTimeout(interval.progressTimeout);
- clearTimeout(interval.rerenderTimeout);
- activeQuestIntervals.delete(questId);
- }
- });
- }
-
- rerenderQuests();
- }
- },
- notifyOnQuestComplete: {
- type: OptionType.BOOLEAN,
- description: "Show a notification when a Quest is auto-completed.",
- default: true,
- hidden: true
- },
- questButton: {
- type: OptionType.COMPONENT,
- component: QuestButtonSettings,
- description: "Show a Quest button in the server list."
- },
- questButtonDisplay: {
- type: OptionType.STRING,
- description: "Which display type to use for the Quest button in the server list.",
- default: defaultQuestButtonDisplay, // "always", "unclaimed", "never"
- hidden: true,
- },
- questRewardIncludeRewardCode: {
- type: OptionType.BOOLEAN,
- description: "Include Quests with Reward Codes when displaying Quest counts.",
- default: true,
- hidden: true,
- },
- questRewardIncludeNitroCode: {
- type: OptionType.BOOLEAN,
- description: "Include Quests with Nitro Codes when displaying Quest counts.",
- default: true,
- hidden: true,
- },
- questRewardIncludeInGame: {
- type: OptionType.BOOLEAN,
- description: "Include Quests with In-Game rewards when displaying Quest counts.",
- default: true,
- hidden: true,
- },
- questRewardIncludeCollectibles: {
- type: OptionType.BOOLEAN,
- description: "Include Quests with Collectibles when displaying Quest counts.",
- default: true,
- hidden: true,
- },
- questRewardIncludeOrbs: {
- type: OptionType.BOOLEAN,
- description: "Include Quests with Orbs when displaying Quest counts.",
- default: true,
- hidden: true,
- },
- questButtonUnclaimed: {
- type: OptionType.STRING,
- description: "Which display type to use for the unclaimed indicator on the Quest button in the server list.",
- default: defaultQuestButtonUnclaimed, // "pill", "badge", "both", "none"
- hidden: true,
- },
- questButtonBadgeColor: {
- type: OptionType.NUMBER | OptionType.CUSTOM,
- description: "The color of the Quest button badge in the server list.",
- default: defaultUnclaimedColor, // Decimal, null (Discord Default)
- hidden: true
- },
- questButtonLeftClickAction: {
- type: OptionType.STRING,
- description: "The action to perform when left-clicking the Quest button in the server list.",
- default: defaultLeftClickAction, // "open-quests", "context-menu", "plugin-settings", "nothing"
- hidden: true
- },
- questButtonMiddleClickAction: {
- type: OptionType.STRING,
- description: "The action to perform when middle-clicking the Quest button in the server list.",
- default: defaultMiddleClickAction, // "open-quests", "context-menu", "plugin-settings", "nothing"
- hidden: true
- },
- questButtonRightClickAction: {
- type: OptionType.STRING,
- description: "The action to perform when right-clicking the Quest button in the server list.",
- default: defaultRightClickAction, // "open-quests", "context-menu", "plugin-settings", "nothing"
- hidden: true
- },
- fetchingQuests: {
- type: OptionType.COMPONENT,
- component: FetchingQuestsSetting,
- description: "Fetch Quests from Discord."
- },
- fetchingQuestsInterval: {
- type: OptionType.NUMBER | OptionType.CUSTOM,
- description: "The interval in seconds to fetch Quests from Discord.",
- default: 2700, // Digit >= 0, null (Disabled)
- hidden: true
- },
- fetchingQuestsAlert: {
- type: OptionType.STRING | OptionType.CUSTOM,
- description: "The sound to play when new Quests are detected.",
- default: defaultFetchQuestsAlert, // Item from predefined list or a URL to CSP valid audio file.
- hidden: true
- },
- fetchingQuestsAlertVolume: {
- type: OptionType.NUMBER,
- description: "The volume for the new Quest alert sound.",
- default: 100, // 0 - 100
- hidden: true
- },
- restyleQuests: {
- type: OptionType.COMPONENT,
- component: RestyleQuestsSetting,
- description: "Customize the appearance of Quest tiles in the Quests page."
- },
- restyleQuestsUnclaimed: {
- type: OptionType.NUMBER | OptionType.CUSTOM,
- description: "The color of unclaimed Quest tiles in the Quests page.",
- default: defaultUnclaimedColor, // Decimal, null (Discord Default)
- hidden: true
- },
- restyleQuestsClaimed: {
- type: OptionType.NUMBER | OptionType.CUSTOM,
- description: "The color of claimed Quest tiles in the Quests page.",
- default: defaultClaimedColor, // Decimal, null (Discord Default)
- hidden: true
- },
- restyleQuestsIgnored: {
- type: OptionType.NUMBER | OptionType.CUSTOM,
- description: "The color of ignored Quest tiles in the Quests page.",
- default: defaultIgnoredColor, // Decimal, null (Discord Default)
- hidden: true
- },
- restyleQuestsExpired: {
- type: OptionType.NUMBER | OptionType.CUSTOM,
- description: "The color of expired Quest tiles in the Quests page.",
- default: defaultExpiredColor, // Decimal, null (Discord Default)
- hidden: true
- },
- restyleQuestsGradient: {
- type: OptionType.STRING,
- description: "Style of the gradient used in the Quest tiles.",
- default: defaultRestyleQuestsGradient, // "intense", "default", "black", "hide"
- hidden: true
- },
- restyleQuestsPreload: {
- type: OptionType.BOOLEAN,
- description: "Attempt to preload the assets for the Quest tiles.",
- default: true,
- hidden: true
- },
- reorderQuests: {
- type: OptionType.COMPONENT,
- description: "Sort Quests by their status. Leave empty for default sorting. Comma-separated list must contain all of: UNCLAIMED, CLAIMED, IGNORED, EXPIRED.",
- default: defaultQuestOrder,
- component: ReorderQuestsSetting,
- },
- unclaimedSubsort: {
- type: OptionType.STRING,
- description: "Subsort method for unclaimed Quests.",
- default: "Expiring ASC", // "Recent ASC", "Recent DESC", "Expiring ASC", "Expiring DESC"
- hidden: true
- },
- claimedSubsort: {
- type: OptionType.STRING,
- description: "Subsort method for claimed Quests.",
- default: "Claimed DESC", // "Recent ASC", "Recent DESC", "Claimed ASC", "Claimed DESC"
- hidden: true
- },
- ignoredSubsort: {
- type: OptionType.STRING,
- description: "Subsort method for ignored Quests.",
- default: "Recent DESC", // "Recent ASC", "Recent DESC", "Expiring ASC", "Expiring DESC"
- hidden: true
- },
- expiredSubsort: {
- type: OptionType.STRING,
- description: "Subsort method for expired Quests.",
- default: "Expiring DESC", // "Recent ASC", "Recent DESC", "Expiring ASC", "Expiring DESC"
- hidden: true
- },
- unclaimedUnignoredQuests: {
- type: OptionType.NUMBER,
- description: "Tracks the number of unclaimed and unignored Quests.",
- default: 0, // Digit >= 0
- hidden: true
- },
- onQuestsPage: {
- type: OptionType.BOOLEAN,
- description: "Whether the user is currently on the Quests page.",
- default: false,
- hidden: true
- },
- triggerQuestsRerender: {
- type: OptionType.BOOLEAN,
- description: "Trigger a rerender of the Quests page by toggling this setting.",
- default: false,
- hidden: true
- },
- ignoredQuestProfile: {
- type: OptionType.STRING,
- description: "The profile used for ignored Quests.",
- default: "private", // "shared", "private"
- hidden: true
- },
- rememberQuestPageSort: {
- type: OptionType.BOOLEAN,
- description: "Remember the last used sort on the Quests page.",
- default: true,
- hidden: true
- },
- rememberQuestPageFilters: {
- type: OptionType.BOOLEAN,
- description: "Remember the last used filters on the Quests page.",
- default: true,
- hidden: true
- },
- lastQuestPageSort: {
- type: OptionType.STRING,
- description: "Remember the last used sort on the Quests page.",
- default: "questify" as string, // sort key
- hidden: true
- },
- lastQuestPageFilters: {
- type: OptionType.CUSTOM,
- description: "Remember the last used filters on the Quests page.",
- default: {} as { [filter: string]: { group: string, filter: string; }; }, // Array of filters
- hidden: true
- },
- ignoredQuestIDs: {
- type: OptionType.CUSTOM,
- description: "An array of Quest IDs that are ignored.",
- default: {} as Record,
- hidden: true,
- },
- resumeQuestIDs: {
- type: OptionType.CUSTOM,
- description: "An array of Quest IDs that are being auto-completed in the background.",
- default: { "watch": [], "play": [], "achievement": [] } as Record<"watch" | "play" | "achievement", string[]>, // Type to Quest ID
- hidden: true,
- },
-});
diff --git a/src/equicordplugins/questify/settings/def.ts b/src/equicordplugins/questify/settings/def.ts
new file mode 100644
index 000000000..433b8fd5e
--- /dev/null
+++ b/src/equicordplugins/questify/settings/def.ts
@@ -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.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>;
+
+export const defaultAutoCompleteQuestTypes = Object.fromEntries(
+ autoCompleteQuestTaskTypes.map(questType => [questType, false])
+) as AutoCompleteQuestTypes;
+
+export type QuestButtonIncludedTypes = Record;
+
+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;
+export const ignoredQuestIDsKey = "questIDs";
+export const defaultIgnoredQuestIDs = { [ignoredQuestIDsKey]: [] } as Record;
+export const defaultResumeQuestIDs = {} as Record;
diff --git a/src/equicordplugins/questify/settings/fetching.ts b/src/equicordplugins/questify/settings/fetching.ts
new file mode 100644
index 000000000..dafd335af
--- /dev/null
+++ b/src/equicordplugins/questify/settings/fetching.ts
@@ -0,0 +1,96 @@
+/*
+ * 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, settings } from "./store";
+
+let autoFetchInterval: null | ReturnType = null;
+const minimumAutoFetchIntervalValue = 30 * 60; // 30 minutes
+const maximumAutoFetchIntervalValue = 12 * 60 * 60; // 12 hours
+
+export function autoFetchCompatible(): boolean {
+ const {
+ newExcludedQuestAlertSound,
+ newQuestAlertSound,
+ questFetchInterval,
+ notifyOnNewExcludedQuests,
+ notifyOnNewQuests,
+ questButtonDisplay: displayMode,
+ questButtonIndicator: indicatorMode
+ } = settings.store;
+ const fetching = !settings.store.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 } = settings.store;
+ 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 } = settings.store;
+ 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),
+ };
+}
diff --git a/src/equicordplugins/questify/settings/ignoredQuests.ts b/src/equicordplugins/questify/settings/ignoredQuests.ts
new file mode 100644
index 000000000..6517389d3
--- /dev/null
+++ b/src/equicordplugins/questify/settings/ignoredQuests.ts
@@ -0,0 +1,86 @@
+/*
+ * 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 { ignoredQuestIDsKey } from "./def";
+import { rerenderQuests } from "./rerender";
+import { settings } from "./store";
+
+function validateQuestBadgeCount(): void {
+ const questButtonIncludedTypes = settings.store.questButtonIncludedTypes as QuestIncludedTypes;
+ const quests = Array.from(QuestStore.quests.values());
+ const ignoredQuestIds = getIgnoredQuestIDs();
+ const count = countIncludedUnclaimedQuests(quests, ignoredQuestIds, questButtonIncludedTypes);
+
+ settings.store.questButtonBadgeCount = count;
+}
+
+export function getIgnoredQuestIDs(): string[] {
+ const { ignoredQuestIDs } = settings.store;
+ const ignoredQuestIDsForKey = Array.from(ignoredQuestIDs[ignoredQuestIDsKey] ?? []);
+ return ignoredQuestIDsForKey;
+}
+
+function setIgnoredQuestIDs(questIDs: string[]): void {
+ settings.store.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(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();
+
+ 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();
+}
diff --git a/src/equicordplugins/questify/settings/rerender.ts b/src/equicordplugins/questify/settings/rerender.ts
new file mode 100644
index 000000000..1d963ebf2
--- /dev/null
+++ b/src/equicordplugins/questify/settings/rerender.ts
@@ -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);
+}
diff --git a/src/equicordplugins/questify/settings/restartTracking.ts b/src/equicordplugins/questify/settings/restartTracking.ts
new file mode 100644
index 000000000..19aa59c03
--- /dev/null
+++ b/src/equicordplugins/questify/settings/restartTracking.ts
@@ -0,0 +1,65 @@
+/*
+ * Vencord, a Discord client mod
+ * Copyright (c) 2026 Vendicated and contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+import { SettingsStore } from "@api/Settings";
+import type { DefinedSettings, SettingsDefinition } from "@utils/types";
+
+import { Alerts } from "../utils/ui";
+
+type RestartTrackingSettings = Pick, "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;
+}
diff --git a/src/equicordplugins/questify/settings/store.ts b/src/equicordplugins/questify/settings/store.ts
new file mode 100644
index 000000000..a698ae837
--- /dev/null
+++ b/src/equicordplugins/questify/settings/store.ts
@@ -0,0 +1,386 @@
+/*
+ * 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 { UserStore } from "@webpack/common";
+
+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";
+
+export function getCurrentUserId(userId?: string): string | null {
+ return userId ?? UserStore.getCurrentUser()?.id ?? null;
+}
+
+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,
+ },
+});
diff --git a/src/equicordplugins/questify/styles.css b/src/equicordplugins/questify/styles.css
index c3ebcc613..788b92ddf 100644
--- a/src/equicordplugins/questify/styles.css
+++ b/src/equicordplugins/questify/styles.css
@@ -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;
-}
diff --git a/src/equicordplugins/questify/utils/completion.ts b/src/equicordplugins/questify/utils/completion.ts
new file mode 100644
index 000000000..eb0816edb
--- /dev/null
+++ b/src/equicordplugins/questify/utils/completion.ts
@@ -0,0 +1,1205 @@
+/*
+ * Vencord, a Discord client mod
+ * Copyright (c) 2026 Vendicated and contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+import type { PluginNative } from "@utils/types";
+import type { Quest } from "@vencord/discord-types";
+import { QuestTaskType } from "@vencord/discord-types/enums";
+import { findByCodeLazy, findLazy } from "@webpack";
+import { FluxDispatcher, QuestStore, RestAPI, showToast, Toasts } from "@webpack/common";
+
+import { autoCompleteQuestTaskTypes, isDesktopCompatible } from "../settings/def";
+import { resetQuestsToResume } from "../settings/fetching";
+import { getIgnoredQuestIDs } from "../settings/ignoredQuests";
+import { rerenderQuests } from "../settings/rerender";
+import { getCurrentUserId, settings } from "../settings/store";
+import { AuthorizedAppsStore } from "./fetching";
+import { normalizeQuestName } from "./filtering";
+import { QL } from "./logging";
+import { getQuestStatus, getQuestStoredProgress, isVideoQuestTask, QuestStatus, QuestTask, refreshQuest } from "./questState";
+import { hasInjectedDesktopVideoCompatibility } from "./questTiles";
+
+type QuestEnrollResult =
+ | { type: "success"; }
+ | { type: "captcha_failed"; }
+ | { type: "unknown_error"; }
+ | { type: "previous_in_flight_request"; };
+
+interface QuestEnrollmentMetadata {
+ questContent: unknown;
+ questContentCTA?: unknown;
+ sourceQuestContent: unknown;
+ sourceQuestContentCTA?: unknown;
+ questContentPosition: unknown;
+ questContentRowIndex: unknown;
+}
+
+interface QuestCTAConstants {
+ START_QUEST: string;
+ ACCEPT_QUEST: string;
+}
+
+interface QuestButtonAnalyticsArgs {
+ taskType?: QuestTaskType;
+ analyticsCtxQuestContent?: QuestEnrollmentMetadata["questContent"];
+ analyticsCtxSourceQuestContent?: QuestEnrollmentMetadata["sourceQuestContent"];
+ analyticsCtxQuestContentPosition?: QuestEnrollmentMetadata["questContentPosition"];
+ analyticsCtxQuestContentRowIndex?: QuestEnrollmentMetadata["questContentRowIndex"];
+}
+
+export interface QuestButtonPropsArgs extends Partial {
+ quest: Quest;
+ preClickCallback?: () => void;
+}
+
+export interface QuestButtonPatchProps {
+ icon: string | undefined;
+ text: string;
+ onClick: () => void;
+}
+
+interface QuestButtonTextOptions {
+ prepositional?: boolean;
+}
+
+export enum QuestCompletionState {
+ Completing = "COMPLETING",
+ Queued = "QUEUED",
+ Accepted = "ACCEPTED",
+ Unenrolled = "UNENROLLED",
+}
+
+type QuestCompletionStateTuple = [text: string | null, state: QuestCompletionState];
+
+interface QuestRewardItem {
+ orbQuantity?: number;
+ messages?: {
+ nameWithArticle?: string;
+ };
+}
+
+const reportVideoProgress = findByCodeLazy(".QUESTS_VIDEO_PROGRESS(") as (questId: string, progress: number) => Promise;
+const sendHeartbeat = findByCodeLazy(".QUESTS_HEARTBEAT(") as (options: {
+ questId: string;
+ streamKey?: string;
+ applicationId: string;
+ terminal?: boolean;
+ executableFingerprint?: unknown;
+}) => Promise;
+export const enrollInQuest = findByCodeLazy('type:"QUESTS_ENROLL_BEGIN",') as (questId: string, options: QuestEnrollmentMetadata) => Promise;
+
+const QuestCTA = findLazy(m => !!m?.START_QUEST && !!m?.ACCEPT_QUEST) as QuestCTAConstants;
+
+function resolveQuestCTA(taskType?: QuestTaskType): string | undefined {
+ return !taskType ? undefined : [QuestTaskType.ACHIEVEMENT_IN_ACTIVITY, QuestTaskType.PLAY_ACTIVITY, QuestTaskType.WATCH_VIDEO].includes(taskType) ? QuestCTA.START_QUEST : QuestCTA.ACCEPT_QUEST;
+}
+
+export function makeEnrollmentData(args: QuestButtonAnalyticsArgs): QuestEnrollmentMetadata {
+ return {
+ questContent: args.analyticsCtxQuestContent,
+ questContentCTA: resolveQuestCTA(args.taskType),
+ sourceQuestContent: args.analyticsCtxSourceQuestContent,
+ sourceQuestContentCTA: resolveQuestCTA(args.taskType),
+ questContentPosition: args.analyticsCtxQuestContentPosition,
+ questContentRowIndex: args.analyticsCtxQuestContentRowIndex
+ };
+}
+
+const QuestifyNative = VencordNative?.pluginHelpers?.Questify as PluginNative | undefined;
+
+const videoQuestLeeway = 24;
+const resumeExpiryMs = 60 * 60 * 1000;
+export type AutoCompleteQuestKind = "watch" | "play" | "achievement";
+export type AutoCompleteQuestStatus = "queued" | "running";
+export type AutoCompleteStartSource = "manual" | "resume" | "auto";
+
+export interface AutoCompleteEntry {
+ questId: string;
+ questName: string;
+ task: QuestTask;
+ kind: AutoCompleteQuestKind;
+ status: AutoCompleteQuestStatus;
+ progress: number | null;
+ abortController: AbortController;
+ progressInterval: ReturnType | null;
+ rerenderInterval: ReturnType | null;
+}
+
+export interface AutoCompleteStartOptions {
+ force?: boolean;
+ source?: AutoCompleteStartSource;
+}
+
+export interface AutoCompleteStopOptions {
+ manual?: boolean;
+ preserveResume?: boolean;
+ terminalHeartbeat?: boolean;
+}
+
+type HeartbeatDispatchResult =
+ | { type: "success"; userStatus: Quest["userStatus"]; }
+ | { type: "failure"; error: unknown; }
+ | { type: "timeout"; };
+
+interface AutoCompleteQuestTarget {
+ raw: number;
+ adjusted: number;
+}
+
+type PlayHeartbeatResult = { progress: number | null; completed: boolean; };
+type HeartbeatDispatchWaiter = { promise: Promise; cancel: () => void; };
+
+interface VideoProgressReportOptions {
+ attempts?: number;
+ delay?: number;
+ displayProgress?: number;
+}
+
+const activeAutoCompletes = new Map();
+const manuallyStoppedQuestIds = new Set();
+let suppressQueueDrain = false;
+let videoProgressStackTracePatchSucceeded = false;
+let heartbeatStackTracePatchSucceeded = false;
+let didShowBrokenAutoCompleteToast = false;
+
+function sleep(ms: number, signal?: AbortSignal): Promise {
+ if (signal?.aborted) {
+ return Promise.reject(new Error("Auto-complete aborted."));
+ }
+
+ return new Promise((resolve, reject) => {
+ const timeoutId = setTimeout(() => {
+ signal?.removeEventListener("abort", onAbort);
+ resolve();
+ }, ms);
+
+ function onAbort() {
+ clearTimeout(timeoutId);
+ signal?.removeEventListener("abort", onAbort);
+ reject(new Error("Auto-complete aborted."));
+ }
+
+ signal?.addEventListener("abort", onAbort, { once: true });
+ });
+}
+
+function clampFloat(value: number, precision: number = 6): number {
+ return Number(value.toFixed(precision));
+}
+
+function randomBetween(min: number, max: number): number {
+ return clampFloat(min + Math.random() * (max - min));
+}
+
+export function setVideoProgressStackTracePatchSucceeded(): void {
+ videoProgressStackTracePatchSucceeded = true;
+}
+
+export function setHeartbeatStackTracePatchSucceeded(): void {
+ heartbeatStackTracePatchSucceeded = true;
+}
+
+export function getStackTracePatchesSucceeded(): { videoProgress: boolean; heartbeat: boolean; } {
+ return {
+ videoProgress: videoProgressStackTracePatchSucceeded,
+ heartbeat: heartbeatStackTracePatchSucceeded,
+ };
+}
+
+function showBrokenAutoCompleteToast(): void {
+ if (didShowBrokenAutoCompleteToast) {
+ return;
+ }
+
+ didShowBrokenAutoCompleteToast = true;
+ showToast("AutoComplete is broken. A fix will be implemented shortly.", Toasts.Type.FAILURE);
+}
+
+export function hasEnabledAutoCompleteQuestTypes(): boolean {
+ return autoCompleteQuestTaskTypes.some(questType => settings.store.autoCompleteQuestTypes[questType]);
+}
+
+function isAutoCompleteRuntimeReady(notify: boolean = false): boolean {
+ const stackTracePatches = getStackTracePatchesSucceeded();
+
+ if (stackTracePatches.videoProgress && stackTracePatches.heartbeat) {
+ return true;
+ }
+
+ if (notify) {
+ showBrokenAutoCompleteToast();
+ QL.warn("AUTO_COMPLETE_STACK_TRACE_PATCH_MISSING", stackTracePatches);
+ }
+
+ return false;
+}
+
+export function getQuestAutoCompleteProgress(quest: Quest): number | null {
+ return activeAutoCompletes.get(quest.id)?.progress ?? null;
+}
+
+export function getQuestAutoCompleteEntry(questOrId: Quest | string): Readonly | null {
+ const questId = typeof questOrId === "string" ? questOrId : questOrId.id;
+
+ return activeAutoCompletes.get(questId) ?? null;
+}
+
+export function getAutoCompleteQuestTarget(task: QuestTask): AutoCompleteQuestTarget {
+ const raw = task.target;
+
+ return {
+ raw,
+ adjusted: Math.max(0, raw - (isVideoQuestTask(task.type) && settings.store.completeVideoQuestsQuicker ? videoQuestLeeway : 0)),
+ };
+}
+
+function getAutoCompleteQuestTasks(quest: Quest): QuestTask[] {
+ const tasks = quest.config.taskConfigV2?.tasks;
+
+ if (!tasks) {
+ return [];
+ }
+
+ return autoCompleteQuestTaskTypes.flatMap(taskType => {
+ const task = tasks[taskType] as QuestTask | undefined;
+
+ return task ? [task] : [];
+ });
+}
+
+function getQuestAutoCompleteKind(task: QuestTask): AutoCompleteQuestKind | null {
+ switch (task.type) {
+ case QuestTaskType.WATCH_VIDEO:
+ case QuestTaskType.WATCH_VIDEO_ON_MOBILE:
+ return "watch";
+ case QuestTaskType.PLAY_ON_DESKTOP:
+ case QuestTaskType.PLAY_ON_XBOX:
+ case QuestTaskType.PLAY_ON_PLAYSTATION:
+ case QuestTaskType.PLAY_ACTIVITY:
+ return "play";
+ case QuestTaskType.ACHIEVEMENT_IN_ACTIVITY:
+ return "achievement";
+ default:
+ return null;
+ }
+}
+
+function getEffectiveAutoCompleteTaskType(task: QuestTask, quest: Quest): QuestTaskType {
+ return task.type === QuestTaskType.WATCH_VIDEO && hasInjectedDesktopVideoCompatibility(quest)
+ ? QuestTaskType.WATCH_VIDEO_ON_MOBILE
+ : task.type;
+}
+
+function isAutoCompleteQuestTaskEnabled(quest: Quest, task: QuestTask): boolean {
+ const taskType = getEffectiveAutoCompleteTaskType(task, quest);
+ const compatible = isDesktopCompatible(taskType);
+ const enabled = settings.store.autoCompleteQuestTypes[taskType] === true;
+
+ return compatible && enabled && getQuestAutoCompleteKind(task) != null;
+}
+
+function resolveAutoCompleteQuest(quest: Quest): { task: QuestTask; kind: AutoCompleteQuestKind; } | null {
+ for (const task of getAutoCompleteQuestTasks(quest)) {
+ const kind = getQuestAutoCompleteKind(task);
+
+ if (kind && isAutoCompleteQuestTaskEnabled(quest, task)) {
+ return { task, kind };
+ }
+ }
+
+ return null;
+}
+
+function getResumeQuestIds(): string[] {
+ return Array.from(activeAutoCompletes.keys());
+}
+
+function isResumeStateExpired(timestamp: number): boolean {
+ return Date.now() - timestamp > resumeExpiryMs;
+}
+
+function updateResumeState(questIDs: string[] = getResumeQuestIds()): void {
+ const userId = getCurrentUserId();
+
+ if (!userId) {
+ QL.warn("AUTO_COMPLETE_RESUME_NO_USER");
+ return;
+ }
+
+ if (!settings.store.resumeInterruptedQuests || questIDs.length === 0) {
+ delete settings.store.resumeQuestIDs[userId];
+ return;
+ }
+
+ settings.store.resumeQuestIDs[userId] = {
+ timestamp: Date.now(),
+ questIDs,
+ };
+}
+
+function createAutoCompleteEntry(
+ quest: Quest,
+ task: QuestTask,
+ kind: AutoCompleteQuestKind,
+): AutoCompleteEntry {
+ return {
+ questId: quest.id,
+ questName: normalizeQuestName(quest),
+ task,
+ kind,
+ status: "queued",
+ progress: getQuestStoredProgress(quest, task),
+ abortController: new AbortController(),
+ progressInterval: null,
+ rerenderInterval: null,
+ };
+}
+
+function clearEntryTimers(entry: AutoCompleteEntry): void {
+ if (entry.progressInterval) {
+ clearInterval(entry.progressInterval);
+ entry.progressInterval = null;
+ }
+
+ if (entry.rerenderInterval) {
+ clearInterval(entry.rerenderInterval);
+ entry.rerenderInterval = null;
+ }
+}
+
+function isEntryActive(entry: AutoCompleteEntry): boolean {
+ return activeAutoCompletes.get(entry.questId) === entry && !entry.abortController.signal.aborted;
+}
+
+function formatQuestTime(seconds: number, prepositional: boolean = false): string {
+ const remaining = Math.max(0, Math.floor(seconds));
+ const minutes = Math.floor(remaining / 60);
+ const secondsPart = remaining % 60;
+
+ if (prepositional) {
+ return remaining >= 60 ? `${minutes}m ${secondsPart}s` : `${secondsPart}s`;
+ }
+
+ return `${String(minutes).padStart(2, "0")}:${String(secondsPart).padStart(2, "0")}`;
+}
+
+function isQuestQueuedForResume(questId: string): boolean {
+ if (!settings.store.resumeInterruptedQuests) {
+ return false;
+ }
+
+ const userId = getCurrentUserId();
+ const resumeState = userId ? settings.store.resumeQuestIDs[userId] : null;
+
+ return Boolean(resumeState && !isResumeStateExpired(resumeState.timestamp) && resumeState.questIDs.includes(questId));
+}
+
+function getQueuedAutoCompletePosition(questId: string): number | null {
+ const queuedQuestIds = Array.from(activeAutoCompletes.values())
+ .filter(entry => entry.status === "queued")
+ .map(entry => entry.questId);
+ const queuedIndex = queuedQuestIds.indexOf(questId);
+
+ return queuedIndex === -1 ? null : queuedIndex + 1;
+}
+
+export function getQuestCompletionState(quest: Quest, options: QuestButtonTextOptions = {}): QuestCompletionStateTuple {
+ quest = refreshQuest(quest);
+
+ const activeEntry = activeAutoCompletes.get(quest.id);
+ const resolvedQuest = activeEntry ?? resolveAutoCompleteQuest(quest);
+
+ if (!resolvedQuest) {
+ return [null, QuestCompletionState.Unenrolled];
+ }
+
+ const { task, kind } = resolvedQuest;
+ const target = getAutoCompleteQuestTarget(task).adjusted;
+ const enrolledAt = quest.userStatus?.enrolledAt ? new Date(quest.userStatus.enrolledAt) : null;
+ const storedProgress = activeEntry?.progress ?? getQuestStoredProgress(quest, task) ?? 0;
+ const elapsedProgress = !activeEntry && enrolledAt && isVideoQuestTask(task.type) && settings.store.completeVideoQuestsQuicker
+ ? Math.max(0, (Date.now() - enrolledAt.getTime()) / 1000)
+ : 0;
+ const progress = Math.min(target, Math.max(storedProgress, elapsedProgress));
+ const timeRemaining = Math.max(0, target - progress);
+ const formattedTime = formatQuestTime(timeRemaining, options.prepositional);
+ const immediate = kind === "achievement" || timeRemaining <= 1;
+
+ if (activeEntry?.status === "running") {
+ const text = immediate
+ ? "Completing..."
+ : `Completing ${options.prepositional ? `in ${formattedTime}` : `(${formattedTime})`}`;
+
+ return [text, QuestCompletionState.Completing];
+ }
+
+ if (activeEntry?.status === "queued") {
+ return [`Queued (${getQueuedAutoCompletePosition(quest.id) ?? "?"})`, QuestCompletionState.Queued];
+ }
+
+ if (enrolledAt) {
+ if (isQuestQueuedForResume(quest.id)) {
+ return ["Resuming...", QuestCompletionState.Queued];
+ }
+
+ const meaningfulProgress = progress >= 1;
+
+ return [
+ immediate ? "Complete Now" : `${meaningfulProgress ? "Resume" : "Complete"} (${formatQuestTime(timeRemaining)})`,
+ QuestCompletionState.Accepted,
+ ];
+ }
+
+ return [
+ immediate ? "Complete Now" : `Complete (${formatQuestTime(target)})`,
+ QuestCompletionState.Unenrolled,
+ ];
+}
+
+export function getQuestButtonProps(args: QuestButtonPropsArgs): QuestButtonPatchProps | null {
+ if (!canAutoCompleteQuest(args.quest)) {
+ return null;
+ }
+
+ const [label, completionState] = getQuestCompletionState(args.quest);
+
+ if (!label) {
+ return null;
+ }
+
+ return {
+ icon: undefined,
+ text: label,
+ onClick: async () => {
+ if (completionState === QuestCompletionState.Unenrolled) {
+ args.preClickCallback?.();
+ const enrollment = await enrollInQuest(args.quest.id, makeEnrollmentData(args));
+
+ if (["success", "previous_in_flight_request"].includes(enrollment.type)) {
+ processQuestForAutoComplete(args.quest, { force: true, source: "manual" });
+ rerenderQuests();
+ } else {
+ showToast(`Enrollment in ${normalizeQuestName(args.quest)} Quest failed.`, Toasts.Type.FAILURE);
+ }
+ } else if (completionState === QuestCompletionState.Completing) {
+ stopQuestAutoComplete(args.quest, { manual: true, preserveResume: false, terminalHeartbeat: true });
+ rerenderQuests();
+ } else if (completionState === QuestCompletionState.Queued) {
+ stopQuestAutoComplete(args.quest, { manual: true, preserveResume: false, terminalHeartbeat: true });
+ rerenderQuests();
+ } else if (completionState === QuestCompletionState.Accepted) {
+ processQuestForAutoComplete(args.quest, { force: true, source: "manual" });
+ rerenderQuests();
+ }
+ }
+ };
+}
+
+export function getQuestPanelSubtitleText(quest: Quest): string | null {
+ quest = refreshQuest(quest);
+
+ const questCompleted = Boolean(quest.userStatus?.completedAt)
+ && getQuestStatus(quest, getIgnoredQuestIDs()) === QuestStatus.Unclaimed;
+ const [completingText] = getQuestCompletionState(quest, { prepositional: true });
+ const completedText = questCompleted ? "Completed" : null;
+ const statusText = completedText ?? completingText;
+
+ if (!statusText) {
+ return null;
+ }
+
+ const rewardItem = (quest.config.rewardsConfig.rewards[0] ?? null) as QuestRewardItem | null;
+ const orbsReward = rewardItem?.orbQuantity ?? 0;
+
+ if (orbsReward > 0) {
+ return `${statusText} for ${orbsReward} Orbs.`;
+ }
+
+ if (rewardItem?.messages?.nameWithArticle) {
+ return `${statusText} for ${rewardItem.messages.nameWithArticle}.`;
+ }
+
+ return `${statusText} for an unrecognized reward.`;
+}
+
+export function canAutoCompleteQuest(quest: Quest): boolean {
+ if (!isAutoCompleteRuntimeReady()) {
+ return false;
+ }
+
+ if (quest.userStatus?.completedAt) {
+ return false;
+ }
+
+ if (getQuestStatus(quest, getIgnoredQuestIDs()) !== QuestStatus.Unclaimed) {
+ return false;
+ }
+
+ return resolveAutoCompleteQuest(quest) != null;
+}
+
+export function setQuestAutoCompleteProgress(questOrId: Quest | string, progress: number | null): boolean {
+ const questId = typeof questOrId === "string" ? questOrId : questOrId.id;
+ const entry = activeAutoCompletes.get(questId);
+
+ if (!entry) {
+ return false;
+ }
+
+ entry.progress = progress == null ? null : Math.max(0, progress);
+
+ return true;
+}
+
+async function waitUntilEnrolled(quest: Quest, entry: AutoCompleteEntry, timeout: number = 60000, interval: number = 500): Promise {
+ const startedAt = Date.now();
+
+ quest = refreshQuest(quest);
+
+ if (quest.userStatus?.enrolledAt) {
+ return quest;
+ }
+
+ if (quest.userStatus?.completedAt) {
+ return null;
+ }
+
+ while (isEntryActive(entry) && !quest.userStatus?.enrolledAt && (Date.now() - startedAt) < timeout) {
+ await sleep(interval, entry.abortController.signal);
+ quest = refreshQuest(quest);
+ }
+
+ if (quest.userStatus?.enrolledAt) {
+ return quest;
+ }
+
+ QL.warn("AUTO_COMPLETE_ENROLL_TIMEOUT", { questId: quest.id, questName: entry.questName, timeout });
+ return null;
+}
+
+async function reportVideoQuestProgress(quest: Quest, entry: AutoCompleteEntry, progress: number, options: VideoProgressReportOptions = {}): Promise {
+ quest = refreshQuest(quest);
+
+ const attempts = options.attempts ?? 1;
+ const delay = options.delay ?? 2500;
+ const displayProgress = options.displayProgress ?? progress;
+
+ if (!quest.userStatus?.enrolledAt) {
+ QL.warn("AUTO_COMPLETE_VIDEO_UNENROLLED", { questId: quest.id, questName: entry.questName });
+ return false;
+ }
+
+ if (quest.userStatus?.completedAt) {
+ return true;
+ }
+
+ for (let attempt = 1; attempt <= attempts; attempt++) {
+ try {
+ await reportVideoProgress(quest.id, progress);
+ setQuestAutoCompleteProgress(quest, displayProgress);
+
+ QL.info("AUTO_COMPLETE_VIDEO_PROGRESS_REPORTED", { questId: quest.id, questName: entry.questName, progress, displayProgress, attempt, attempts });
+ return true;
+ } catch (error) {
+ QL.error("AUTO_COMPLETE_VIDEO_PROGRESS_FAILED", { questId: quest.id, questName: entry.questName, progress, attempt, attempts, error });
+
+ if (attempt < attempts) {
+ await sleep(delay, entry.abortController.signal);
+ }
+ }
+ }
+
+ return false;
+}
+
+function waitForHeartbeatDispatchResult(questId: string, timeoutMs: number): HeartbeatDispatchWaiter {
+ let settled = false;
+ let cleanup = () => { };
+ const promise = new Promise(resolve => {
+ const successEvent = "QUESTS_SEND_HEARTBEAT_SUCCESS";
+ const failureEvent = "QUESTS_SEND_HEARTBEAT_FAILURE";
+ const timeoutId = setTimeout(() => {
+ cleanup();
+ resolve({ type: "timeout" });
+ }, timeoutMs);
+
+ cleanup = () => {
+ if (settled) {
+ return;
+ }
+
+ settled = true;
+ 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);
+ });
+
+ return { promise, cancel: cleanup };
+}
+
+function getPlayProgressValue(userStatus: Quest["userStatus"], task: QuestTask): number | null {
+ return userStatus?.progress?.[task.type]?.value ?? null;
+}
+
+async function reportPlayQuestProgress(
+ quest: Quest,
+ entry: AutoCompleteEntry,
+ terminal: boolean,
+ options: { attempts?: number; delay?: number; timeout?: number; applicationId?: string; streamKey?: string; } = {},
+): Promise {
+ quest = refreshQuest(quest);
+
+ if (!quest.userStatus?.enrolledAt) {
+ QL.warn("AUTO_COMPLETE_PLAY_UNENROLLED", { questId: quest.id, questName: entry.questName });
+ return { progress: null, completed: false };
+ }
+
+ if (quest.userStatus?.completedAt && !terminal) {
+ return { progress: getQuestStoredProgress(quest, entry.task), completed: true };
+ }
+
+ const attempts = options.attempts ?? 1;
+ const delay = options.delay ?? 2500;
+ const timeout = options.timeout ?? 10000;
+ const applicationId = options.applicationId ?? quest.config.application.id;
+
+ for (let attempt = 1; attempt <= attempts; attempt++) {
+ try {
+ const dispatchWaiter = waitForHeartbeatDispatchResult(quest.id, timeout);
+
+ try {
+ await sendHeartbeat({
+ questId: quest.id,
+ streamKey: options.streamKey,
+ applicationId,
+ terminal,
+ });
+ } catch (error) {
+ dispatchWaiter.cancel();
+ throw error;
+ }
+
+ const dispatchResult = await dispatchWaiter.promise;
+
+ if (dispatchResult.type === "failure") {
+ QL.error("AUTO_COMPLETE_PLAY_HEARTBEAT_FAILURE", { questId: quest.id, questName: entry.questName, attempt, attempts, error: dispatchResult.error });
+ } else if (dispatchResult.type === "timeout") {
+ QL.warn("AUTO_COMPLETE_PLAY_HEARTBEAT_TIMEOUT", { questId: quest.id, questName: entry.questName, attempt, attempts, timeout });
+ } else {
+ const updatedQuest = refreshQuest(quest);
+ const progress = getPlayProgressValue(dispatchResult.userStatus, entry.task)
+ ?? getQuestStoredProgress(updatedQuest, entry.task)
+ ?? 0;
+ const completed = !!dispatchResult.userStatus?.completedAt || !!updatedQuest.userStatus?.completedAt;
+
+ setQuestAutoCompleteProgress(quest, terminal ? progress : Math.max(progress, entry.progress ?? 0));
+ QL.info("AUTO_COMPLETE_PLAY_HEARTBEAT_SENT", { questId: quest.id, questName: entry.questName, progress, target: entry.task.target, terminal });
+
+ return { progress, completed };
+ }
+ } catch (error) {
+ QL.error("AUTO_COMPLETE_PLAY_HEARTBEAT_ERROR", { questId: quest.id, questName: entry.questName, attempt, attempts, error });
+ }
+
+ if (attempt < attempts) {
+ await sleep(delay, entry.abortController.signal);
+ }
+ }
+
+ return { progress: null, completed: false };
+}
+
+function startRerenderInterval(entry: AutoCompleteEntry): void {
+ clearEntryTimers(entry);
+
+ entry.rerenderInterval = setInterval(() => {
+ if (!isEntryActive(entry)) {
+ clearEntryTimers(entry);
+ return;
+ }
+
+ rerenderQuests();
+ }, 1000);
+}
+
+async function runVideoQuest(quest: Quest, entry: AutoCompleteEntry, target: AutoCompleteQuestTarget): Promise {
+ quest = await waitUntilEnrolled(quest, entry, 60000, 500) ?? quest;
+
+ if (!isEntryActive(entry) || !quest.userStatus?.enrolledAt) {
+ return false;
+ }
+
+ const reportTarget = target.raw;
+ const completionTarget = target.adjusted;
+ const enrolledAt = new Date(quest.userStatus.enrolledAt);
+ const initialStoredProgress = getQuestStoredProgress(quest, entry.task) ?? 0;
+ const initialProgress = settings.store.completeVideoQuestsQuicker
+ ? clampFloat(Math.max(1, (Date.now() - enrolledAt.getTime()) / 1000))
+ : Math.max(0, initialStoredProgress);
+ let displayedProgress = clampFloat(Math.min(completionTarget, initialProgress));
+ let reportedProgress = clampFloat(Math.min(reportTarget, initialProgress));
+ let maximumPlaybackTimestamp = Math.floor(reportedProgress);
+ let nextReportThreshold = 0;
+ let hasReportedInitialProgress = reportedProgress <= 0;
+ let lastTickAt = performance.now();
+
+ setQuestAutoCompleteProgress(quest, displayedProgress);
+ startRerenderInterval(entry);
+
+ const timeRemaining = Math.max(0, completionTarget - displayedProgress);
+ const progressToCover = Math.max(0, reportTarget - reportedProgress);
+ const speedFactor = timeRemaining > 0 ? progressToCover / timeRemaining : 0;
+
+ QL.info("AUTO_COMPLETE_VIDEO_STARTED", { questId: quest.id, questName: entry.questName, timeRemaining, target });
+
+ if (timeRemaining <= 0) {
+ return reportVideoQuestProgress(quest, entry, Math.min(reportTarget, maximumPlaybackTimestamp), { attempts: 3, displayProgress: completionTarget });
+ }
+
+ if (reportedProgress > 0) {
+ const initialReport = clampFloat(Math.min(reportTarget, reportedProgress));
+ const reported = await reportVideoQuestProgress(quest, entry, initialReport, { attempts: 3, displayProgress: displayedProgress });
+
+ if (!reported || !isEntryActive(entry)) {
+ return false;
+ }
+
+ hasReportedInitialProgress = true;
+ nextReportThreshold = clampFloat(initialReport + randomBetween(6, 8));
+ }
+
+ while (isEntryActive(entry) && displayedProgress < completionTarget) {
+ await sleep(250, entry.abortController.signal);
+
+ const now = performance.now();
+ const elapsedSeconds = clampFloat((now - lastTickAt) / 1000);
+ lastTickAt = now;
+
+ displayedProgress = clampFloat(Math.min(completionTarget, displayedProgress + elapsedSeconds));
+ reportedProgress = clampFloat(Math.min(reportTarget, reportedProgress + elapsedSeconds * speedFactor));
+ maximumPlaybackTimestamp = Math.max(maximumPlaybackTimestamp, Math.floor(reportedProgress));
+ setQuestAutoCompleteProgress(quest, displayedProgress);
+
+ if (hasReportedInitialProgress && reportedProgress >= nextReportThreshold && displayedProgress < completionTarget) {
+ const progressToReport = clampFloat(Math.min(reportTarget, nextReportThreshold + randomBetween(0, Math.max(0, reportedProgress - nextReportThreshold))));
+ const reported = await reportVideoQuestProgress(quest, entry, progressToReport, { displayProgress: displayedProgress });
+
+ if (!reported) {
+ return false;
+ }
+
+ nextReportThreshold = clampFloat(progressToReport + randomBetween(6, 8));
+ }
+ }
+
+ return isEntryActive(entry) && reportVideoQuestProgress(quest, entry, Math.min(reportTarget, maximumPlaybackTimestamp), { attempts: 3, displayProgress: completionTarget });
+}
+
+async function runPlayQuest(quest: Quest, entry: AutoCompleteEntry, target: AutoCompleteQuestTarget): Promise {
+ quest = await waitUntilEnrolled(quest, entry, 60000, 500) ?? quest;
+
+ if (!isEntryActive(entry) || !quest.userStatus?.enrolledAt) {
+ return false;
+ }
+
+ const questTarget = target.adjusted;
+ const initialProgress = getQuestStoredProgress(quest, entry.task) ?? 0;
+ const maximumHeartbeatDurationMs = 60 * 1000;
+ const heartbeatBufferMs = 1000;
+
+ setQuestAutoCompleteProgress(quest, initialProgress);
+ startRerenderInterval(entry);
+
+ entry.progressInterval = setInterval(() => {
+ if (!isEntryActive(entry) || entry.progress == null) {
+ return;
+ }
+
+ setQuestAutoCompleteProgress(entry.questId, Math.min(questTarget, entry.progress + 1));
+ }, 1000);
+
+ QL.info("AUTO_COMPLETE_PLAY_STARTED", { questId: quest.id, questName: entry.questName, remaining: Math.max(0, questTarget - initialProgress), target });
+
+ let heartbeat = await reportPlayQuestProgress(quest, entry, false, { attempts: 3, delay: 2500 });
+
+ if (heartbeat.progress === null) {
+ return false;
+ }
+
+ while (isEntryActive(entry)) {
+ if (heartbeat.completed || heartbeat.progress >= questTarget) {
+ await reportPlayQuestProgress(refreshQuest(quest), entry, true, { attempts: 3, delay: 2500 });
+ return true;
+ }
+
+ const remainingMs = Math.max(0, (questTarget - heartbeat.progress) * 1000);
+ await sleep(remainingMs <= maximumHeartbeatDurationMs ? remainingMs + heartbeatBufferMs : maximumHeartbeatDurationMs, entry.abortController.signal);
+ heartbeat = await reportPlayQuestProgress(quest, entry, false, { attempts: 3, delay: 2500 });
+
+ if (heartbeat.progress === null) {
+ return false;
+ }
+ }
+
+ return false;
+}
+
+async function runAchievementQuest(quest: Quest, entry: AutoCompleteEntry, target: AutoCompleteQuestTarget): Promise {
+ quest = await waitUntilEnrolled(quest, entry, 60000, 500) ?? quest;
+
+ if (!isEntryActive(entry) || !quest.userStatus?.enrolledAt) {
+ return false;
+ }
+
+ if (!QuestifyNative?.complete) {
+ QL.error("AUTO_COMPLETE_ACHIEVEMENT_NATIVE_MISSING", { questId: quest.id, questName: entry.questName });
+ return false;
+ }
+
+ const appId = entry.task.applications?.[0]?.id;
+
+ if (!appId) {
+ QL.warn("AUTO_COMPLETE_ACHIEVEMENT_APP_MISSING", { questId: quest.id, questName: entry.questName });
+ return false;
+ }
+
+ setQuestAutoCompleteProgress(quest, 0);
+
+ let authCode: string | null = null;
+
+ try {
+ const response = await RestAPI.post({
+ url: `/oauth2/authorize?client_id=${appId}&response_type=code&scope=identify%20applications.entitlements&state=`,
+ body: { authorize: true },
+ });
+ const location = response?.body?.location;
+
+ authCode = location ? new URL(location).searchParams.get("code") : null;
+ } catch (error) {
+ QL.error("AUTO_COMPLETE_ACHIEVEMENT_AUTH_FAILED", { questId: quest.id, questName: entry.questName, error });
+ return false;
+ }
+
+ if (!authCode) {
+ QL.warn("AUTO_COMPLETE_ACHIEVEMENT_AUTH_CODE_MISSING", { questId: quest.id, questName: entry.questName });
+ return false;
+ }
+
+ const result = await QuestifyNative.complete(appId, authCode, target.adjusted);
+ const success = result.success === true;
+
+ setQuestAutoCompleteProgress(quest, success ? target.adjusted : 0);
+
+ try {
+ const deauthToken = AuthorizedAppsStore.getNewestTokenForApplication(appId)?.id;
+
+ if (deauthToken) {
+ await RestAPI.del({ url: `/oauth2/tokens/${deauthToken}` });
+ }
+ } catch (error) {
+ QL.error("AUTO_COMPLETE_ACHIEVEMENT_DEAUTH_FAILED", { questId: quest.id, questName: entry.questName, error });
+ }
+
+ if (!success) {
+ QL.error("AUTO_COMPLETE_ACHIEVEMENT_FAILED", { questId: quest.id, questName: entry.questName, error: result.error });
+ }
+
+ return success;
+}
+
+async function runAutoCompleteQuest(quest: Quest, entry: AutoCompleteEntry): Promise {
+ const target = getAutoCompleteQuestTarget(entry.task);
+
+ QL.info("AUTO_COMPLETE_RUN_START", {
+ questId: quest.id,
+ questName: entry.questName,
+ kind: entry.kind,
+ taskType: entry.task.type,
+ target,
+ });
+
+ switch (entry.kind) {
+ case "watch":
+ return runVideoQuest(quest, entry, target);
+ case "play":
+ return runPlayQuest(quest, entry, target);
+ case "achievement":
+ return runAchievementQuest(quest, entry, target);
+ }
+}
+
+function hasRunningQueuedAutoComplete(): boolean {
+ return Array.from(activeAutoCompletes.values()).some(entry => entry.status === "running");
+}
+
+async function runEntry(quest: Quest, entry: AutoCompleteEntry): Promise {
+ entry.status = "running";
+ updateResumeState();
+
+ try {
+ const completed = await runAutoCompleteQuest(quest, entry);
+
+ if (!isEntryActive(entry)) {
+ return;
+ }
+
+ if (completed) {
+ QL.info("AUTO_COMPLETE_COMPLETED", { questId: entry.questId, questName: entry.questName });
+ } else {
+ QL.warn("AUTO_COMPLETE_FAILED", { questId: entry.questId, questName: entry.questName });
+ }
+ } catch (error) {
+ if (entry.abortController.signal.aborted) {
+ QL.warn("AUTO_COMPLETE_RUN_ABORTED", { questId: entry.questId, questName: entry.questName });
+ } else {
+ QL.error("AUTO_COMPLETE_RUN_ERROR", { questId: entry.questId, questName: entry.questName, error });
+ }
+ } finally {
+ clearEntryTimers(entry);
+
+ if (activeAutoCompletes.get(entry.questId) !== entry) {
+ return;
+ }
+
+ activeAutoCompletes.delete(entry.questId);
+ resetQuestsToResume(quest);
+ updateResumeState();
+
+ if (!settings.store.autoCompleteQuestsSimultaneously && !suppressQueueDrain) {
+ runNextQueuedQuest();
+ }
+ }
+}
+
+function runNextQueuedQuest(): void {
+ if (suppressQueueDrain || settings.store.autoCompleteQuestsSimultaneously || hasRunningQueuedAutoComplete()) {
+ return;
+ }
+
+ const nextEntry = Array.from(activeAutoCompletes.values()).find(entry => entry.status === "queued");
+
+ if (!nextEntry) {
+ return;
+ }
+
+ const nextQuest = QuestStore.getQuest(nextEntry.questId);
+
+ if (!nextQuest) {
+ activeAutoCompletes.delete(nextEntry.questId);
+ updateResumeState();
+ runNextQueuedQuest();
+ return;
+ }
+
+ void runEntry(nextQuest, nextEntry);
+}
+
+export function processQuestForAutoComplete(quest: Quest, options: AutoCompleteStartOptions = {}): boolean {
+ const { force = false, source = "manual" } = options;
+ const questName = normalizeQuestName(quest);
+ const existingEntry = activeAutoCompletes.get(quest.id);
+
+ if (!isAutoCompleteRuntimeReady(true)) {
+ return false;
+ }
+
+ if (existingEntry) {
+ return true;
+ }
+
+ if (force) {
+ manuallyStoppedQuestIds.delete(quest.id);
+ } else if (manuallyStoppedQuestIds.has(quest.id)) {
+ QL.warn("AUTO_COMPLETE_MANUALLY_STOPPED", { questId: quest.id, questName });
+ return false;
+ }
+
+ if (quest.userStatus?.completedAt || getQuestStatus(quest, getIgnoredQuestIDs()) !== QuestStatus.Unclaimed) {
+ QL.warn("AUTO_COMPLETE_INCOMPATIBLE_STATUS", { questId: quest.id, questName });
+ return false;
+ }
+
+ const resolvedQuest = resolveAutoCompleteQuest(quest);
+
+ if (!resolvedQuest) {
+ QL.warn("AUTO_COMPLETE_INCOMPATIBLE_TASKS", { questId: quest.id, questName });
+ return false;
+ }
+
+ const entry = createAutoCompleteEntry(quest, resolvedQuest.task, resolvedQuest.kind);
+
+ activeAutoCompletes.set(quest.id, entry);
+
+ if (settings.store.autoCompleteQuestsSimultaneously) {
+ void runEntry(quest, entry);
+ } else {
+ updateResumeState();
+ runNextQueuedQuest();
+ }
+
+ QL.info("AUTO_COMPLETE_STARTED", {
+ questId: quest.id,
+ questName,
+ source,
+ mode: settings.store.autoCompleteQuestsSimultaneously ? "simultaneous" : "queue",
+ taskType: entry.task.type,
+ });
+
+ return true;
+}
+
+export function stopQuestAutoComplete(questOrId: Quest | string, options: AutoCompleteStopOptions = {}): boolean {
+ const { manual = true, preserveResume = false, terminalHeartbeat = false } = options;
+ const questId = typeof questOrId === "string" ? questOrId : questOrId.id;
+ const entry = activeAutoCompletes.get(questId);
+ const resumeQuestIds = preserveResume ? getResumeQuestIds() : undefined;
+
+ if (manual) {
+ manuallyStoppedQuestIds.add(questId);
+ }
+
+ if (!entry) {
+ updateResumeState(resumeQuestIds);
+ return false;
+ }
+
+ clearEntryTimers(entry);
+ entry.abortController.abort();
+ activeAutoCompletes.delete(questId);
+
+ const quest = typeof questOrId === "string" ? QuestStore.getQuest(questId) : questOrId;
+
+ if (terminalHeartbeat && quest && entry.kind === "play" && entry.status === "running") {
+ void reportPlayQuestProgress(refreshQuest(quest), entry, true, { attempts: 1 });
+ }
+
+ if (!preserveResume) {
+ resetQuestsToResume(quest);
+ }
+
+ updateResumeState(resumeQuestIds);
+
+ if (!settings.store.autoCompleteQuestsSimultaneously && !suppressQueueDrain) {
+ runNextQueuedQuest();
+ }
+
+ QL.info("AUTO_COMPLETE_STOPPED", { questId, manual, preserveResume, terminalHeartbeat });
+ return true;
+}
+
+export function stopAllAutoCompletes(options: AutoCompleteStopOptions = {}): void {
+ const resumeQuestIds = options.preserveResume ? getResumeQuestIds() : undefined;
+
+ suppressQueueDrain = true;
+
+ try {
+ for (const questId of Array.from(activeAutoCompletes.keys())) {
+ stopQuestAutoComplete(questId, options);
+ }
+
+ updateResumeState(resumeQuestIds);
+ } finally {
+ suppressQueueDrain = false;
+ }
+}
+
+export function stopAutoCompletesForRunningGames(gameIds: string[]): boolean {
+ let stoppedAny = false;
+
+ for (const entry of Array.from(activeAutoCompletes.values())) {
+ const quest = QuestStore.getQuest(entry.questId);
+
+ if (entry.kind === "play" && quest && gameIds.includes(quest.config.application.id)) {
+ stopQuestAutoComplete(quest, { manual: false, preserveResume: false });
+ stoppedAny = true;
+ }
+ }
+
+ if (stoppedAny) {
+ rerenderQuests();
+ }
+
+ return stoppedAny;
+}
+
+export function resumeInterruptedAutoCompletes(): void {
+ if (!settings.store.resumeInterruptedQuests) {
+ return;
+ }
+
+ const userId = getCurrentUserId();
+ const resumeState = userId ? settings.store.resumeQuestIDs[userId] : null;
+
+ if (!userId || !resumeState?.questIDs.length) {
+ return;
+ }
+
+ if (isResumeStateExpired(resumeState.timestamp)) {
+ delete settings.store.resumeQuestIDs[userId];
+ QL.info("AUTO_COMPLETE_RESUME_EXPIRED", { userId, timestamp: resumeState.timestamp, maxAge: resumeExpiryMs });
+ return;
+ }
+
+ const resumeQuestIds = Array.from(new Set(resumeState.questIDs));
+ const resumableQuests = resumeQuestIds
+ .map(questId => QuestStore.getQuest(questId))
+ .filter((quest): quest is Quest => quest != null);
+
+ if (resumableQuests.length === 0) {
+ updateResumeState();
+ return;
+ }
+
+ QL.info("AUTO_COMPLETE_RESUME_INTERRUPTED", {
+ userId,
+ timestamp: resumeState.timestamp,
+ questIDs: resumeQuestIds,
+ });
+
+ const resumedQuestIds = new Set();
+
+ for (const quest of resumableQuests) {
+ if (processQuestForAutoComplete(quest, { force: true, source: "resume" })) {
+ resumedQuestIds.add(quest.id);
+ }
+ }
+
+ updateResumeState();
+
+ const droppedQuestIds = resumeQuestIds.filter(questId => !resumedQuestIds.has(questId));
+
+ if (droppedQuestIds.length > 0) {
+ QL.warn("AUTO_COMPLETE_RESUME_DROPPED_QUESTS", {
+ userId,
+ questIDs: droppedQuestIds,
+ });
+ }
+}
+
+export function getActiveAutoCompletes(): readonly Readonly[] {
+ return Array.from(activeAutoCompletes.values());
+}
diff --git a/src/equicordplugins/questify/utils/components.tsx b/src/equicordplugins/questify/utils/components.tsx
deleted file mode 100644
index 832ff862c..000000000
--- a/src/equicordplugins/questify/utils/components.tsx
+++ /dev/null
@@ -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 {
- 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;
-
-export type QuestMap = Map;
-
-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 (
-
- );
-}
-export function QuestIcon(height: number, width: number, className?: string): JSX.Element {
- return (
-
- );
-}
-
-export interface ServerListItemUpperBadgeProps {
- icon?: (() => JSX.Element),
- color?: string;
- className?: string;
-}
-
-export function ServerListItemUpperBadge(props: ServerListItemUpperBadgeProps): JSX.Element {
- return (
-
- );
-}
-
-export interface ServerListItemLowerBadgeProps {
- count: number;
- className?: string;
- color?: string;
- style?: React.CSSProperties;
- maxDigits?: number;
-}
-
-export function ServerListItemLowerBadge(props: ServerListItemLowerBadgeProps): JSX.Element {
- return (
-
- );
-}
-
-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 ? (
-
- {icon}
-
- ) : undefined;
-
- const componentProps: Record = {
- ...(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 (
-
- {visible && (
-
-
-
-
-
- setHovered(true)}
- onMouseLeave={() => setHovered(false)}
- {...componentProps}
- />
-
-
- )}
-
- );
-}
-
-export interface DynamicDropdownProps extends React.ComponentProps {
- feedback?: string;
- maxOptionsVisible?: number;
-}
-
-export const DynamicDropdown: React.FC = props => {
- return ;
-};
diff --git a/src/equicordplugins/questify/utils/fetching.ts b/src/equicordplugins/questify/utils/fetching.ts
new file mode 100644
index 000000000..9253a010d
--- /dev/null
+++ b/src/equicordplugins/questify/utils/fetching.ts
@@ -0,0 +1,217 @@
+/*
+ * 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 { questIsIgnored } from "../settings/ignoredQuests";
+import { settings } from "../settings/store";
+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 | 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 {
+ 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 {
+ 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 {
+ 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 {
+ const alertSound = settings.store.newQuestAlertSound;
+ const alertVolume = settings.store.newQuestAlertVolume;
+ const excludedAlertSound = settings.store.newExcludedQuestAlertSound;
+ const excludedAlertVolume = settings.store.newExcludedQuestAlertVolume;
+ const shouldFetchExcludedQuests = settings.store.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.store.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.store.notifyOnNewQuests && newIncludedQuests.length > 0;
+ const shouldNotifyExcluded = settings.store.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;
+}
diff --git a/src/equicordplugins/questify/utils/filtering.ts b/src/equicordplugins/questify/utils/filtering.ts
new file mode 100644
index 000000000..ca3800183
--- /dev/null
+++ b/src/equicordplugins/questify/utils/filtering.ts
@@ -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;
+
+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;
+}
diff --git a/src/equicordplugins/questify/utils/logging.ts b/src/equicordplugins/questify/utils/logging.ts
new file mode 100644
index 000000000..90bb6ecd5
--- /dev/null
+++ b/src/equicordplugins/questify/utils/logging.ts
@@ -0,0 +1,79 @@
+/*
+ * Vencord, a Discord client mod
+ * Copyright (c) 2026 Vendicated and contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+import { 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 });
+}
diff --git a/src/equicordplugins/questify/utils/misc.tsx b/src/equicordplugins/questify/utils/misc.tsx
deleted file mode 100644
index 7f7eb1b31..000000000
--- a/src/equicordplugins/questify/utils/misc.tsx
+++ /dev/null
@@ -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 {
- 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();
-
- 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 {
- 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 {
- 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 {
- 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 {
- 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)];
- }
-}
diff --git a/src/equicordplugins/questify/utils/questState.ts b/src/equicordplugins/questify/utils/questState.ts
new file mode 100644
index 000000000..a880a41e5
--- /dev/null
+++ b/src/equicordplugins/questify/utils/questState.ts
@@ -0,0 +1,321 @@
+/*
+ * 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 { ignoredQuestIDsKey } from "../settings/def";
+import { settings } from "../settings/store";
+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(settings.store.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 = settings.use(["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 {
+ if (!quest || settings.store.disableQuestsEverything || settings.store.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,
+ 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,
+ 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;
+}
diff --git a/src/equicordplugins/questify/utils/questTiles.ts b/src/equicordplugins/questify/utils/questTiles.ts
new file mode 100644
index 000000000..af24881f8
--- /dev/null
+++ b/src/equicordplugins/questify/utils/questTiles.ts
@@ -0,0 +1,365 @@
+/*
+ * 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 { defaultClaimedSubsort, defaultExpiredSubsort, defaultIgnoredSubsort, defaultQuestOrder, defaultUnclaimedSubsort, type QuestOrderStatus, type QuestSubsort, type QuestTileColorSetting, type QuestTileGradient } from "../settings/def";
+import { getIgnoredQuestIDs } from "../settings/ignoredQuests";
+import { settings } from "../settings/store";
+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();
+
+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;
+
+const validSubsorts = new Set([
+ "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 = settings.use([
+ "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 {
+ const questTiles = settings.use([
+ "disableQuestsEverything",
+ "ignoredQuestIDs",
+ "questTileUnclaimedColor",
+ "questTileClaimedColor",
+ "questTileIgnoredColor",
+ "questTileExpiredColor",
+ ]);
+
+ const style: Record = {};
+ 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(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 = settings.use([
+ "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 = {
+ 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 {
+ return !settings.store.disableQuestsEverything && settings.store.questTilePreload;
+}
+
+export function getLastSortChoice(): string | null {
+ const { rememberQuestPageSort, lastQuestPageSort } = settings.store;
+
+ return rememberQuestPageSort ? lastQuestPageSort : "questify";
+}
+
+export function setLastSortChoice(sort: string): void {
+ settings.store.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 } = settings.store;
+
+ 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) {
+ settings.store.lastQuestPageFilters = {};
+
+ return;
+ }
+
+ if (!filters.every(filter => filter?.group && filter?.filter)) {
+ return;
+ }
+
+ settings.store.lastQuestPageFilters = JSON.parse(JSON.stringify(filters)).reduce((acc, item) => {
+ acc[getFilterChoiceKey(item)] = item;
+
+ return acc;
+ }, {} as Record);
+}
diff --git a/src/equicordplugins/questify/utils/ui.ts b/src/equicordplugins/questify/utils/ui.ts
new file mode 100644
index 000000000..47e61c1af
--- /dev/null
+++ b/src/equicordplugins/questify/utils/ui.ts
@@ -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;
+}
+
+const AlertsModule = findByPropsLazy("show", "close", "confirm");
+
+export const Alerts: AlertsType = {
+ show: options => AlertsModule.show(options),
+ close: () => AlertsModule.close(),
+ confirm: options => AlertsModule.confirm(options),
+};