From e3f6212689a06eef03f9fc09926942a9af7ce1d4 Mon Sep 17 00:00:00 2001 From: Etorix <92535668+EtorixDev@users.noreply.github.com> Date: Fri, 1 Aug 2025 07:36:37 -0700 Subject: [PATCH] Questify (#317) * Rewrite. * Improve select sorting & dummy quest picking. * Improvement + typo. * Map fix. * Misc. * Remove remnants of previous version. * Fix pill bumping. * Pill height fix. * Gradient improvement + README. * Misc. * Only alert if QuestsStore was already populated. * Remove query selector. * Misc. * Simplify conditional class. * Hook order fix. * Patch fixes. * Stop audio output on settings close. * Find fixes. * Fixes. * Null alert fix. * Fetch source distinction. * Add README image. * Use proper JSX. * Remove needless use of Dispatch and simplify prop passing. * Remove unnecessary functions. * Fix pill jumpiness. * Fix pill CSS. * Fix button CSS. * Remove audio delay. * Reorder improvements + immediately apply settings. * Cleanup subsort options. * Unclaimed badge bug fix. * README update. * Unclaimed badge bug fix (again). * Legacy CSS cleanup. * Add option for completing mobile quests on desktop. * Squash settings. * Reviewed CSS changes. * Reviewed CSS changes. * CSS regression fix. * Add option for completing video quests in the background. * README update. * Merge redundant settings. * Code style nitpicks. * Add option for completing game quests in the background. * Removing unnecessary log. * Add Desktop client check. * Misc. * Add preface that game quest option is desktop only. * Semantics. * Better i18n syntax. * Prevent duplicate game heartbeats when launching game being background completed + logger fixes. * Prevent delay on near-complete quest. * Fix dropdown bug and also hide orbs badge if setting enabled. * Fix hardcoded classes. * Make alert icon color transition smooth. * Label improvements + patch improvements. * Import fix. * Flux change. * Comments. * Fix console logs. * Adjust authors. * Move to equicordplugins dir. * Fixes and improvements. * Improve patch. * Improve progress display above user panel. * Fix dynamic label being overwritten by built-in filter. * Semantics. * Patch update. --- .gitignore | 1 + src/equicordplugins/questify/README.md | 69 + src/equicordplugins/questify/index.tsx | 1147 +++++++++++ src/equicordplugins/questify/settings.tsx | 1669 +++++++++++++++++ src/equicordplugins/questify/styles.css | 340 ++++ .../questify/utils/components.tsx | 302 +++ src/equicordplugins/questify/utils/misc.tsx | 339 ++++ 7 files changed, 3867 insertions(+) create mode 100644 src/equicordplugins/questify/README.md create mode 100644 src/equicordplugins/questify/index.tsx create mode 100644 src/equicordplugins/questify/settings.tsx create mode 100644 src/equicordplugins/questify/styles.css create mode 100644 src/equicordplugins/questify/utils/components.tsx create mode 100644 src/equicordplugins/questify/utils/misc.tsx diff --git a/.gitignore b/.gitignore index 9ac8b586a..9ed57e067 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ lerna-debug.log* .pnpm-debug.log* *.tsbuildinfo +.modules/ src/userplugins ExtensionCache/ diff --git a/src/equicordplugins/questify/README.md b/src/equicordplugins/questify/README.md new file mode 100644 index 000000000..b74ac2e4e --- /dev/null +++ b/src/equicordplugins/questify/README.md @@ -0,0 +1,69 @@ +# Questify + +Enhance your Quest experience with a suite of features, or disable them entirely if they're not your thing. + +![An example of the Restyle and Reorder features.](https://github.com/user-attachments/assets/452ba894-d0e0-46d5-894a-af901c42d8e5) + +## Features +1. Quest Features + - Modify specific Quest features. + - Allows for disabling the following Quest related features: + - Everything — All of the below is disabled. + - Discovery Tab — Quests tab becomes hidden. + - Fetching Quests — Will prevent fetching Quests resulting in a blank Quests page. + - Badge on User Profiles — Hides the profile badge which doubles as a shortcut to the Quests page. + - Popup Above User Panel — Hides the new Quest promotion that appears above your profile panel. + - Gift Inventory Relocation Notice — Hides the text in the gift inventory which doubles as a shortcut to the Quests page. + - Friends List Active Now Promotion — Hides the promotion that displays when your friends play a game which has an active Quest. + - Allows for modifying the following Quest related features: + - Complete Video Quests in Background — Click the watch button on video Quests and wait for the video duration to pass. The first click will start the Quest without opening the video modal. Subsequent clicks will show the modal as normal. + - Make Mobile Quests Desktop Compatible — Make mobile-only video Quests completable on Desktop. +2. Quests Button + - Show a Quest button in the server list with an optional indicator for unclaimed Quests. + - Button Visibility + - Always — Always display the button. + - Unclaimed — Only display the button when you have unclaimed Quests. + - Never — Never display the button. + - Unclaimed Indicator + - Pill — Shows an "unread" pill when there are unclaimed Quests. + - Badge — Shows the number of unclaimed Quests as a "ping" badge. + - Both — Both of the above. + - None — Show no indication of unclaimed Quests. + - Badge Color + - Use the color picker to pick a custom badge color or use the default Discord ping badge color by clicking `Disable`. + - Left / Middle / Right Click Actions + - Open Quests — Navigate to the Quests tab in Discovery. + - Plugin Settings — Open the plugin settings. + - Context Menu — Show the options `Mark All Ignored`, `Reset Ignored List`, and `Fetch Quests`. + - Nothing — Do nothing. +3. Fetching Quests + - Configure how often to fetch Quests from Discord and set up alerts for new Quests. + - By default, Discord only fetches Quests on load and when visiting the Quests page. This means that without a fetch interval defined below, this plugin will become unaware of new Quests added throughout the day. + - This relies on the Quest Button being enabled and set to either `Unclaimed`, or set to `Always` with unclaimed `Pill`, `Badge`, or `Both` indicators enabled. Otherwise, there is no reason to periodically fetch Quests. + - Also, if `Fetching Quests` is blocked in the `Quest Features` setting, this will not work. + - Fetch Interval + - Any time between 30 minutes and 12 hours. Defaults to every 45 minutes. + - Alert Sound + - Pick a default Discord sound to play when new Quests are fetched, or provide a custom audio URL. The audio must be hosted on a domain whitelisted by the Vencord CSP, such as `catbox`. + - If the provided URL is not on a supported domain, you will receive an error. If it *is* on a supported domain, but the audio file is invalid, it will silently fail. To ensure your audio file plays fine, click the audio preview button in the plugin settings. +4. Restyle Quests + - Highlight Quests with optional theme colors for visibility. + - Unclaimed / Claimed / Ignored / Expired + - Use the color picker to pick a custom Quest tile color or use the default Discord coloring by clicking `Disable`. + - Gradient Style + - Intense Restyle Gradient — A more intense gradient than default in order to hide hard edges under the gradient. + - Default Restyle Gradient — The default gradient intensity with the restyle color. + - Subtle Black Gradient — A much less intense black gradient for contrast with the Quest assets. + - No Gradient — Remove the gradient altogether. May cause the Quest assets to be difficult to see. + - Asset Preload + - Load All Quest Assets On Page Load — As soon as the Quest page is opened, load all Quest assets on the page. + - Load Quest Assets During Page Scroll — Keep the default behavior of loading Quest assets as they come into view. +5. Reorder Quests + - Sort Quests by their status. + - Comma-separated list must contain all of: `UNCLAIMED, CLAIMED, IGNORED, EXPIRED`. + - Subsort status groups by Quest added date, expiring date, or claimed date. +6. Ignored Quests + - A list of Quest names to exclude from the `Unclaimed Indicator`. + - One Quest name per line. Names must match the spelling displayed on the Quests page. + - Alternatively, click the three dots on a Quest tile and select the `Mark as Ignored` option. + - This list is automatically cleaned of removed Quests as new Quests come in and as Quests are completed. diff --git a/src/equicordplugins/questify/index.tsx b/src/equicordplugins/questify/index.tsx new file mode 100644 index 000000000..5f3583978 --- /dev/null +++ b/src/equicordplugins/questify/index.tsx @@ -0,0 +1,1147 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import "./styles.css"; + +import { addServerListElement, removeServerListElement, ServerListRenderPosition } from "@api/ServerList"; +import { ErrorBoundary, openPluginModal } from "@components/index"; +import { EquicordDevs } from "@utils/constants"; +import { getIntlMessage } from "@utils/index"; +import definePlugin, { StartAt } from "@utils/types"; +import { ContextMenuApi, Menu, NavigationRouter } from "@webpack/common"; +import { JSX } from "react"; + +import { addIgnoredQuest, autoFetchCompatible, fetchAndAlertQuests, maximumAutoFetchIntervalValue, minimumAutoFetchIntervalValue, removeIgnoredQuest, rerenderQuests, settings, startAutoFetchingQuests, stopAutoFetchingQuests, validateAndOverwriteIgnoredQuests } from "./settings"; +import { GuildlessServerListItem, Quest, QuestIcon, QuestMap, QuestStatus, RGB } from "./utils/components"; +import { adjustRGB, decimalToRGB, fetchAndDispatchQuests, formatLowerBadge, getFormattedNow, getQuestStatus, isDarkish, leftClick, middleClick, normalizeQuestName, q, QuestifyLogger, questPath, QuestsStore, refreshQuest, reportPlayGameQuestProgress, reportVideoQuestProgress, rightClick, waitUntilEnrolled } from "./utils/misc"; + +const patchedMobileQuests = new Set(); +export const activeQuestIntervals = new Map(); + +function questMenuUnignoreClicked(): void { + validateAndOverwriteIgnoredQuests(""); +} + +function questMenuIgnoreClicked(): void { + const quests = (QuestsStore.quests as QuestMap); + const ignoredQuestsSet = new Set(); + + for (const quest of quests.values()) { + const questName = normalizeQuestName(quest.config.messages.questName); + const questStatus = getQuestStatus(quest, false); + + if (questStatus === QuestStatus.Unclaimed) { + ignoredQuestsSet.add(questName); + } + } + + settings.store.ignoredQuests = Array.from(ignoredQuestsSet).join("\n"); + settings.store.unclaimedUnignoredQuests = 0; +} + +export function QuestButton(): JSX.Element { + const { questButtonDisplay, questButtonUnclaimed, questButtonBadgeColor, unclaimedUnignoredQuests, onQuestsPage } = settings.use(["questButtonDisplay", "questButtonUnclaimed", "questButtonBadgeColor", "unclaimedUnignoredQuests", "onQuestsPage"]); + const questButtonBadgeColorRGB = questButtonBadgeColor === null ? null : decimalToRGB(questButtonBadgeColor); + + function showQuestsButton(): boolean { + const canShow = questButtonDisplay !== "never"; + const alwaysShow = questButtonDisplay === "always"; + return canShow && (alwaysShow || !!unclaimedUnignoredQuests || onQuestsPage); + } + + function handleClick(event: React.MouseEvent) { + // ListItem does not support onAuxClick, so we have to listen for mousedown events. + // Ignore left and right clicks sent via mousedown events to prevent double events. + if (event.type === "mousedown" && event.button !== middleClick) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + let todo: string | null = null; + + if (event.button === middleClick) { + todo = settings.store.questButtonMiddleClickAction; + } else if (event.button === rightClick) { + todo = settings.store.questButtonRightClickAction; + } else if (event.button === leftClick) { + todo = settings.store.questButtonLeftClickAction; + } + + if (todo === "open-quests") { + NavigationRouter.transitionTo(questPath); + } else if (todo === "plugin-settings") { + openPluginModal(Vencord.Plugins.plugins.Questify); + } else if (todo === "context-menu") { + ContextMenuApi.openContextMenu(event, () => ( + + + + fetchAndAlertQuests("Questify-ManualFetch", QuestifyLogger)} + /> + + )); + } + } + + const lowerBadgeProps = { + count: !["badge", "both"].includes(questButtonUnclaimed) ? 0 : unclaimedUnignoredQuests, + maxDigits: 2, + ...(questButtonBadgeColorRGB ? { color: `rgb(${questButtonBadgeColorRGB.r}, ${questButtonBadgeColorRGB.g}, ${questButtonBadgeColorRGB.b})` } : {}), + ...(questButtonBadgeColorRGB ? { style: { color: isDarkish(questButtonBadgeColorRGB) ? "white" : "black" } } : {}) + }; + + return ( + + ); +} + +function shouldHideGiftInventoryRelocationNotice(): boolean { + const { + disableQuestsGiftInventoryRelocationNotice, + disableQuestsEverything + } = settings.use([ + "disableQuestsGiftInventoryRelocationNotice", + "disableQuestsEverything" + ]); + + return disableQuestsGiftInventoryRelocationNotice || disableQuestsEverything; +} + +function shouldHideDiscoveryTab(): boolean { + const { + disableQuestsDiscoveryTab, + disableQuestsEverything + } = settings.use([ + "disableQuestsDiscoveryTab", + "disableQuestsEverything" + ]); + + return disableQuestsDiscoveryTab || disableQuestsEverything; +} + +function shouldHideBadgeOnUserProfiles(): boolean { + const { + disableQuestsBadgeOnUserProfiles, + disableQuestsEverything + } = settings.use([ + "disableQuestsBadgeOnUserProfiles", + "disableQuestsEverything" + ]); + + return disableQuestsBadgeOnUserProfiles || disableQuestsEverything; +} + +function shouldHideQuestPopup(quest: Quest | null): boolean { + const { + disableQuestsPopupAboveAccountPanel, + disableQuestsEverything, + triggerQuestsRerender + } = settings.use([ + "disableQuestsPopupAboveAccountPanel", + "disableQuestsEverything", + "triggerQuestsRerender" + ]); + + const noProgress = !quest?.userStatus?.progress || Object.keys(quest?.userStatus?.progress || {}).length === 0; + return !quest || ((disableQuestsPopupAboveAccountPanel || disableQuestsEverything) && noProgress); +} + +function shouldPreventFetchingQuests(): boolean { + return settings.store.disableQuestsFetchingQuests || settings.store.disableQuestsEverything; +} + +function shouldHideFriendsListActiveNowPromotion(): boolean { + const { + disableFriendsListActiveNowPromotion, + disableQuestsEverything + } = settings.use([ + "disableFriendsListActiveNowPromotion", + "disableQuestsEverything" + ]); + + return disableFriendsListActiveNowPromotion || disableQuestsEverything; +} + +function shouldDisableQuestTileOptions(quest: Quest, shouldBeIgnored: boolean): boolean { + const questStatus = getQuestStatus(quest); + + return !( + (shouldBeIgnored ? questStatus === QuestStatus.Ignored : questStatus === QuestStatus.Unclaimed) + ); +} + +function QuestTileContextMenu(children: React.ReactNode[], props: { quest: any; }) { + children.unshift(( + + { addIgnoredQuest(props.quest.config.messages.questName); }} + /> + { removeIgnoredQuest(props.quest.config.messages.questName); }} + /> + {activeQuestIntervals.has(props.quest.id) && + { + const interval = activeQuestIntervals.get(props.quest.id); + + if (interval) { + clearInterval(interval.progressTimeout); + clearTimeout(interval.rerenderTimeout); + activeQuestIntervals.delete(props.quest.id); + rerenderQuests(); + } + }} + /> + } + + )); +} + +export function getQuestTileClasses(originalClasses: string, quest: Quest, color: number | null | undefined, gradient: string | undefined): string { + const { + restyleQuestsUnclaimed, + restyleQuestsClaimed, + restyleQuestsIgnored, + restyleQuestsExpired, + restyleQuestsGradient, + ignoredQuests + } = settings.use([ + "restyleQuestsUnclaimed", + "restyleQuestsClaimed", + "restyleQuestsIgnored", + "restyleQuestsExpired", + "restyleQuestsGradient", + "ignoredQuests" + ]); + + const customClasses = [ + q("quest-item-restyle"), + q("quest-item-intense-gradient"), + q("quest-item-default-gradient"), + q("quest-item-black-gradient"), + q("quest-item-hide-gradient"), + q("quest-item-contrast-logo") + ]; + + if (originalClasses.includes(q("dummy-quest"))) { + return originalClasses; + } + + const questStatus = getQuestStatus(quest); + const baseClasses = originalClasses.split(" ").filter(cls => cls && !customClasses.includes(cls)); + const returnClasses: string[] = [...baseClasses]; + const hasColorOverride = color !== undefined; + const skipColorCheck = hasColorOverride && color === null; + let isRestyledAndDarkish: any = null; + + if (!skipColorCheck) { + if (questStatus === QuestStatus.Claimed && (color || restyleQuestsClaimed !== null)) { + returnClasses.push(q("quest-item-restyle")); + isRestyledAndDarkish = isDarkish(decimalToRGB(color ?? restyleQuestsClaimed), 0.875); + } else if (questStatus === QuestStatus.Unclaimed && (color || restyleQuestsUnclaimed !== null)) { + returnClasses.push(q("quest-item-restyle")); + isRestyledAndDarkish = isDarkish(decimalToRGB(color ?? restyleQuestsUnclaimed), 0.875); + } else if (questStatus === QuestStatus.Ignored && (color || restyleQuestsIgnored !== null)) { + returnClasses.push(q("quest-item-restyle")); + isRestyledAndDarkish = isDarkish(decimalToRGB(color ?? restyleQuestsIgnored), 0.875); + } else if (questStatus === QuestStatus.Expired && (color || restyleQuestsExpired !== null)) { + returnClasses.push(q("quest-item-restyle")); + isRestyledAndDarkish = isDarkish(decimalToRGB(color ?? restyleQuestsExpired), 0.875); + } + } + + if (isRestyledAndDarkish !== null) { + if ((gradient || restyleQuestsGradient) === "black") { + returnClasses.push(q("quest-item-black-gradient")); + } else if ((gradient || restyleQuestsGradient) === "hide") { + returnClasses.push(q("quest-item-hide-gradient")); + } else { + if ((gradient || restyleQuestsGradient) === "default") { + returnClasses.push(q("quest-item-default-gradient")); + } else { + returnClasses.push(q("quest-item-intense-gradient")); + } + + if (!isRestyledAndDarkish) { + returnClasses.push(q("quest-item-contrast-logo")); + } + } + } + + return returnClasses.join(" "); +} + +function preprocessQuests(quests: Quest[]): Quest[] { + const { + ignoredQuests, + reorderQuests, + unclaimedSubsort, + claimedSubsort, + ignoredSubsort, + expiredSubsort, + makeMobileQuestsDesktopCompatible, + completeVideoQuestsInBackground, + completeGameQuestsInBackground, + triggerQuestsRerender + } = settings.use([ + "ignoredQuests", + "reorderQuests", + "unclaimedSubsort", + "claimedSubsort", + "ignoredSubsort", + "expiredSubsort", + "makeMobileQuestsDesktopCompatible", + "completeVideoQuestsInBackground", + "completeGameQuestsInBackground", + "triggerQuestsRerender" + ]); + + if (makeMobileQuestsDesktopCompatible) { + quests.forEach(quest => { + const config = quest.config?.taskConfigV2; + const tasks = config?.tasks; + + if (tasks?.WATCH_VIDEO_ON_MOBILE && !tasks?.WATCH_VIDEO) { + patchedMobileQuests.add(quest.id); + + tasks.WATCH_VIDEO = { + ...tasks.WATCH_VIDEO_ON_MOBILE, + type: "WATCH_VIDEO" + }; + } + }); + } else if (patchedMobileQuests.size > 0) { + patchedMobileQuests.forEach(questId => { + const quest = quests.find(q => q.id === questId); + const config = quest?.config?.taskConfigV2; + + if (config) { + delete config.tasks.WATCH_VIDEO; + } + }); + + patchedMobileQuests.clear(); + } + + if (!reorderQuests || !reorderQuests.trim()) { + return quests; + } + + const orderList = reorderQuests.split(",").map(q => q.trim().toLowerCase()); + + const questGroups: { [key: string]: Quest[]; } = { + claimed: [], + expired: [], + ignored: [], + unclaimed: [], + unknown: [] + }; + + quests.forEach(quest => { + const questStatus = getQuestStatus(quest); + + if (questStatus === QuestStatus.Claimed) { + questGroups.claimed.push(quest); + } else if (questStatus === QuestStatus.Unclaimed) { + questGroups.unclaimed.push(quest); + } else if (questStatus === QuestStatus.Ignored) { + questGroups.ignored.push(quest); + } else if (questStatus === QuestStatus.Expired) { + questGroups.expired.push(quest); + } else { + questGroups.unknown.push(quest); + } + }); + + const createSortFunction = (subsort: string) => { + switch (subsort) { + case "Recent ASC": + return (a: Quest, b: Quest) => new Date(a.config.startsAt).getTime() - new Date(b.config.startsAt).getTime(); + case "Recent DESC": + return (a: Quest, b: Quest) => new Date(b.config.startsAt).getTime() - new Date(a.config.startsAt).getTime(); + case "Expiring ASC": + return (a: Quest, b: Quest) => new Date(a.config.expiresAt).getTime() - new Date(b.config.expiresAt).getTime(); + case "Expiring DESC": + return (a: Quest, b: Quest) => new Date(b.config.expiresAt).getTime() - new Date(a.config.expiresAt).getTime(); + case "Claimed ASC": + return (a: Quest, b: Quest) => new Date(a.userStatus?.claimedAt || 0).getTime() - new Date(b.userStatus?.claimedAt || 0).getTime(); + case "Claimed DESC": + return (a: Quest, b: Quest) => new Date(b.userStatus?.claimedAt || 0).getTime() - new Date(a.userStatus?.claimedAt || 0).getTime(); + default: + return (a: Quest, b: Quest) => new Date(b.config.startsAt).getTime() - new Date(a.config.startsAt).getTime(); + } + }; + + const unclaimedSortFunction = createSortFunction(unclaimedSubsort || "Recent DESC"); + + // Divide unclaimed quests by completion status before applying subsort. + questGroups.unclaimed.sort((a: Quest, b: Quest) => { + const aCompleted = !!a.userStatus?.completedAt; + const bCompleted = !!b.userStatus?.completedAt; + + if (aCompleted !== bCompleted) { + return aCompleted ? 1 : -1; + } + + return unclaimedSortFunction(a, b); + }); + + questGroups.claimed.sort(createSortFunction(claimedSubsort || "Claimed DESC")); + questGroups.ignored.sort(createSortFunction(ignoredSubsort || "Recent DESC")); + questGroups.expired.sort(createSortFunction(expiredSubsort || "Expiring DESC")); + + const sortedQuests: Quest[] = []; + + orderList.forEach(status => { + if (questGroups[status]) { + sortedQuests.push(...questGroups[status]); + } + }); + + Object.keys(questGroups).forEach(status => { + if (!orderList.includes(status)) { + sortedQuests.push(...questGroups[status]); + } + }); + + return sortedQuests; +} + +function getQuestTileStyle(quest: Quest): Record { + const { + restyleQuests, + ignoredQuests, + } = settings.use([ + "restyleQuests", + "ignoredQuests", + ]); + + const style: Record = {}; + let themeColor: RGB | null = null; + + const restyleUnclaimed = settings.store.restyleQuestsUnclaimed; + const restyleClaimed = settings.store.restyleQuestsClaimed; + const restyleIgnored = settings.store.restyleQuestsIgnored; + const restyleExpired = settings.store.restyleQuestsExpired; + + const claimedColor = restyleClaimed !== null ? decimalToRGB(restyleClaimed) : ""; + const unclaimedColor = restyleUnclaimed !== null ? decimalToRGB(restyleUnclaimed) : ""; + const ignoredColor = restyleIgnored !== null ? decimalToRGB(restyleIgnored) : ""; + const expiredColor = restyleExpired !== null ? decimalToRGB(restyleExpired) : ""; + const dummyProvided = quest.dummyColor !== undefined; + const dummyColor = (quest.dummyColor !== undefined && quest.dummyColor !== null) ? decimalToRGB(quest.dummyColor) : null; + const questStatus = getQuestStatus(quest); + + if (questStatus === QuestStatus.Claimed) { + themeColor = dummyProvided ? dummyColor : claimedColor || null; + } else if (questStatus === QuestStatus.Unclaimed) { + themeColor = dummyProvided ? dummyColor : unclaimedColor || null; + } else if (questStatus === QuestStatus.Ignored) { + themeColor = dummyProvided ? dummyColor : ignoredColor || null; + } else if (questStatus === QuestStatus.Expired) { + themeColor = dummyProvided ? dummyColor : expiredColor || null; + } + + if (!themeColor) return style; + + const darkish = isDarkish(themeColor); + const sign = darkish ? 1 : -1; + const questNameColor = adjustRGB(themeColor, 200 * sign); + const rewardTitleColor = adjustRGB(themeColor, 150 * sign); + const rewardDescriptionColor = adjustRGB(themeColor, 100 * sign); + const buttonNormalColor = adjustRGB(themeColor, 50 * sign); + const buttonHoverColor = adjustRGB(themeColor, 75 * sign); + + style["--questify-color"] = `rgb(${themeColor.r}, ${themeColor.g}, ${themeColor.b})`; + style["--questify-quest-name"] = `rgb(${questNameColor.r}, ${questNameColor.g}, ${questNameColor.b})`; + style["--questify-reward-title"] = `rgb(${rewardTitleColor.r}, ${rewardTitleColor.g}, ${rewardTitleColor.b})`; + style["--questify-reward-description"] = `rgb(${rewardDescriptionColor.r}, ${rewardDescriptionColor.g}, ${rewardDescriptionColor.b})`; + style["--questify-button-normal"] = `rgb(${buttonNormalColor.r}, ${buttonNormalColor.g}, ${buttonNormalColor.b})`; + style["--questify-button-hover"] = `rgb(${buttonHoverColor.r}, ${buttonHoverColor.g}, ${buttonHoverColor.b})`; + + return style; +} + +function shouldPreloadQuestAssets(): boolean { + const { restyleQuestsPreload } = settings.use(["restyleQuestsPreload"]); + return restyleQuestsPreload; +} + +async function startVideoProgressTracking(quest: Quest, questDuration: number): Promise { + const questName = normalizeQuestName(quest.config.messages.questName); + const questEnrolledAt = quest.userStatus?.enrolledAt ? new Date(quest.userStatus.enrolledAt) : null; + const initialProgress = Math.floor(((new Date()).getTime() - (questEnrolledAt ?? new Date()).getTime()) / 1000) || 1; // Max up to 10 seconds into the future can be reported. + + if (!questEnrolledAt) { + const enrollmentTimeout = 60000; + const enrolled = await waitUntilEnrolled(quest, enrollmentTimeout, 15, QuestifyLogger); + quest = refreshQuest(quest); + + if (!enrolled) { + QuestifyLogger.warn(`[${getFormattedNow()}] Quest ${questName} not enrolled within ${enrollmentTimeout / 1000} seconds.`); + return; + } + } + + let progressIntervalId: NodeJS.Timeout; + let currentProgress = initialProgress; + const timeRemaining = Math.max(0, questDuration - currentProgress); + const reportEverySec = 10; + + async function handleSendComplete() { + clearInterval(progressIntervalId); + clearTimeout(renderIntervalId); + activeQuestIntervals.delete(quest.id); + const success = await reportVideoQuestProgress(quest, questDuration, QuestifyLogger); + + if (success) { + QuestifyLogger.info(`[${getFormattedNow()}] Quest ${questName} completed.`); + } else { + QuestifyLogger.error(`[${getFormattedNow()}] Failed to complete Quest ${questName}.`); + } + } + + if (timeRemaining < reportEverySec) { + progressIntervalId = setTimeout(async () => { + await handleSendComplete(); + }, timeRemaining * 1000); + } else { + reportVideoQuestProgress(quest, initialProgress, QuestifyLogger); + + progressIntervalId = setInterval(async () => { + currentProgress += reportEverySec; + + if (currentProgress >= questDuration - 10) { + await handleSendComplete(); + } else { + await reportVideoQuestProgress(quest, currentProgress, QuestifyLogger); + } + }, reportEverySec * 1000); + } + + const renderIntervalId = setInterval(() => { + const intervalData = activeQuestIntervals.get(quest.id); + + if (!!intervalData) { + intervalData.progress += 1; + } else { + clearInterval(renderIntervalId); + } + + rerenderQuests(); + }, 1000); + + activeQuestIntervals.set(quest.id, { progressTimeout: progressIntervalId, rerenderTimeout: renderIntervalId, progress: initialProgress, duration: questDuration, type: "watch" }); + + if (timeRemaining > 0) { + QuestifyLogger.info(`[${getFormattedNow()}] Quest ${questName} will be completed in the background in ${timeRemaining} seconds.`); + } +} + +async function startPlayGameProgressTracking(quest: Quest, questDuration: number): Promise { + const questName = normalizeQuestName(quest.config.messages.questName); + const questEnrolledAt = quest.userStatus?.enrolledAt ? new Date(quest.userStatus.enrolledAt) : null; + const playType = quest.config.taskConfigV2?.tasks.PLAY_ON_DESKTOP || quest.config.taskConfigV2?.tasks.PLAY_ON_XBOX || quest.config.taskConfigV2?.tasks.PLAY_ON_PLAYSTATION || quest.config.taskConfigV2?.tasks.PLAY_ACTIVITY; + const initialProgress = quest.userStatus?.progress?.[playType?.type || ""]?.value || 0; + const remaining = Math.max(0, questDuration - initialProgress); + const heartbeatInterval = 20; // Heartbeats must be at most 2 minutes apart. + + if (!questEnrolledAt) { + const enrollmentTimeout = 60000; + const enrolled = await waitUntilEnrolled(quest, enrollmentTimeout, 500, QuestifyLogger); + quest = refreshQuest(quest); + + if (!enrolled) { + QuestifyLogger.warn(`[${getFormattedNow()}] Quest ${questName} not enrolled after waiting for ${enrollmentTimeout / 1000} seconds.`); + return; + } + } + + reportPlayGameQuestProgress(quest, false, QuestifyLogger); + + const progressIntervalId = setInterval(async () => { + const result = await reportPlayGameQuestProgress(quest, false, QuestifyLogger); + + if (result.progress === null) { + clearInterval(progressIntervalId); + activeQuestIntervals.delete(quest.id); + QuestifyLogger.error(`[${getFormattedNow()}] Failed to send heartbeat for Quest ${questName}.`); + return; + } + + const isComplete = result.progress >= questDuration; + const timeRemaining = questDuration - result.progress; + + if (isComplete) { + clearInterval(progressIntervalId); + clearTimeout(renderIntervalId); + activeQuestIntervals.delete(quest.id); + const success = await reportPlayGameQuestProgress(quest, true, QuestifyLogger); + + if (success) { + QuestifyLogger.info(`[${getFormattedNow()}] Quest ${questName} completed.`); + } else { + QuestifyLogger.error(`[${getFormattedNow()}] Failed to complete Quest ${questName}.`); + } + } else if (timeRemaining < heartbeatInterval) { + clearInterval(progressIntervalId); + clearTimeout(renderIntervalId); + + setTimeout(async () => { + activeQuestIntervals.delete(quest.id); + const success = await reportPlayGameQuestProgress(quest, true, QuestifyLogger); + + if (success) { + QuestifyLogger.info(`[${getFormattedNow()}] Quest ${questName} completed.`); + } else { + QuestifyLogger.error(`[${getFormattedNow()}] Failed to complete Quest ${questName}.`); + } + }, (timeRemaining + 1) * 1000); + } + }, heartbeatInterval * 1000); + + const renderIntervalId = setInterval(() => { + const intervalData = activeQuestIntervals.get(quest.id); + + if (!!intervalData) { + intervalData.progress += 1; + } else { + clearInterval(renderIntervalId); + } + + rerenderQuests(); + }, 1000); + + activeQuestIntervals.set(quest.id, { progressTimeout: progressIntervalId, rerenderTimeout: renderIntervalId, progress: initialProgress, duration: questDuration, type: "play" }); + + if (remaining > 0) { + QuestifyLogger.info(`[${getFormattedNow()}] Quest ${questName} will be completed in the background in ${remaining} seconds.`); + } +} + +function processQuestForAutoComplete(quest: Quest): boolean { + const questName = normalizeQuestName(quest.config.messages.questName); + const { completeGameQuestsInBackground, completeVideoQuestsInBackground } = settings.store; + const playType = quest.config.taskConfigV2?.tasks.PLAY_ON_DESKTOP || quest.config.taskConfigV2?.tasks.PLAY_ON_XBOX || quest.config.taskConfigV2?.tasks.PLAY_ON_PLAYSTATION || quest.config.taskConfigV2?.tasks.PLAY_ACTIVITY; + const watchType = quest.config.taskConfigV2?.tasks.WATCH_VIDEO || quest.config.taskConfigV2?.tasks.WATCH_VIDEO_ON_MOBILE; + const questDuration = playType?.target || watchType?.target || 0; + const existingInterval = activeQuestIntervals.get(quest.id); + + if (quest.userStatus?.completedAt || existingInterval) { + return true; + } else if (!playType && !watchType) { + QuestifyLogger.warn(`[${getFormattedNow()}] Could not recognize the quest type for ${questName}.`); + return true; + } else if ((watchType && !completeVideoQuestsInBackground) || (playType && (!completeGameQuestsInBackground || !IS_DISCORD_DESKTOP))) { + return true; + } else if (!questDuration) { + QuestifyLogger.warn(`[${getFormattedNow()}] Could not find duration for quest ${questName}.`); + return true; + } else if (watchType) { + startVideoProgressTracking(quest, questDuration); + return false; + } else if (playType) { + startPlayGameProgressTracking(quest, questDuration); + return false; + } + + return true; // True means continue as normal, false means prevent default action. +} + +function shouldDisableQuestAcceptedButton(quest: Quest): boolean | null { + const { completeGameQuestsInBackground } = settings.store; + + if (activeQuestIntervals.has(quest.id)) { + return true; + } else if (completeGameQuestsInBackground) { + if (!IS_DISCORD_DESKTOP) { + return true; + } else { + return false; + } + } + + return null; +} + +function getQuestAcceptedButtonText(quest: Quest): string | null { + const { completeGameQuestsInBackground, completeVideoQuestsInBackground } = settings.store; + const playType = quest.config.taskConfigV2?.tasks.PLAY_ON_DESKTOP || quest.config.taskConfigV2?.tasks.PLAY_ON_XBOX || quest.config.taskConfigV2?.tasks.PLAY_ON_PLAYSTATION || quest.config.taskConfigV2?.tasks.PLAY_ACTIVITY; + const watchType = quest.config.taskConfigV2?.tasks.WATCH_VIDEO || quest.config.taskConfigV2?.tasks.WATCH_VIDEO_ON_MOBILE; + const taskType = playType || watchType; + const duration = taskType?.target || 0; + const intervalData = activeQuestIntervals.get(quest.id); + const progress = Math.min( + (intervalData?.progress ?? (quest.userStatus?.progress?.[taskType?.type || ""]?.value || 0)), + duration + ); + const timeRemaining = Math.max(0, duration - progress); + const progressFormatted = `${String(Math.floor(timeRemaining / 60)).padStart(2, "0")}:${String(timeRemaining % 60).padStart(2, "0")}`; + + if ((playType && completeGameQuestsInBackground) || (watchType && completeVideoQuestsInBackground)) { + if (!!intervalData) { + return getIntlMessage("QUESTS_VIDEO_WATCH_RESUME_WITH_TIME_CTA", { remainTime: progressFormatted })[0].replace(getIntlMessage("GAME_LIBRARY_UPDATES_ACTION_RESUME"), getIntlMessage(playType ? "USER_ACTIVITY_PLAYING" : "USER_ACTIVITY_WATCHING")); + } else if (watchType || (playType && IS_DISCORD_DESKTOP)) { + return getIntlMessage("QUESTS_VIDEO_WATCH_RESUME_WITH_TIME_CTA", { remainTime: progressFormatted })[0]; + } + } + + return null; +} + +function getActiveQuestClosestToCompletion(): Quest | null { + let closestQuest: Quest | null = null; + let closestTimeRemaining = Infinity; + + activeQuestIntervals.forEach((interval, questId) => { + const quest = QuestsStore.getQuest(questId); + + if (!quest) { + return; + } + + const timeRemaining = interval.duration - interval.progress; + + if (timeRemaining < closestTimeRemaining) { + closestTimeRemaining = timeRemaining; + closestQuest = quest; + } + }); + + return closestQuest; +} + +export default definePlugin({ + name: "Questify", + description: "Enhance your Quest experience with a suite of features, or disable them entirely if they're not your thing.", + authors: [EquicordDevs.Etorix], + dependencies: ["ServerListAPI"], + startAt: StartAt.Init, // Needed in order to beat Read All Messages to inserting above the server list. + settings, + + formatLowerBadge, + getQuestTileStyle, + preprocessQuests, + getQuestTileClasses, + shouldPreloadQuestAssets, + shouldHideQuestPopup, + shouldHideDiscoveryTab, + shouldPreventFetchingQuests, + shouldHideBadgeOnUserProfiles, + shouldHideGiftInventoryRelocationNotice, + shouldHideFriendsListActiveNowPromotion, + shouldDisableQuestAcceptedButton, + getActiveQuestClosestToCompletion, + getQuestAcceptedButtonText, + processQuestForAutoComplete, + activeQuestIntervals, + + patches: [ + { + find: "could not play audio", + group: true, + replacement: [ + { + // Enables external audio sources for playing audio. + match: /(\i\(\d+\)\(".\/".concat\(this.name,".mp3"\)\))/, + replace: "this.name.startsWith('https')?this.name:$1" + }, + { + // Adds an optional callback to the audio player. This is needed to detect + // when the audio has finished playing as playWithListener() relies on a duration + // variable which is never present. + match: /(constructor\(\i,\i,\i,\i)(\){)/, + replace: "$1,callback$2this.callback=callback||null," + }, + { + // Makes use of the callback if provided. + match: /(?<=.onended=\(\)=>)(this.destroyAudio\(\))/, + replace: "{this.callback?this.callback():null;$1;}" + } + ] + }, + { + // Hides the notice in the gift inventory that Quests have been relocated to the Discovery tab. + find: "quests-wumpus-hikes-mountain-transparent-background", + replacement: { + match: /return(\(0,\i.\i\)\("div",{className:)/, + replace: "return $self.shouldHideGiftInventoryRelocationNotice()?null:$1" + } + }, + { + // Hides Quests tab in the Discovery page. + find: "GlobalDiscoverySidebar", + group: true, + replacement: [ + { + match: /(let \i=function\(\){)/, + replace: "$1const shouldHideDiscoveryTab=$self.shouldHideDiscoveryTab();" + }, + { + match: /(GLOBAL_DISCOVERY_TABS).map/, + replace: '$1.filter(tab=>!(tab==="quests"&&shouldHideDiscoveryTab)).map' + } + ] + }, + { + // Hides the Quest badge on user profiles. + find: ".MODAL]:26", + group: true, + replacement: [ + { + match: /(return 0===\i.length\?null:\(0,)/, + replace: "const shouldHideBadgeOnUserProfiles=$self.shouldHideBadgeOnUserProfiles();$1" + }, + { + match: /(badges:\i)/, + replace: '$1.filter(badge=>!(["quest_completed","orb_profile_badge"].includes(badge.id)&&shouldHideBadgeOnUserProfiles))', + } + ] + }, + { + // Hides the new Quest popup above the account panel. + // Allows in-progress quests to still show. + find: "QUESTS_BAR,questId", + replacement: { + match: /return null==(\i)\?null:\(/, + replace: "return !$self.shouldHideQuestPopup($1)&&(" + } + }, + { + // Replaces the default displayed Quest with the soonest to + // be completed Quest which is actively being auto-completed. + find: "questDeliveryOverride)?", + replacement: { + match: /(\i=)(\i.\i.questDeliveryOverride)/, + replace: "$1$self.getActiveQuestClosestToCompletion()??$2" + } + }, + { + // Hides the Friends List "Active Now" promotion. + find: '"application-stream-"', + group: true, + replacement: [ + { + match: /(let{party)/, + replace: "const shouldHideFriendsListActiveNowPromotion=$self.shouldHideFriendsListActiveNowPromotion();$1" + }, + { + match: /(null!=(\i)&&null!=\i&&)/, + replace: "!shouldHideFriendsListActiveNowPromotion&&$1" + } + ] + }, + { + // Prevents fetching Quests. + find: 'type:"QUESTS_FETCH_CURRENT_QUESTS_BEGIN"', + group: true, + replacement: [ + { + // QUESTS_FETCH_CURRENT_QUESTS_BEGIN + match: /(if\(!\i.\i.isFetchingCurrentQuests\))/, + replace: "if($self.shouldPreventFetchingQuests())return;$1" + }, + { + // QUESTS_FETCH_QUEST_TO_DELIVER_BEGIN + match: /(var \i,\i,\i,\i,\i,\i,\i;\i.\i.dispatch\({)/, + replace: "if($self.shouldPreventFetchingQuests())return;$1" + } + ] + }, + { + // Adds a feedback prop to the SearchableSelect component which will display on invalid searches. + find: '"onSearchChange",', + group: true, + replacement: [ + { + // Extracts the custom dropdown prop before the variable is overwritten. + match: /(\((\i),\i\){)(var{options:\i,)/, + replace: "$1const vcDynamicDropdownFeedback=$2.feedback;$3" + }, + { + // Passes the custom prop to the dropdown's invalid handler. + match: /((\i);return\(0,\i.\i\)\(\i,{)(loading:\i,)/, + replace: "$1feedback:vcDynamicDropdownFeedback,$3" + }, + { + // Makes use of the custom prop if provided, otherwise assume default behavior. + match: /(\i.intl.string\(\i.\i#{intl::NO_RESULTS_FOUND}\))/, + replace: "arguments[0]?.feedback??$1" + } + ] + }, + { + // Adds a maxDigits prop to the LowerBadge component which allows for not truncating, or for truncating at a specific threshold. + find: "STATUS_DANGER,disableColor", + group: true, + replacement: [ + { + // Extracts the custom maxDigits prop. + match: /(=>{var{count:\i,)/, + replace: "$1maxDigits," + }, + { + // Passes maxDigits to the rounding function. + match: /(children:\i\(\i)/, + replace: "$1,maxDigits" + }, + { + // Makes use of the custom prop if provided by using custom logic for negatives and truncation. + // If the prop is not provided, assume default behavior for native badges or other plugins not + // utilizing the custom prop. + match: /function (\i\((\i))\){return (.{0,100}?k\+"\))/, + replace: "function $1,maxDigits){return maxDigits===undefined?($3):$self.formatLowerBadge($2,maxDigits)[0]" + } + ] + }, + { + find: "id:\"quest-tile-\".concat", + group: true, + replacement: [ + { + // Restyles quest tiles with colors. + match: /className:(\i\(\)\(\i.container,\i\)),/, + replace: "className:$self.getQuestTileClasses($1,arguments[0].quest),style:$self.getQuestTileStyle(arguments[0].quest)," + }, + { + // Encourages banners to load quicker if the setting is enabled. + match: /(warningHints:\i,)isVisibleInViewport:(\i)/, + replace: "$1isVisibleInViewport:$self.shouldPreloadQuestAssets()?true:$2" + }, + { + // Encourages reward icons to load quicker if the setting is enabled. + match: /(onReceiveErrorHints:\i,)isVisibleInViewport:(\i)/, + replace: "$1isVisibleInViewport:$self.shouldPreloadQuestAssets()?true:$2" + } + ] + }, + { + // Prevents default pinning of specific Quests to the top of the list. + find: "QUEST_HOME_DESKTOP},", + replacement: { + match: /\i.unshift\(\i\):(\i.push\(\i\))/, + replace: "$1:$1" + } + }, + { + // Sorts the "All Quests" tab quest tiles. + // Also sets mobile-only Quests as desktop compatible if the setting is enabled. + find: ".ALL);return(", + replacement: { + match: /(quests:(\i).{0,100}?quests:)\i/, + replace: "$1$self.preprocessQuests($2)" + } + }, + { + // Sorts the "Claimed Quests" tab quest tiles. + find: ".ALL)}):(", + replacement: { + match: /(claimedQuests:(\i).{0,50}?;)/, + replace: "$1$2=$self.preprocessQuests($2);" + } + }, + { + // Whether preloading assets is enabled or not, the placeholders loading + // before the assets causes a lot of element shifting, whereas if + // the elements load immediately instead, it doesn't. + find: "rewardDescriptionContainer,children", + replacement: { + match: /showPlaceholder:!\i/, + replace: "showPlaceholder:false" + } + }, + { + // Sets intervals to progress Video Quests and Play Game Quests in the background. + find: "IN_PROGRESS:if(", + group: true, + replacement: [ + { + // Resume Video Quest + match: /(onClick:\(\)=>)(\(0,\i.openVideoQuestModal\)\({quest:(\i))/, + replace: "$1$self.processQuestForAutoComplete($3)&&$2" + }, + { + // Start Quest + match: /(\(0,\i.\i\)\((\i).id,{questContent:)/, + replace: "const questifyContinue=$self.processQuestForAutoComplete($2);$1" + }, + { + // Open Video Modal + match: /(\i\?)(\(0,\i.openVideoQuestModal\)\({quest:(\i))/, + replace: "$1questifyContinue&&$2" + }, + { + // The "Resume (XX:XX)" text is changed to "Watching (XX:XX)" if the Quest is active. + match: /(if\(\i\)return{text:)/, + replace: "$1$self.getQuestAcceptedButtonText(arguments[0].quest)??", + }, + { + // Add trigger to memo for rerendering the progress label. + match: /(\i.intl.string\(\i.\i#{intl::QUESTS_SEE_CODE}\)}\)}},\[)/, + replace: "$1$self.settings.store.triggerQuestsRerender," + }, + ] + }, + { + // Sets intervals to progress Play Game Quests in the background. + // Triggers if a Quest has already been started but was interrupted, such as by a reload. + find: "platformSelectorPrimary,", + group: true, + replacement: [ + { + // Initial select drop down for picking a platform. + match: /(select:)(\i)(,serialize:\i=>{)/, + replace: "$1(platform)=>{$self.processQuestForAutoComplete(arguments[0].quest),$2(platform)}$3" + }, + { + // The Quest Accepted button is disabled by default. If the user reloads the client, they need a way + // to resume the automatic completion, so patch in optionally enabling it if the feature is enabled. + match: /(START_QUEST_CTA.{0,400}?)(!0)/, + replace: "$1$self.shouldDisableQuestAcceptedButton(arguments[0].quest)??$2" + }, + { + // When the Quest Accepted button which has been enabled again by the above patch is + // clicked, resume the automatic completion of the Quest and disable the button again. + match: /(disabled:\i.\i.\i\[)/, + replace: "onClick:()=>{$self.processQuestForAutoComplete(arguments[0].quest)},$1", + }, + { + // The "Quest Accepted" text is changed to "Resume" if the Quest is in progress but not active. + match: /(\i.intl.string\(\i.\i#{intl::QUEST_ACCEPTED}\))/, + replace: "$self.getQuestAcceptedButtonText(arguments[0].quest)??$1" + } + ] + }, + { + // Sets intervals to progress Play Game Quests in the background. + // Triggers if a Quest has already been started but was interrupted, such as by a reload. + find: "quest-home-platform-select-", + group: true, + replacement: [ + { + // Subsequent select drop down for changing platform to Desktop. + match: /(action:\(\)=>)(\i\(\i.\i.DESKTOP\)),/, + replace: "$1($self.processQuestForAutoComplete(arguments[0].quest),$2)," + }, + { + // Subsequent select drop down for changing platform to Console. + match: /(action:\(\)=>)(\i\(\i.\i.CONSOLE\)),/, + replace: "$1($self.processQuestForAutoComplete(arguments[0].quest),$2)," + }, + ] + } + ], + + contextMenus: { + "quests-entry": QuestTileContextMenu + }, + + flux: { + CHANNEL_SELECT(data) { + settings.store.onQuestsPage = (window.location.pathname === questPath); + }, + + QUESTS_FETCH_CURRENT_QUESTS_SUCCESS(data) { + const source = data.source ? ` [${data.source}]` : ""; + QuestifyLogger.info(`[${getFormattedNow()}] [QUESTS_FETCH_CURRENT_QUESTS_SUCCESS]${source}\n`, data); + validateAndOverwriteIgnoredQuests(undefined, data.quests); + }, + + QUESTS_ENROLL_SUCCESS(data) { + QuestifyLogger.info(`[${getFormattedNow()}] [QUESTS_ENROLL_SUCCESS]\n`, data); + fetchAndDispatchQuests("Questify", QuestifyLogger); + validateAndOverwriteIgnoredQuests(); + }, + + QUESTS_CLAIM_REWARD_SUCCESS(data) { + QuestifyLogger.info(`[${getFormattedNow()}] [QUESTS_CLAIM_REWARD_SUCCESS]\n`, data); + fetchAndDispatchQuests("Questify", QuestifyLogger); + validateAndOverwriteIgnoredQuests(); + }, + + // Stops any Game Quest background completion intervals for running games to prevent duplicate heartbeats. + // This will also update the button text back to "Quest Accepted" from "Resume" if the Quest is in progress. + RUNNING_GAMES_CHANGE(data) { + const gameIDs: string[] = data.games.map(game => game.id); + let shouldRerenderQuests = false; + + (Array.from(QuestsStore.quests.values()) as Quest[]).forEach(quest => { + const questName = normalizeQuestName(quest.config.messages.questName); + const questAppID = quest.config.application.id; + + if (gameIDs.includes(questAppID) && activeQuestIntervals.has(quest.id)) { + const intervalData = activeQuestIntervals.get(quest.id); + clearInterval(intervalData?.progressTimeout); + clearTimeout(intervalData?.rerenderTimeout); + activeQuestIntervals.delete(quest.id); + QuestifyLogger.info(`[${getFormattedNow()}] Application for Quest ${questName} that was being completed in the background has been launched. Stopping background completion to prevent duplicate heartbeats.`); + shouldRerenderQuests = true; + } + }); + + if (shouldRerenderQuests) { + rerenderQuests(); + } + }, + }, + + renderQuestifyButton: ErrorBoundary.wrap(QuestButton, { noop: true }), + + start() { + addServerListElement(ServerListRenderPosition.Above, this.renderQuestifyButton); + const interval = settings.store.fetchingQuestsInterval; + const intervalValid = interval >= minimumAutoFetchIntervalValue && interval <= maximumAutoFetchIntervalValue; + + if (!!intervalValid && autoFetchCompatible()) { + startAutoFetchingQuests(); + } + }, + + stop() { + removeServerListElement(ServerListRenderPosition.Above, this.renderQuestifyButton); + stopAutoFetchingQuests(); + } +}); diff --git a/src/equicordplugins/questify/settings.tsx b/src/equicordplugins/questify/settings.tsx new file mode 100644 index 000000000..b0e7f76f6 --- /dev/null +++ b/src/equicordplugins/questify/settings.tsx @@ -0,0 +1,1669 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { definePluginSettings } from "@api/Settings"; +import { ErrorBoundary } from "@components/index"; +import { Logger } from "@utils/Logger"; +import { OptionType } from "@utils/types"; +import { findLazy } from "@webpack"; +import { Button, ColorPicker, ContextMenuApi, Forms, Menu, Select, TextArea, TextInput, useEffect, useRef, useState } from "@webpack/common"; +import { JSX } from "react"; + +import { activeQuestIntervals, getQuestTileClasses } from "./index"; +import { DynamicDropdown, DynamicDropdownSettingOption, GuildlessServerListItem, Quest, QuestIcon, QuestStatus, QuestTile, RadioGroup, RadioOption, SelectOption, SoundIcon } from "./utils/components"; +import { AudioPlayer, decimalToRGB, fetchAndDispatchQuests, getFormattedNow, getQuestStatus, isDarkish, isSoundAllowed, leftClick, middleClick, normalizeQuestName, q, QuestifyLogger, QuestsStore, rightClick, validCommaSeparatedList } from "./utils/misc"; + +let defaultSounds: string[] | null = null; +let autoFetchInterval: null | ReturnType = null; +const defaultLeftClickAction = "open-quests"; +const defaultMiddleClickAction = "plugin-settings"; +const defaultRightClickAction = "context-menu"; +const defaultQuestButtonDisplay = "always"; +const defaultQuestButtonUnclaimed = "both"; +const defaultQuestOrder = "UNCLAIMED, CLAIMED, IGNORED, EXPIRED"; +const defaultUnclaimedColor = 2842239; +const defaultClaimedColor = 6105983; +const defaultIgnoredColor = 8334124; +const defaultExpiredColor = 2368553; +const defaultRestyleQuestsGradient = "intense"; +const defaultFetchQuestsAlert = "discodo"; +const findDefaultSounds = findLazy(module => module.resolve && module.id && module.keys().some(key => key.endsWith(".mp3"))); +export const minimumAutoFetchIntervalValue = 30 * 60; +export const maximumAutoFetchIntervalValue = 12 * 60 * 60; + +export function rerenderQuests(): void { + settings.store.triggerQuestsRerender = !settings.store.triggerQuestsRerender; +} + +export function fetchAndAlertQuests(source: string, logger: Logger): void { + const currentQuests = Array.from(QuestsStore.quests.values()) as Quest[]; + + fetchAndDispatchQuests(source, logger).then(newQuests => { + if (newQuests !== null && Array.isArray(newQuests) && currentQuests.length > 0) { + const currentIds = new Set(currentQuests.map((q: Quest) => q.id)); + const newOnly = newQuests.filter((q: Quest) => !currentIds.has(q.id)); + + if (newOnly.length > 0) { + const shouldAlert = settings.store.fetchingQuestsAlert; + + if (shouldAlert) { + logger.info(`[${getFormattedNow()}] New quests detected. Playing alert sound.`); + AudioPlayer(shouldAlert, 1).play(); + } else { + logger.info(`[${getFormattedNow()}] New quests detected.`); + } + } + } + }); +} + +function checkAutoFetchInterval(interval: number | null): void { + if (!!interval && autoFetchCompatible()) { + startAutoFetchingQuests(); + } else { + stopAutoFetchingQuests(); + } +} + +export function startAutoFetchingQuests(seconds?: number): void { + if (autoFetchInterval) { + clearInterval(autoFetchInterval); + autoFetchInterval = null; + } + + const interval = seconds ? seconds * 1000 : settings.store.fetchingQuestsInterval * 1000; + QuestifyLogger.info(`[${getFormattedNow()}] Starting AutoFetch of quests every ${(interval / 60000).toFixed(2)} minutes.`); + autoFetchInterval = setInterval(() => { fetchAndAlertQuests("Questify-AutoFetch", QuestifyLogger); }, interval); +} + +export function stopAutoFetchingQuests(): void { + if (autoFetchInterval) { + QuestifyLogger.info(`[${getFormattedNow()}] Stopping AutoFetch of quests.`); + clearInterval(autoFetchInterval); + autoFetchInterval = null; + } +} + +export function autoFetchCompatible(): boolean { + const display = settings.store.questButtonDisplay; + const unclaimed = settings.store.questButtonUnclaimed; + const fetching = !settings.store.disableQuestsEverything && !settings.store.disableQuestsFetchingQuests; + + if (display === "always") { + return fetching && ["pill", "badge", "both"].includes(unclaimed); + } else if (display === "unclaimed") { + return fetching; + } else { + return false; + } +} + +export const intervalScales = { + second: { + singular: "Second", + plural: "Seconds", + multiplier: 1 + }, + minute: { + singular: "Minute", + plural: "Minutes", + multiplier: 60 + }, + hour: { + singular: "Hour", + plural: "Hours", + multiplier: 60 * 60 + }, + day: { + singular: "Day", + plural: "Days", + multiplier: 24 * 60 * 60 + }, + week: { + singular: "Week", + plural: "Weeks", + multiplier: 7 * 24 * 60 * 60 + } +}; + +export function removeIgnoredQuest(questName: string): void { + const ignoredQuests = settings.store.ignoredQuests.split("\n"); + validateAndOverwriteIgnoredQuests(ignoredQuests.filter(name => normalizeQuestName(name) !== normalizeQuestName(questName)).join("\n")); +} + +export function addIgnoredQuest(questName: string): void { + const { ignoredQuests } = settings.store; + validateAndOverwriteIgnoredQuests(ignoredQuests + "\n" + normalizeQuestName(questName)); +} + +export function questIsIgnored(questName: string): boolean { + const ignoredQuests = new Set(settings.store.ignoredQuests.split("\n").map(normalizeQuestName)); + return ignoredQuests.has(normalizeQuestName(questName)); +} + +export function validateIgnoredQuests(ignoredQuests?: string, questsData?: Quest[]): [string, number] { + const quests = questsData ?? Array.from(QuestsStore.quests.values()) as Quest[]; + const currentlyIgnored = new Set((ignoredQuests ?? settings.store.ignoredQuests).split("\n").map(normalizeQuestName)); + const validIgnored = new Set(); + let numUnclaimedUnignoredQuests = 0; + + for (const quest of quests) { + const questStatus = getQuestStatus(quest, false); + const normalizedName = normalizeQuestName(quest.config.messages.questName); + + if (questStatus === QuestStatus.Unclaimed) { + if (currentlyIgnored.has(normalizedName)) { + validIgnored.add(normalizedName); + } else { + numUnclaimedUnignoredQuests++; + } + } + } + + const ignoredStr = Array.from(validIgnored).join("\n"); + return [ignoredStr, numUnclaimedUnignoredQuests]; +} + +export function validateAndOverwriteIgnoredQuests(ignoredQuests?: string, questsData?: Quest[]): string { + const [ignoredStr, numUnclaimedUnignoredQuests] = validateIgnoredQuests(ignoredQuests, questsData); + settings.store.unclaimedUnignoredQuests = numUnclaimedUnignoredQuests; + settings.store.ignoredQuests = ignoredStr; + return ignoredStr; +} + +interface DummyQuestButtonProps { + visible: boolean; + selected: boolean; + showPill: boolean; + showBadge: boolean; + badgeColor: number | null; + leftClickAction: string; + middleClickAction: string; + rightClickAction: string; + onSelectedChange: (selected: boolean) => void; +} + +function DummyQuestButton({ + visible, + selected, + showPill, + showBadge, + badgeColor, + leftClickAction, + middleClickAction, + rightClickAction, + onSelectedChange +}: DummyQuestButtonProps): JSX.Element { + function handleClick(event: React.MouseEvent) { + // ListItem does not support onAuxClick, so we have to listen for mousedown events. + // Ignore left and right clicks sent via mousedown events to prevent double events. + if (event.type === "mousedown" && event.button !== middleClick) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + let todo: string | null = null; + + if (event.button === middleClick) { + todo = middleClickAction; + } else if (event.button === rightClick) { + todo = rightClickAction; + } else if (event.button === leftClick) { + todo = leftClickAction; + } + + if (todo === "open-quests") { + onSelectedChange(!selected); + } else if (todo === "plugin-settings") { + } else if (todo === "context-menu") { + ContextMenuApi.openContextMenu(event, () => ( + + + + + + )); + } + } + + const dummyBadgeColorRGB = badgeColor ? decimalToRGB(badgeColor) : null; + const lowerBadgeProps = { + count: showBadge ? 3 : 0, + maxDigits: 2, + ...(dummyBadgeColorRGB ? { color: `rgb(${dummyBadgeColorRGB.r},${dummyBadgeColorRGB.g},${dummyBadgeColorRGB.b})` } : {}), + ...(dummyBadgeColorRGB ? { style: { color: isDarkish(dummyBadgeColorRGB) ? "white" : "black" } } : {}) + }; + + return ( + + ); +} + +function validateQuestButtonSetting() { + const { + questButtonDisplay, + questButtonUnclaimed, + questButtonBadgeColor, + questButtonLeftClickAction, + questButtonMiddleClickAction, + questButtonRightClickAction + } = settings.use([ + "questButtonDisplay", + "questButtonUnclaimed", + "questButtonBadgeColor", + "questButtonLeftClickAction", + "questButtonMiddleClickAction", + "questButtonRightClickAction" + ]); + + if (!["always", "unclaimed", "never"].includes(questButtonDisplay)) { + settings.store.questButtonDisplay = "always"; + } + + if (!["pill", "badge", "both", "none"].includes(questButtonUnclaimed)) { + settings.store.questButtonUnclaimed = "both"; + } + + if (typeof questButtonBadgeColor !== "number" && questButtonBadgeColor !== null) { + settings.store.questButtonBadgeColor = defaultUnclaimedColor; + } + + if (!["open-quests", "context-menu", "plugin-settings", "nothing"].includes(questButtonLeftClickAction)) { + settings.store.questButtonLeftClickAction = defaultLeftClickAction; + } + + if (!["open-quests", "context-menu", "plugin-settings", "nothing"].includes(questButtonMiddleClickAction)) { + settings.store.questButtonMiddleClickAction = defaultMiddleClickAction; + } + + if (!["open-quests", "context-menu", "plugin-settings", "nothing"].includes(questButtonRightClickAction)) { + settings.store.questButtonRightClickAction = defaultRightClickAction; + } +} + +function validateDisableQuestSetting() { + const { + disableQuestsEverything, + disableQuestsDiscoveryTab, + disableQuestsFetchingQuests, + disableQuestsPopupAboveAccountPanel, + disableQuestsBadgeOnUserProfiles, + disableQuestsGiftInventoryRelocationNotice, + disableFriendsListActiveNowPromotion + } = settings.use([ + "disableQuestsEverything", + "disableQuestsDiscoveryTab", + "disableQuestsFetchingQuests", + "disableQuestsPopupAboveAccountPanel", + "disableQuestsBadgeOnUserProfiles", + "disableQuestsGiftInventoryRelocationNotice", + "disableFriendsListActiveNowPromotion" + ]); + + if (disableQuestsDiscoveryTab || disableQuestsFetchingQuests || disableQuestsPopupAboveAccountPanel || disableQuestsBadgeOnUserProfiles || disableQuestsGiftInventoryRelocationNotice || disableFriendsListActiveNowPromotion) { + settings.store.disableQuestsEverything = false; + } +} + +function QuestButtonSettings(): JSX.Element { + validateQuestButtonSetting(); + + const { + questButtonDisplay, + questButtonUnclaimed, + questButtonBadgeColor, + questButtonLeftClickAction, + questButtonMiddleClickAction, + questButtonRightClickAction + } = settings.use([ + "questButtonDisplay", + "questButtonUnclaimed", + "questButtonBadgeColor", + "questButtonLeftClickAction", + "questButtonMiddleClickAction", + "questButtonRightClickAction" + ]); + + const questButtonDisplayOptions: RadioOption[] = [ + { name: "Always", value: "always" }, + { name: "Unclaimed", value: "unclaimed" }, + { name: "Never", value: "never" } + ]; + + const questButtonUnclaimedOptions: RadioOption[] = [ + { name: "Pill", value: "pill" }, + { name: "Badge", value: "badge" }, + { name: "Both", value: "both" }, + { name: "None", value: "none" } + ]; + + const questButtonClickOptions: SelectOption[] = [ + { label: "Open Quests", value: "open-quests" }, + { label: "Context Menu", value: "context-menu" }, + { label: "Plugin Settings", value: "plugin-settings" }, + { label: "Nothing", value: "nothing" } + ]; + + const [currentQuestButtonDisplay, setCurrentQuestButtonDisplay] = useState((questButtonDisplayOptions.find(option => option.value === questButtonDisplay) as RadioOption)); + const [currentQuestButtonUnclaimed, setCurrentQuestButtonUnclaimed] = useState((questButtonUnclaimedOptions.find(option => option.value === questButtonUnclaimed) as RadioOption)); + const [currentQuestButtonLeftClickAction, setCurrentQuestButtonLeftClickAction] = useState<"open-quests" | "plugin-settings" | "context-menu" | "nothing">(questButtonLeftClickAction as "open-quests" | "plugin-settings" | "context-menu" | "nothing"); + const [currentQuestButtonMiddleClickAction, setCurrentQuestButtonMiddleClickAction] = useState<"open-quests" | "plugin-settings" | "context-menu" | "nothing">(questButtonMiddleClickAction as "open-quests" | "plugin-settings" | "context-menu" | "nothing"); + const [currentQuestButtonRightClickAction, setCurrentQuestButtonRightClickAction] = useState<"open-quests" | "plugin-settings" | "context-menu" | "nothing">(questButtonRightClickAction as "open-quests" | "plugin-settings" | "context-menu" | "nothing"); + const [currentBadgeColor, setCurrentBadgeColor] = useState((questButtonBadgeColor as number | null)); + const [dummySelected, setDummySelected] = useState(false); + + function handleQuestButtonDisplayChange(value: RadioOption) { + setCurrentQuestButtonDisplay(value); + settings.store.questButtonDisplay = value.value; + checkAutoFetchInterval(settings.store.fetchingQuestsInterval); + } + + function handleQuestButtonUnclaimedChange(value: RadioOption) { + setCurrentQuestButtonUnclaimed(value); + settings.store.questButtonUnclaimed = value.value; + checkAutoFetchInterval(settings.store.fetchingQuestsInterval); + } + + function handleBadgeColorChange(value: number | null) { + setCurrentBadgeColor(value); + settings.store.questButtonBadgeColor = value as any; + } + + function handleLeftClickActionChange(value: "open-quests" | "context-menu" | "plugin-settings" | "nothing") { + setCurrentQuestButtonLeftClickAction(value); + settings.store.questButtonLeftClickAction = value; + } + + function handleMiddleClickActionChange(value: "open-quests" | "context-menu" | "plugin-settings" | "nothing") { + setCurrentQuestButtonMiddleClickAction(value); + settings.store.questButtonMiddleClickAction = value; + } + + function handleRightClickActionChange(value: "open-quests" | "context-menu" | "plugin-settings" | "nothing") { + setCurrentQuestButtonRightClickAction(value); + settings.store.questButtonRightClickAction = value; + } + + return ( + + +
+ +
+
+ + Quest Button + + + Show a Quest button in the server list with an optional indicator for unclaimed Quests. + +
+
+ +
+
+
+
+ + Button Visibility + + + + Badge Color + +
+ + + +
+
+
+ + Unclaimed Indicator + + +
+
+
+
+ + Left Click Action + + value === currentQuestButtonMiddleClickAction} + select={handleMiddleClickActionChange} + /> +
+
+ + Right Click Action + +