Equicord/src/equicordplugins/questify/settings.tsx
Etorix 02f3c92e11
Questify: Implement AutoComplete for ACHIEVEMENT_IN_ACTIVITY Quests. (#593)
* Implement AutoComplete for ACHIEVEMENT_IN_ACTIVITY Quests.

* Drop QuestCompleter and QuestFocused.
2025-12-24 16:36:32 -05:00

1963 lines
88 KiB
TypeScript

/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { defaultAudioNames, playAudio } from "@api/AudioPlayer";
import { definePluginSettings } from "@api/Settings";
import { Divider, ErrorBoundary, Heading, Paragraph } from "@components/index";
import { Logger } from "@utils/Logger";
import { makeRange, OptionType } from "@utils/types";
import { Button, ColorPicker, ContextMenuApi, Menu, Select, Slider, TextInput, useEffect, useRef, useState } from "@webpack/common";
import { JSX } from "react";
import { activeQuestIntervals, getQuestTileClasses, getQuestTileStyle } from "./index";
import { DynamicDropdown, DynamicDropdownSettingOption, ExcludedQuest, GuildlessServerListItem, Quest, QuestIcon, QuestRewardType, QuestStatus, QuestTile, RadioGroup, RadioOption, SelectOption, SoundIcon } from "./utils/components";
import { decimalToRGB, fetchAndDispatchQuests, getFormattedNow, getIgnoredQuestIDs, getQuestStatus, isDarkish, isSoundAllowed, leftClick, middleClick, q, QuestifyLogger, QuestsStore, rightClick, setIgnoredQuestIDs, validCommaSeparatedList } from "./utils/misc";
let autoFetchInterval: null | ReturnType<typeof setInterval> = 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";
export const minimumAutoFetchIntervalValue = 30 * 60;
export const maximumAutoFetchIntervalValue = 12 * 60 * 60;
const rerenderCallbacks = new Set<() => void>();
export function addRerenderCallback(callback: () => void): () => void {
rerenderCallbacks.add(callback);
return () => rerenderCallbacks.delete(callback);
}
export function rerenderQuests(): void {
settings.store.triggerQuestsRerender = !settings.store.triggerQuestsRerender;
rerenderCallbacks.forEach(callback => callback());
}
export function fetchAndAlertQuests(source: string, logger: Logger): void {
const { questRewardIncludeRewardCode, questRewardIncludeNitroCode, questRewardIncludeCollectibles, questRewardIncludeInGame, questRewardIncludeOrbs } = settings.store;
const rewardTypeToSettingMap = {
[QuestRewardType.REWARD_CODE]: questRewardIncludeRewardCode,
[QuestRewardType.IN_GAME]: questRewardIncludeInGame,
[QuestRewardType.COLLECTIBLE]: questRewardIncludeCollectibles,
[QuestRewardType.VIRTUAL_CURRENCY]: questRewardIncludeOrbs,
[QuestRewardType.FRACTIONAL_PREMIUM]: questRewardIncludeNitroCode
};
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));
const newOnlyFiltered = newOnly.filter(quest => {
const rewardType = getQuestRewardType(quest);
return rewardType in rewardTypeToSettingMap && rewardTypeToSettingMap[rewardType];
});
if (newOnly.length > 0) {
const shouldAlert = settings.store.fetchingQuestsAlert;
if (shouldAlert && newOnlyFiltered.length > 0) {
logger.info(`[${getFormattedNow()}] New Quests detected. Playing alert sound.`);
playAudio(shouldAlert, { volume: settings.store.fetchingQuestsAlertVolume });
} 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(questID: string): void {
const ignoredQuests = getIgnoredQuestIDs();
const newIgnoredQuests = ignoredQuests.filter(id => id !== questID);
validateAndOverwriteIgnoredQuests(newIgnoredQuests);
}
export function addIgnoredQuest(questID: string): void {
const ignoredQuests = getIgnoredQuestIDs();
const newIgnoredQuests = ignoredQuests.concat(questID);
validateAndOverwriteIgnoredQuests(newIgnoredQuests);
}
export function questIsIgnored(questID: string): boolean {
const ignoredQuests = getIgnoredQuestIDs();
return ignoredQuests.includes(questID);
}
function getQuestRewardType(quest: Quest): QuestRewardType {
const reward = quest.config.rewardsConfig.rewards[0];
if (!(reward.type in QuestRewardType)) return QuestRewardType.UNKNOWN;
return reward.type as QuestRewardType;
}
export function validateIgnoredQuests(ignoredQuests?: string[], questsData?: Quest[]): [string[], number] {
const { questRewardIncludeRewardCode, questRewardIncludeNitroCode, questRewardIncludeCollectibles, questRewardIncludeInGame, questRewardIncludeOrbs } = settings.store;
const rewardTypeToSettingMap = {
[QuestRewardType.REWARD_CODE]: questRewardIncludeRewardCode,
[QuestRewardType.IN_GAME]: questRewardIncludeInGame,
[QuestRewardType.COLLECTIBLE]: questRewardIncludeCollectibles,
[QuestRewardType.VIRTUAL_CURRENCY]: questRewardIncludeOrbs,
[QuestRewardType.FRACTIONAL_PREMIUM]: questRewardIncludeNitroCode
};
const quests = questsData ?? Array.from(QuestsStore.quests.values()) as Quest[];
const excludedQuests = Array.from(QuestsStore.excludedQuests.values()) as ExcludedQuest[];
const currentlyIgnored = ignoredQuests ? new Set(ignoredQuests) : new Set(getIgnoredQuestIDs(questsData?.[0]?.userStatus?.userId));
const validIgnored = new Set<string>();
let numUnclaimedUnignoredQuests = 0;
for (const quest of quests) {
const questStatus = getQuestStatus(quest, false);
if (currentlyIgnored.has(quest.id)) {
validIgnored.add(quest.id);
} else if (questStatus === QuestStatus.Unclaimed) {
const rewardType = getQuestRewardType(quest);
if (rewardTypeToSettingMap[rewardType]) {
numUnclaimedUnignoredQuests++;
}
}
}
for (const quest of excludedQuests) {
if (currentlyIgnored.has(quest.id)) {
validIgnored.add(quest.id);
}
}
return [Array.from(validIgnored), numUnclaimedUnignoredQuests];
}
export function validateAndOverwriteIgnoredQuests(ignoredQuests?: string[], questsData?: Quest[]): string[] {
const [validIgnored, numUnclaimedUnignoredQuests] = validateIgnoredQuests(ignoredQuests, questsData);
settings.store.unclaimedUnignoredQuests = numUnclaimedUnignoredQuests;
setIgnoredQuestIDs(validIgnored, questsData?.[0]?.userStatus?.userId);
rerenderQuests();
return validIgnored;
}
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<Element>) {
// ListItem does not support onAuxClick, so we have to listen for mousedown events.
// Ignore left and right clicks sent via mousedown events to prevent double events.
if (event.type === "mousedown" && event.button !== middleClick) {
return;
}
event.preventDefault();
event.stopPropagation();
let todo: string | null = null;
if (event.button === middleClick) {
todo = 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, () => (
<Menu.Menu
navId={q("dummy-quest-button-context-menu")}
onClose={ContextMenuApi.closeContextMenu}
aria-label="Quest Button Menu"
>
<Menu.MenuItem
id={q("dummy-quest-button-mark-all-ignored")}
label="Mark All Ignored"
disabled={true}
/>
<Menu.MenuItem
id={q("dummy-quest-button-reset-ignored-list")}
label="Reset Ignored List"
disabled={true}
/>
<Menu.MenuItem
id={q("dummy-quest-button-fetch-quests")}
label="Fetch Quests"
disabled={true}
/>
</Menu.Menu>
));
}
}
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 (
<GuildlessServerListItem
id={q("dummy-quest-button")}
className={q("dummy-quest-button", "quest-button")}
icon={QuestIcon(26, 26)}
tooltip="Quests"
showPill={true}
isVisible={visible}
isSelected={selected}
hasUnread={showPill}
lowerBadgeProps={lowerBadgeProps}
onClick={handleClick}
onContextMenu={handleClick}
onMouseDown={handleClick}
/>
);
}
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,
disableQuestsDirectMessagesTab,
disableQuestsPageSponsoredBanner,
disableQuestsPopupAboveAccountPanel,
disableQuestsBadgeOnUserProfiles,
disableQuestsGiftInventoryRelocationNotice,
disableFriendsListActiveNowPromotion,
disableMembersListActivelyPlayingIcon
} = settings.use([
"disableQuestsEverything",
"disableQuestsDiscoveryTab",
"disableQuestsFetchingQuests",
"disableQuestsDirectMessagesTab",
"disableQuestsPageSponsoredBanner",
"disableQuestsPopupAboveAccountPanel",
"disableQuestsBadgeOnUserProfiles",
"disableQuestsGiftInventoryRelocationNotice",
"disableFriendsListActiveNowPromotion",
"disableMembersListActivelyPlayingIcon"
]);
if (disableQuestsDiscoveryTab || disableQuestsDirectMessagesTab || disableQuestsPageSponsoredBanner || disableQuestsFetchingQuests || disableQuestsPopupAboveAccountPanel || disableQuestsBadgeOnUserProfiles || disableQuestsGiftInventoryRelocationNotice || disableFriendsListActiveNowPromotion || disableMembersListActivelyPlayingIcon) {
settings.store.disableQuestsEverything = false;
}
}
function QuestButtonSettings(): JSX.Element {
validateQuestButtonSetting();
const {
questButtonDisplay,
questRewardIncludeRewardCode,
questRewardIncludeNitroCode,
questRewardIncludeCollectibles,
questRewardIncludeInGame,
questRewardIncludeOrbs,
questButtonUnclaimed,
questButtonBadgeColor,
questButtonLeftClickAction,
questButtonMiddleClickAction,
questButtonRightClickAction
} = settings.use([
"questButtonDisplay",
"questRewardIncludeRewardCode",
"questRewardIncludeNitroCode",
"questRewardIncludeCollectibles",
"questRewardIncludeInGame",
"questRewardIncludeOrbs",
"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 questButtonRewardDisplayOptions: DynamicDropdownSettingOption[] = [
{ label: "Orbs", value: "orbs", selected: questRewardIncludeOrbs },
{ label: "Nitro Codes", value: "nitro-code", selected: questRewardIncludeNitroCode },
{ label: "Reward Codes", value: "reward-code", selected: questRewardIncludeRewardCode },
{ label: "In Game Items", value: "in-game", selected: questRewardIncludeInGame },
{ label: "Profile Collectibles", value: "collectibles", selected: questRewardIncludeCollectibles },
];
const [currentRewardsOptions, setCurrentRewardsOptions] = useState(questButtonRewardDisplayOptions.filter(option => option.selected));
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 updateSettingsTruthy(enabled: DynamicDropdownSettingOption[]) {
const enabledValues = enabled.map(option => option.value);
questButtonRewardDisplayOptions.forEach(option => {
option.selected = enabledValues.includes(option.value);
});
settings.store.questRewardIncludeRewardCode = enabledValues.includes("reward-code");
settings.store.questRewardIncludeNitroCode = enabledValues.includes("nitro-code");
settings.store.questRewardIncludeCollectibles = enabledValues.includes("collectibles");
settings.store.questRewardIncludeInGame = enabledValues.includes("in-game");
settings.store.questRewardIncludeOrbs = enabledValues.includes("orbs");
setCurrentRewardsOptions(enabled);
validateAndOverwriteIgnoredQuests();
}
function handleQuestRewardDisplayChange(values: Array<DynamicDropdownSettingOption | string>) {
if (values.length === 0) {
updateSettingsTruthy([]);
return;
}
const stringlessValues = values.filter(v => typeof v !== "string") as DynamicDropdownSettingOption[];
const selectedOption = values.find(v => typeof v === "string") as string;
const option = questButtonRewardDisplayOptions.find(option => option.value === selectedOption) as DynamicDropdownSettingOption;
if (option.selected) {
updateSettingsTruthy(stringlessValues.filter(v => v.value !== selectedOption));
} else {
updateSettingsTruthy([...stringlessValues, option]);
}
}
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 (
<ErrorBoundary>
<Divider className={q("setting-divider")} />
<div className={q("setting", "quest-icon-setting")}>
<section>
<div className={q("main-inline-group")}>
<div>
<Heading className={q("form-title")}>
Quest Button
</Heading>
<Paragraph className={q("form-description")}>
Show a Quest button in the server list with an optional indicator for unclaimed Quests.
</Paragraph>
</div>
<div className={q("dummy-quest-button")}>
<DummyQuestButton
visible={currentQuestButtonDisplay.value !== "never"}
selected={dummySelected}
showPill={currentQuestButtonUnclaimed.value === "pill" || currentQuestButtonUnclaimed.value === "both"}
showBadge={currentQuestButtonUnclaimed.value === "badge" || currentQuestButtonUnclaimed.value === "both"}
badgeColor={currentBadgeColor}
leftClickAction={currentQuestButtonLeftClickAction}
middleClickAction={currentQuestButtonMiddleClickAction}
rightClickAction={currentQuestButtonRightClickAction}
onSelectedChange={setDummySelected}
/>
</div>
</div>
<div className={q("main-inline-group")}>
<div className={q("inline-group-item")}>
<Heading className={q("form-subtitle", "form-subtitle-spacier")}>
Left Click Action
</Heading>
<Select
options={questButtonClickOptions}
className={q("select")}
popoutPosition="top"
serialize={String}
isSelected={(value: string) => value === currentQuestButtonLeftClickAction}
select={handleLeftClickActionChange}
/>
</div>
<div className={q("inline-group-item")}>
<Heading className={q("form-subtitle", "form-subtitle-spacier")}>
Middle Click Action
</Heading>
<Select
options={questButtonClickOptions}
className={q("select")}
popoutPosition="top"
serialize={String}
isSelected={(value: string) => value === currentQuestButtonMiddleClickAction}
select={handleMiddleClickActionChange}
/>
</div>
<div className={q("inline-group-item")}>
<Heading className={q("form-subtitle", "form-subtitle-spacier")}>
Right Click Action
</Heading>
<Select
options={questButtonClickOptions}
className={q("select")}
popoutPosition="top"
serialize={String}
isSelected={(value: string) => value === currentQuestButtonRightClickAction}
select={handleRightClickActionChange}
/>
</div>
</div>
<div className={q("main-inline-group")}>
<div className={q("inline-group-item")}>
<Heading className={q("form-subtitle")}>
Button Visibility
</Heading>
<RadioGroup
value={(currentQuestButtonDisplay as any).value}
options={questButtonDisplayOptions}
onChange={handleQuestButtonDisplayChange}
/>
<Heading className={q("form-subtitle")}>
Badge Color
</Heading>
<div className={q("sub-inline-group")}>
<ColorPicker
color={currentBadgeColor}
onChange={handleBadgeColorChange}
showEyeDropper={true}
/>
<Button
className={q("button", "button-blue")}
onClick={() => handleBadgeColorChange(defaultUnclaimedColor)}
>
Default
</Button>
<Button
className={q("button", "button-red")}
onClick={() => handleBadgeColorChange(null)}
>
Disable
</Button>
</div>
</div>
<div className={q("inline-group-item")}>
<Heading className={q("form-subtitle")}>
Unclaimed Indicator
</Heading>
<RadioGroup
value={(currentQuestButtonUnclaimed as any).value}
options={questButtonUnclaimedOptions}
onChange={handleQuestButtonUnclaimedChange}
/>
</div>
</div>
<div className={q("main-inline-group")}>
<section>
<Heading className={q("form-subtitle", "form-subtitle-spacier")}>
Included Reward Types
</Heading>
<Paragraph className={q("form-description")}>
Only count Quests with these reward types as unclaimed when determining button
visibility, badge count, and when playing the alert sound.
</Paragraph>
<DynamicDropdown
placeholder="Select which reward types to include in the unclaimed count..."
feedback="There's no supported Quest feature by that name."
className={q("select")}
maxVisibleItems={questButtonRewardDisplayOptions.length}
clearable={true}
multi={true}
value={currentRewardsOptions as any}
options={questButtonRewardDisplayOptions}
onChange={handleQuestRewardDisplayChange}
closeOnSelect={false}
>
</DynamicDropdown>
</section>
</div>
</section>
</div>
</ErrorBoundary>
);
}
function DisableQuestsSetting(): JSX.Element {
validateDisableQuestSetting();
const {
disableQuestsEverything,
disableQuestsDiscoveryTab,
disableQuestsFetchingQuests,
disableQuestsDirectMessagesTab,
disableQuestsPageSponsoredBanner,
disableQuestsPopupAboveAccountPanel,
disableQuestsBadgeOnUserProfiles,
disableQuestsGiftInventoryRelocationNotice,
disableFriendsListActiveNowPromotion,
disableMembersListActivelyPlayingIcon,
makeMobileQuestsDesktopCompatible,
completeVideoQuestsInBackground,
completeGameQuestsInBackground,
completeAchievementQuestsInBackground,
notifyOnQuestComplete
} = settings.use([
"disableQuestsEverything",
"disableQuestsDiscoveryTab",
"disableQuestsFetchingQuests",
"disableQuestsDirectMessagesTab",
"disableQuestsPageSponsoredBanner",
"disableQuestsPopupAboveAccountPanel",
"disableQuestsBadgeOnUserProfiles",
"disableQuestsGiftInventoryRelocationNotice",
"disableFriendsListActiveNowPromotion",
"disableMembersListActivelyPlayingIcon",
"makeMobileQuestsDesktopCompatible",
"completeVideoQuestsInBackground",
"completeGameQuestsInBackground",
"completeAchievementQuestsInBackground",
"notifyOnQuestComplete",
]);
const options: DynamicDropdownSettingOption[] = [
{ label: "Disable Everything", value: "everything", selected: disableQuestsEverything, type: "disable" },
{ label: "Disable Fetching Quests", value: "fetching", selected: disableQuestsFetchingQuests, type: "disable" },
{ label: "Disable Quests Tab in DMs", value: "dms", selected: disableQuestsDirectMessagesTab, type: "disable" },
{ label: "Disable Badge on User Profiles", value: "badge", selected: disableQuestsBadgeOnUserProfiles, type: "disable" },
{ label: "Disable Popup Above User Panel", value: "popup", selected: disableQuestsPopupAboveAccountPanel, type: "disable" },
{ label: "Disable Discovery Tab Relocation Notice", value: "discovery", selected: disableQuestsDiscoveryTab, type: "disable" },
{ label: "Disable Gift Inventory Relocation Notice", value: "inventory", selected: disableQuestsGiftInventoryRelocationNotice, type: "disable" },
{ label: "Disable Sponsored Banner on Quests Page", value: "sponsored-banner", selected: disableQuestsPageSponsoredBanner, type: "disable" },
{ label: "Disable Friends List Active Now Promotion", value: "friends-list", selected: disableFriendsListActiveNowPromotion, type: "disable" },
{ label: "Disable Members List Actively Playing Icon", value: "members-list", selected: disableMembersListActivelyPlayingIcon, type: "disable" },
{ label: "Complete Watch Video Quests in Background", value: "video-quests-background", selected: completeVideoQuestsInBackground, type: "modification" },
{ label: "Complete Play Game/Activity Quests in Background", value: "game-quests-background", selected: completeGameQuestsInBackground, type: "modification" },
{ label: "Complete Task in Activity Quests in Background", value: "achievement-quests-background", selected: completeAchievementQuestsInBackground, type: "modification" },
{ label: "Make Mobile Quests Desktop Compatible", value: "mobile-desktop-compatible", selected: makeMobileQuestsDesktopCompatible, type: "modification" },
{ label: "Notify on Auto-Complete", value: "notify-on-complete", selected: notifyOnQuestComplete, type: "modification" },
];
const disableOptions = options.filter(option => option.type === "disable");
const modificationOptions = options.filter(option => option.type === "modification");
const everythingOnly = options.find(option => option.value === "everything") as DynamicDropdownSettingOption;
const [currentValue, setCurrentValue] = useState(options.filter(option => option.selected));
function updateSettingsTruthy(enabled: DynamicDropdownSettingOption[]) {
const enabledValues = enabled.map(option => option.value);
options.forEach(option => {
option.selected = enabledValues.includes(option.value);
});
const redoAutoFetch = (
settings.store.disableQuestsEverything !== enabledValues.includes("everything") ||
settings.store.disableQuestsFetchingQuests !== enabledValues.includes("fetching")
);
settings.store.disableQuestsEverything = enabledValues.includes("everything");
settings.store.disableQuestsDiscoveryTab = enabledValues.includes("discovery");
settings.store.disableQuestsFetchingQuests = enabledValues.includes("fetching");
settings.store.disableQuestsDirectMessagesTab = enabledValues.includes("dms");
settings.store.disableQuestsPageSponsoredBanner = enabledValues.includes("sponsored-banner");
settings.store.disableQuestsPopupAboveAccountPanel = enabledValues.includes("popup");
settings.store.disableQuestsBadgeOnUserProfiles = enabledValues.includes("badge");
settings.store.disableQuestsGiftInventoryRelocationNotice = enabledValues.includes("inventory");
settings.store.disableFriendsListActiveNowPromotion = enabledValues.includes("friends-list");
settings.store.disableMembersListActivelyPlayingIcon = enabledValues.includes("members-list");
settings.store.makeMobileQuestsDesktopCompatible = enabledValues.includes("mobile-desktop-compatible");
settings.store.completeVideoQuestsInBackground = enabledValues.includes("video-quests-background");
settings.store.completeGameQuestsInBackground = enabledValues.includes("game-quests-background");
settings.store.completeAchievementQuestsInBackground = enabledValues.includes("achievement-quests-background");
settings.store.notifyOnQuestComplete = enabledValues.includes("notify-on-complete");
redoAutoFetch ? checkAutoFetchInterval(settings.store.fetchingQuestsInterval) : null;
setCurrentValue(enabled);
}
function handleChange(values: Array<DynamicDropdownSettingOption | string>) {
if (values.length === 0) {
updateSettingsTruthy([]);
return;
}
const stringlessValues = values.filter(v => typeof v !== "string") as DynamicDropdownSettingOption[];
const selectedOption = values.find(v => typeof v === "string") as string;
if (selectedOption === "everything") {
if (everythingOnly.selected) { // If was already selected when clicked.
updateSettingsTruthy([...stringlessValues.filter(option => option.type !== "disable")]);
} else {
updateSettingsTruthy([...stringlessValues.filter(option => option.type !== "disable"), everythingOnly]);
}
} else if (disableOptions.some(option => option.value === selectedOption)) {
const option = disableOptions.find(option => option.value === selectedOption) as DynamicDropdownSettingOption;
if (option.selected) { // If was already selected when clicked.
updateSettingsTruthy(stringlessValues.filter(option => option.value !== selectedOption && option.value !== everythingOnly.value));
} else {
updateSettingsTruthy([...stringlessValues.filter(option => option.value !== everythingOnly.value), option]);
}
} else if (modificationOptions.some(option => option.value === selectedOption)) {
const option = modificationOptions.find(option => option.value === selectedOption) as DynamicDropdownSettingOption;
if (option.selected) { // If was already selected when clicked.
updateSettingsTruthy(stringlessValues.filter(option => option.value !== selectedOption));
} else {
updateSettingsTruthy([...stringlessValues, option]);
}
}
}
return (
<ErrorBoundary>
<Divider className={q("setting-divider")} />
<div className={q("setting", "disable-quests-setting")}>
<section>
<Heading className={q("form-title")}>
Quest Features
</Heading>
<Paragraph className={q("form-description")}>
Modify specific Quest features.
<br /><br />
The <span className={q("inline-code-block")}>Disable Quest Popup Above Account Panel</span> option
will be ignored for completed Quests and Quest progress tracking.
<br /><br />
The <span className={q("inline-code-block")}>Complete Video Quests in Background</span> option
will wait for the duration of the Video Quest and mark it as completed automatically.
<br /><br />
Similarly, the <span className={q("inline-code-block")}>Complete Play Game/Activity Quests in Background</span> option
will wait for the duration of the Game Quest and mark it as completed automatically. This option is only supported
on the official desktop client.
<br /><br />
The <span className={q("inline-code-block")}>Complete Task in Activity Quests in Background</span> option
will immediately mark Task in Activity Quests as completed when started.
<br /><br />
You still must start the Quests manually. The first click will start the Quests in the background.
For Video Quests, subsequent clicks will open the video modal as normal. For Activity Quests, subsequent clicks
will open the activity as normal. To abort the Quests, you can open the context menu on the Quest tile and
select <span className={q("inline-code-block")}>Stop Auto-Complete</span>.
<br /><br />
Using any of the Auto-Complete options is against Discord's TOS. Use at your own risk.
</Paragraph>
<DynamicDropdown
placeholder="Select which Quest features to modify."
feedback="There's no supported Quest feature by that name."
className={q("select")}
maxVisibleItems={options.length}
clearable={true}
multi={true}
value={currentValue as any}
options={options}
onChange={handleChange}
closeOnSelect={false}
>
</DynamicDropdown>
</section>
</div>
</ErrorBoundary>
);
}
const DummyQuestPreview = ({ quest, dummyColor, dummyGradient }: { quest: Quest; dummyColor: number | null; dummyGradient: string; }) => {
const classes = getQuestTileClasses("", quest, dummyColor, dummyGradient);
return (
<QuestTile
className={[q("dummy-quest"), classes].join(" ")}
quest={quest}
/>
);
};
function RestyleQuestsSetting() {
const {
restyleQuestsUnclaimed,
restyleQuestsClaimed,
restyleQuestsIgnored,
restyleQuestsExpired,
restyleQuestsGradient,
restyleQuestsPreload
} = settings.use([
"restyleQuestsUnclaimed",
"restyleQuestsClaimed",
"restyleQuestsIgnored",
"restyleQuestsExpired",
"restyleQuestsGradient",
"restyleQuestsPreload",
]);
const [unclaimedColor, setUnclaimedColor] = useState<number | null>(restyleQuestsUnclaimed);
const [claimedColor, setClaimedColor] = useState<number | null>(restyleQuestsClaimed);
const [ignoredColor, setIgnoredColor] = useState<number | null>(restyleQuestsIgnored);
const [expiredColor, setExpiredColor] = useState<number | null>(restyleQuestsExpired);
const [restyleQuestsGradientValue, setRestyleQuestsGradientValue] = useState(restyleQuestsGradient);
const [restyleQuestsPreloadValue, setRestyleQuestsPreloadValue] = useState(restyleQuestsPreload);
const [dummyColor, setDummyColor] = useState<number | null>(restyleQuestsUnclaimed);
const [dummyGradient, setDummyGradient] = useState(restyleQuestsGradient);
const [hasQuests, setHasQuests] = useState(false);
const [dummyQuest, setDummyQuest] = useState<Quest | null>(null);
const dummyQuestStyle = getQuestTileStyle(dummyQuest);
useEffect(() => {
const handleChange = () => {
if (QuestsStore.quests.size > 0) {
if (!dummyQuest) {
const questArray = Array.from(QuestsStore.quests.values()) as Quest[];
const questIndex = Math.floor(Math.random() * questArray.length);
const questData = structuredClone(questArray[questIndex]);
questData.dummyColor = dummyColor as any;
setDummyQuest(questData);
} else if (dummyQuest) {
const updatedQuest = structuredClone(dummyQuest);
updatedQuest.dummyColor = dummyColor as any;
setDummyQuest(updatedQuest);
}
setHasQuests(true);
} else {
setHasQuests(false);
setDummyQuest(null);
}
};
QuestsStore.addChangeListener(handleChange);
handleChange();
return () => QuestsStore.removeChangeListener(handleChange);
}, [dummyColor]);
function handleRestyleChange(colorIndex: number, newColorValue: number | null) {
if (colorIndex === 0) setUnclaimedColor(newColorValue);
if (colorIndex === 1) setClaimedColor(newColorValue);
if (colorIndex === 2) setIgnoredColor(newColorValue);
if (colorIndex === 3) setExpiredColor(newColorValue);
setDummyColor(newColorValue);
settings.store.restyleQuestsUnclaimed = colorIndex === 0 ? newColorValue : unclaimedColor as any;
settings.store.restyleQuestsClaimed = colorIndex === 1 ? newColorValue : claimedColor as any;
settings.store.restyleQuestsIgnored = colorIndex === 2 ? newColorValue : ignoredColor as any;
settings.store.restyleQuestsExpired = colorIndex === 3 ? newColorValue : expiredColor as any;
}
function handleGradientChange(value: "intense" | "default" | "black" | "hide") {
setDummyGradient(value);
setRestyleQuestsGradientValue(value);
settings.store.restyleQuestsGradient = value;
}
function handlePreloadChange(value: boolean) {
setRestyleQuestsPreloadValue(value);
settings.store.restyleQuestsPreload = value;
}
const colorPickers = [
{
label: "Unclaimed",
idx: 0,
defaultValue: defaultUnclaimedColor,
value: unclaimedColor
},
{
label: "Claimed",
idx: 1,
defaultValue: defaultClaimedColor,
value: claimedColor
},
{
label: "Ignored",
idx: 2,
defaultValue: defaultIgnoredColor,
value: ignoredColor
},
{
label: "Expired",
idx: 3,
defaultValue: defaultExpiredColor,
value: expiredColor
}
];
const gradientOptions = [
{ label: "Intense Restyle Gradient", value: "intense" },
{ label: "Default Restyle Gradient", value: "default" },
{ label: "Subtle Black Gradient", value: "black" },
{ label: "No Gradient", value: "hide" }
];
const preloadOptions = [
{ label: "Load All Quest Assets On Page Load", value: true },
{ label: "Load Quest Assets During Page Scroll", value: false }
];
return (
<ErrorBoundary>
<Divider className={q("setting-divider")} />
<div className={q("setting", "restyle-quests-setting")}>
<section>
<div>
<Heading className={q("form-title")}>
Restyle Quests
</Heading>
<Paragraph className={q("form-description")}>
Highlight Quests with optional theme colors for visibility.
<br /><br />
Claimed and Expired Quest styles will take precedence even if a Quest is ignored.
</Paragraph>
</div>
<div className={q("main-inline-group")}>
<div className={q("gradient-setting-group", "inline-group-item", "flex-35")}>
<Heading className={q("form-subtitle")}>
Gradient Style
</Heading>
<Select
options={gradientOptions}
className={q("select")}
popoutPosition="top"
serialize={String}
isSelected={(value: string) => value === restyleQuestsGradientValue}
select={handleGradientChange}
/>
</div>
<div className={q("preload-setting-group", "inline-group-item", "flex-65")}>
<Heading className={q("form-subtitle")}>
Asset Preload
</Heading>
<Select
options={preloadOptions}
className={q("select")}
popoutPosition="top"
serialize={String}
isSelected={(value: boolean) => value === restyleQuestsPreloadValue}
select={handlePreloadChange}
/>
</div>
</div>
<div className={q("color-picker-container")}>
{colorPickers.map(({ label, idx, defaultValue, value }) => (
<div
key={label}
className={q("inline-group-item", "color-picker-group")}
>
<Heading className={q("form-subtitle")}>
{label}
</Heading>
<div className={q("color-picker-with-buttons")}>
<ColorPicker
color={value}
onChange={newValue => handleRestyleChange(idx, newValue)}
showEyeDropper={true}
/>
<div className={q("sub-inline-group")}>
<Button
className={q("button", "wide-button", "button-blue")}
onClick={() => handleRestyleChange(idx, defaultValue)}
>
Default
</Button>
<Button
className={q("button", "wide-button", "button-red")}
onClick={() => handleRestyleChange(idx, null)}
>
Disable
</Button>
</div>
</div>
</div>
))}
</div>
<div className={q("dummy-quest-preview")} style={dummyQuestStyle}>
{hasQuests && dummyQuest && (
<DummyQuestPreview quest={dummyQuest} dummyColor={dummyColor} dummyGradient={dummyGradient} />
)}
</div>
</section>
</div>
</ErrorBoundary>
);
}
function ReorderQuestsSetting(): JSX.Element {
const [reorderQuests, setReorderQuests] = useState(settings.store.reorderQuests);
const {
unclaimedSubsort,
claimedSubsort,
ignoredSubsort,
expiredSubsort,
ignoredQuestProfile,
rememberQuestPageSort,
rememberQuestPageFilters
} = settings.use([
"unclaimedSubsort",
"claimedSubsort",
"ignoredSubsort",
"expiredSubsort",
"ignoredQuestProfile",
"rememberQuestPageSort",
"rememberQuestPageFilters"
]);
const getSubsortOptions = (source: string): SelectOption[] => {
const baseOptions = [
{ label: "Added (Newest)", value: "Recent DESC" },
{ label: "Added (Oldest)", value: "Recent ASC" }
];
if (source === "expired") {
baseOptions.push(
{ label: "Expired (Most Recent)", value: "Expiring DESC" },
{ label: "Expired (Least Recent)", value: "Expiring ASC" }
);
} else if (source !== "claimed") {
baseOptions.push(
{ label: "Expiring (Soonest)", value: "Expiring ASC" },
{ label: "Expiring (Latest)", value: "Expiring DESC" }
);
}
if (source === "claimed") {
baseOptions.push(
{ label: "Claimed (Most Recent)", value: "Claimed DESC" },
{ label: "Claimed (Least Recent)", value: "Claimed ASC" }
);
}
return baseOptions;
};
return (
<ErrorBoundary>
<Divider className={q("setting-divider")} />
<div className={q("setting", "reorder-quests-setting")}>
<section>
<div>
<Heading className={q("form-title")}>
Reorder Quests
</Heading>
<Paragraph className={q("form-description")}>
Sort Quests by their status. Applied when the "Questify" sort option is selected on the Quest page.
<br /><br />
Comma-separated list must contain all of: <span className={q("inline-code-block")}>UNCLAIMED, CLAIMED, IGNORED, EXPIRED</span>.
</Paragraph>
</div>
<div>
<TextInput
inputClassName={q("text-input")}
value={reorderQuests}
onChange={newValue => {
const trimmedValue = newValue.toUpperCase();
const isValid = validCommaSeparatedList(trimmedValue, ["UNCLAIMED", "CLAIMED", "IGNORED", "EXPIRED"], true, true, true, false);
setReorderQuests(trimmedValue);
if (isValid) {
const cleaned = trimmedValue
.split(",")
.map(item => item.trim())
.join(", ");
settings.store.reorderQuests = cleaned;
}
}}
placeholder="You must include all of UNCLAIMED, CLAIMED, IGNORED, EXPIRED"
error={
validCommaSeparatedList(reorderQuests, ["UNCLAIMED", "CLAIMED", "IGNORED", "EXPIRED"], false, true, true, false)
? undefined
: "Invalid format."
}
>
</TextInput>
</div>
<div className={q("main-inline-group")}>
<div className={q("inline-group-item")}>
<Heading className={q("form-subtitle")}>
Unclaimed Subsort
</Heading>
<Select
options={getSubsortOptions("unclaimed")}
className={q("select")}
popoutPosition="bottom"
serialize={String}
isSelected={(value: string) => value === unclaimedSubsort}
select={(value: string) => {
settings.store.unclaimedSubsort = value;
}}
/>
</div>
<div className={q("inline-group-item")}>
<Heading className={q("form-subtitle")}>
Claimed Subsort
</Heading>
<Select
options={getSubsortOptions("claimed")}
className={q("select")}
popoutPosition="bottom"
serialize={String}
isSelected={(value: string) => value === claimedSubsort}
select={(value: string) => {
settings.store.claimedSubsort = value;
}}
/>
</div>
</div>
<div className={q("main-inline-group")}>
<div className={q("inline-group-item")}>
<Heading className={q("form-subtitle")}>
Ignored Subsort
</Heading>
<Select
options={getSubsortOptions("ignored")}
className={q("select")}
popoutPosition="bottom"
serialize={String}
isSelected={(value: string) => value === ignoredSubsort}
select={(value: string) => {
settings.store.ignoredSubsort = value;
}}
/>
</div>
<div className={q("inline-group-item")}>
<Heading className={q("form-subtitle")}>
Expired Subsort
</Heading>
<Select
options={getSubsortOptions("expired")}
className={q("select")}
popoutPosition="bottom"
serialize={String}
isSelected={(value: string) => value === expiredSubsort}
select={(value: string) => {
settings.store.expiredSubsort = value;
}}
/>
</div>
</div>
<div className={q("main-inline-group")}>
<div className={q("inline-group-item")}>
<Heading className={q("form-subtitle")}>
Ignored Quest Profile
</Heading>
<Select
options={[
{ label: "Shared: All accounts on this client share ignores.", value: "shared" },
{ label: "Private: All accounts on this client have separate ignores.", value: "private" }
]}
className={q("select")}
popoutPosition="bottom"
serialize={String}
isSelected={(value: string) => value === ignoredQuestProfile}
select={(value: string) => { settings.store.ignoredQuestProfile = value; }}
/>
</div>
</div>
<div className={q("main-inline-group")}>
<div className={q("inline-group-item")}>
<Heading className={q("form-subtitle")}>
Remember Sort Choice
</Heading>
<Select
options={[
{ label: "Yes", value: true },
{ label: "No", value: false }
]}
className={q("select")}
popoutPosition="bottom"
serialize={String}
isSelected={(value: boolean) => value === rememberQuestPageSort}
select={(value: boolean) => { settings.store.rememberQuestPageSort = value; }}
/>
</div>
<div className={q("inline-group-item")}>
<Heading className={q("form-subtitle")}>
Remember Filter Choice
</Heading>
<Select
options={[
{ label: "Yes", value: true },
{ label: "No", value: false }
]}
className={q("select")}
popoutPosition="bottom"
serialize={String}
isSelected={(value: boolean) => value === rememberQuestPageFilters}
select={(value: boolean) => { settings.store.rememberQuestPageFilters = value; }}
/>
</div>
</div>
<div className={q("main-inline-group")}>
<Paragraph className={q("form-description")}>
This sort and filter choice refers to the built-in sort and filter options on the Quest page.
The custom sorting above is only applied when the "Questify" sort option is selected on the Quest page.
If remembering is disabled, the sort or filter options will be reset each time you open the Quest page.
</Paragraph>
</div>
</section>
</div>
</ErrorBoundary>
);
}
function FetchingQuestsSetting(): JSX.Element {
const {
fetchingQuestsInterval,
fetchingQuestsAlert
} = settings.use([
"fetchingQuestsInterval",
"fetchingQuestsAlert"
]);
const allowedScales: Record<string, { singular: string; plural: string; multiplier: number; }> = {
minutes: { singular: "Minute", plural: "Minutes", multiplier: 60 },
hours: { singular: "Hour", plural: "Hours", multiplier: 60 * 60 },
};
const resolvedIntervals: SelectOption[] = [
{ value: 0, label: "Disabled" },
{ value: 60 * 30, label: "30 Minutes" },
{ value: 60 * 60, label: "1 Hour" },
{ value: 60 * 60 * 2, label: "3 Hours" },
{ value: 60 * 60 * 4, label: "6 Hours" },
{ value: 60 * 60 * 6, label: "12 Hours" },
];
const resolvedSounds: SelectOption[] = defaultAudioNames().map(sound => {
const label = sound.toUpperCase().replace(/_/g, " ").replace(/(\d+)/g, " $1");
return { value: sound, label };
});
function createIntervalSelectOptionFromValue(value: number): SelectOption {
const existingOption = resolvedIntervals.find(option => option.value === value);
if (existingOption) { return existingOption; }
const relevantScales = Object.entries(allowedScales).filter(([_, scale]) => { return value >= scale.multiplier; });
const largestScale = relevantScales[relevantScales.length - 1]?.[1];
const valueInScale = Math.ceil((value / largestScale.multiplier) * 100) / 100;
const label = valueInScale === 1 ? largestScale.singular : largestScale.plural;
return {
value: value,
label: `${valueInScale} ${label}`
};
}
function createAlertSelectOptionFromValue(value: string): SelectOption | null {
if (value === null) {
return null;
}
const existingOption = resolvedSounds.find(option => option.value === value);
if (existingOption) {
return existingOption;
}
const filename = value
.split("/")
.pop()
?.split("?")[0]
?.split(".")[0]
?.replace(/_/g, " ")
.replace(/\w\S*/g, word =>
word.toUpperCase()
) || "CUSTOM SOUND";
return {
value: value,
label: filename
};
}
function getAllIntervalOptions(currentValue: SelectOption) {
const otherOptions = resolvedIntervals.filter(option => option.value !== currentValue.value);
return [
currentValue,
...otherOptions
].sort((a, b) => Number(a.value) - Number(b.value));
}
function getAllAlertOptions(currentValue: SelectOption | null) {
const otherOptions = currentValue ? resolvedSounds.filter(option => option.value !== currentValue.value) : resolvedSounds;
if (!currentValue) {
return otherOptions.sort((a, b) => a.label.localeCompare(b.label));
}
return [
currentValue,
...otherOptions
].sort((a, b) => a.label.localeCompare(b.label));
}
function handleScaleSearchChange(searchValue: string) {
if (!searchValue.trim()) {
// Show all options for empty searches.
setCurrentIntervalOptions(getAllIntervalOptions(currentIntervalSelection));
return;
}
const isDisabledTerm = resolvedIntervals.find(option => option.value === 0 && option.label.toLowerCase().startsWith(searchValue.toLowerCase()));
if (isDisabledTerm) {
// Show the dedicated "disabled" option if it matches the search term.
setCurrentIntervalOptions([isDisabledTerm]);
return;
}
const match = searchValue.match(/^\s*(\d+\.?(?:\d+)?)\s*([a-zA-Z]+)?\s*$/i);
if (!match) {
// If the input can't be parsed, show feedback message.
setCurrentIntervalOptions([]);
return;
}
const num = Number(match[1]);
const scaleName = match[2]?.toLowerCase();
const resolvedScale = Object.keys(allowedScales).filter(key => allowedScales[key].singular.toLowerCase().startsWith(scaleName) || allowedScales[key].plural.toLowerCase().startsWith(scaleName))[0];
if (isNaN(num)) {
// If the input isn't valid, show feedback message.
setCurrentIntervalOptions([]);
return;
}
const options: SelectOption[] = [];
if (!resolvedScale) {
for (const scale of Object.values(intervalScales)) {
// Try each allowed scale to see if the value fits within the min/max range.
const valueInSeconds = Math.ceil((num * scale.multiplier) * 100) / 100;
if (valueInSeconds >= minimumAutoFetchIntervalValue && valueInSeconds <= maximumAutoFetchIntervalValue) {
options.push(createIntervalSelectOptionFromValue(valueInSeconds));
}
}
} else {
// If a specific scale was provided, use it to create the option.
const scale = allowedScales[resolvedScale];
const valueInSeconds = Math.ceil((num * scale.multiplier) * 100) / 100;
if (valueInSeconds >= minimumAutoFetchIntervalValue && valueInSeconds <= maximumAutoFetchIntervalValue) {
options.push(createIntervalSelectOptionFromValue(valueInSeconds));
}
}
setCurrentIntervalOptions(options);
}
function handleAlertSearchChange(searchValue: string) {
if (!searchValue.trim()) {
// Show all options for empty searches.
setCurrentAlertOptions(getAllAlertOptions(currentAlertSelection));
return;
}
const isCustomSound = searchValue.startsWith("http://") || searchValue.startsWith("https://");
const customSoundOption = createAlertSelectOptionFromValue(searchValue);
if (isCustomSound) {
isSoundAllowed(searchValue).then(allowed => {
if (allowed) {
// If the input is a custom sound URL, show it as the only option.
setCurrentAlertOptions([customSoundOption as SelectOption]);
} else {
// If the input is a custom sound URL but not allowed, show feedback message.
setCurrentAlertOptions([]);
}
});
return;
}
const matches = resolvedSounds.filter(option => option.label.toLowerCase().replace(/\s+/, "").includes(searchValue.toLowerCase().replace(/\s+/, "")));
if (matches) {
// If a matching sound is found, show it as the only option.
setCurrentAlertOptions(matches);
return;
}
// If no match is found, show feedback message.
setCurrentAlertOptions([]);
}
const resolvedIntervalValue = fetchingQuestsInterval;
const [currentIntervalSelection, setCurrentSelection] = useState(createIntervalSelectOptionFromValue(resolvedIntervalValue));
const [currentIntervalOptions, setCurrentIntervalOptions] = useState(getAllIntervalOptions(currentIntervalSelection));
const resolvedAlertValue = fetchingQuestsAlert;
const [currentAlertSelection, setCurrentAlertSelection] = useState<SelectOption | null>(createAlertSelectOptionFromValue(resolvedAlertValue));
const [currentAlertOptions, setCurrentAlertOptions] = useState(getAllAlertOptions(currentAlertSelection));
// Needed to update the playing state of the preview button.
const [isPlaying, setIsPlaying] = useState<boolean>(false);
// Needed to stop audio output on settings close mid-preview.
const activePlayer = useRef<any>(null);
function clearActivePlayer() {
activePlayer.current?.stop();
activePlayer.current = null;
setIsPlaying(false);
}
useEffect(() => {
return () => {
clearActivePlayer();
};
}, []);
return (
<ErrorBoundary>
<Divider className={q("setting-divider")} />
<div className={q("setting", "fetching-quests-setting")}>
<section>
<div>
<Heading className={q("form-title")}>
Fetching Quests
</Heading>
<Paragraph className={q("form-description")}>
Configure how often to fetch Quests from Discord and set up alerts for new Quests.
<br /><br />
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.
<br /><br />
This relies on the Quest Button being enabled and set to either <span className={q("inline-code-block")}>Unclaimed</span>, or set to <span className={q("inline-code-block")}>Always</span> with
unclaimed <span className={q("inline-code-block")}>Pill</span>, <span className={q("inline-code-block")}>Badge</span>, or <span className={q("inline-code-block")}>Both</span> indicators enabled. Otherwise, there is no reason to periodically fetch Quests.
<br /><br />
Also, if <span className={q("inline-code-block")}>Fetching Quests</span> is blocked in the <span className={q("inline-code-block")}>Quest Features</span> setting, this will not work.
</Paragraph>
</div>
<div>
<div>
<Heading className={q("form-subtitle", "form-subtitle-spacier")}>
Fetch Interval
</Heading>
</div>
<div>
<DynamicDropdown
filter={(options, query) => options}
placeholder="Select or type an interval between 30 minutes and 12 hours."
feedback="Intervals must be between 30 minutes and 12 hours."
className={q("select")}
maxVisibleItems={resolvedIntervals.length + 1}
clearable={false}
multi={false}
value={currentIntervalSelection as any}
options={currentIntervalOptions}
closeOnSelect={true}
onSearchChange={handleScaleSearchChange}
onClose={() => { setCurrentIntervalOptions(getAllIntervalOptions(currentIntervalSelection)); }}
onChange={value => {
const option = currentIntervalOptions.find(o => o.value === value) as SelectOption;
settings.store.fetchingQuestsInterval = option.value as number;
setCurrentSelection(option);
setCurrentIntervalOptions(getAllIntervalOptions(option));
checkAutoFetchInterval(option.value as number);
}}
/>
</div>
</div>
<div>
<div>
<Heading className={q("form-subtitle", "form-subtitle-spacier")}>
Alert Sound
</Heading>
</div>
<div className={q("sub-inline-group")}>
<div className={q("inline-group-item")}>
<DynamicDropdown
filter={(options, query) => options}
placeholder="Select a sound or provide a custom sound URL."
feedback="Sound not found, or URL is not from a supported domain."
className={q("select")}
clearable={true}
multi={false}
maxVisibleItems={7}
value={currentAlertSelection as any}
options={currentAlertOptions}
closeOnSelect={false}
onSearchChange={handleAlertSearchChange}
onClose={() => { setCurrentAlertOptions(getAllAlertOptions(currentAlertSelection)); }}
onChange={value => {
const option = currentAlertOptions.find(o => o.value === value) as SelectOption;
settings.store.fetchingQuestsAlert = value ? option.value as string : null as any;
setCurrentAlertSelection(value ? option : null);
setCurrentAlertOptions(getAllAlertOptions(option));
}}
/>
</div>
<div
className={q("inline-group-item", "alert-icon", { "playing-audio": !!isPlaying })}
onClick={() => {
if (currentAlertSelection?.value) {
if (activePlayer.current) {
clearActivePlayer();
} else {
activePlayer.current = playAudio(currentAlertSelection.value as string, { onEnded: clearActivePlayer, volume: settings.store.fetchingQuestsAlertVolume });
setIsPlaying(true);
}
}
}}
style={{ cursor: currentAlertSelection && currentAlertSelection.value ? "pointer" : "default" }}
>
{SoundIcon(24, 24)}
</div>
</div>
<div className={q("sub-inline-group")}>
<div className={q("inline-group-item", "volume-slider-container")}>
<Slider
markers={makeRange(0, 100, 10)}
initialValue={settings.store.fetchingQuestsAlertVolume}
onValueChange={val => { settings.store.fetchingQuestsAlertVolume = val; }}
className={q("volume-slider")}
/>
</div>
</div>
</div>
</section>
</div>
</ErrorBoundary>
);
}
export const settings = definePluginSettings({
disableQuests: {
type: OptionType.COMPONENT,
component: DisableQuestsSetting,
description: "Select which Quest features to disable."
},
disableQuestsEverything: {
type: OptionType.BOOLEAN,
description: "Disable all Quest features.",
default: false,
hidden: true
},
disableQuestsFetchingQuests: {
type: OptionType.BOOLEAN,
description: "Disable fetching Quests from Discord.",
default: false,
hidden: true
},
disableQuestsDiscoveryTab: {
type: OptionType.BOOLEAN,
description: "Disable Quest tab in the Discovery page.",
default: false,
hidden: true
},
disableQuestsDirectMessagesTab: {
type: OptionType.BOOLEAN,
description: "Disable Quest tab in Direct Messages.",
default: false,
hidden: true
},
disableQuestsPageSponsoredBanner: {
type: OptionType.BOOLEAN,
description: "Disable the sponsored banner on the Quest page.",
default: false,
hidden: true
},
disableQuestsPopupAboveAccountPanel: {
type: OptionType.BOOLEAN,
description: "Disable the Quest popup above your account panel.",
default: true,
hidden: true
},
disableQuestsBadgeOnUserProfiles: {
type: OptionType.BOOLEAN,
description: "Disable the Quest badge on user profiles.",
default: false,
hidden: true
},
disableQuestsGiftInventoryRelocationNotice: {
type: OptionType.BOOLEAN,
description: "Disable the gift inventory Quest relocation notice.",
default: true,
hidden: true
},
disableFriendsListActiveNowPromotion: {
type: OptionType.BOOLEAN,
description: "Disable the promotion of Quests for games played by friends.",
default: true,
hidden: true
},
disableMembersListActivelyPlayingIcon: {
type: OptionType.BOOLEAN,
description: "Disable the actively playing icon in members list items.",
default: true,
hidden: true
},
makeMobileQuestsDesktopCompatible: {
type: OptionType.BOOLEAN,
description: "Make mobile-only Quests compatible with desktop.",
default: true,
hidden: true
},
completeVideoQuestsInBackground: {
type: OptionType.BOOLEAN,
description: "Complete Video Quests in the background after the video duration has passed.",
default: false,
hidden: true,
onChange: (value: boolean) => {
if (!value) {
activeQuestIntervals.forEach((interval, questId) => {
if (interval.type === "watch") {
clearTimeout(interval.progressTimeout);
clearTimeout(interval.rerenderTimeout);
activeQuestIntervals.delete(questId);
}
});
}
rerenderQuests();
}
},
completeGameQuestsInBackground: {
type: OptionType.BOOLEAN,
description: "Complete Game Quests in the background after the game duration has passed.",
default: false,
hidden: true,
onChange: (value: boolean) => {
if (!value) {
activeQuestIntervals.forEach((interval, questId) => {
if (interval.type === "play") {
clearTimeout(interval.progressTimeout);
clearTimeout(interval.rerenderTimeout);
activeQuestIntervals.delete(questId);
}
});
}
rerenderQuests();
},
},
completeAchievementQuestsInBackground: {
type: OptionType.BOOLEAN,
description: "Complete Achievement in Activity Quests in the background.",
default: false,
hidden: true,
onChange: (value: boolean) => {
if (!value) {
activeQuestIntervals.forEach((interval, questId) => {
if (interval.type === "achievement") {
clearTimeout(interval.progressTimeout);
clearTimeout(interval.rerenderTimeout);
activeQuestIntervals.delete(questId);
}
});
}
rerenderQuests();
}
},
notifyOnQuestComplete: {
type: OptionType.BOOLEAN,
description: "Show a notification when a Quest is auto-completed.",
default: true,
hidden: true
},
questButton: {
type: OptionType.COMPONENT,
component: QuestButtonSettings,
description: "Show a Quest button in the server list."
},
questButtonDisplay: {
type: OptionType.STRING,
description: "Which display type to use for the Quest button in the server list.",
default: defaultQuestButtonDisplay, // "always", "unclaimed", "never"
hidden: true,
},
questRewardIncludeRewardCode: {
type: OptionType.BOOLEAN,
description: "Include Quests with Reward Codes when displaying Quest counts.",
default: true,
hidden: true,
},
questRewardIncludeNitroCode: {
type: OptionType.BOOLEAN,
description: "Include Quests with Nitro Codes when displaying Quest counts.",
default: true,
hidden: true,
},
questRewardIncludeInGame: {
type: OptionType.BOOLEAN,
description: "Include Quests with In-Game rewards when displaying Quest counts.",
default: true,
hidden: true,
},
questRewardIncludeCollectibles: {
type: OptionType.BOOLEAN,
description: "Include Quests with Collectibles when displaying Quest counts.",
default: true,
hidden: true,
},
questRewardIncludeOrbs: {
type: OptionType.BOOLEAN,
description: "Include Quests with Orbs when displaying Quest counts.",
default: true,
hidden: true,
},
questButtonUnclaimed: {
type: OptionType.STRING,
description: "Which display type to use for the unclaimed indicator on the Quest button in the server list.",
default: defaultQuestButtonUnclaimed, // "pill", "badge", "both", "none"
hidden: true,
},
questButtonBadgeColor: {
type: OptionType.NUMBER | OptionType.CUSTOM,
description: "The color of the Quest button badge in the server list.",
default: defaultUnclaimedColor, // Decimal, null (Discord Default)
hidden: true
},
questButtonLeftClickAction: {
type: OptionType.STRING,
description: "The action to perform when left-clicking the Quest button in the server list.",
default: defaultLeftClickAction, // "open-quests", "context-menu", "plugin-settings", "nothing"
hidden: true
},
questButtonMiddleClickAction: {
type: OptionType.STRING,
description: "The action to perform when middle-clicking the Quest button in the server list.",
default: defaultMiddleClickAction, // "open-quests", "context-menu", "plugin-settings", "nothing"
hidden: true
},
questButtonRightClickAction: {
type: OptionType.STRING,
description: "The action to perform when right-clicking the Quest button in the server list.",
default: defaultRightClickAction, // "open-quests", "context-menu", "plugin-settings", "nothing"
hidden: true
},
fetchingQuests: {
type: OptionType.COMPONENT,
component: FetchingQuestsSetting,
description: "Fetch Quests from Discord."
},
fetchingQuestsInterval: {
type: OptionType.NUMBER | OptionType.CUSTOM,
description: "The interval in seconds to fetch Quests from Discord.",
default: 2700, // Digit >= 0, null (Disabled)
hidden: true
},
fetchingQuestsAlert: {
type: OptionType.STRING | OptionType.CUSTOM,
description: "The sound to play when new Quests are detected.",
default: defaultFetchQuestsAlert, // Item from predefined list or a URL to CSP valid audio file.
hidden: true
},
fetchingQuestsAlertVolume: {
type: OptionType.NUMBER,
description: "The volume for the new Quest alert sound.",
default: 100, // 0 - 100
hidden: true
},
restyleQuests: {
type: OptionType.COMPONENT,
component: RestyleQuestsSetting,
description: "Customize the appearance of Quest tiles in the Quests page."
},
restyleQuestsUnclaimed: {
type: OptionType.NUMBER | OptionType.CUSTOM,
description: "The color of unclaimed Quest tiles in the Quests page.",
default: defaultUnclaimedColor, // Decimal, null (Discord Default)
hidden: true
},
restyleQuestsClaimed: {
type: OptionType.NUMBER | OptionType.CUSTOM,
description: "The color of claimed Quest tiles in the Quests page.",
default: defaultClaimedColor, // Decimal, null (Discord Default)
hidden: true
},
restyleQuestsIgnored: {
type: OptionType.NUMBER | OptionType.CUSTOM,
description: "The color of ignored Quest tiles in the Quests page.",
default: defaultIgnoredColor, // Decimal, null (Discord Default)
hidden: true
},
restyleQuestsExpired: {
type: OptionType.NUMBER | OptionType.CUSTOM,
description: "The color of expired Quest tiles in the Quests page.",
default: defaultExpiredColor, // Decimal, null (Discord Default)
hidden: true
},
restyleQuestsGradient: {
type: OptionType.STRING,
description: "Style of the gradient used in the Quest tiles.",
default: defaultRestyleQuestsGradient, // "intense", "default", "black", "hide"
hidden: true
},
restyleQuestsPreload: {
type: OptionType.BOOLEAN,
description: "Attempt to preload the assets for the Quest tiles.",
default: true,
hidden: true
},
reorderQuests: {
type: OptionType.COMPONENT,
description: "Sort Quests by their status. Leave empty for default sorting. Comma-separated list must contain all of: UNCLAIMED, CLAIMED, IGNORED, EXPIRED.",
default: defaultQuestOrder,
component: ReorderQuestsSetting,
},
unclaimedSubsort: {
type: OptionType.STRING,
description: "Subsort method for unclaimed Quests.",
default: "Expiring ASC", // "Recent ASC", "Recent DESC", "Expiring ASC", "Expiring DESC"
hidden: true
},
claimedSubsort: {
type: OptionType.STRING,
description: "Subsort method for claimed Quests.",
default: "Claimed DESC", // "Recent ASC", "Recent DESC", "Claimed ASC", "Claimed DESC"
hidden: true
},
ignoredSubsort: {
type: OptionType.STRING,
description: "Subsort method for ignored Quests.",
default: "Recent DESC", // "Recent ASC", "Recent DESC", "Expiring ASC", "Expiring DESC"
hidden: true
},
expiredSubsort: {
type: OptionType.STRING,
description: "Subsort method for expired Quests.",
default: "Expiring DESC", // "Recent ASC", "Recent DESC", "Expiring ASC", "Expiring DESC"
hidden: true
},
unclaimedUnignoredQuests: {
type: OptionType.NUMBER,
description: "Tracks the number of unclaimed and unignored Quests.",
default: 0, // Digit >= 0
hidden: true
},
onQuestsPage: {
type: OptionType.BOOLEAN,
description: "Whether the user is currently on the Quests page.",
default: false,
hidden: true
},
triggerQuestsRerender: {
type: OptionType.BOOLEAN,
description: "Trigger a rerender of the Quests page by toggling this setting.",
default: false,
hidden: true
},
ignoredQuestProfile: {
type: OptionType.STRING,
description: "The profile used for ignored Quests.",
default: "private", // "shared", "private"
hidden: true
},
rememberQuestPageSort: {
type: OptionType.BOOLEAN,
description: "Remember the last used sort on the Quests page.",
default: true,
hidden: true
},
rememberQuestPageFilters: {
type: OptionType.BOOLEAN,
description: "Remember the last used filters on the Quests page.",
default: true,
hidden: true
},
lastQuestPageSort: {
type: OptionType.STRING,
description: "Remember the last used sort on the Quests page.",
default: "questify" as string, // sort key
hidden: true
},
lastQuestPageFilters: {
type: OptionType.CUSTOM,
description: "Remember the last used filters on the Quests page.",
default: {} as { [filter: string]: { group: string, filter: string; }; }, // Array of filters
hidden: true
},
ignoredQuestIDs: {
type: OptionType.CUSTOM,
description: "An array of Quest IDs that are ignored.",
default: {} as Record<string, string[]>,
hidden: true,
}
});