Equicord/src/equicordplugins/questify/index.tsx
2025-12-09 21:03:29 -05:00

1448 lines
59 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 { addServerListElement, removeServerListElement, ServerListRenderPosition } from "@api/ServerList";
import { ErrorBoundary, openPluginModal } from "@components/index";
import { EquicordDevs } from "@utils/constants";
import definePlugin, { StartAt } from "@utils/types";
import { onceReady } from "@webpack";
import { ContextMenuApi, Menu, NavigationRouter, 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 { ExcludedQuestMap, GuildlessServerListItem, Quest, QuestIcon, QuestMap, QuestStatus, RGB } from "./utils/components";
import { adjustRGB, decimalToRGB, fetchAndDispatchQuests, formatLowerBadge, getFormattedNow, getIgnoredQuestIDs, getQuestStatus, isDarkish, leftClick, middleClick, normalizeQuestName, q, QuestifyLogger, questPath, QuestsStore, refreshQuest, reportPlayGameQuestProgress, reportVideoQuestProgress, rightClick, setIgnoredQuestIDs, waitUntilEnrolled } from "./utils/misc";
const patchedMobileQuests = new Set<string>();
export const activeQuestIntervals = new Map<string, { progressTimeout: NodeJS.Timeout; rerenderTimeout: NodeJS.Timeout; progress: number; duration: number, type: string; }>();
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(Vencord.Plugins.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,
triggerQuestsRerender
} = 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 shouldDisableQuestTileOptions(quest: Quest, shouldBeIgnored: boolean): boolean {
const isIgnored = questIsIgnored(quest.id);
return !(
(shouldBeIgnored ? isIgnored : !isIgnored)
);
}
function QuestTileContextMenu(children: React.ReactNode[], props: { quest: any; }) {
children.unshift((
<Menu.MenuGroup>
<Menu.MenuItem
id={q("ignore-quests")}
label="Mark as Ignored"
disabled={shouldDisableQuestTileOptions(props.quest, false)}
action={() => { addIgnoredQuest(props.quest.id); }}
/>
<Menu.MenuItem
id={q("unignore-quests")}
label="Unmark as Ignored"
disabled={shouldDisableQuestTileOptions(props.quest, true)}
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();
}
}}
/>
}
</Menu.MenuGroup>
));
}
export function getQuestTileClasses(originalClasses: string, quest: Quest, color: number | null | undefined, gradient: string | undefined): string {
const {
ignoredQuestIDs,
ignoredQuestProfile,
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, triggerQuestsRerender } = 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.add(quest.id);
tasks.WATCH_VIDEO = {
...tasks.WATCH_VIDEO_ON_MOBILE,
type: "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 {
ignoredQuestIDs,
ignoredQuestProfile,
reorderQuests,
unclaimedSubsort,
claimedSubsort,
ignoredSubsort,
expiredSubsort,
completeVideoQuestsInBackground,
completeGameQuestsInBackground,
triggerQuestsRerender
} = settings.use([
"ignoredQuestIDs",
"ignoredQuestProfile",
"reorderQuests",
"unclaimedSubsort",
"claimedSubsort",
"ignoredSubsort",
"expiredSubsort",
"completeVideoQuestsInBackground",
"completeGameQuestsInBackground",
"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> {
const {
restyleQuests,
ignoredQuestIDs,
ignoredQuestProfile
} = 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, questDuration: 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; // Max up to 10 seconds into the future can be reported.
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.`);
return;
}
}
let progressIntervalId: NodeJS.Timeout;
let currentProgress = initialProgress;
const timeRemaining = Math.max(0, questDuration - currentProgress);
const reportEverySec = 10;
async function handleSendComplete() {
clearInterval(progressIntervalId);
clearTimeout(renderIntervalId);
activeQuestIntervals.delete(quest.id);
const success = await reportVideoQuestProgress(quest, questDuration, QuestifyLogger);
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 {
reportVideoQuestProgress(quest, initialProgress, QuestifyLogger);
progressIntervalId = setInterval(async () => {
currentProgress += reportEverySec;
if (currentProgress >= questDuration - 10) {
await handleSendComplete();
} else {
await reportVideoQuestProgress(quest, currentProgress, QuestifyLogger);
}
}, reportEverySec * 1000);
}
const renderIntervalId = setInterval(() => {
const intervalData = activeQuestIntervals.get(quest.id);
if (!!intervalData) {
intervalData.progress += 1;
} else {
clearInterval(renderIntervalId);
}
rerenderQuests();
}, 1000);
activeQuestIntervals.set(quest.id, { progressTimeout: progressIntervalId, rerenderTimeout: renderIntervalId, progress: initialProgress, duration: questDuration, type: "watch" });
if (timeRemaining > 0) {
QuestifyLogger.info(`[${getFormattedNow()}] Quest ${questName} will be completed in the background in ${timeRemaining} seconds.`);
}
}
async function startPlayGameProgressTracking(quest: Quest, questDuration: number): Promise<void> {
const questName = normalizeQuestName(quest.config.messages.questName);
const questEnrolledAt = quest.userStatus?.enrolledAt ? new Date(quest.userStatus.enrolledAt) : null;
const playType = 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;
const initialProgress = quest.userStatus?.progress?.[playType?.type || ""]?.value || 0;
const remaining = Math.max(0, questDuration - initialProgress);
const heartbeatInterval = 20; // Heartbeats must be at most 2 minutes apart.
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.`);
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 >= questDuration;
const timeRemaining = questDuration - result.progress;
const intervalData = activeQuestIntervals.get(quest.id);
intervalData && (intervalData.progress = result.progress);
if (isComplete) {
clearInterval(progressIntervalId);
clearTimeout(renderIntervalId);
activeQuestIntervals.delete(quest.id);
const success = await reportPlayGameQuestProgress(quest, true, QuestifyLogger, { attempts: 3, delay: 2500 });
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 () => {
activeQuestIntervals.delete(quest.id);
const success = await reportPlayGameQuestProgress(quest, true, QuestifyLogger, { attempts: 3, delay: 2500 });
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);
activeQuestIntervals.set(quest.id, { progressTimeout: progressIntervalId, rerenderTimeout: renderIntervalId, progress: initial.progress || initialProgress, duration: questDuration, type: "play" });
if (remaining > 0) {
QuestifyLogger.info(`[${getFormattedNow()}] Quest ${questName} will be completed in the background in ${remaining} seconds.`);
}
}
function processQuestForAutoComplete(quest: Quest): boolean {
const questName = normalizeQuestName(quest.config.messages.questName);
const { completeGameQuestsInBackground, completeVideoQuestsInBackground } = settings.store;
const playType = 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;
const watchType = quest.config.taskConfigV2?.tasks.WATCH_VIDEO || quest.config.taskConfigV2?.tasks.WATCH_VIDEO_ON_MOBILE;
const questDuration = playType?.target || watchType?.target || 0;
const existingInterval = activeQuestIntervals.get(quest.id);
if (quest.userStatus?.completedAt || existingInterval) {
return true;
} else if (!playType && !watchType) {
QuestifyLogger.warn(`[${getFormattedNow()}] Could not recognize the Quest type for ${questName}.`);
return true;
} else if ((watchType && !completeVideoQuestsInBackground) || (playType && (!completeGameQuestsInBackground || !IS_DISCORD_DESKTOP))) {
return true;
} else if (!questDuration) {
QuestifyLogger.warn(`[${getFormattedNow()}] Could not find duration for Quest ${questName}.`);
return true;
} else if (watchType) {
startVideoProgressTracking(quest, questDuration);
return false;
} else if (playType) {
startPlayGameProgressTracking(quest, questDuration);
return false;
}
return true; // true means continue as normal, false means prevent default action.
}
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 true;
} else {
return false;
}
}
return null;
}
function getQuestAcceptedButtonText(quest: Quest): string | null {
const { completeGameQuestsInBackground, completeVideoQuestsInBackground } = settings.store;
const questEnrolledAt = quest.userStatus?.enrolledAt ? new Date(quest.userStatus.enrolledAt) : null;
const playType = 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;
const watchType = quest.config.taskConfigV2?.tasks.WATCH_VIDEO || quest.config.taskConfigV2?.tasks.WATCH_VIDEO_ON_MOBILE;
const taskType = playType || watchType;
const duration = taskType?.target || 0;
const intervalData = activeQuestIntervals.get(quest.id);
const progress = Math.min((intervalData?.progress ?? (quest.userStatus?.progress?.[taskType?.type || ""]?.value || 0)), duration);
const timeRemaining = Math.max(0, duration - progress);
const progressFormatted = `${String(Math.floor(timeRemaining / 60)).padStart(2, "0")}:${String(timeRemaining % 60).padStart(2, "0")}`;
if (questEnrolledAt && ((playType && completeGameQuestsInBackground) || (watchType && completeVideoQuestsInBackground))) {
if (!!intervalData) {
return timeRemaining ? `Completing (${progressFormatted})` : "Completing...";
} else if (watchType || (playType && IS_DISCORD_DESKTOP)) {
return `Resume (~${progressFormatted})`;
}
}
return null;
}
function getQuestPanelOverride(): Quest | null {
let closestQuest: Quest | null = null;
let closestTimeRemaining = Infinity;
activeQuestIntervals.forEach((interval, questId) => {
const quest = QuestsStore.getQuest(questId);
if (!quest) {
return;
}
const timeRemaining = interval.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 completedQuests = Array.from(QuestsStore.quests.values() as Quest[]).filter(q => q.userStatus?.completedAt).sort((a, b) => {
const aTime = new Date(a.userStatus?.completedAt as string);
const bTime = new Date(b.userStatus?.completedAt as string);
return bTime.getTime() - aTime.getTime();
});
completedQuests.forEach(quest => {
const completedQuest = quest.userStatus?.completedAt;
const questStatus = getQuestStatus(quest);
if (completedQuest && questStatus === QuestStatus.Unclaimed) {
closestQuest = quest;
}
});
}
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 {
const { triggerQuestsRerender } = 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 = [
"WATCH_VIDEO",
"WATCH_VIDEO_ON_MOBILE",
"PLAY_ON_DESKTOP",
"PLAY_ON_XBOX",
"PLAY_ON_PLAYSTATION",
"PLAY_ACTIVITY"
];
if (!Array.from(validTasks).some(taskType => Object.values(quest.config.taskConfigV2?.tasks || {}).some(task => task.type === taskType))) {
return {
disabled: disabled,
text: text,
onClick: onClick,
icon: () => { }
};
}
return {
disabled: shouldDisableQuestAcceptedButton(quest) ?? true,
text: getQuestAcceptedButtonText(quest) ?? text,
onClick: () => { processQuestForAutoComplete(quest); },
icon: () => { }
};
}
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,
processQuestForAutoComplete,
getQuestAcceptedButtonProps,
getQuestAcceptedButtonText,
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:$1"
}
},
{
// 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,)(\i)/,
replace: "$self.shouldHideDirectMessagesTab()||$1"
}
]
},
{
// 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: "HANG_STATUS});",
group: true,
replacement: [
{
match: /(?=if\(\(0,.{0,30}?isBlockedOrIgnored)/,
replace: "const shouldHideMembersListActivelyPlayingIcon=$self.shouldHideMembersListActivelyPlayingIcon();"
},
{
match: /(?<=\i\(\),\i&&)/,
replace: "!shouldHideMembersListActivelyPlayingIcon&&"
}
]
},
{
// Same as above, probably? Not sure when
// each function is used, so patching both.
find: "iconOnly)},",
group: true,
replacement: [
{
match: /(?=if\(\i\)return null;let \i=function)/,
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: /return null==(\i)\?null:\(/,
replace: "return !$self.shouldHideQuestPopup($1)&&("
}
},
{
// Replaces the default displayed Quest with the soonest to
// be completed Quest which is actively being auto-completed.
find: "questDeliveryOverride)?",
replacement: {
match: /(\i=)(\i.\i.questDeliveryOverride)/,
replace: "$1$self.getQuestPanelOverride()??$2"
}
},
{
// 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: /(?=var.{0,150}QUESTS_FETCH_QUEST_TO_DELIVER_BEGIN)/,
replace: "if($self.shouldPreventFetchingQuests())return;"
}
]
},
{
// Adds a feedback prop to the SearchableSelect component which will display on invalid searches.
find: '"onSearchChange",',
group: true,
replacement: [
{
// Extracts the custom dropdown prop before the variable is overwritten.
match: /(\((\i),\i\){)(var{options:\i,)/,
replace: "$1const vcDynamicDropdownFeedback=$2.feedback;$3"
},
{
// 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"
}
]
},
{
// Adds a maxDigits prop to the LowerBadge component which allows for not truncating, or for truncating at a specific threshold.
find: '"renderBadgeCount"])',
group: true,
replacement: [
{
// Extracts the custom maxDigits prop.
match: /(=>{var{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 (.{0,100}?k\+"\))/,
replace: "function $1,maxDigits){return maxDigits===undefined?($3):$self.formatLowerBadge($2,maxDigits)[0]"
}
]
},
{
find: "id:\"quest-tile-\".concat",
group: true,
replacement: [
{
// Restyles Quest tiles with colors.
match: /className:(\i\(\)\(\i.container,\i\)),/,
replace: "className:$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: /(?<=function \i\((\i),\i\){let \i=\i.useRef.{0,100}?;)(return \i.useMemo\(\(\)=>{)/,
replace: "const questRerenderTrigger=$self.useQuestRerender();const questifySorted=$self.sortQuests($1,arguments[1].sortMethod!==\"questify\");$2if(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).{0,100}?\)\);)(return )((\i).sort)/,
replace: "$2$1===\"questify\"?$4:$3"
},
{
// 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: "headingControls,children:",
group: true,
replacement: [
{
// Set the initial sort method.
match: /(\i.\i.SUGGESTED)/,
replace: "$self.getLastSortChoice()??$1"
},
{
// Set the initial filters.
match: /(get\(\i\)\)\)\?\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: "rewardDescriptionContainer,children",
replacement: {
match: /showPlaceholder:!\i/,
replace: "showPlaceholder:false"
}
},
{
// Sets intervals to progress Video Quests and Play Game Quests in the background.
find: "IN_PROGRESS:if(",
group: true,
replacement: [
{
// Resume Video Quest
match: /(onClick:\(\)=>)(\(0,\i.openVideoQuestModal\)\({quest:(\i))/,
replace: "$1$self.processQuestForAutoComplete($3)&&$2"
},
{
// Start Video & Play Game Quests.
match: /(?<=onClick:async\(\)=>{)/,
replace: "const startingAutoComplete=$self.processQuestForAutoComplete(arguments[0].quest);"
},
{
// Prevent the new Video Quest entry point.
match: /(?<=QUEST_HOME_DESKTOP\))/,
replace: "&&false"
},
{
// Conditionally open the Video Quest modal.
match: /(\i\?)?(\(0,\i.openVideoQuestModal\)\({quest:(\i))/,
replace: "$1startingAutoComplete&&$2"
},
{
// The "Resume (XX:XX)" text is changed to "Watching (XX:XX)" if the Quest is active.
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.
match: /(?<=,)(\i\(\))(\)}};)/,
replace: "startingAutoComplete&&$1$2"
}
]
},
// This patch covers the new entry point blocked in the above group.
// If the old entry point gets removed in the future, this will be useful.
// {
// 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: "platformSelectorPrimary,",
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)"
}
]
},
{
// 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) {
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();
}
},
stop() {
removeServerListElement(ServerListRenderPosition.Above, this.renderQuestifyButton);
stopAutoFetchingQuests();
activeQuestIntervals.forEach((intervalData, questId) => {
clearInterval(intervalData.progressTimeout);
clearTimeout(intervalData.rerenderTimeout);
activeQuestIntervals.delete(questId);
});
}
});