Equicord/src/equicordplugins/questify/index.tsx
2026-02-21 01:47:36 -05:00

1805 lines
76 KiB
TypeScript

/*
* Vencord, a Discord client mod
* Copyright (c) 2025 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 { addServerListElement, removeServerListElement, ServerListRenderPosition } from "@api/ServerList";
import { migratePluginToSettings, Settings } from "@api/Settings";
import ErrorBoundary from "@components/ErrorBoundary";
import { openPluginModal } from "@components/settings";
import { EquicordDevs } from "@utils/constants";
import { copyToClipboard } from "@utils/index";
import definePlugin, { PluginNative, StartAt } from "@utils/types";
import { onceReady } from "@webpack";
import { ContextMenuApi, Menu, NavigationRouter, RestAPI, useEffect, useState } from "@webpack/common";
import { 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, videoQuestLeeway, waitUntilEnrolled } from "./utils/misc";
let initialQuestDataFetched = false;
const QuestifyNative = VencordNative.pluginHelpers.Questify as PluginNative<typeof import("./native")>;
const patchedMobileQuests = new Set<string>();
export const activeQuestIntervals = new ActiveQuestIntervalsMap();
function questMenuUnignoreAllClicked(): void {
validateAndOverwriteIgnoredQuests([]);
}
function questMenuIgnoreAllClicked(): void {
const quests = (QuestsStore.quests as QuestMap);
const excludedQuests = (QuestsStore.excludedQuests as ExcludedQuestMap);
const ignoredQuestsSet = new Set<string>();
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 showQuestsButton(questButtonDisplay: string, unclaimedUnignoredQuests: number, onQuestsPage: boolean): boolean {
const canShow = questButtonDisplay !== "never";
const alwaysShow = questButtonDisplay === "always";
return canShow && (alwaysShow || !!unclaimedUnignoredQuests || onQuestsPage);
}
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 handleClick(event: React.MouseEvent<Element>) {
// 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 = settings.store.questButtonMiddleClickAction;
} else if (event.button === rightClick) {
todo = settings.store.questButtonRightClickAction;
} else if (event.button === leftClick) {
todo = settings.store.questButtonLeftClickAction;
}
if (todo === "open-quests") {
NavigationRouter.transitionTo(questPath);
} else if (todo === "plugin-settings") {
openPluginModal(plugins.Questify);
} else if (todo === "context-menu") {
ContextMenuApi.openContextMenu(event, () => (
<Menu.Menu
navId={q("quest-button-context-menu")}
onClose={ContextMenuApi.closeContextMenu}
aria-label="Quest Button Menu"
>
<Menu.MenuItem
id={q("ignore-quests-option")}
label="Mark All Ignored"
action={questMenuIgnoreAllClicked}
disabled={!unclaimedUnignoredQuests}
/>
<Menu.MenuItem
id={q("unignore-quests-option")}
label="Reset Ignored List"
action={questMenuUnignoreAllClicked}
disabled={!getIgnoredQuestIDs().length}
/>
<Menu.MenuItem
id={q("fetch-quests-option")}
label="Fetch Quests"
action={() => fetchAndAlertQuests("Questify-ManualFetch", QuestifyLogger)}
/>
</Menu.Menu>
));
}
}
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" } } : {})
};
return (
<GuildlessServerListItem
id={q("quest-button")}
className={q("quest-button")}
icon={QuestIcon(26, 26)}
tooltip="Quests"
showPill={true}
isVisible={showQuestsButton(questButtonDisplay, unclaimedUnignoredQuests, onQuestsPage)}
isSelected={onQuestsPage}
hasUnread={!!unclaimedUnignoredQuests && ["pill", "both"].includes(questButtonUnclaimed)}
lowerBadgeProps={lowerBadgeProps}
onClick={handleClick}
onContextMenu={handleClick}
onMouseDown={handleClick}
/>
);
}
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((
<Menu.MenuGroup>
{!isClaimedMenu && (!isIgnored ? (
<Menu.MenuItem
id={q("ignore-quests")}
label="Mark as Ignored"
action={() => { addIgnoredQuest(props.quest.id); }}
/>
) : (
<Menu.MenuItem
id={q("unignore-quests")}
label="Unmark as Ignored"
action={() => { removeIgnoredQuest(props.quest.id); }}
/>
))}
{activeQuestIntervals.has(props.quest.id) ? (
<Menu.MenuItem
id={q("stop-auto-complete")}
label="Stop Auto-Complete"
action={() => {
const interval = activeQuestIntervals.get(props.quest.id);
if (interval) {
clearInterval(interval.progressTimeout);
clearTimeout(interval.rerenderTimeout);
activeQuestIntervals.delete(props.quest.id);
rerenderQuests();
}
}}
/>) : canAutoComplete ? (
<Menu.MenuItem
id={q("start-auto-complete")}
label="Start Auto-Complete"
action={() => { processQuestForAutoComplete(props.quest); }}
/>) : null
}
<Menu.MenuItem
id={q("copy-quest-id")}
label="Copy Quest ID"
action={() => { copyToClipboard(props.quest.id); }}
/>
</Menu.MenuGroup>
));
}
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<string, string> {
settings.use([
"restyleQuests",
"ignoredQuestIDs",
"ignoredQuestProfile"
]);
const style: Record<string, string> = {};
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<void> {
const questName = normalizeQuestName(quest.config.messages.questName);
const questEnrolledAt = quest.userStatus?.enrolledAt ? new Date(quest.userStatus.enrolledAt) : null;
const initialProgress = Math.floor(((new Date()).getTime() - (questEnrolledAt ?? new Date()).getTime()) / 1000) || 1;
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 reportEverySec = 10;
let progressIntervalId: NodeJS.Timeout;
async function handleSendComplete() {
clearInterval(progressIntervalId);
clearTimeout(renderIntervalId);
const success = await reportVideoQuestProgress(quest, reportTarget, QuestifyLogger);
activeQuestIntervals.delete(quest.id);
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}.`);
}
}
if (timeRemaining < reportEverySec) {
progressIntervalId = setTimeout(async () => {
await handleSendComplete();
}, timeRemaining * 1000);
} else {
const simulatedProgressToCover = reportTarget - currentProgressScaled;
const speedFactor = simulatedProgressToCover / timeRemaining;
const progressIncrementPerInterval = reportEverySec * speedFactor;
const numberOfIntervals = Math.floor(timeRemaining / reportEverySec);
let intervalsRun = 0;
reportVideoQuestProgress(quest, initialProgress, QuestifyLogger);
progressIntervalId = setInterval(async () => {
intervalsRun++;
currentProgress += reportEverySec;
currentProgressScaled += progressIncrementPerInterval;
const progressToReport = Math.min(Math.floor(currentProgressScaled), reportTarget);
if (intervalsRun < numberOfIntervals || (currentProgress < reportTarget - videoQuestLeeway - (reportEverySec / 2))) {
await reportVideoQuestProgress(quest, progressToReport, QuestifyLogger);
}
if (intervalsRun >= numberOfIntervals) {
clearInterval(progressIntervalId);
const timeSpentInIntervals = numberOfIntervals * reportEverySec;
const finalWaitTime = Math.max(0, timeRemaining - timeSpentInIntervals);
progressIntervalId = setTimeout(handleSendComplete, finalWaitTime * 1000);
const intervalData = activeQuestIntervals.get(quest.id);
if (intervalData) intervalData.progressTimeout = progressIntervalId;
}
}, reportEverySec * 1000);
}
const renderIntervalId = setInterval(() => {
const intervalData = activeQuestIntervals.get(quest.id);
if (!!intervalData) {
intervalData.progress += 1;
} else {
clearInterval(renderIntervalId);
}
rerenderQuests();
}, 1000);
const intervalData = activeQuestIntervals.get(quest.id);
if (intervalData) {
intervalData.progressTimeout = progressIntervalId;
intervalData.rerenderTimeout = renderIntervalId;
}
}
async function startPlayGameProgressTracking(quest: Quest, target: { raw: number; adjusted: number; }): Promise<void> {
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 heartbeatInterval = 20; // Heartbeats must be at most 2 minutes apart.
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;
}
const initial = await reportPlayGameQuestProgress(quest, false, QuestifyLogger, { attempts: 3, delay: 2500 });
const progressIntervalId = setInterval(async () => {
const result = await reportPlayGameQuestProgress(quest, false, QuestifyLogger, { attempts: 3, delay: 2500 });
if (result.progress === null) {
clearInterval(progressIntervalId);
activeQuestIntervals.delete(quest.id);
QuestifyLogger.error(`[${getFormattedNow()}] Failed to send heartbeat for Quest ${questName}.`);
return;
}
const isComplete = result.progress >= questTarget;
const timeRemaining = questTarget - result.progress;
const intervalData = activeQuestIntervals.get(quest.id);
intervalData && (intervalData.progress = result.progress);
if (isComplete) {
clearInterval(progressIntervalId);
clearTimeout(renderIntervalId);
const success = await reportPlayGameQuestProgress(quest, true, QuestifyLogger, { attempts: 3, delay: 2500 });
activeQuestIntervals.delete(quest.id);
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}.`);
}
} else if (timeRemaining < heartbeatInterval) {
clearInterval(progressIntervalId);
clearTimeout(renderIntervalId);
setTimeout(async () => {
const success = await reportPlayGameQuestProgress(quest, true, QuestifyLogger, { attempts: 3, delay: 2500 });
activeQuestIntervals.delete(quest.id);
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}.`);
}
}, (timeRemaining + 1) * 1000);
}
}, heartbeatInterval * 1000);
const renderIntervalId = setInterval(() => {
const intervalData = activeQuestIntervals.get(quest.id);
if (!!intervalData) {
intervalData.progress += 1;
} else {
clearInterval(renderIntervalId);
}
rerenderQuests();
}, 1000);
const intervalData = activeQuestIntervals.get(quest.id);
if (intervalData) {
intervalData.progress = initial.progress || initialProgress;
intervalData.progressTimeout = progressIntervalId;
intervalData.rerenderTimeout = renderIntervalId;
}
}
async function startAchievementActivityProgressTracking(quest: Quest, target: { raw: number; adjusted: number; }): Promise<void> {
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);
return;
} 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}`),
});
}
}
}
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): 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 (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;
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 (target > 0) {
if ((isPlay && completeGameQuestsInBackground && IS_DISCORD_DESKTOP) || (isWatch && completeVideoQuestsInBackground)) {
return `Complete (${targetFormatted})`;
} else if (isAchievement && completeAchievementQuestsInBackground) {
return "Complete (Immediate)";
}
}
return null;
}
function getQuestAcceptedButtonText(quest: Quest, prepositional: boolean = false): string | null {
const { completeGameQuestsInBackground, completeVideoQuestsInBackground, completeAchievementQuestsInBackground } = settings.store;
const questEnrolledAt = quest.userStatus?.enrolledAt ? new Date(quest.userStatus.enrolledAt) : null;
const task = getQuestTask(quest);
const intervalData = 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;
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 && questEnrolledAt && ((new Date().getTime() - questEnrolledAt.getTime()) / 1000) >= durationWithLeeway;
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; }
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 {
settings.store.lastQuestPageSort = sort;
}
function setLastFilterChoices(filters: { group: string; filter: string; }[]): void {
if (!filters || !Object.keys(filters).length || !Object.values(filters).every(f => f.group && f.filter)) { return; }
settings.store.lastQuestPageFilters = JSON.parse(JSON.stringify(filters)).reduce((acc, item) => ({ ...acc, [item.filter]: item }), {});
}
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); !startingAutocomplete && onClick ? onClick() : null; },
icon: () => { }
};
}
// 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.",
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,
disguiseHomeButton,
getLastSortChoice,
setLastSortChoice,
useQuestRerender,
patches: [
{
// Hides the notice in the gift inventory that Quests have been relocated to the Discovery tab.
find: "quests-wumpus-hikes-mountain-transparent-background",
replacement: {
match: /return(?=\(0,\i.\i\)\("div",{className:)/,
replace: "return $self.shouldHideGiftInventoryRelocationNotice()?null:"
}
},
{
// Hides Quests tab in the Discovery page.
find: "GLOBAL_DISCOVERY_SIDEBAR},",
replacement: [
{
match: /(GLOBAL_DISCOVERY_TABS).map/,
replace: '$1.filter(tab=>!(tab==="quests"&&$self.shouldHideDiscoveryTab())).map'
}
]
},
{
// Hides Quests tab in the DMs tab list.
find: "QUEST_HOME_V2):",
replacement: [
{
match: /(?<="family-center"\):null,)/,
replace: "$self.shouldHideDirectMessagesTab()||"
}
]
},
{
// Hides the sponsored banner on the Quests page.
find: "{isInDiscoverQuestHomeTab:",
group: true,
replacement: [
{
match: /(?<=resetSortingFiltering\(\)},\[\]\);)/,
replace: "const shouldHideSponsoredQuestBanner=$self.shouldHideSponsoredQuestBanner();"
},
{
match: /(?<=if\(null!=\i\))return(.{0,60}?}\))/,
replace: "if(!shouldHideSponsoredQuestBanner)return $1"
}
]
},
{
// 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: /(?<=\i\)&&"xs"===\i;)/,
replace: "const shouldHideMembersListActivelyPlayingIcon=$self.shouldHideMembersListActivelyPlayingIcon();"
},
{
match: /(?<=\i\(\),\i&&)/,
replace: "!shouldHideMembersListActivelyPlayingIcon&&"
}
]
},
{
// Hides the Quest badge on user profiles.
find: ".MODAL]:26",
group: true,
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))',
}
]
},
{
// Hides the new Quest popup above the account panel.
// Allows in-progress Quests to still show.
find: "QUESTS_BAR,questId",
replacement: {
match: /(?=if\(null==(\i)\)return null;)(.{0,125}?quest:\i}\);return)/,
replace: "const hidePopup=$self.shouldHideQuestPopup($1);$2!hidePopup&&"
}
},
{
// Fixes the progress tracking for auto-completing Quests.
find: ",{progressTextAnimation:",
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.
find: '"progress-title"',
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)/,
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: /(?<=\.scrollIntoView\()/,
replace: "{block:\"nearest\",inline:\"nearest\"}"
},
{
// 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]"
}
]
},
{
// Formats the Orbs balance on the Quests page with locale string formatting.
find: '("BalanceCounter")',
replacement: [
{
match: /(`\${(\i).toFixed\(0\)}`.length)/,
replace: "$1+($2>=1e6?0.8:$2>=1e3?0.4:0)"
},
{
match: /(?<=children:\i.to\(\i=>`\${\i)(.toFixed\(0\))/,
replace: ".toLocaleString(undefined,{maximumFractionDigits:0})"
}
]
},
{
// 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",
group: true,
replacement: [
{
// Extracts the custom maxDigits prop.
match: /(=>{let{count:\i,)/,
replace: "$1maxDigits,"
},
{
// Passes maxDigits to the rounding function.
match: /(children:\i\(\i)/,
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.
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: /(warningHints:\i,)isVisibleInViewport:(\i)/,
replace: "$1isVisibleInViewport:$self.shouldPreloadQuestAssets()?true:$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: /return ((\i).SUGGESTED="suggested",)/,
replace: "return $2.QUESTIFY=\"questify\",$1"
}
},
{
// 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: /(return \i.useMemo\(\(\)=>{)(?=if\(0===(\i).length\))/,
replace: "const questRerenderTrigger=$self.useQuestRerender();const questifySorted=$self.sortQuests($2,arguments[1].sortMethod!==\"questify\");$1if(arguments[1].sortMethod===\"questify\"){$2=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).{0,115}?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.
match: /(get\(\i\)\)\?\?)(\i)/,
replace: "$1$self.getLastFilterChoices()??$2"
},
{
// Update the last used sort method when it changes.
match: /(onChange:)(\i)(.{0,40}?selectedSortMethod)/,
replace: "$1(value)=>{$self.settings.store.lastQuestPageSort=value;$2(value);}$3"
},
{
// Update the last used filter choices when they change.
match: /(onChange:)(\i)(.{0,40}?selectedFilters)/,
replace: "$1(value)=>{$self.settings.store.lastQuestPageFilters=value.reduce((acc,item)=>({...acc,[item.filter]:item}),{});$2(value);}$3"
},
{
// 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"
}
]
},
{
// 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: ",{rewardWithArticleHook:()",
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)&&$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);"
},
{
// 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"
}
]
},
{
// Adds support for dev://experiment/2025-12-quest-cta-refactor-rollout
find: '"primary",preClickCallback:',
replacement: [
{
match: /(?=let{quest:)/,
replace: "const questifyText=$self.getQuestUnacceptedButtonText(arguments[0].quest);"
},
{
match: /(?<=}\),)(\i\?\.\(\))/,
replace: "!$self.processQuestForAutoComplete(arguments[0].quest)&&($1)"
},
{
match: /(?<=,text:)(\i),icon:\i/,
replace: "questifyText??$1"
}
]
},
{
// Sets intervals to progress Video Quests in the background.
find: "CAPTCHA_FAILED:",
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: "WATCH_VIDEO,skipEnrollmentCheck:!0})",
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),$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);
},
QUESTS_FETCH_CURRENT_QUESTS_SUCCESS(data) {
initialQuestDataFetched = true;
const source = data.source ? ` [${data.source}]` : "";
QuestifyLogger.info(`[${getFormattedNow()}] [QUESTS_FETCH_CURRENT_QUESTS_SUCCESS]${source}\n`, data);
validateAndOverwriteIgnoredQuests(undefined, data.quests);
},
QUESTS_ENROLL_SUCCESS(data) {
QuestifyLogger.info(`[${getFormattedNow()}] [QUESTS_ENROLL_SUCCESS]\n`, data);
fetchAndDispatchQuests("Questify", QuestifyLogger);
validateAndOverwriteIgnoredQuests();
},
QUESTS_CLAIM_REWARD_SUCCESS(data) {
QuestifyLogger.info(`[${getFormattedNow()}] [QUESTS_CLAIM_REWARD_SUCCESS]\n`, data);
fetchAndDispatchQuests("Questify", QuestifyLogger);
validateAndOverwriteIgnoredQuests();
},
QUESTS_USER_STATUS_UPDATE(data) {
QuestifyLogger.info(`[${getFormattedNow()}] [QUESTS_USER_STATUS_UPDATE]\n`, data);
validateAndOverwriteIgnoredQuests();
},
// 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;
(Array.from(QuestsStore.quests.values()) as Quest[]).forEach(quest => {
const questName = normalizeQuestName(quest.config.messages.questName);
const questAppID = quest.config.application.id;
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 (shouldRerenderQuests) {
rerenderQuests();
}
},
LOGOUT(data) {
settings.store.unclaimedUnignoredQuests = 0;
settings.store.onQuestsPage = false;
},
LOGIN_SUCCESS(data) {
onceReady.then(() => {
fetchAndDispatchQuests("Questify", QuestifyLogger);
});
}
},
renderQuestifyButton: ErrorBoundary.wrap(QuestButton, { noop: true }),
start() {
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) {
settings.store.resumeQuestIDs = settings.def.resumeQuestIDs.default;
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);
});
},
stop() {
removeServerListElement(ServerListRenderPosition.Above, this.renderQuestifyButton);
stopAutoFetchingQuests();
if (!Settings.plugins.Questify.enabled) {
settings.store.resumeQuestIDs = settings.def.resumeQuestIDs.default;
}
activeQuestIntervals.forEach((intervalData, questId) => {
clearInterval(intervalData.progressTimeout);
clearTimeout(intervalData.rerenderTimeout);
activeQuestIntervals.delete(questId);
});
}
});