Questify Updates. (#1296)

* Questify Updates.

* Fixes.

* Fixes.
This commit is contained in:
Etorix 2026-08-07 14:52:24 -07:00 • committed by GitHub
parent 12ae667621
commit 94f6709f26
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 944 additions and 423 deletions

View file

@ -13,7 +13,9 @@ import type { CSSProperties, JSX, MouseEvent } from "react";
import { getQuestifySettings, useQuestifySettings } from "../settings/access";
import type { QuestButtonAction, QuestButtonDisplayMode, QuestButtonIndicatorMode } from "../settings/def";
import { getIgnoredQuestIDs, ignoreAllQuests, resetIgnoredQuests } from "../settings/ignoredQuests";
import { rerenderQuests } from "../settings/rerender";
import { initialQuestDataFetched } from "../state";
import { getActiveAutoCompletes, getQueueableAutoCompleteQuests, isQuestEnrollmentRateLimited, isQueueAllAutoCompleteQuestsInProgress, queueAllAutoCompleteQuests, stopAllAutoCompletes } from "../utils/completion";
import { fetchAndAlertQuests } from "../utils/fetching";
import { decimalToRGB, formatLowerBadge, isDarkish, leftClick, middleClick, q, QUEST_PAGE, rightClick } from "../utils/ui";
import { openQuestifySettingsModal } from "./settingsModal";
@ -190,8 +192,16 @@ function getQuestButtonLowerBadgeProps(
export function QuestButtonContextMenu({ dummy = false }: { dummy?: boolean; }): JSX.Element {
const navId = q(dummy ? "dummy-quest-button-context-menu" : "quest-button-context-menu");
const markAllIgnoredDisabled = dummy || getQuestifySettings().questButtonBadgeCount <= 0;
const settings = getQuestifySettings();
const markAllIgnoredDisabled = dummy || settings.questButtonBadgeCount <= 0;
const resetIgnoredDisabled = dummy || getIgnoredQuestIDs().length <= 0;
const noneAutoCompletable = getQueueableAutoCompleteQuests().length <= 0;
const queueAllQuestsVisible = !settings.autoCompleteQuestsSimultaneously && !noneAutoCompletable;
const enrollmentRateLimited = isQuestEnrollmentRateLimited();
const queueAllQuestsInProgress = isQueueAllAutoCompleteQuestsInProgress();
const queueAllQuestsDisabled = dummy || queueAllQuestsInProgress || enrollmentRateLimited;
const labelSuffix = (dummy ? "" : queueAllQuestsInProgress ? " (In-Progress)" : enrollmentRateLimited ? " (Rate-Limited)" : "");
const clearQueueVisible = queueAllQuestsInProgress || getActiveAutoCompletes().length > 0;
const fetchQuestsDisabled = dummy;
return (
@ -212,6 +222,37 @@ export function QuestButtonContextMenu({ dummy = false }: { dummy?: boolean; }):
action={resetIgnoredQuests}
disabled={resetIgnoredDisabled}
/>
{queueAllQuestsVisible && (
<Menu.MenuItem
id={q(`${navId}-queue-all-quests`)}
label={"Queue All Quests" + labelSuffix}
action={() => {
const queueAllPromise = queueAllAutoCompleteQuests();
rerenderQuests();
void queueAllPromise.finally(() => {
rerenderQuests();
});
}}
disabled={queueAllQuestsDisabled}
/>
)}
{clearQueueVisible && (
<Menu.MenuItem
id={q(`${navId}-clear-queue`)}
label="Clear Quest Queue"
action={() => {
stopAllAutoCompletes({
manual: true,
preserveResume: false,
terminalHeartbeat: true,
});
rerenderQuests();
}}
disabled={dummy}
/>
)}
<Menu.MenuItem
id={q(`${navId}-fetch-quests`)}
label="Fetch Quests"

View file

@ -8,7 +8,8 @@ import { QuestTaskType } from "@vencord/discord-types/enums";
import type { JSX } from "react";
import { getQuestifySettings, useQuestifySettings } from "../settings/access";
import { autoCompleteQuestTaskTypes, defaultAllowChangingDangerousSettings, defaultAutoCompleteQuestsSimultaneously, defaultAutoCompleteQuestTypes, defaultCompleteVideoQuestsQuicker, defaultMakeMobileVideoQuestsDesktopCompatible, defaultResumeInterruptedQuests, isDesktopCompatible } from "../settings/def";
import { resetDangerousSettings } from "../settings/dangerous";
import { autoCompleteQuestTaskTypes, defaultAutoCompleteQuestTypes, isDesktopCompatible } from "../settings/def";
import { Alerts, q } from "../utils/ui";
import { ManaButton, type ManaSelectOption, SettingsCard, SettingsDescription, SettingsHeader, SettingsNotice, SettingsParagraph, SettingsSelect, SettingsSubheader, SettingsSubtleSwitch } from "./shared";
@ -25,6 +26,7 @@ type QuestModifySettingKey =
| "autoCompleteQuestsSimultaneously"
| "resumeInterruptedQuests"
| "completeVideoQuestsQuicker"
| "preventVideoQuestsPausing"
| "makeMobileVideoQuestsDesktopCompatible";
interface QuestDisableOption {
@ -134,6 +136,7 @@ export function QuestFeaturesSetting(): JSX.Element {
"autoCompleteQuestsSimultaneously",
"autoCompleteQuestTypes",
"completeVideoQuestsQuicker",
"preventVideoQuestsPausing",
]);
const selectedDisableValues = disableFeatureOptions
@ -195,17 +198,6 @@ export function QuestFeaturesSetting(): JSX.Element {
}
}
function resetDangerousSettings() {
const settings = getQuestifySettings();
settings.allowChangingDangerousSettings = defaultAllowChangingDangerousSettings;
settings.autoCompleteQuestsSimultaneously = defaultAutoCompleteQuestsSimultaneously;
settings.completeVideoQuestsQuicker = defaultCompleteVideoQuestsQuicker;
settings.makeMobileVideoQuestsDesktopCompatible = defaultMakeMobileVideoQuestsDesktopCompatible;
settings.resumeInterruptedQuests = defaultResumeInterruptedQuests;
settings.autoCompleteQuestTypes = { ...defaultAutoCompleteQuestTypes };
}
function updateDangerousAccess(checked: boolean) {
function setDangerousAccess() {
getQuestifySettings().allowChangingDangerousSettings = checked;
@ -265,11 +257,11 @@ export function QuestFeaturesSetting(): JSX.Element {
<SettingsSubheader> Modify Features </SettingsSubheader>
<SettingsNotice className={["notice-card-red", questFeatures.disableQuestsEverything ? "dimmed-settings-item" : undefined, questFeatures.allowChangingDangerousSettings ? undefined : "notice-card-solo", "no-bottom-margin"].filter(c => c !== undefined)}>
<SettingsParagraph>
Discord has began issuing warnings to users of scripts or plugins that modify how Quests are completed, which is against their <a href="https://discord.com/safety/platform-manipulation-policy-explainer" target="_blank" rel="noreferrer">Terms of Service</a>.
Discord has implemented a Quest Ban system which will temporarily or permanently limit your access to completing Quests and claiming their rewards if you are found to be completing them through unofficial means. Modifying the completion of Quests is against their <a href="https://discord.com/safety/platform-manipulation-policy-explainer" target="_blank" rel="noreferrer">Terms of Service</a>.
</SettingsParagraph>
<br />
<SettingsParagraph>
The warnings appear limited to threat of loss of access to Quests or their rewards, but Discord may escalate at any time.
The punishment appears limited to loss of access to Quests and their rewards, but Discord may escalate at any time.
</SettingsParagraph>
<br />
<SettingsParagraph>
@ -299,6 +291,19 @@ export function QuestFeaturesSetting(): JSX.Element {
+ "\n\nThis setting will only apply to auto-completing Video Quests and relies on the auto-complete setting below. Manually completing Video Quests will still require waiting the full duration and is not dependent on enrollment time."
}}
/>
<SettingsSubtleSwitch
disabled={questFeatures.disableQuestsEverything || !questFeatures.allowChangingDangerousSettings}
checked={questFeatures.preventVideoQuestsPausing}
label="Prevent Video Quests from pausing when losing focus:"
onChange={checked => updateModifyValue("preventVideoQuestsPausing", checked)}
bottomSpacing="5"
tooltip={{
position: "top",
text: "Discord forces you to be tabbed into the client for Video Quests to play their video."
+ "\n\nThis setting prevents the video from pausing and allows completion to progress as normal."
+ "\n\nThis setting applies only to manually completing Video Quests."
}}
/>
<SettingsSubtleSwitch
disabled={questFeatures.disableQuestsEverything || !questFeatures.allowChangingDangerousSettings}
checked={questFeatures.makeMobileVideoQuestsDesktopCompatible}

View file

@ -9,6 +9,7 @@ import { findComponentByCodeLazy } from "@webpack";
import { QuestStore, useEffect, useMemo, useRef, useState, useStateFromStores } from "@webpack/common";
import type { JSX, SyntheticEvent } from "react";
import { enabledOnStartup } from "..";
import { getQuestifySettings, useQuestifySettings } from "../settings/access";
import { defaultQuestTileClaimedColorSetting, defaultQuestTileExpiredColorSetting, defaultQuestTileIgnoredColorSetting, defaultQuestTileUnclaimedColorSetting, type QuestTileColorSetting, type QuestTileGradient } from "../settings/def";
import { rerenderQuests } from "../settings/rerender";
@ -320,11 +321,11 @@ export function QuestTilesSetting(): JSX.Element {
);
})}
</SettingsRow>
<DummyQuestPreview
{enabledOnStartup && <DummyQuestPreview
disabled={disabled}
dummyColor={previewColor}
dummyGradient={questTiles.questTileGradient as QuestTileGradient}
/>
/>}
</SettingsCard>
);
}

View file

@ -4,6 +4,8 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import "../settings.css";
import { Card } from "@components/Card";
import { Heading } from "@components/Heading";
import { Paragraph } from "@components/Paragraph";

View file

@ -20,6 +20,7 @@ import { QuestTileContextMenu } from "./components/questTileContextMenu";
import { getQuestifySettings } from "./settings/access";
import { resetQuestsToResume, startAutoFetchingQuests, stopAutoFetchingQuests } from "./settings/fetching";
import { validateIgnoredQuests } from "./settings/ignoredQuests";
import { showPendingQuestifyNotice } from "./settings/notices";
import { rerenderQuests, useQuestRerender } from "./settings/rerender";
import { disposeRestartTracking, initializeRestartTracking, promptToRestartIfDirty, setRestartDirty } from "./settings/restartTracking";
import { settings } from "./settings/store";
@ -304,6 +305,24 @@ export default definePlugin({
}
]
},
{
// Prevent Video Quests from pausing on lost focus.
find: "[QV] | Pausing video | playerState:",
predicate: () => !getQuestifySettings().disableQuestsEverything && getQuestifySettings().preventVideoQuestsPausing,
replacement: {
match: /(?<=setCaptionEnabled\),)({focused:)(\i)/,
replace: "$2=true,$1questifyFocused"
}
},
{
// Prevent Video Quests from pausing on lost focus.
find: ",listenForHlsErrors:!1",
predicate: () => !getQuestifySettings().disableQuestsEverything && getQuestifySettings().preventVideoQuestsPausing,
replacement: {
match: /(?<=pauseOnLostVisibility:)!\i/,
replace: "false",
}
},
{
find: "QUEST_HOME)},[]),",
group: true,
@ -338,13 +357,13 @@ export default definePlugin({
replacement: [
{
// Overwrite button props for UNENROLLED Quests.
match: /(?<=onClick:\(\)=>{\i\?\.\(\),\i\(\)},text:\i,icon:\i,iconPosition:\i,fullWidth:!0)/,
match: /(?<=,text:\i,icon:\i,iconPosition:\i,fullWidth:!0)(?=,"aria-disabled":\i\|\|void 0)/,
replace: ",...($self.getQuestButtonProps(arguments[0])??{})"
},
{
// Overwrite button props for ENROLLED/INCOMPLETE Quests.
match: /(?<=let{quest:\i,taskType:\i,surface:\i.{0,150}?size:\i}=\i;return)(.{0,300}?,size:\i,surface:\i,analyticsCtxQuestContent:\i,analyticsCtxSourceQuestContent:\i}\))/,
replace: " $self.enrolledIncompleteButton(arguments[0])||($1)"
match: /(case \i\.\i\.(?:ENROLLED|INCOMPLETE):return)(?=\(0,\i\.jsx\)\(\i,\{quest:(\i),taskType:\i\.type,size:(\i),)/g,
replace: "$1 $self.enrolledIncompleteButton({quest:$2,size:$3})||"
}
]
},
@ -362,7 +381,7 @@ export default definePlugin({
find: "prevIsQuestAccepted:",
predicate: () => !getQuestifySettings().disableQuestsEverything && !getQuestifySettings().disableAccountPanelQuestProgress,
replacement: {
match: /(?<=isLoading:\i}=\(0,\i.\i\)\(\),\i=\i\.useContext\(\i\.\i\)\|\|\i&&)(\i)/,
match: /(?<=isLoading:\i}=\(0,\i\.\i\)\(\),\i=\i\.useContext\(\i\.\i\),\i=\i\|\|\i&&)(\i)/,
replace: "($1||$self.shouldForceQuestPanelVisible(arguments[0].quest))"
}
},
@ -647,6 +666,8 @@ export default definePlugin({
}
onceReady.then(() => {
showPendingQuestifyNotice();
if (!getQuestifySettings().disableQuestsEverything) {
startPerAccountTasks("PLUGIN_START");
} else {

View file

@ -0,0 +1,398 @@
/* Settings */
.questify-setting {
border-radius: 3px;
border-left: 3px solid var(--text-strong);
padding: 8px;
user-select: text;
}
.questify-settings-divider-small {
margin: 10px 0;
}
.questify-settings-divider-medium {
margin: 15px 0;
}
.questify-settings-divider-large {
margin: 20px 0;
}
.questify-dimmed-settings-item {
filter: brightness(0.5);
}
.questify-setting-header {
font-size: 19px;
font-weight: 600;
margin-bottom: 10px;
}
.questify-setting-subheader {
font-size: 16px;
font-weight: 500;
margin: 15px 0 5px;
}
.questify-setting-paragraph {
font-size: 14px;
font-weight: 400;
margin: 5px 0;
line-height: normal;
}
.questify-setting-description {
margin-bottom: 15px;
line-height: normal;
}
.questify-setting-description-multi-paragraph {
line-height: 0.2em;
}
[class*="clickTrapContainer"] > div > .questify-settings-tooltip {
margin-right: 4px;
}
.questify-settings-tooltip-content {
text-align: justify;
white-space: pre-line;
}
.questify-setting-switch {
margin-bottom: 5px;
}
.questify-setting-switch [class*="container_"] [class*="labelContainer_"] label {
font-size: 16px;
font-weight: 500;
}
.questify-setting-subtle-switch [class*="container_"] [class*="labelContainer_"] label,
.questify-setting-switch [class*="container_"] [class*="labelContainer_"] div {
font-size: 14px;
font-weight: 400;
}
.questify-notice-card {
margin: 12px 0;
padding: 12px;
border-radius: 4px;
line-height: 0.2em;
}
.questify-notice-card-red {
border-left: 3px solid var(--status-danger);
background-color: color-mix(in srgb, var(--status-danger) 12%, transparent);
}
.questify-notice-card-blue {
border-left: 3px solid var(--background-brand);
background-color: color-mix(in srgb, var(--background-brand) 12%, transparent);
}
.questify-notice-card-solo {
margin: 12px 0 0;
}
.questify-settings-button > button[data-mana-component="button"] {
border-radius: 3px;
}
.questify-settings-button > button[data-mana-component="button"] span {
font-size: 14px;
}
.questify-setting [data-floating-ui-focusable][class*="selectDropdown"],
.questify-setting [data-mana-component="select-input-field"] > [class*="selectField"],
.questify-setting [data-mana-component="select-input-field"] [class*="tagList"] > div {
border-radius: 3px;
}
.questify-inline-volume-slider {
margin-top: 0;
}
.questify-sound-preview-button {
color: var(--interactive-normal);
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: 0;
border-radius: 4px;
background: transparent;
cursor: pointer;
flex: 0 0 auto;
}
.questify-sound-preview-button:hover,
.questify-sound-preview-button.questify-playing-audio {
background: var(--background-modifier-hover);
}
.questify-sound-preview-button:hover {
color: var(--interactive-active);
}
.questify-sound-preview-button.questify-playing-audio {
color: var(--red-500);
}
.questify-sound-preview-button:disabled {
cursor: default;
}
.questify-sound-select [class*="selectedIcon"] {
display: none;
}
.questify-no-top-margin,
.questify-margin-top-0 {
margin-top: 0;
}
.questify-margin-top-1 {
margin-top: 1px;
}
.questify-margin-top-2 {
margin-top: 2px;
}
.questify-margin-top-3 {
margin-top: 3px;
}
.questify-margin-top-4 {
margin-top: 4px;
}
.questify-margin-top-5 {
margin-top: 5px;
}
.questify-margin-top-6 {
margin-top: 6px;
}
.questify-margin-top-7 {
margin-top: 7px;
}
.questify-margin-top-8 {
margin-top: 8px;
}
.questify-margin-top-9 {
margin-top: 9px;
}
.questify-margin-top-10 {
margin-top: 10px;
}
.questify-margin-top-11 {
margin-top: 11px;
}
.questify-margin-top-12 {
margin-top: 12px;
}
.questify-margin-top-13 {
margin-top: 13px;
}
.questify-margin-top-14 {
margin-top: 14px;
}
.questify-margin-top-15 {
margin-top: 15px;
}
.questify-no-bottom-margin,
.questify-margin-bottom-0 {
margin-bottom: 0;
}
.questify-margin-bottom-1 {
margin-bottom: 1px;
}
.questify-margin-bottom-2 {
margin-bottom: 2px;
}
.questify-margin-bottom-3 {
margin-bottom: 3px;
}
.questify-margin-bottom-4 {
margin-bottom: 4px;
}
.questify-margin-bottom-5 {
margin-bottom: 5px;
}
.questify-margin-bottom-6 {
margin-bottom: 6px;
}
.questify-margin-bottom-7 {
margin-bottom: 7px;
}
.questify-margin-bottom-8 {
margin-bottom: 8px;
}
.questify-margin-bottom-9 {
margin-bottom: 9px;
}
.questify-margin-bottom-10 {
margin-bottom: 10px;
}
.questify-margin-bottom-11 {
margin-bottom: 11px;
}
.questify-margin-bottom-12 {
margin-bottom: 12px;
}
.questify-margin-bottom-13 {
margin-bottom: 13px;
}
.questify-margin-bottom-14 {
margin-bottom: 14px;
}
.questify-margin-bottom-15 {
margin-bottom: 15px;
}
.questify-settings-row {
margin-top: 4px;
display: flex;
gap: 4px;
align-items: flex-start;
}
.questify-settings-row-item {
flex: 1 1 0;
min-width: 0;
}
.questify-settings-row-item-content {
flex: 0 0 auto;
}
.questify-settings-slider-control-container {
margin: 4px 0 0;
background-color: var(--input-background-default, var(--input-background));
padding: 0 12px 0 4px;
border-radius: 3px;
border: 1px solid var(--border-normal);
min-height: 40px;
box-sizing: border-box;
display: flex;
align-items: center;
}
.questify-settings-slider-control {
margin: 0 4px;
width: 100%;
}
.questify-sound-select-row-item {
flex: 0 1 67%;
}
.questify-volume-slider-row-item {
flex: 0 1 33%;
}
/* Quest Tiles */
.questify-quest-tile-gradient-row-item {
flex: 0 1 42%;
}
.questify-quest-tile-preload-row-item {
flex: 0 1 58%;
}
.questify-quest-tile-color-row {
align-items: stretch;
}
.questify-quest-tile-color-row-item {
display: flex;
flex-direction: column;
}
.questify-settings-color-picker.questify-quest-tile-color-picker {
display: flex;
height: 38px;
min-height: 38px;
min-width: 0;
margin-top: 5px;
}
.questify-settings-color-picker.questify-quest-tile-color-picker > [class*="colorSwatch"] {
display: flex;
width: 100%;
height: 100%;
min-height: 38px;
}
.questify-settings-color-picker.questify-quest-tile-color-picker [class*="swatch"],
.questify-settings-color-picker.questify-quest-tile-color-picker [class*="Swatch"] {
width: 100%;
height: 100%;
min-height: 38px;
border-radius: 3px;
}
.questify-settings-color-picker.questify-disabled-color-picker {
filter: brightness(0.5);
pointer-events: none;
}
.questify-quest-tile-color-button {
margin-top: 6px;
}
/* Quest Button Settings */
.questify-settings-color-picker.questify-quest-button-color-picker {
display: flex;
height: 38px;
min-height: 38px;
min-width: 0;
margin-top: 5px;
}
.questify-settings-color-picker.questify-quest-button-color-picker > [class*="colorSwatch"] {
display: flex;
width: 100%;
height: 100%;
min-height: 38px;
}
.questify-settings-color-picker.questify-quest-button-color-picker [class*="swatch"],
.questify-settings-color-picker.questify-quest-button-color-picker [class*="Swatch"] {
width: 100%;
height: 100%;
min-height: 38px;
border-radius: 3px;
}

View file

@ -0,0 +1,20 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { getQuestifySettings } from "./access";
import { defaultAllowChangingDangerousSettings, defaultAutoCompleteQuestsSimultaneously, defaultAutoCompleteQuestTypes, defaultCompleteVideoQuestsQuicker, defaultMakeMobileVideoQuestsDesktopCompatible, defaultPreventVideoQuestsPausing, defaultResumeInterruptedQuests } from "./def";
export function resetDangerousSettings(): void {
const settings = getQuestifySettings();
settings.allowChangingDangerousSettings = defaultAllowChangingDangerousSettings;
settings.autoCompleteQuestsSimultaneously = defaultAutoCompleteQuestsSimultaneously;
settings.completeVideoQuestsQuicker = defaultCompleteVideoQuestsQuicker;
settings.makeMobileVideoQuestsDesktopCompatible = defaultMakeMobileVideoQuestsDesktopCompatible;
settings.preventVideoQuestsPausing = defaultPreventVideoQuestsPausing;
settings.resumeInterruptedQuests = defaultResumeInterruptedQuests;
settings.autoCompleteQuestTypes = { ...defaultAutoCompleteQuestTypes };
}

View file

@ -46,8 +46,10 @@ export const defaultDisableFriendsListPromo = true;
export const defaultDisableMembersListPromo = true;
export const defaultResumeInterruptedQuests = false;
export const defaultAllowChangingDangerousSettings = false; // true -> Risky
export const defaultAcknowledgedNotices: Record<string, true> = {};
export const defaultMakeMobileVideoQuestsDesktopCompatible = false; // true -> Risky
export const defaultCompleteVideoQuestsQuicker = false; // true -> Risky
export const defaultPreventVideoQuestsPausing = false; // true -> Risky
export const defaultAutoCompleteQuestsSimultaneously = false; // true -> Risky
export const defaultNotifyOnQuestComplete = true;
export const defaultNotifyOnNewQuests = true;

View file

@ -0,0 +1,125 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import type { ModalActionVariant, RenderModalProps } from "@vencord/discord-types";
import { Modal, openModal } from "@webpack/common";
import type { JSX } from "react";
import { getQuestifySettings } from "./access";
import { resetDangerousSettings } from "./dangerous";
import { promptToRestartIfDirty } from "./restartTracking";
interface NoticeAction {
text: string;
variant?: ModalActionVariant;
run?: () => void;
promptForRestart?: boolean;
}
interface OneTimeNotice {
id: string;
title: string;
renderBody: () => JSX.Element;
condition: () => boolean;
autoAcknowledgeCondition?: () => boolean;
actions: readonly NoticeAction[];
}
const oneTimeNotices = [
{
id: "quest-ban-warning-2026-08-07",
title: "Questify Notice",
renderBody: () => <div className="questify-startup-notice-body">
<p>
Discord has implemented a Quest Ban system which will temporarily or permanently limit your access to completing Quests and claiming their rewards if you are found to be completing them through unofficial means. Modifying the completion of Quests is against their <a href="https://discord.com/safety/platform-manipulation-policy-explainer" target="_blank" rel="noreferrer">Terms of Service</a>.
</p>
<br />
<p>
The punishment appears limited to loss of access to Quests and their rewards, but Discord may escalate at any time.
</p>
<br />
<p>
Due to the various methods Discord uses to track users, there's no way to realistically evade detection. If you proceed, understand that Discord likely will detect it at some point.
</p>
</div>,
condition: () => {
const settings = getQuestifySettings();
return settings.enabled && settings.allowChangingDangerousSettings;
},
autoAcknowledgeCondition: () => !getQuestifySettings().allowChangingDangerousSettings,
actions: [
{
text: "Keep Using Dangerous Questify Settings",
variant: "critical-primary",
},
{
text: "Disable Dangerous Questify Settings",
variant: "secondary",
run: resetDangerousSettings,
promptForRestart: true,
},
],
},
] as const satisfies readonly OneTimeNotice[];
function acknowledgeNotice(id: string): void {
const settings = getQuestifySettings();
settings.acknowledgedNotices = {
...settings.acknowledgedNotices,
[id]: true,
};
}
function runNoticeAction(notice: OneTimeNotice, action: NoticeAction, onClose: () => void): void {
acknowledgeNotice(notice.id);
action.run?.();
onClose();
setTimeout(() => {
if (!action.promptForRestart || !promptToRestartIfDirty({ onDecline: showPendingQuestifyNotice })) {
showPendingQuestifyNotice();
}
}, 0);
}
function OneTimeNoticeModal({ notice, ...modalProps }: RenderModalProps & { notice: OneTimeNotice; }): JSX.Element {
return (
<Modal
{...modalProps}
role="alertdialog"
size="lg"
title={notice.title}
actions={notice.actions.map(action => ({
text: action.text,
variant: action.variant ?? "primary",
onClick: () => runNoticeAction(notice, action, modalProps.onClose),
}))}
>
{notice.renderBody()}
</Modal>
);
}
export function showPendingQuestifyNotice(): void {
for (const notice of oneTimeNotices) {
if (getQuestifySettings().acknowledgedNotices[notice.id]) {
continue;
}
if (!notice.condition()) {
if (notice.autoAcknowledgeCondition?.()) {
acknowledgeNotice(notice.id);
}
continue;
}
openModal(modalProps => <OneTimeNoticeModal {...modalProps} notice={notice} />);
return;
}
}

View file

@ -11,6 +11,10 @@ import { Alerts } from "../utils/ui";
type RestartTrackingSettings = Pick<DefinedSettings<SettingsDefinition>, "def" | "pluginName">;
interface RestartPromptOptions {
onDecline?: () => void;
}
let restartDirty = false;
let didAttachRestartListeners = false;
const restartListenerCleanups: (() => void)[] = [];
@ -52,17 +56,37 @@ export function setRestartDirty(dirty: boolean): void {
restartDirty = dirty;
}
export function promptToRestartIfDirty(): boolean {
export function promptToRestartIfDirty({ onDecline }: RestartPromptOptions = {}): boolean {
if (!restartDirty) {
return false;
}
let didConfirm = false;
let didDecline = false;
function declineRestart(): void {
if (didConfirm || didDecline) {
return;
}
didDecline = true;
if (onDecline) {
setTimeout(onDecline, 0);
}
}
Alerts.show({
title: "Restart Required",
body: "A change you've made to Questify's settings requires a restart.",
confirmText: "Restart",
cancelText: "Later",
onConfirm: () => location.reload(),
onConfirm: () => {
didConfirm = true;
location.reload();
},
onCancel: declineRestart,
onCloseCallback: declineRestart,
});
return true;

View file

@ -13,7 +13,7 @@ import { QuestFeaturesSetting } from "../components/questFeaturesSetting";
import { QuestNotificationsSetting } from "../components/questNotificationsSetting";
import { QuestTilesSetting } from "../components/questTilesSetting";
import { ReorderQuestsSetting } from "../components/reorderQuestsSetting";
import { defaultAllowChangingDangerousSettings, defaultAutoCompleteQuestsSimultaneously, defaultAutoCompleteQuestTypes, defaultClaimedSubsort, defaultCompleteVideoQuestsQuicker, defaultDisableAccountPanelPromo, defaultDisableAccountPanelQuestProgress, defaultDisableFriendsListPromo, defaultDisableMembersListPromo, defaultDisableOrbsAndQuestsBadges, defaultDisableQuestsEverything, defaultDisableRelocationNotices, defaultDisableSponsoredBanner, defaultExpiredSubsort, defaultIgnoredQuestIDs, defaultIgnoredSubsort, defaultIsOnQuestsPage, defaultLastQuestPageFilters, defaultLastQuestPageSort, defaultLeftClickAction, defaultMakeMobileVideoQuestsDesktopCompatible, defaultMiddleClickAction, defaultNewExcludedQuestAlertSound, defaultNewExcludedQuestAlertVolume, defaultNewQuestAlertSound, defaultNewQuestAlertVolume, defaultNotifyOnNewExcludedQuests, defaultNotifyOnNewQuests, defaultNotifyOnQuestComplete, defaultQuestButtonBadgeColor, defaultQuestButtonBadgeCount, defaultQuestButtonDisplay, defaultQuestButtonIncludedTypes, defaultQuestButtonIndicator, defaultQuestCompletedAlertSound, defaultQuestCompletedAlertVolume, defaultQuestFetchInterval, defaultQuestOrder, defaultQuestTileClaimedColorSetting, defaultQuestTileExpiredColorSetting, defaultQuestTileGradient, defaultQuestTileIgnoredColorSetting, defaultQuestTilePreload, defaultQuestTileUnclaimedColorSetting, defaultRememberQuestPageFilters, defaultRememberQuestPageSort, defaultResumeInterruptedQuests, defaultResumeQuestIDs, defaultRightClickAction, defaultUnclaimedSubsort, type QuestButtonAction, type QuestButtonDisplayMode, type QuestButtonIncludedTypes, type QuestButtonIndicatorMode, type QuestOrderStatus } from "./def";
import { defaultAcknowledgedNotices, defaultAllowChangingDangerousSettings, defaultAutoCompleteQuestsSimultaneously, defaultAutoCompleteQuestTypes, defaultClaimedSubsort, defaultCompleteVideoQuestsQuicker, defaultDisableAccountPanelPromo, defaultDisableAccountPanelQuestProgress, defaultDisableFriendsListPromo, defaultDisableMembersListPromo, defaultDisableOrbsAndQuestsBadges, defaultDisableQuestsEverything, defaultDisableRelocationNotices, defaultDisableSponsoredBanner, defaultExpiredSubsort, defaultIgnoredQuestIDs, defaultIgnoredSubsort, defaultIsOnQuestsPage, defaultLastQuestPageFilters, defaultLastQuestPageSort, defaultLeftClickAction, defaultMakeMobileVideoQuestsDesktopCompatible, defaultMiddleClickAction, defaultNewExcludedQuestAlertSound, defaultNewExcludedQuestAlertVolume, defaultNewQuestAlertSound, defaultNewQuestAlertVolume, defaultNotifyOnNewExcludedQuests, defaultNotifyOnNewQuests, defaultNotifyOnQuestComplete, defaultPreventVideoQuestsPausing, defaultQuestButtonBadgeColor, defaultQuestButtonBadgeCount, defaultQuestButtonDisplay, defaultQuestButtonIncludedTypes, defaultQuestButtonIndicator, defaultQuestCompletedAlertSound, defaultQuestCompletedAlertVolume, defaultQuestFetchInterval, defaultQuestOrder, defaultQuestTileClaimedColorSetting, defaultQuestTileExpiredColorSetting, defaultQuestTileGradient, defaultQuestTileIgnoredColorSetting, defaultQuestTilePreload, defaultQuestTileUnclaimedColorSetting, defaultRememberQuestPageFilters, defaultRememberQuestPageSort, defaultResumeInterruptedQuests, defaultResumeQuestIDs, defaultRightClickAction, defaultUnclaimedSubsort, type QuestButtonAction, type QuestButtonDisplayMode, type QuestButtonIncludedTypes, type QuestButtonIndicatorMode, type QuestOrderStatus } from "./def";
const MIGRATION_TARGET = 1;
const CURRENT_SETTINGS = PlainSettings.plugins.Questify;
@ -39,6 +39,12 @@ export const settings = definePluginSettings({
default: MIGRATION_TARGET,
hidden: true,
},
acknowledgedNotices: {
type: OptionType.CUSTOM,
description: "One-time notices that the user has acknowledged.",
default: { ...defaultAcknowledgedNotices },
hidden: true,
},
questFeatures: {
type: OptionType.COMPONENT,
component: ErrorBoundary.wrap(QuestFeaturesSetting) as any,
@ -134,6 +140,13 @@ export const settings = definePluginSettings({
restartNeeded: true,
hidden: true,
},
preventVideoQuestsPausing: {
type: OptionType.BOOLEAN,
description: "Prevent pausing the video when the Quest modal loses focus.",
default: defaultPreventVideoQuestsPausing,
restartNeeded: true,
hidden: true,
},
autoCompleteQuestTypes: {
type: OptionType.CUSTOM,
description: "Which types of Quests to auto-complete in the background.",

View file

@ -26,379 +26,13 @@
font-variant-numeric: tabular-nums;
}
/* Settings */
/* Notices */
.questify-setting {
border-radius: 3px;
border-left: 3px solid var(--text-strong);
padding: 8px;
user-select: text;
}
.questify-settings-divider-small {
margin: 10px 0;
}
.questify-settings-divider-medium {
margin: 15px 0;
}
.questify-settings-divider-large {
margin: 20px 0;
}
.questify-dimmed-settings-item {
filter: brightness(0.5);
}
.questify-setting-header {
font-size: 19px;
font-weight: 600;
margin-bottom: 10px;
}
.questify-setting-subheader {
font-size: 16px;
font-weight: 500;
margin: 15px 0 5px;
}
.questify-setting-paragraph {
font-size: 14px;
font-weight: 400;
margin: 5px 0;
.questify-startup-notice-body {
line-height: normal;
}
.questify-setting-description {
margin-bottom: 15px;
line-height: normal;
}
.questify-setting-description-multi-paragraph {
line-height: 0.2em;
}
[class*="clickTrapContainer"] > div > .questify-settings-tooltip {
margin-right: 4px;
}
.questify-settings-tooltip-content {
text-align: justify;
white-space: pre-line;
}
.questify-setting-switch {
margin-bottom: 5px;
}
.questify-setting-switch [class*="container_"] [class*="labelContainer_"] label {
font-size: 16px;
font-weight: 500;
}
.questify-setting-subtle-switch [class*="container_"] [class*="labelContainer_"] label,
.questify-setting-switch [class*="container_"] [class*="labelContainer_"] div {
font-size: 14px;
font-weight: 400;
}
.questify-notice-card {
margin: 12px 0;
padding: 12px;
border-radius: 4px;
line-height: 0.2em;
}
.questify-notice-card-red {
border-left: 3px solid var(--status-danger);
background-color: color-mix(in srgb, var(--status-danger) 12%, transparent);
}
.questify-notice-card-blue {
border-left: 3px solid var(--background-brand);
background-color: color-mix(in srgb, var(--background-brand) 12%, transparent);
}
.questify-notice-card-solo {
margin: 12px 0 0;
}
.questify-settings-button > button[data-mana-component="button"] {
border-radius: 3px;
}
.questify-settings-button > button[data-mana-component="button"] span {
font-size: 14px;
}
.questify-setting [data-floating-ui-focusable][class*="selectDropdown"],
.questify-setting [data-mana-component="select-input-field"] > [class*="selectField"],
.questify-setting [data-mana-component="select-input-field"] [class*="tagList"] > div {
border-radius: 3px;
}
.questify-inline-volume-slider {
margin-top: 0;
}
.questify-sound-preview-button {
color: var(--interactive-normal);
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: 0;
border-radius: 4px;
background: transparent;
cursor: pointer;
flex: 0 0 auto;
}
.questify-sound-preview-button:hover,
.questify-sound-preview-button.questify-playing-audio {
background: var(--background-modifier-hover);
}
.questify-sound-preview-button:hover {
color: var(--interactive-active);
}
.questify-sound-preview-button.questify-playing-audio {
color: var(--red-500);
}
.questify-sound-preview-button:disabled {
cursor: default;
}
.questify-sound-select [class*="selectedIcon"] {
display: none;
}
.questify-no-top-margin,
.questify-margin-top-0 {
margin-top: 0;
}
.questify-margin-top-1 {
margin-top: 1px;
}
.questify-margin-top-2 {
margin-top: 2px;
}
.questify-margin-top-3 {
margin-top: 3px;
}
.questify-margin-top-4 {
margin-top: 4px;
}
.questify-margin-top-5 {
margin-top: 5px;
}
.questify-margin-top-6 {
margin-top: 6px;
}
.questify-margin-top-7 {
margin-top: 7px;
}
.questify-margin-top-8 {
margin-top: 8px;
}
.questify-margin-top-9 {
margin-top: 9px;
}
.questify-margin-top-10 {
margin-top: 10px;
}
.questify-margin-top-11 {
margin-top: 11px;
}
.questify-margin-top-12 {
margin-top: 12px;
}
.questify-margin-top-13 {
margin-top: 13px;
}
.questify-margin-top-14 {
margin-top: 14px;
}
.questify-margin-top-15 {
margin-top: 15px;
}
.questify-no-bottom-margin,
.questify-margin-bottom-0 {
margin-bottom: 0;
}
.questify-margin-bottom-1 {
margin-bottom: 1px;
}
.questify-margin-bottom-2 {
margin-bottom: 2px;
}
.questify-margin-bottom-3 {
margin-bottom: 3px;
}
.questify-margin-bottom-4 {
margin-bottom: 4px;
}
.questify-margin-bottom-5 {
margin-bottom: 5px;
}
.questify-margin-bottom-6 {
margin-bottom: 6px;
}
.questify-margin-bottom-7 {
margin-bottom: 7px;
}
.questify-margin-bottom-8 {
margin-bottom: 8px;
}
.questify-margin-bottom-9 {
margin-bottom: 9px;
}
.questify-margin-bottom-10 {
margin-bottom: 10px;
}
.questify-margin-bottom-11 {
margin-bottom: 11px;
}
.questify-margin-bottom-12 {
margin-bottom: 12px;
}
.questify-margin-bottom-13 {
margin-bottom: 13px;
}
.questify-margin-bottom-14 {
margin-bottom: 14px;
}
.questify-margin-bottom-15 {
margin-bottom: 15px;
}
.questify-settings-row {
margin-top: 4px;
display: flex;
gap: 4px;
align-items: flex-start;
}
.questify-settings-row-item {
flex: 1 1 0;
min-width: 0;
}
.questify-settings-row-item-content {
flex: 0 0 auto;
}
.questify-settings-slider-control-container {
margin: 4px 0 0;
background-color: var(--input-background-default, var(--input-background));
padding: 0 12px 0 4px;
border-radius: 3px;
border: 1px solid var(--border-normal);
min-height: 40px;
box-sizing: border-box;
display: flex;
align-items: center;
}
.questify-settings-slider-control {
margin: 0 4px;
width: 100%;
}
.questify-sound-select-row-item {
flex: 0 1 67%;
}
.questify-volume-slider-row-item {
flex: 0 1 33%;
}
/* Quest Tiles */
.questify-quest-tile-gradient-row-item {
flex: 0 1 42%;
}
.questify-quest-tile-preload-row-item {
flex: 0 1 58%;
}
.questify-quest-tile-color-row {
align-items: stretch;
}
.questify-quest-tile-color-row-item {
display: flex;
flex-direction: column;
}
.questify-settings-color-picker.questify-quest-tile-color-picker {
display: flex;
height: 38px;
min-height: 38px;
min-width: 0;
margin-top: 5px;
}
.questify-settings-color-picker.questify-quest-tile-color-picker > [class*="colorSwatch"] {
display: flex;
width: 100%;
height: 100%;
min-height: 38px;
}
.questify-settings-color-picker.questify-quest-tile-color-picker [class*="swatch"],
.questify-settings-color-picker.questify-quest-tile-color-picker [class*="Swatch"] {
width: 100%;
height: 100%;
min-height: 38px;
border-radius: 3px;
}
.questify-settings-color-picker.questify-disabled-color-picker {
filter: brightness(0.5);
pointer-events: none;
}
.questify-quest-tile-color-button {
margin-top: 6px;
}
/* Quest tile preview */
.questify-dummy-quest-preview {
cursor: default;
@ -529,29 +163,6 @@ div[id^="quest-tile-"] div[class^="confetti__"] {
border-radius: 4px;
}
.questify-settings-color-picker.questify-quest-button-color-picker {
display: flex;
height: 38px;
min-height: 38px;
min-width: 0;
margin-top: 5px;
}
.questify-settings-color-picker.questify-quest-button-color-picker > [class*="colorSwatch"] {
display: flex;
width: 100%;
height: 100%;
min-height: 38px;
}
.questify-settings-color-picker.questify-quest-button-color-picker [class*="swatch"],
.questify-settings-color-picker.questify-quest-button-color-picker [class*="Swatch"] {
width: 100%;
height: 100%;
min-height: 38px;
border-radius: 3px;
}
.questify-quest-button-pill {
position: absolute;
top: 0;

View file

@ -6,7 +6,7 @@
import type { PluginNative } from "@utils/types";
import type { Quest, User } from "@vencord/discord-types";
import { QuestTaskType } from "@vencord/discord-types/enums";
import { QuestTargetedContent, QuestTaskType } from "@vencord/discord-types/enums";
import { findByCodeLazy, findLazy } from "@webpack";
import { AuthorizedAppsStore, FluxDispatcher, QuestStore, RestAPI, showToast, Toasts, UserStore } from "@webpack/common";
@ -15,7 +15,8 @@ import { autoCompleteQuestTaskTypes, isDesktopCompatible } from "../settings/def
import { resetQuestsToResume } from "../settings/fetching";
import { getIgnoredQuestIDs } from "../settings/ignoredQuests";
import { rerenderQuests } from "../settings/rerender";
import { normalizeQuestName } from "./filtering";
import { snakeToCamel } from "./fetching";
import { normalizeQuestName, type QuestIncludedTypes, questMatchesIncludedTypes } from "./filtering";
import { QL } from "./logging";
import { getQuestStatus, getQuestStoredProgress, isVideoQuestTask, QuestStatus, QuestTask, refreshQuest } from "./questState";
import { hasInjectedDesktopVideoCompatibility } from "./questTiles";
@ -26,6 +27,18 @@ type QuestEnrollResult =
| { type: "unknown_error"; }
| { type: "previous_in_flight_request"; };
type QuestManualEnrollResult =
| { type: "success"; }
| { type: "rate_limited"; retryAfter: number | null; }
| { type: "cancelled"; }
| { type: "unknown_error"; error: unknown; };
type QuestEnrollmentResult =
| { type: "success"; }
| { type: "rate_limited"; retryAfter: number | null; }
| { type: "cancelled"; }
| { type: "unknown_error"; };
interface QuestEnrollmentMetadata {
questContent: unknown;
questContentCTA?: unknown;
@ -89,7 +102,7 @@ const sendHeartbeat = findByCodeLazy(".QUESTS_HEARTBEAT(") as (options: {
executableFingerprint?: unknown;
}) => Promise<void>;
const getApplicationProxyTicket = findByCodeLazy("APPLICATION_PROXY_TICKET", "body.ticket") as (applicationId: string, channelId?: string) => Promise<string>;
export const enrollInQuest = findByCodeLazy('type:"QUESTS_ENROLL_BEGIN",') as (questId: string, options: QuestEnrollmentMetadata) => Promise<QuestEnrollResult>;
export const enrollInQuestNative = findByCodeLazy('type:"QUESTS_ENROLL_BEGIN",') as (questId: string, options: QuestEnrollmentMetadata) => Promise<QuestEnrollResult>;
const getQuestOrbQuantity = findByCodeLazy("premiumOrbQuantity??", "orbQuantity") as (
config: Quest["config"],
user: User | null | undefined
@ -130,6 +143,9 @@ const QuestifyNative = VencordNative?.pluginHelpers?.Questify as PluginNative<ty
const videoQuestLeeway = 24;
const resumeExpiryMs = 60 * 60 * 1000;
const maxBatchEnrollmentAttempts = 25;
const maxBatchNonRateLimitEnrollmentFailures = 3;
const maxBatchRateLimitEnrollmentFailures = 1;
export type AutoCompleteQuestKind = "watch" | "play" | "achievement";
export type AutoCompleteQuestStatus = "queued" | "running";
export type AutoCompleteStartSource = "manual" | "resume" | "auto";
@ -178,6 +194,8 @@ interface VideoProgressReportOptions {
const activeAutoCompletes = new Map<string, AutoCompleteEntry>();
const manuallyStoppedQuestIds = new Set<string>();
let enrollmentRateLimitBlockedUntil = 0;
let queueAllAutoCompleteQuestsAbortController: AbortController | null = null;
let suppressQueueDrain = false;
let videoProgressStackTracePatchSucceeded = false;
let heartbeatStackTracePatchSucceeded = false;
@ -494,13 +512,10 @@ export function getQuestButtonProps(args: QuestButtonPropsArgs): QuestButtonPatc
onClick: async () => {
if (completionState === QuestCompletionState.Unenrolled) {
args.preClickCallback?.();
const enrollment = await enrollInQuest(args.quest.id, makeEnrollmentData(args));
if (["success", "previous_in_flight_request"].includes(enrollment.type)) {
if ((await ensureQuestEnrolledForAutoComplete(args.quest, { analytics: args, method: "native" })).type === "success") {
processQuestForAutoComplete(args.quest, { force: true, source: "manual" });
rerenderQuests();
} else {
showToast(`Enrollment in ${normalizeQuestName(args.quest)} Quest failed.`, Toasts.Type.FAILURE);
}
} else if (completionState === QuestCompletionState.Completing) {
stopQuestAutoComplete(args.quest, { manual: true, preserveResume: false, terminalHeartbeat: true });
@ -559,6 +574,153 @@ export function canAutoCompleteQuest(quest: Quest): boolean {
return resolveAutoCompleteQuest(quest) != null;
}
function getEnrollmentRetryAfter(error: unknown): number | null {
const response = error as { body?: { retry_after?: unknown; retryAfter?: unknown; }; retry_after?: unknown; retryAfter?: unknown; };
const retryAfter = response.body?.retry_after ?? response.body?.retryAfter ?? response.retry_after ?? response.retryAfter;
return typeof retryAfter === "number" && Number.isFinite(retryAfter) && retryAfter > 0
? retryAfter
: null;
}
function isEnrollmentRateLimitError(error: unknown): boolean {
const response = error as { status?: unknown; };
return response.status === 429 || getEnrollmentRetryAfter(error) !== null;
}
function getEnrollmentRateLimitRemainingSeconds(): number | null {
if (enrollmentRateLimitBlockedUntil <= Date.now()) {
enrollmentRateLimitBlockedUntil = 0;
return null;
}
return (enrollmentRateLimitBlockedUntil - Date.now()) / 1000;
}
export function isQuestEnrollmentRateLimited(): boolean {
return getEnrollmentRateLimitRemainingSeconds() !== null;
}
function blockQuestEnrollmentForRetryAfter(retryAfter: number | null): void {
if (retryAfter === null) {
return;
}
enrollmentRateLimitBlockedUntil = Math.max(enrollmentRateLimitBlockedUntil, Date.now() + retryAfter * 1000);
}
function showQuestEnrollmentFailureToast(quest: Quest, result: Exclude<QuestEnrollmentResult, { type: "success"; }>): void {
const rateLimitSuffix = result.type === "rate_limited" ? " due to rate limits" : "";
showToast(`Enrollment in ${normalizeQuestName(quest)} Quest failed${rateLimitSuffix}.`, Toasts.Type.FAILURE);
}
export async function enrollInQuestManually(quest: Quest): Promise<QuestManualEnrollResult> {
quest = refreshQuest(quest);
const userId = getCurrentUserId();
if (quest.userStatus?.enrolledAt) {
return { type: "success" };
}
const blockedFor = getEnrollmentRateLimitRemainingSeconds();
if (blockedFor !== null) {
return { type: "rate_limited", retryAfter: blockedFor };
}
FluxDispatcher.dispatch({
type: "QUESTS_ENROLL_BEGIN",
questId: quest.id,
});
try {
const response = await RestAPI.post({
url: `/quests/${quest.id}/enroll`,
body: { location: QuestTargetedContent.QUEST_HOME_DESKTOP },
});
if (getCurrentUserId() !== userId) {
return { type: "cancelled" };
}
FluxDispatcher.dispatch({
type: "QUESTS_ENROLL_SUCCESS",
enrolledQuestUserStatus: snakeToCamel(response.body) as Quest["userStatus"],
});
return { type: "success" };
} catch (error) {
if (getCurrentUserId() !== userId) {
return { type: "cancelled" };
}
FluxDispatcher.dispatch({
type: "QUESTS_ENROLL_FAILURE",
questId: quest.id,
});
if (isEnrollmentRateLimitError(error)) {
const retryAfter = getEnrollmentRetryAfter(error);
blockQuestEnrollmentForRetryAfter(retryAfter);
return { type: "rate_limited", retryAfter };
}
return { type: "unknown_error", error };
}
}
async function ensureQuestEnrolledForAutoComplete(
quest: Quest,
options: { analytics?: QuestButtonAnalyticsArgs; method?: "manual" | "native"; } = {},
): Promise<QuestEnrollmentResult> {
quest = refreshQuest(quest);
if (quest.userStatus?.enrolledAt) {
return { type: "success" };
}
const result = options.method === "manual"
? await enrollInQuestManually(quest)
: await enrollInQuestNative(quest.id, makeEnrollmentData(options.analytics ?? {}));
if (result.type === "cancelled") {
return { type: "cancelled" };
}
if (result.type === "success" || result.type === "previous_in_flight_request") {
return { type: "success" };
}
const failure = result.type === "rate_limited"
? { type: "rate_limited", retryAfter: result.retryAfter } as const
: { type: "unknown_error" } as const;
showQuestEnrollmentFailureToast(quest, failure);
return failure;
}
function getQuestExpiryTime(quest: Quest): number {
const time = new Date(quest.config.expiresAt).getTime();
return Number.isFinite(time) ? time : Infinity;
}
export function getQueueableAutoCompleteQuests(): Quest[] {
const settings = getQuestifySettings();
const includedTypes = settings.questButtonIncludedTypes as QuestIncludedTypes;
return Array.from(QuestStore.quests.values())
.filter(quest => !activeAutoCompletes.has(quest.id))
.filter(quest => questMatchesIncludedTypes(quest, includedTypes))
.filter(canAutoCompleteQuest)
.sort((a, b) => getQuestExpiryTime(a) - getQuestExpiryTime(b));
}
export function setQuestAutoCompleteProgress(questOrId: Quest | string, progress: number | null): boolean {
const questId = typeof questOrId === "string" ? questOrId : questOrId.id;
const entry = activeAutoCompletes.get(questId);
@ -1091,6 +1253,100 @@ export function processQuestForAutoComplete(quest: Quest, options: AutoCompleteS
return true;
}
export function isQueueAllAutoCompleteQuestsInProgress(): boolean {
return queueAllAutoCompleteQuestsAbortController !== null;
}
export function stopQueueAllAutoCompleteQuests(): void {
queueAllAutoCompleteQuestsAbortController?.abort();
}
export async function queueAllAutoCompleteQuests(): Promise<number> {
if (
queueAllAutoCompleteQuestsAbortController !== null ||
getQuestifySettings().autoCompleteQuestsSimultaneously ||
isQuestEnrollmentRateLimited()
) {
return 0;
}
const abortController = new AbortController();
const { signal } = abortController;
queueAllAutoCompleteQuestsAbortController = abortController;
try {
let queued = 0;
let enrollmentAttempts = 0;
let rateLimitEnrollmentFailures = 0;
let nonRateLimitEnrollmentFailures = 0;
let first = true;
for (const quest of getQueueableAutoCompleteQuests()) {
if (signal.aborted) {
break;
}
if (!first) {
try {
await sleep(1000, signal);
} catch (error) {
if (signal.aborted) {
break;
}
throw error;
}
}
first = false;
const refreshedQuest = refreshQuest(quest);
if (!refreshedQuest.userStatus?.enrolledAt) {
if (enrollmentAttempts >= maxBatchEnrollmentAttempts) {
break;
}
enrollmentAttempts++;
}
const enrollment = await ensureQuestEnrolledForAutoComplete(refreshedQuest, { method: "manual" });
if (signal.aborted) {
break;
}
if (enrollment.type !== "success") {
if (enrollment.type === "rate_limited") {
rateLimitEnrollmentFailures++;
if (rateLimitEnrollmentFailures >= maxBatchRateLimitEnrollmentFailures) {
break;
}
} else {
nonRateLimitEnrollmentFailures++;
if (nonRateLimitEnrollmentFailures >= maxBatchNonRateLimitEnrollmentFailures) {
break;
}
}
continue;
}
if (processQuestForAutoComplete(refreshQuest(refreshedQuest), { force: true, source: "manual" })) {
queued++;
}
}
return queued;
} finally {
if (queueAllAutoCompleteQuestsAbortController === abortController) {
queueAllAutoCompleteQuestsAbortController = null;
}
}
}
export function stopQuestAutoComplete(questOrId: Quest | string, options: AutoCompleteStopOptions = {}): boolean {
const { manual = true, preserveResume = false, terminalHeartbeat = false } = options;
const questId = typeof questOrId === "string" ? questOrId : questOrId.id;
@ -1131,6 +1387,8 @@ export function stopQuestAutoComplete(questOrId: Quest | string, options: AutoCo
}
export function stopAllAutoCompletes(options: AutoCompleteStopOptions = {}): void {
stopQueueAllAutoCompleteQuests();
const resumeQuestIds = options.preserveResume ? getResumeQuestIds() : undefined;
suppressQueueDrain = true;