From 94f6709f266113a924345d8e4b733ec1c9a47dba Mon Sep 17 00:00:00 2001
From: Etorix <92535668+EtorixDev@users.noreply.github.com>
Date: Fri, 7 Aug 2026 14:52:24 -0700
Subject: [PATCH] Questify Updates. (#1296)
* Questify Updates.
* Fixes.
* Fixes.
---
.../questify/components/questButton.tsx | 43 +-
.../components/questFeaturesSetting.tsx | 33 +-
.../questify/components/questTilesSetting.tsx | 5 +-
.../questify/components/shared.tsx | 2 +
src/equicordplugins/questify/index.tsx | 29 +-
src/equicordplugins/questify/settings.css | 398 ++++++++++++++++++
.../questify/settings/dangerous.ts | 20 +
src/equicordplugins/questify/settings/def.ts | 2 +
.../questify/settings/notices.tsx | 125 ++++++
.../questify/settings/restartTracking.ts | 28 +-
.../questify/settings/store.ts | 15 +-
src/equicordplugins/questify/styles.css | 395 +----------------
.../questify/utils/completion.ts | 272 +++++++++++-
13 files changed, 944 insertions(+), 423 deletions(-)
create mode 100644 src/equicordplugins/questify/settings.css
create mode 100644 src/equicordplugins/questify/settings/dangerous.ts
create mode 100644 src/equicordplugins/questify/settings/notices.tsx
diff --git a/src/equicordplugins/questify/components/questButton.tsx b/src/equicordplugins/questify/components/questButton.tsx
index 753d19015..abea34ea4 100644
--- a/src/equicordplugins/questify/components/questButton.tsx
+++ b/src/equicordplugins/questify/components/questButton.tsx
@@ -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 && (
+
{
+ const queueAllPromise = queueAllAutoCompleteQuests();
+
+ rerenderQuests();
+
+ void queueAllPromise.finally(() => {
+ rerenderQuests();
+ });
+ }}
+ disabled={queueAllQuestsDisabled}
+ />
+ )}
+ {clearQueueVisible && (
+ {
+ stopAllAutoCompletes({
+ manual: true,
+ preserveResume: false,
+ terminalHeartbeat: true,
+ });
+ rerenderQuests();
+ }}
+ disabled={dummy}
+ />
+ )}
Modify Features
c !== undefined)}>
- Discord has began issuing warnings to users of scripts or plugins that modify how Quests are completed, which is against their Terms of Service.
+ 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 Terms of Service.
- The warnings appear limited to threat of loss of access to Quests or their rewards, but Discord may escalate at any time.
+ The punishment appears limited to loss of access to Quests and their rewards, but Discord may escalate at any time.
@@ -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."
}}
/>
+ 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."
+ }}
+ />
-
+ />}
);
}
diff --git a/src/equicordplugins/questify/components/shared.tsx b/src/equicordplugins/questify/components/shared.tsx
index 4e2e4cd2e..58b0afe94 100644
--- a/src/equicordplugins/questify/components/shared.tsx
+++ b/src/equicordplugins/questify/components/shared.tsx
@@ -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";
diff --git a/src/equicordplugins/questify/index.tsx b/src/equicordplugins/questify/index.tsx
index 92b041a5a..16175453d 100644
--- a/src/equicordplugins/questify/index.tsx
+++ b/src/equicordplugins/questify/index.tsx
@@ -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 {
diff --git a/src/equicordplugins/questify/settings.css b/src/equicordplugins/questify/settings.css
new file mode 100644
index 000000000..1d0439c27
--- /dev/null
+++ b/src/equicordplugins/questify/settings.css
@@ -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;
+}
diff --git a/src/equicordplugins/questify/settings/dangerous.ts b/src/equicordplugins/questify/settings/dangerous.ts
new file mode 100644
index 000000000..2c0eb1e69
--- /dev/null
+++ b/src/equicordplugins/questify/settings/dangerous.ts
@@ -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 };
+}
diff --git a/src/equicordplugins/questify/settings/def.ts b/src/equicordplugins/questify/settings/def.ts
index 433b8fd5e..ea1398935 100644
--- a/src/equicordplugins/questify/settings/def.ts
+++ b/src/equicordplugins/questify/settings/def.ts
@@ -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 = {};
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;
diff --git a/src/equicordplugins/questify/settings/notices.tsx b/src/equicordplugins/questify/settings/notices.tsx
new file mode 100644
index 000000000..daebfec03
--- /dev/null
+++ b/src/equicordplugins/questify/settings/notices.tsx
@@ -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: () =>
+
+ 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 Terms of Service.
+
+
+
+ The punishment appears limited to loss of access to Quests and their rewards, but Discord may escalate at any time.
+
+
+
+ Due to the various methods Discord uses to track users, there's no way to realistically evade detection. If you proceed, understand that Discord likely will detect it at some point.
+
+
,
+ 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 (
+ ({
+ text: action.text,
+ variant: action.variant ?? "primary",
+ onClick: () => runNoticeAction(notice, action, modalProps.onClose),
+ }))}
+ >
+ {notice.renderBody()}
+
+ );
+}
+
+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 => );
+ return;
+ }
+}
diff --git a/src/equicordplugins/questify/settings/restartTracking.ts b/src/equicordplugins/questify/settings/restartTracking.ts
index 93acdbe99..45c294ff3 100644
--- a/src/equicordplugins/questify/settings/restartTracking.ts
+++ b/src/equicordplugins/questify/settings/restartTracking.ts
@@ -11,6 +11,10 @@ import { Alerts } from "../utils/ui";
type RestartTrackingSettings = Pick, "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;
diff --git a/src/equicordplugins/questify/settings/store.ts b/src/equicordplugins/questify/settings/store.ts
index a0faddbf0..531b8ff5f 100644
--- a/src/equicordplugins/questify/settings/store.ts
+++ b/src/equicordplugins/questify/settings/store.ts
@@ -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.",
diff --git a/src/equicordplugins/questify/styles.css b/src/equicordplugins/questify/styles.css
index 9e6c7256c..3808c110e 100644
--- a/src/equicordplugins/questify/styles.css
+++ b/src/equicordplugins/questify/styles.css
@@ -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;
diff --git a/src/equicordplugins/questify/utils/completion.ts b/src/equicordplugins/questify/utils/completion.ts
index 94f1161b8..846e8efee 100644
--- a/src/equicordplugins/questify/utils/completion.ts
+++ b/src/equicordplugins/questify/utils/completion.ts
@@ -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;
const getApplicationProxyTicket = findByCodeLazy("APPLICATION_PROXY_TICKET", "body.ticket") as (applicationId: string, channelId?: string) => Promise;
-export const enrollInQuest = findByCodeLazy('type:"QUESTS_ENROLL_BEGIN",') as (questId: string, options: QuestEnrollmentMetadata) => Promise;
+export const enrollInQuestNative = findByCodeLazy('type:"QUESTS_ENROLL_BEGIN",') as (questId: string, options: QuestEnrollmentMetadata) => Promise;
const getQuestOrbQuantity = findByCodeLazy("premiumOrbQuantity??", "orbQuantity") as (
config: Quest["config"],
user: User | null | undefined
@@ -130,6 +143,9 @@ const QuestifyNative = VencordNative?.pluginHelpers?.Questify as PluginNative();
const manuallyStoppedQuestIds = new Set();
+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): 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 {
+ 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 {
+ 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 {
+ 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;