Questify Rewrite. (#1047)
* Rewrite. * Remove some as any casts. * Attempt fix for undefined. * Attempt fix for undefined. * Circular Deps Issue. * Circular Deps Issue. * Startup Fixes. * Startup Fixes. * Startup Fixes. * Use module import rather than lazy finds. * Button patch syntax. * Mobile Video Quest Semantics. * Button Semantics. * ErrorBoundary. * Comment. * Fix Deauth URL. * Config Structure. * Various Fixes & Additions. * Some more stuff. * Loosen Button Restrictions. * Better Labeling.
This commit is contained in:
parent
8f5b2cc5d0
commit
f0c25cec1a
29 changed files with 6611 additions and 5025 deletions
|
|
@ -7,7 +7,7 @@
|
|||
import { BaseText } from "@components/BaseText";
|
||||
import { ChannelTabsProps, closeTab, ensureUnreadFallbackCountsLoaded, getNotificationDotState, getUnreadFallbackCounts, isTabSelected, moveDraggedTabs, moveToTab, openedTabs, settings, updateUnreadFallbackCounts } from "@equicordplugins/channelTabs/util";
|
||||
import { ActivityIcon, CircleQuestionIcon, DiscoveryIcon, EnvelopeIcon, FriendsIcon, ICYMIIcon, NitroIcon, QuestIcon, ShopIcon } from "@equicordplugins/channelTabs/util/icons";
|
||||
import { activeQuestIntervals } from "@equicordplugins/questify"; // sorry murphy!
|
||||
import { getActiveAutoCompletes } from "@equicordplugins/questify/utils/completion";
|
||||
import { classNameFactory } from "@utils/css";
|
||||
import { getGuildAcronym, getIntlMessage, getUniqueUsername } from "@utils/discord";
|
||||
import { classes } from "@utils/misc";
|
||||
|
|
@ -467,20 +467,19 @@ export default function ChannelTab(props: ChannelTabsProps & { index: number; })
|
|||
}), []);
|
||||
drag(drop(ref));
|
||||
|
||||
// check if quests running (questify momentLet)
|
||||
const hasActiveQuests = activeQuestIntervals.size > 0;
|
||||
const hasActiveQuests = getActiveAutoCompletes().length > 0;
|
||||
return <div
|
||||
className={cl("tab", {
|
||||
"tab-compact": compact,
|
||||
"tab-selected": isTabSelected(id),
|
||||
"tab-entering": isEntering,
|
||||
"tab-closing": isClosing,
|
||||
"tab-dragging": isDragging,
|
||||
"tab-drop-target": isDropTarget,
|
||||
"tab-nitro": channelId === "__nitro__",
|
||||
"tab-quests-active": channelId === "__quests__" && hasActiveQuests,
|
||||
wider: settings.store.widerTabsAndBookmarks
|
||||
})}
|
||||
"tab-compact": compact,
|
||||
"tab-selected": isTabSelected(id),
|
||||
"tab-entering": isEntering,
|
||||
"tab-closing": isClosing,
|
||||
"tab-dragging": isDragging,
|
||||
"tab-drop-target": isDropTarget,
|
||||
"tab-nitro": channelId === "__nitro__",
|
||||
"tab-quests-active": channelId === "__quests__" && hasActiveQuests,
|
||||
wider: settings.store.widerTabsAndBookmarks
|
||||
})}
|
||||
key={index}
|
||||
ref={ref}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
|
|
|
|||
368
src/equicordplugins/questify/components/questButton.tsx
Normal file
368
src/equicordplugins/questify/components/questButton.tsx
Normal file
|
|
@ -0,0 +1,368 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { plugins } from "@api/PluginManager";
|
||||
import { ErrorBoundary } from "@components/index";
|
||||
import { findComponentByCodeLazy } from "@webpack";
|
||||
import { ContextMenuApi, Menu, NavigationRouter, useState } from "@webpack/common";
|
||||
import type { CSSProperties, JSX, MouseEvent } from "react";
|
||||
|
||||
import { initialQuestDataFetched } from "..";
|
||||
import type { QuestButtonAction, QuestButtonDisplayMode, QuestButtonIndicatorMode } from "../settings/def";
|
||||
import { getIgnoredQuestIDs, ignoreAllQuests, resetIgnoredQuests } from "../settings/ignoredQuests";
|
||||
import { settings } from "../settings/store";
|
||||
import { fetchAndAlertQuests } from "../utils/fetching";
|
||||
import { decimalToRGB, formatLowerBadge, isDarkish, leftClick, middleClick, q, QUEST_PAGE, rightClick } from "../utils/ui";
|
||||
import { openQuestifySettingsModal } from "./settingsModal";
|
||||
|
||||
const GuildlessServerListItemComponent = findComponentByCodeLazy("tooltip:", "lowerBadgeSize:");
|
||||
const ServerListItemPillComponent = findComponentByCodeLazy('"pill":"empty"');
|
||||
const ServerListItemLowerBadgeComponent = findComponentByCodeLazy("BADGE_NOTIFICATION_BACKGROUND", "let{count:");
|
||||
|
||||
interface QuestButtonLowerBadgeProps {
|
||||
count: number;
|
||||
color?: string;
|
||||
style?: CSSProperties;
|
||||
maxDigits?: number;
|
||||
}
|
||||
|
||||
interface QuestButtonViewProps {
|
||||
id: string;
|
||||
className: string;
|
||||
badgeProps: QuestButtonLowerBadgeProps;
|
||||
hasUnread: boolean;
|
||||
selected: boolean;
|
||||
visible: boolean;
|
||||
onPress: (event: MouseEvent<Element>) => void;
|
||||
}
|
||||
|
||||
export function QuestIcon(height: number, width: number, className?: string): JSX.Element {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
height={height}
|
||||
width={width}
|
||||
fill="none"
|
||||
className={className}
|
||||
>
|
||||
<path fill="currentColor" d="M7.5 21.7a8.95 8.95 0 0 1 9 0 1 1 0 0 0 1-1.73c-.6-.35-1.24-.64-1.9-.87.54-.3 1.05-.65 1.52-1.07a3.98 3.98 0 0 0 5.49-1.8.77.77 0 0 0-.24-.95 3.98 3.98 0 0 0-2.02-.76A4 4 0 0 0 23 10.47a.76.76 0 0 0-.71-.71 4.06 4.06 0 0 0-1.6.22 3.99 3.99 0 0 0 .54-5.35.77.77 0 0 0-.95-.24c-.75.36-1.37.95-1.77 1.67V6a4 4 0 0 0-4.9-3.9.77.77 0 0 0-.6.72 4 4 0 0 0 3.7 4.17c.89 1.3 1.3 2.95 1.3 4.51 0 3.66-2.75 6.5-6 6.5s-6-2.84-6-6.5c0-1.56.41-3.21 1.3-4.51A4 4 0 0 0 11 2.82a.77.77 0 0 0-.6-.72 4.01 4.01 0 0 0-4.9 3.96A4.02 4.02 0 0 0 3.73 4.4a.77.77 0 0 0-.95.24 3.98 3.98 0 0 0 .55 5.35 4 4 0 0 0-1.6-.22.76.76 0 0 0-.72.71l-.01.28a4 4 0 0 0 2.65 3.77c-.75.06-1.45.33-2.02.76-.3.22-.4.62-.24.95a4 4 0 0 0 5.49 1.8c.47.42.98.78 1.53 1.07-.67.23-1.3.52-1.91.87a1 1 0 1 0 1 1.73Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function splitClassNames(className: string): string[] {
|
||||
const classNames = className.split(/\s+/).filter(Boolean);
|
||||
|
||||
return classNames.length ? classNames : [q("quest-button")];
|
||||
}
|
||||
|
||||
function suffixClassNames(classNames: string[], suffix: string): string {
|
||||
return classNames.map(className => `${className}-${suffix}`).join(" ");
|
||||
}
|
||||
|
||||
function QuestButtonLowerBadge(props: QuestButtonLowerBadgeProps & { className: string; }): JSX.Element {
|
||||
return <ServerListItemLowerBadgeComponent {...props} />;
|
||||
}
|
||||
|
||||
function QuestButtonView({
|
||||
id,
|
||||
className,
|
||||
badgeProps,
|
||||
hasUnread,
|
||||
selected,
|
||||
visible,
|
||||
onPress,
|
||||
}: QuestButtonViewProps): JSX.Element {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const baseClasses = splitClassNames(className);
|
||||
const mainContainerClass = suffixClassNames(baseClasses, "container");
|
||||
const iconContainerClass = suffixClassNames(baseClasses, "icon-container");
|
||||
const buttonContainerClass = suffixClassNames(baseClasses, "server-list-button-container");
|
||||
const pillContainerClass = suffixClassNames(baseClasses, "pill-container");
|
||||
const buttonClass = suffixClassNames(baseClasses, "button");
|
||||
const pillClass = suffixClassNames(baseClasses, "pill");
|
||||
const lowerBadgeClass = suffixClassNames(baseClasses, "lower-badge");
|
||||
const lowerBadgeSize = { width: formatLowerBadge(badgeProps.count, badgeProps.maxDigits)[1] };
|
||||
const lowerBadge = badgeProps.count === 0
|
||||
? null
|
||||
: (
|
||||
<QuestButtonLowerBadge
|
||||
{...badgeProps}
|
||||
className={lowerBadgeClass}
|
||||
style={{ ...(badgeProps.style ?? {}), ...lowerBadgeSize }}
|
||||
/>
|
||||
);
|
||||
const icon = (
|
||||
<div className={iconContainerClass}>
|
||||
{QuestIcon(26, 26)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
{visible && (
|
||||
<div id={id} className={mainContainerClass}>
|
||||
<div className={pillContainerClass}>
|
||||
<ServerListItemPillComponent
|
||||
unread={hasUnread}
|
||||
selected={selected}
|
||||
hovered={hovered}
|
||||
className={`${pillClass}${selected ? " selected" : hovered ? " hovered" : ""}`}
|
||||
/>
|
||||
</div>
|
||||
<div className={buttonContainerClass}>
|
||||
<GuildlessServerListItemComponent
|
||||
icon={() => icon}
|
||||
tooltip="Quests"
|
||||
showPill={false}
|
||||
selected={selected}
|
||||
className={buttonClass}
|
||||
onClick={onPress}
|
||||
onMouseDown={onPress}
|
||||
onContextMenu={onPress}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
{...(lowerBadge ? { lowerBadge, lowerBadgeSize } : {})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
export function showQuestButton(displayMode: QuestButtonDisplayMode, badgeCount: number, isOnQuestsPage: boolean): boolean {
|
||||
return displayMode !== "never" && (displayMode === "always" || badgeCount > 0 || isOnQuestsPage);
|
||||
}
|
||||
|
||||
export function disguiseHomeButton(pathname: string): boolean {
|
||||
const buttonSettings = settings.use(["questButtonDisplay", "questButtonBadgeCount", "isOnQuestsPage"]);
|
||||
|
||||
if (!showQuestButton(buttonSettings.questButtonDisplay, buttonSettings.questButtonBadgeCount, buttonSettings.isOnQuestsPage)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return pathname === QUEST_PAGE;
|
||||
}
|
||||
|
||||
function resolveQuestButtonAction(
|
||||
event: MouseEvent<Element>,
|
||||
leftAction: QuestButtonAction,
|
||||
middleAction: QuestButtonAction,
|
||||
rightAction: QuestButtonAction,
|
||||
): QuestButtonAction | null {
|
||||
if (event.button === middleClick) {
|
||||
return middleAction;
|
||||
}
|
||||
|
||||
if (event.button === rightClick) {
|
||||
return rightAction;
|
||||
}
|
||||
|
||||
if (event.button === leftClick) {
|
||||
return leftAction;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getQuestButtonLowerBadgeProps(
|
||||
badgeCount: number,
|
||||
indicatorMode: QuestButtonIndicatorMode,
|
||||
badgeColor: number | null,
|
||||
): QuestButtonLowerBadgeProps {
|
||||
const resolvedColor = badgeColor === null ? null : decimalToRGB(badgeColor);
|
||||
|
||||
return {
|
||||
count: indicatorMode === "badge" || indicatorMode === "both" ? badgeCount : 0,
|
||||
maxDigits: 2,
|
||||
...(resolvedColor
|
||||
? { color: `rgb(${resolvedColor.r}, ${resolvedColor.g}, ${resolvedColor.b})` }
|
||||
: {}),
|
||||
...(resolvedColor
|
||||
? { style: { color: isDarkish(resolvedColor) ? "white" : "black" } }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function QuestButtonContextMenu({ dummy = false }: { dummy?: boolean; }): JSX.Element {
|
||||
const navId = q(dummy ? "dummy-quest-button-context-menu" : "quest-button-context-menu");
|
||||
const markAllIgnoredDisabled = dummy || settings.store.questButtonBadgeCount <= 0;
|
||||
const resetIgnoredDisabled = dummy || getIgnoredQuestIDs().length <= 0;
|
||||
const fetchQuestsDisabled = dummy;
|
||||
|
||||
return (
|
||||
<Menu.Menu
|
||||
navId={navId}
|
||||
onClose={ContextMenuApi.closeContextMenu}
|
||||
aria-label="Quest Button Menu"
|
||||
>
|
||||
<Menu.MenuItem
|
||||
id={q(`${navId}-mark-all-ignored`)}
|
||||
label="Mark All Ignored"
|
||||
action={ignoreAllQuests}
|
||||
disabled={markAllIgnoredDisabled}
|
||||
/>
|
||||
<Menu.MenuItem
|
||||
id={q(`${navId}-reset-ignored-list`)}
|
||||
label="Reset Ignored List"
|
||||
action={resetIgnoredQuests}
|
||||
disabled={resetIgnoredDisabled}
|
||||
/>
|
||||
<Menu.MenuItem
|
||||
id={q(`${navId}-fetch-quests`)}
|
||||
label="Fetch Quests"
|
||||
action={() => { void fetchAndAlertQuests("MANUAL_FETCH"); }}
|
||||
disabled={fetchQuestsDisabled}
|
||||
/>
|
||||
</Menu.Menu>
|
||||
);
|
||||
}
|
||||
|
||||
export function QuestButton(): JSX.Element {
|
||||
const {
|
||||
isOnQuestsPage,
|
||||
questButtonBadgeColor,
|
||||
questButtonDisplay,
|
||||
questButtonBadgeCount,
|
||||
questButtonLeftClickAction,
|
||||
questButtonMiddleClickAction,
|
||||
questButtonRightClickAction,
|
||||
questButtonIndicator,
|
||||
} = settings.use([
|
||||
"isOnQuestsPage",
|
||||
"questButtonBadgeColor",
|
||||
"questButtonDisplay",
|
||||
"questButtonBadgeCount",
|
||||
"questButtonLeftClickAction",
|
||||
"questButtonMiddleClickAction",
|
||||
"questButtonRightClickAction",
|
||||
"questButtonIndicator",
|
||||
]);
|
||||
|
||||
const staleData = !initialQuestDataFetched;
|
||||
const badgeColor = questButtonBadgeColor;
|
||||
const badgeCount = staleData ? 0 : questButtonBadgeCount;
|
||||
const onQuestsPage = staleData ? false : isOnQuestsPage;
|
||||
const displayMode = questButtonDisplay as QuestButtonDisplayMode;
|
||||
const leftClickAction = questButtonLeftClickAction as QuestButtonAction;
|
||||
const middleClickAction = questButtonMiddleClickAction as QuestButtonAction;
|
||||
const rightClickAction = questButtonRightClickAction as QuestButtonAction;
|
||||
const indicatorMode = questButtonIndicator as QuestButtonIndicatorMode;
|
||||
|
||||
const lowerBadgeProps = getQuestButtonLowerBadgeProps(badgeCount, indicatorMode, badgeColor);
|
||||
const hasUnread = indicatorMode === "pill" || indicatorMode === "both";
|
||||
const isVisible = showQuestButton(displayMode, badgeCount, onQuestsPage);
|
||||
|
||||
function handlePress(event: MouseEvent<Element>): void {
|
||||
// List items do not support onAuxClick, so middle-click is handled on mousedown.
|
||||
if (event.type === "mousedown" && event.button !== middleClick) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const action = resolveQuestButtonAction(
|
||||
event,
|
||||
leftClickAction,
|
||||
middleClickAction,
|
||||
rightClickAction,
|
||||
);
|
||||
|
||||
if (action === "open-quests") {
|
||||
NavigationRouter.transitionTo(QUEST_PAGE);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "plugin-settings") {
|
||||
openQuestifySettingsModal(plugins.Questify);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "context-menu") {
|
||||
ContextMenuApi.openContextMenu(event, () => <QuestButtonContextMenu />);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<QuestButtonView
|
||||
id={q("quest-button")}
|
||||
className={q("quest-button")}
|
||||
selected={onQuestsPage}
|
||||
visible={isVisible}
|
||||
hasUnread={badgeCount > 0 && hasUnread}
|
||||
badgeProps={lowerBadgeProps}
|
||||
onPress={handlePress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export interface DummyQuestButtonProps {
|
||||
badgeColor: number | null;
|
||||
leftClickAction: QuestButtonAction;
|
||||
middleClickAction: QuestButtonAction;
|
||||
rightClickAction: QuestButtonAction;
|
||||
showBadge: boolean;
|
||||
showPill: boolean;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
export function DummyQuestButton({
|
||||
badgeColor,
|
||||
leftClickAction,
|
||||
middleClickAction,
|
||||
rightClickAction,
|
||||
showBadge,
|
||||
showPill,
|
||||
visible,
|
||||
}: DummyQuestButtonProps): JSX.Element {
|
||||
const badgeCount = showBadge ? 3 : 0;
|
||||
const indicatorMode = showBadge ? (showPill ? "both" : "badge") : (showPill ? "pill" : "none");
|
||||
const lowerBadgeProps = getQuestButtonLowerBadgeProps(badgeCount, indicatorMode, badgeColor);
|
||||
const [dummySelected, setDummySelected] = useState(false);
|
||||
|
||||
function handlePress(event: MouseEvent<Element>): void {
|
||||
// List items do not support onAuxClick, so middle-click is handled on mousedown.
|
||||
if (event.type === "mousedown" && event.button !== middleClick) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const action = resolveQuestButtonAction(
|
||||
event,
|
||||
leftClickAction,
|
||||
middleClickAction,
|
||||
rightClickAction,
|
||||
);
|
||||
|
||||
if (action === "open-quests") {
|
||||
setDummySelected(!dummySelected);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "context-menu") {
|
||||
ContextMenuApi.openContextMenu(event, () => <QuestButtonContextMenu dummy={true} />);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<QuestButtonView
|
||||
id={q("dummy-quest-button")}
|
||||
className={q("dummy-quest-button", "quest-button")}
|
||||
selected={dummySelected}
|
||||
visible={visible}
|
||||
hasUnread={showPill}
|
||||
badgeProps={lowerBadgeProps}
|
||||
onPress={handlePress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
276
src/equicordplugins/questify/components/questButtonSettings.tsx
Normal file
276
src/equicordplugins/questify/components/questButtonSettings.tsx
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { QuestRewardType, QuestTaskType } from "@vencord/discord-types/enums";
|
||||
import type { JSX } from "react";
|
||||
|
||||
import { type QuestButtonAction, type QuestButtonDisplayMode, type QuestButtonIncludedTypes, type QuestButtonIndicatorMode } from "../settings/def";
|
||||
import { startAutoFetchingQuests } from "../settings/fetching";
|
||||
import { validateIgnoredQuests } from "../settings/ignoredQuests";
|
||||
import { settings } from "../settings/store";
|
||||
import { canShowBadge, canShowButton, canShowPill } from "../utils/ui";
|
||||
import { DummyQuestButton } from "./questButton";
|
||||
import { type ManaSelectOption, SettingsCard, SettingsColorPicker, SettingsDescription, SettingsHeader, SettingsRow, SettingsRowItem, SettingsSelect, SettingsSubheader } from "./shared";
|
||||
|
||||
interface QuestButtonIncludedTypeOption {
|
||||
label: string;
|
||||
value: QuestTaskType | QuestRewardType;
|
||||
}
|
||||
|
||||
const questButtonDisplayOptions = [
|
||||
{ label: "Always", value: "always" },
|
||||
{ label: "Unclaimed", value: "unclaimed" },
|
||||
{ label: "Never", value: "never" },
|
||||
] as const satisfies readonly { label: string; value: QuestButtonDisplayMode; }[];
|
||||
|
||||
const questButtonIndicatorOptions = [
|
||||
{ label: "Pill", value: "pill" },
|
||||
{ label: "Badge", value: "badge" },
|
||||
{ label: "Both", value: "both" },
|
||||
{ label: "None", value: "none" },
|
||||
] as const satisfies readonly { label: string; value: QuestButtonIndicatorMode; }[];
|
||||
|
||||
const questButtonClickOptions = [
|
||||
{ label: "Open Quests", value: "open-quests" },
|
||||
{ label: "Context Menu", value: "context-menu" },
|
||||
{ label: "Plugin Settings", value: "plugin-settings" },
|
||||
{ label: "Nothing", value: "nothing" },
|
||||
] as const satisfies readonly { label: string; value: QuestButtonAction; }[];
|
||||
|
||||
const questButtonRewardTypeOptions = [
|
||||
{ label: "Orbs", value: QuestRewardType.VIRTUAL_CURRENCY },
|
||||
{ label: "Nitro Codes", value: QuestRewardType.FRACTIONAL_PREMIUM },
|
||||
{ label: "Reward Codes", value: QuestRewardType.REWARD_CODE },
|
||||
{ label: "In Game Items", value: QuestRewardType.IN_GAME },
|
||||
{ label: "Profile Collectibles", value: QuestRewardType.COLLECTIBLE },
|
||||
] as const satisfies readonly QuestButtonIncludedTypeOption[];
|
||||
|
||||
const questButtonQuestTypeOptions = [
|
||||
{ label: "Watch Video", value: QuestTaskType.WATCH_VIDEO },
|
||||
{ label: "Watch Video on Mobile", value: QuestTaskType.WATCH_VIDEO_ON_MOBILE },
|
||||
{ label: "Achievement in Activity", value: QuestTaskType.ACHIEVEMENT_IN_ACTIVITY },
|
||||
{ label: "Achievement in Game", value: QuestTaskType.ACHIEVEMENT_IN_GAME },
|
||||
{ label: "Play Activity", value: QuestTaskType.PLAY_ACTIVITY },
|
||||
{ label: "Play on Desktop", value: QuestTaskType.PLAY_ON_DESKTOP },
|
||||
{ label: "Play on Desktop V2", value: QuestTaskType.PLAY_ON_DESKTOP_V2 },
|
||||
{ label: "Stream on Desktop", value: QuestTaskType.STREAM_ON_DESKTOP },
|
||||
{ label: "Play on PlayStation", value: QuestTaskType.PLAY_ON_PLAYSTATION },
|
||||
{ label: "Play on Xbox", value: QuestTaskType.PLAY_ON_XBOX },
|
||||
] as const satisfies readonly QuestButtonIncludedTypeOption[];
|
||||
|
||||
function toManaOptions<T extends string | number>(options: readonly { label: string; value: T; }[]): ManaSelectOption[] {
|
||||
return options.map(({ label, value }) => ({
|
||||
id: String(value),
|
||||
label,
|
||||
value: String(value),
|
||||
}));
|
||||
}
|
||||
|
||||
const questButtonDisplayManaOptions = toManaOptions(questButtonDisplayOptions);
|
||||
const questButtonIndicatorManaOptions = toManaOptions(questButtonIndicatorOptions);
|
||||
const questButtonClickManaOptions = toManaOptions(questButtonClickOptions);
|
||||
const questButtonRewardTypeManaOptions = toManaOptions(questButtonRewardTypeOptions);
|
||||
const questButtonQuestTypeManaOptions = toManaOptions(questButtonQuestTypeOptions);
|
||||
|
||||
export function QuestButtonSetting(): JSX.Element {
|
||||
const questButton = settings.use([
|
||||
"disableQuestsEverything",
|
||||
"questButtonDisplay",
|
||||
"questButtonIncludedTypes",
|
||||
"questButtonIndicator",
|
||||
"questButtonBadgeCount",
|
||||
"questButtonBadgeColor",
|
||||
"questButtonLeftClickAction",
|
||||
"questButtonMiddleClickAction",
|
||||
"questButtonRightClickAction",
|
||||
]);
|
||||
|
||||
const disabled = questButton.disableQuestsEverything;
|
||||
const includedTypes = questButton.questButtonIncludedTypes as QuestButtonIncludedTypes;
|
||||
|
||||
const selectedRewardTypes = questButtonRewardTypeOptions
|
||||
.filter(({ value }) => includedTypes[value])
|
||||
.map(({ value }) => String(value));
|
||||
|
||||
const selectedQuestTypes = questButtonQuestTypeOptions
|
||||
.filter(({ value }) => includedTypes[value])
|
||||
.map(({ value }) => String(value));
|
||||
|
||||
function updateQuestButtonDisplay(value: string | string[] | null) {
|
||||
if (typeof value !== "string") return;
|
||||
|
||||
settings.store.questButtonDisplay = value as QuestButtonDisplayMode;
|
||||
startAutoFetchingQuests(true);
|
||||
}
|
||||
|
||||
function updateQuestButtonIndicator(value: string | string[] | null) {
|
||||
if (typeof value !== "string") return;
|
||||
|
||||
settings.store.questButtonIndicator = value as QuestButtonIndicatorMode;
|
||||
startAutoFetchingQuests(true);
|
||||
}
|
||||
|
||||
function updateQuestButtonAction(key: "questButtonLeftClickAction" | "questButtonMiddleClickAction" | "questButtonRightClickAction", value: string | string[] | null) {
|
||||
if (typeof value !== "string") return;
|
||||
|
||||
settings.store[key] = value as QuestButtonAction;
|
||||
}
|
||||
|
||||
function updateBadgeColor(value: number | null) {
|
||||
settings.store.questButtonBadgeColor = value;
|
||||
}
|
||||
|
||||
function updateIncludedTypes(options: readonly QuestButtonIncludedTypeOption[], value: string | string[] | null) {
|
||||
const selectedValues = new Set(Array.isArray(value) ? value : value ? [value] : []);
|
||||
const nextIncludedTypes = { ...settings.store.questButtonIncludedTypes };
|
||||
|
||||
for (const option of options) {
|
||||
nextIncludedTypes[option.value] = selectedValues.has(String(option.value));
|
||||
}
|
||||
|
||||
settings.store.questButtonIncludedTypes = nextIncludedTypes;
|
||||
validateIgnoredQuests();
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsCard>
|
||||
<SettingsRow>
|
||||
<SettingsRowItem>
|
||||
<SettingsHeader> Quest Button </SettingsHeader>
|
||||
<SettingsDescription> Show a Quest Button in the server list with an optional indicator for unclaimed and unignored Quests. </SettingsDescription>
|
||||
</SettingsRowItem>
|
||||
<SettingsRowItem width="content">
|
||||
<DummyQuestButton
|
||||
badgeColor={questButton.questButtonBadgeColor}
|
||||
leftClickAction={questButton.questButtonLeftClickAction}
|
||||
middleClickAction={questButton.questButtonMiddleClickAction}
|
||||
rightClickAction={questButton.questButtonRightClickAction}
|
||||
showBadge={canShowBadge(questButton.questButtonIndicator)}
|
||||
showPill={canShowPill(questButton.questButtonIndicator)}
|
||||
visible={canShowButton(questButton.questButtonDisplay)}
|
||||
/>
|
||||
</SettingsRowItem>
|
||||
</SettingsRow>
|
||||
<SettingsSubheader className="no-top-margin"> Button Behavior </SettingsSubheader>
|
||||
<SettingsRow>
|
||||
<SettingsRowItem>
|
||||
<SettingsSelect
|
||||
label="Left Click Action:"
|
||||
options={questButtonClickManaOptions}
|
||||
value={questButton.questButtonLeftClickAction}
|
||||
selectionMode="single"
|
||||
disabled={disabled}
|
||||
fullWidth={true}
|
||||
onSelectionChange={value => updateQuestButtonAction("questButtonLeftClickAction", value)}
|
||||
/>
|
||||
</SettingsRowItem>
|
||||
<SettingsRowItem>
|
||||
<SettingsSelect
|
||||
label="Middle Click Action:"
|
||||
options={questButtonClickManaOptions}
|
||||
value={questButton.questButtonMiddleClickAction}
|
||||
selectionMode="single"
|
||||
disabled={disabled}
|
||||
fullWidth={true}
|
||||
onSelectionChange={value => updateQuestButtonAction("questButtonMiddleClickAction", value)}
|
||||
/>
|
||||
</SettingsRowItem>
|
||||
<SettingsRowItem>
|
||||
<SettingsSelect
|
||||
label="Right Click Action:"
|
||||
options={questButtonClickManaOptions}
|
||||
value={questButton.questButtonRightClickAction}
|
||||
selectionMode="single"
|
||||
disabled={disabled}
|
||||
fullWidth={true}
|
||||
onSelectionChange={value => updateQuestButtonAction("questButtonRightClickAction", value)}
|
||||
/>
|
||||
</SettingsRowItem>
|
||||
</SettingsRow>
|
||||
<SettingsRow>
|
||||
<SettingsRowItem>
|
||||
<SettingsSelect
|
||||
label="Button Visibility:"
|
||||
options={questButtonDisplayManaOptions}
|
||||
value={questButton.questButtonDisplay}
|
||||
selectionMode="single"
|
||||
disabled={disabled}
|
||||
fullWidth={true}
|
||||
onSelectionChange={updateQuestButtonDisplay}
|
||||
tooltip={{
|
||||
position: "top",
|
||||
text: "Always shows the Quest Button whenever this feature is enabled."
|
||||
+ "\n\nUnclaimed only shows it while you have relevant unclaimed Quest rewards."
|
||||
+ "\n\nNever hides the Quest Button."
|
||||
}}
|
||||
/>
|
||||
</SettingsRowItem>
|
||||
<SettingsRowItem>
|
||||
<SettingsSelect
|
||||
label="Unclaimed Indicator:"
|
||||
options={questButtonIndicatorManaOptions}
|
||||
value={questButton.questButtonIndicator}
|
||||
selectionMode="single"
|
||||
disabled={disabled}
|
||||
fullWidth={true}
|
||||
onSelectionChange={updateQuestButtonIndicator}
|
||||
tooltip={{
|
||||
position: "top",
|
||||
text: "Pill shows Discord's unread-style marker beside the Quest Button."
|
||||
+ "\n\nBadge shows the number of relevant unclaimed Quest rewards."
|
||||
+ "\n\nBoth shows the pill and badge together."
|
||||
+ "\n\nNone hides unclaimed indicators."
|
||||
}}
|
||||
/>
|
||||
</SettingsRowItem>
|
||||
<SettingsRowItem>
|
||||
<SettingsColorPicker
|
||||
label="Badge Color:"
|
||||
className="quest-button-color-picker"
|
||||
color={questButton.questButtonBadgeColor}
|
||||
disabled={disabled}
|
||||
onChange={updateBadgeColor}
|
||||
showEyeDropper={true}
|
||||
/>
|
||||
</SettingsRowItem>
|
||||
</SettingsRow>
|
||||
<SettingsSubheader> Quest Relevancy </SettingsSubheader>
|
||||
<SettingsSelect
|
||||
label="Included Reward Types:"
|
||||
wrapTags={true}
|
||||
options={questButtonRewardTypeManaOptions}
|
||||
value={selectedRewardTypes}
|
||||
closeOnSelect={false}
|
||||
maxOptionsVisible={questButtonRewardTypeManaOptions.length}
|
||||
selectionMode="multiple"
|
||||
disabled={disabled}
|
||||
fullWidth={true}
|
||||
onSelectionChange={value => updateIncludedTypes(questButtonRewardTypeOptions, value)}
|
||||
tooltip={{
|
||||
position: "top",
|
||||
text: "Only count Quests with these reward types as unclaimed when determining button visibility, badge count, and alert behavior."
|
||||
}}
|
||||
/>
|
||||
<SettingsSelect
|
||||
label="Included Quest Types:"
|
||||
labelClassName="margin-top-9"
|
||||
wrapTags={true}
|
||||
options={questButtonQuestTypeManaOptions}
|
||||
value={selectedQuestTypes}
|
||||
closeOnSelect={false}
|
||||
maxOptionsVisible={questButtonQuestTypeManaOptions.length}
|
||||
selectionMode="multiple"
|
||||
disabled={disabled}
|
||||
fullWidth={true}
|
||||
onSelectionChange={value => updateIncludedTypes(questButtonQuestTypeOptions, value)}
|
||||
tooltip={{
|
||||
position: "top",
|
||||
text: "Only count Quests with these task types as unclaimed when determining button visibility, badge count, and alert behavior."
|
||||
}}
|
||||
/>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
364
src/equicordplugins/questify/components/questFeaturesSetting.tsx
Normal file
364
src/equicordplugins/questify/components/questFeaturesSetting.tsx
Normal file
|
|
@ -0,0 +1,364 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { QuestTaskType } from "@vencord/discord-types/enums";
|
||||
import type { JSX } from "react";
|
||||
|
||||
import { autoCompleteQuestTaskTypes, defaultAllowChangingDangerousSettings, defaultAutoCompleteQuestsSimultaneously, defaultAutoCompleteQuestTypes, defaultCompleteVideoQuestsQuicker, defaultMakeMobileVideoQuestsDesktopCompatible, defaultResumeInterruptedQuests, isDesktopCompatible } from "../settings/def";
|
||||
import { settings } from "../settings/store";
|
||||
import { Alerts, q } from "../utils/ui";
|
||||
import { ManaButton, type ManaSelectOption, SettingsCard, SettingsDescription, SettingsHeader, SettingsNotice, SettingsParagraph, SettingsSelect, SettingsSubheader, SettingsSubtleSwitch } from "./shared";
|
||||
|
||||
type QuestDisableSettingKey =
|
||||
| "disableAccountPanelPromo"
|
||||
| "disableAccountPanelQuestProgress"
|
||||
| "disableFriendsListPromo"
|
||||
| "disableMembersListPromo"
|
||||
| "disableOrbsAndQuestsBadges"
|
||||
| "disableRelocationNotices"
|
||||
| "disableSponsoredBanner";
|
||||
|
||||
type QuestModifySettingKey =
|
||||
| "autoCompleteQuestsSimultaneously"
|
||||
| "resumeInterruptedQuests"
|
||||
| "completeVideoQuestsQuicker"
|
||||
| "makeMobileVideoQuestsDesktopCompatible";
|
||||
|
||||
interface QuestDisableOption {
|
||||
key: QuestDisableSettingKey;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const disableFeatureOptions = [
|
||||
{
|
||||
key: "disableSponsoredBanner",
|
||||
label: "Sponsored Banner",
|
||||
},
|
||||
{
|
||||
key: "disableRelocationNotices",
|
||||
label: "Relocation Notices",
|
||||
},
|
||||
{
|
||||
key: "disableFriendsListPromo",
|
||||
label: "Friends List Promo",
|
||||
},
|
||||
{
|
||||
key: "disableMembersListPromo",
|
||||
label: "Members List Promo",
|
||||
},
|
||||
{
|
||||
key: "disableAccountPanelPromo",
|
||||
label: "Account Panel Promo",
|
||||
},
|
||||
{
|
||||
key: "disableAccountPanelQuestProgress",
|
||||
label: "Account Panel Progress",
|
||||
},
|
||||
{
|
||||
key: "disableOrbsAndQuestsBadges",
|
||||
label: "Quest & Orbs Badges",
|
||||
},
|
||||
] as const satisfies readonly QuestDisableOption[];
|
||||
|
||||
const disableManaOptions: ManaSelectOption[] = disableFeatureOptions.map(({ key, label }) => ({
|
||||
id: key,
|
||||
label,
|
||||
value: key,
|
||||
}));
|
||||
|
||||
const autoCompleteQuestTypeLabels = {
|
||||
[QuestTaskType.WATCH_VIDEO]: "Watch Video",
|
||||
[QuestTaskType.WATCH_VIDEO_ON_MOBILE]: "Watch Video on Mobile",
|
||||
[QuestTaskType.ACHIEVEMENT_IN_ACTIVITY]: "Achievement in Activity",
|
||||
[QuestTaskType.PLAY_ACTIVITY]: "Play Activity",
|
||||
[QuestTaskType.PLAY_ON_DESKTOP]: "Play on Desktop",
|
||||
[QuestTaskType.PLAY_ON_PLAYSTATION]: "Play on PlayStation",
|
||||
[QuestTaskType.PLAY_ON_XBOX]: "Play on Xbox",
|
||||
} as const satisfies Record<typeof autoCompleteQuestTaskTypes[number], string>;
|
||||
|
||||
const autoCompleteQuestTypeOptions = autoCompleteQuestTaskTypes.map(questType => ({
|
||||
label: autoCompleteQuestTypeLabels[questType],
|
||||
value: questType,
|
||||
})) satisfies { label: string; value: QuestTaskType; }[];
|
||||
|
||||
const autoCompleteQuestTypeManaOptions: ManaSelectOption[] = autoCompleteQuestTypeOptions.map(({ label, value }) => ({
|
||||
id: String(value),
|
||||
label,
|
||||
value: String(value),
|
||||
disabled: !isDesktopCompatible(value),
|
||||
}));
|
||||
|
||||
interface SettingsAllowDangerousButtonProps {
|
||||
allowed: boolean;
|
||||
disabled?: boolean;
|
||||
onClick?: (e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
function SettingsAllowDangerousButton({
|
||||
allowed,
|
||||
disabled,
|
||||
onClick,
|
||||
}: SettingsAllowDangerousButtonProps): JSX.Element {
|
||||
return (
|
||||
<div className={q("settings-button", "allow-dangerous-button")}>
|
||||
<ManaButton
|
||||
text={allowed
|
||||
? "Reset and disallow changing dangerous settings..."
|
||||
: "Allow changing dangerous settings..."}
|
||||
variant={allowed ? "critical-secondary" : "critical-primary"}
|
||||
fullWidth={true}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function QuestFeaturesSetting(): JSX.Element {
|
||||
const questFeatures = settings.use([
|
||||
"disableQuestsEverything",
|
||||
"disableSponsoredBanner",
|
||||
"disableRelocationNotices",
|
||||
"disableFriendsListPromo",
|
||||
"disableMembersListPromo",
|
||||
"disableAccountPanelPromo",
|
||||
"disableAccountPanelQuestProgress",
|
||||
"disableOrbsAndQuestsBadges",
|
||||
"resumeInterruptedQuests",
|
||||
"allowChangingDangerousSettings",
|
||||
"makeMobileVideoQuestsDesktopCompatible",
|
||||
"autoCompleteQuestsSimultaneously",
|
||||
"autoCompleteQuestTypes",
|
||||
"completeVideoQuestsQuicker",
|
||||
]);
|
||||
|
||||
const selectedDisableValues = disableFeatureOptions
|
||||
.filter(({ key }) => questFeatures[key])
|
||||
.map(({ key }) => key);
|
||||
|
||||
const selectedAutoCompleteQuestTypeValues = autoCompleteQuestTypeOptions
|
||||
.filter(({ value }) => isDesktopCompatible(value) && questFeatures.autoCompleteQuestTypes[value] === true)
|
||||
.map(({ value }) => String(value));
|
||||
|
||||
function updateDisableValue(value: string | string[] | null) {
|
||||
const selectedKeys = new Set(Array.isArray(value) ? value : value ? [value] : []);
|
||||
|
||||
for (const { key } of disableFeatureOptions) {
|
||||
settings.store[key] = selectedKeys.has(key);
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDangerousSettingChange(body: string, onConfirm: () => void) {
|
||||
Alerts.show({
|
||||
title: "Are you sure?",
|
||||
body,
|
||||
confirmText: "Continue",
|
||||
confirmVariant: "critical-primary",
|
||||
cancelText: "Cancel",
|
||||
onConfirm,
|
||||
});
|
||||
}
|
||||
|
||||
function updateAutoCompleteQuestTypes(value: string | string[] | null) {
|
||||
const selectedValues = new Set(Array.isArray(value) ? value : value ? [value] : []);
|
||||
const nextAutoCompleteQuestTypes = { ...defaultAutoCompleteQuestTypes };
|
||||
|
||||
for (const { value: questType } of autoCompleteQuestTypeOptions) {
|
||||
const nextValue = selectedValues.has(String(questType))
|
||||
&& isDesktopCompatible(questType);
|
||||
|
||||
nextAutoCompleteQuestTypes[questType] = nextValue;
|
||||
}
|
||||
|
||||
settings.store.autoCompleteQuestTypes = nextAutoCompleteQuestTypes;
|
||||
}
|
||||
|
||||
function updateDisableEverything(checked: boolean) {
|
||||
function setDisableEverything() {
|
||||
settings.store.disableQuestsEverything = checked;
|
||||
}
|
||||
|
||||
if (checked) {
|
||||
confirmDangerousSettingChange(
|
||||
"This will completely disable Quest functionality.",
|
||||
() => {
|
||||
resetDangerousSettings();
|
||||
setDisableEverything();
|
||||
}
|
||||
);
|
||||
} else {
|
||||
setDisableEverything();
|
||||
}
|
||||
}
|
||||
|
||||
function resetDangerousSettings() {
|
||||
settings.store.allowChangingDangerousSettings = defaultAllowChangingDangerousSettings;
|
||||
settings.store.autoCompleteQuestsSimultaneously = defaultAutoCompleteQuestsSimultaneously;
|
||||
settings.store.completeVideoQuestsQuicker = defaultCompleteVideoQuestsQuicker;
|
||||
settings.store.makeMobileVideoQuestsDesktopCompatible = defaultMakeMobileVideoQuestsDesktopCompatible;
|
||||
settings.store.resumeInterruptedQuests = defaultResumeInterruptedQuests;
|
||||
settings.store.autoCompleteQuestTypes = { ...defaultAutoCompleteQuestTypes };
|
||||
}
|
||||
|
||||
function updateDangerousAccess(checked: boolean) {
|
||||
function setDangerousAccess() {
|
||||
settings.store.allowChangingDangerousSettings = checked;
|
||||
}
|
||||
|
||||
if (checked) {
|
||||
confirmDangerousSettingChange(
|
||||
"This will allow changing dangerous settings.",
|
||||
setDangerousAccess
|
||||
);
|
||||
} else {
|
||||
resetDangerousSettings();
|
||||
setDangerousAccess();
|
||||
}
|
||||
}
|
||||
|
||||
function updateModifyValue(key: QuestModifySettingKey, checked: boolean) {
|
||||
settings.store[key] = checked;
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsCard>
|
||||
<SettingsHeader> Quest Features </SettingsHeader>
|
||||
<SettingsDescription> Modify how Quests behave to enhance or remove functionality. </SettingsDescription>
|
||||
<SettingsSubheader> Disable Features </SettingsSubheader>
|
||||
<SettingsSubtleSwitch
|
||||
checked={questFeatures.disableQuestsEverything}
|
||||
label="Completely disable Quest functionality:"
|
||||
onChange={updateDisableEverything}
|
||||
bottomSpacing="10"
|
||||
tooltip={{
|
||||
position: "top",
|
||||
text: "This will disable all plugin enhancements, hide the Quests page and Quest elements across Discord, and prevent Discord from fetching Quest data. This will not affect the shop as Orbs are too intrinsically tied to it as a secondary currency."
|
||||
}}
|
||||
/>
|
||||
<SettingsSelect
|
||||
label="Disable specific features:"
|
||||
wrapTags={true}
|
||||
options={disableManaOptions}
|
||||
value={selectedDisableValues}
|
||||
closeOnSelect={false}
|
||||
maxOptionsVisible={7}
|
||||
selectionMode="multiple"
|
||||
disabled={questFeatures.disableQuestsEverything}
|
||||
onSelectionChange={updateDisableValue}
|
||||
tooltip={{
|
||||
position: "top",
|
||||
text: "Sponsored Banner is a paid-for Quest banner at the top of the Quests page."
|
||||
+ "\n\nRelocation Notices are indicators such as in the Discovery page about Quests moving to DMs."
|
||||
+ "\n\nFriends List Promo is a card that displays on the \"Active Now\" section of your Friends List while a user you share a server with is playing a game with an active Quest."
|
||||
+ "\n\nMembers List Promo is an icon that displays on members in a server's Members List while they are playing a game with an active Quest."
|
||||
+ "\n\nAccount Panel Promo is a paid-for Quest promotion that appears above your user account panel."
|
||||
+ "\n\nAccount Panel Progress is the active or completed Quest progress shown above your user account panel."
|
||||
+ "\n\nQuest & Orbs Badges are badges on user profiles for when someone has completed at least one Quest or bought the Orbs badge respectively."
|
||||
}}
|
||||
/>
|
||||
<SettingsSubheader> Modify Features </SettingsSubheader>
|
||||
<SettingsNotice className={["notice-card-red", questFeatures.disableQuestsEverything ? "dimmed-settings-item" : undefined, questFeatures.allowChangingDangerousSettings ? undefined : "notice-card-solo", "no-bottom-margin"].filter(c => c !== undefined)}>
|
||||
<SettingsParagraph>
|
||||
Discord has began issuing warnings to users of scripts or plugins that modify how Quests are completed, which is against their <a href="https://discord.com/safety/platform-manipulation-policy-explainer" target="_blank" rel="noreferrer">Terms of Service</a>.
|
||||
</SettingsParagraph>
|
||||
<br />
|
||||
<SettingsParagraph>
|
||||
The warnings appear limited to threat of loss of access to Quests or their rewards, but Discord may escalate at any time.
|
||||
</SettingsParagraph>
|
||||
<br />
|
||||
<SettingsParagraph>
|
||||
Due to the various methods Discord uses to track users, there's no way to realistically evade detection. If you proceed, understand that Discord likely will detect it at some point.
|
||||
</SettingsParagraph>
|
||||
<br />
|
||||
<SettingsParagraph>
|
||||
Use the following toggle to access potentially dangerous settings at your own risk.
|
||||
</SettingsParagraph>
|
||||
<SettingsAllowDangerousButton
|
||||
allowed={questFeatures.allowChangingDangerousSettings}
|
||||
disabled={questFeatures.disableQuestsEverything}
|
||||
onClick={() => updateDangerousAccess(!questFeatures.allowChangingDangerousSettings)}
|
||||
/>
|
||||
{questFeatures.allowChangingDangerousSettings && <>
|
||||
<SettingsSubtleSwitch
|
||||
disabled={questFeatures.disableQuestsEverything || !questFeatures.allowChangingDangerousSettings}
|
||||
checked={questFeatures.completeVideoQuestsQuicker}
|
||||
label="Accelerate Video Quest auto-completion:"
|
||||
onChange={checked => updateModifyValue("completeVideoQuestsQuicker", checked)}
|
||||
topSpacing="10"
|
||||
bottomSpacing="5"
|
||||
tooltip={{
|
||||
position: "top",
|
||||
text: "Discord allows Video Quests to be completed once 24 seconds less than the duration of the video has passed since you enrolled into the Quest."
|
||||
+ "\n\nThis means that if a Video Quest is 24 seconds or less, or if you enroll in a Video Quest and return later to complete it, it can be completed immediately."
|
||||
+ "\n\nThis setting will only apply to auto-completing Video Quests and relies on the auto-complete setting below. Manually completing Video Quests will still require waiting the full duration and is not dependent on enrollment time."
|
||||
}}
|
||||
/>
|
||||
<SettingsSubtleSwitch
|
||||
disabled={questFeatures.disableQuestsEverything || !questFeatures.allowChangingDangerousSettings}
|
||||
checked={questFeatures.makeMobileVideoQuestsDesktopCompatible}
|
||||
label="Make some mobile-only Video Quests completable on desktop:"
|
||||
onChange={checked => updateModifyValue("makeMobileVideoQuestsDesktopCompatible", checked)}
|
||||
bottomSpacing="5"
|
||||
tooltip={{
|
||||
position: "top",
|
||||
text: "Some mobile-only Video Quests can be enrolled in on desktop, but still must be completed on mobile. This setting will allow those to be completed on desktop."
|
||||
+ "\n\nSome mobile-only Video Quests are only enrollable on mobile. For this setting to affect those, you must enroll in those Quests on your mobile device before returning to desktop and refreshing your Quests."
|
||||
+ "\n\nThis setting, when enabled independently, applies only to manually completing Video Quests. Auto-completing mobile Video Quests from desktop relies on the auto-complete setting below, and will implicitly enable this setting as well."
|
||||
}}
|
||||
/>
|
||||
<SettingsSubtleSwitch
|
||||
disabled={questFeatures.disableQuestsEverything || !questFeatures.allowChangingDangerousSettings}
|
||||
checked={questFeatures.autoCompleteQuestsSimultaneously}
|
||||
label="Auto-complete Quests simultaneously rather than sequentially:"
|
||||
onChange={checked => updateModifyValue("autoCompleteQuestsSimultaneously", checked)}
|
||||
bottomSpacing="5"
|
||||
tooltip={{
|
||||
position: "top",
|
||||
text: "By default, attempting to auto-complete multiple Quests will queue them to be completed in order."
|
||||
+ "\n\nThis setting will alternatively allow all auto-complete Quests to run at the same time."
|
||||
+ "\n\nThis setting will only apply to auto-completing Quests and relies on the auto-complete setting below."
|
||||
}}
|
||||
/>
|
||||
<SettingsSubtleSwitch
|
||||
disabled={questFeatures.disableQuestsEverything || !questFeatures.allowChangingDangerousSettings}
|
||||
checked={questFeatures.resumeInterruptedQuests}
|
||||
label="Resume interrupted auto-completions after a reload or restart:"
|
||||
onChange={checked => updateModifyValue("resumeInterruptedQuests", checked)}
|
||||
bottomSpacing="5"
|
||||
tooltip={{
|
||||
position: "top",
|
||||
text: "This setting will automatically resume any interrupted auto-completions caused by reloads or restarts, including requeuing Quests which had yet to start auto-completing but had been queued."
|
||||
}}
|
||||
/>
|
||||
<SettingsSelect
|
||||
label="Auto-complete specific Quest types:"
|
||||
labelClassName="margin-top-9"
|
||||
wrapTags={true}
|
||||
options={autoCompleteQuestTypeManaOptions}
|
||||
value={selectedAutoCompleteQuestTypeValues}
|
||||
closeOnSelect={false}
|
||||
maxOptionsVisible={7}
|
||||
selectionMode="multiple"
|
||||
disabled={questFeatures.disableQuestsEverything || !questFeatures.allowChangingDangerousSettings}
|
||||
onSelectionChange={updateAutoCompleteQuestTypes}
|
||||
tooltip={{
|
||||
position: "top",
|
||||
wider: true,
|
||||
text: "Watch Video on Mobile Quests will only work on mobile Quests which are enrollable on desktop. If a Quest is locked to enrollment on mobile, you must first enroll in it on your mobile device before returning to desktop and refreshing your Quests."
|
||||
+ "\n\nAll video related Quests usually send a stack trace with the progress reports. This means Discord would know exactly which functions were called, and can therefore verify that their own functions initiated the progress. Questify erases this stack trace, but the absence of it will be just as telling as its presence."
|
||||
+ "\n\nPlay on Desktop, Play on PlayStation, Play on Xbox, and Play Activity Quests are only available on official desktop clients due to a limitation imposed by Discord. 3rd party clients such as web extensions, Vesktop, Equibop, and others, do not support auto-completing these Quest types."
|
||||
+ "\n\nAll game related Quests usually send a game fingerprint with the heartbeat reports. This means Discord can use data from tens of thousands of users to determine whether other users are likely using a real game or are emulating the executable. There is no reasonable method to spoof this value, so Questify leaves it blank, but the absence of it will be just as telling as poorly spoofing the value."
|
||||
+ "\n\nAchievement in Activity Quests can only be auto-completed by completing them immediately. This method may be patched at any time."
|
||||
+ "\n\nAuto-completing Quests is done by clicking their respective buttons on the Quests page. Quests will be auto-completed in the order they were queued, unless the simultaneous completion setting is enabled above."
|
||||
+ "\n\nAuto-completing Quests is the riskiest dangerous setting available. Enable it at your own risk."
|
||||
}}
|
||||
/>
|
||||
</>}
|
||||
</SettingsNotice>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,336 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { type AudioPlayerInterface, createAudioPlayer, defaultAudioNames } from "@api/AudioPlayer";
|
||||
import { useEffect, useMemo, useRef, useState } from "@webpack/common";
|
||||
import type { JSX, MouseEvent } from "react";
|
||||
|
||||
import { startAutoFetchingQuests } from "../settings/fetching";
|
||||
import { settings } from "../settings/store";
|
||||
import { q } from "../utils/ui";
|
||||
import { ManaSelectFormattedOption, ManaSelectOption, SettingsCard, SettingsDescription, SettingsHeader, SettingsRow, SettingsRowItem, SettingsSelect, SettingsSlider, SettingsSubheader, SettingsSubtleSwitch } from "./shared";
|
||||
|
||||
const questFetchIntervalOptions = [
|
||||
{ id: "30-minutes", label: "30 Minutes", value: String(30 * 60) },
|
||||
{ id: "45-minutes", label: "45 Minutes", value: String(45 * 60) },
|
||||
{ id: "1-hour", label: "1 Hour", value: String(60 * 60) },
|
||||
{ id: "3-hours", label: "3 Hours", value: String(3 * 60 * 60) },
|
||||
{ id: "6-hours", label: "6 Hours", value: String(6 * 60 * 60) },
|
||||
{ id: "12-hours", label: "12 Hours", value: String(12 * 60 * 60) },
|
||||
] satisfies ManaSelectOption[];
|
||||
|
||||
function SoundIcon({ className }: { className?: string; }): JSX.Element {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
height={18}
|
||||
width={18}
|
||||
fill="none"
|
||||
className={className}
|
||||
aria-hidden={true}
|
||||
>
|
||||
<path fill="currentColor" d="M12 3a1 1 0 0 0-1-1h-.06a1 1 0 0 0-.74.32L5.92 7H3a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h2.92l4.28 4.68a1 1 0 0 0 .74.32H11a1 1 0 0 0 1-1V3ZM15.1 20.75c-.58.14-1.1-.33-1.1-.92v-.03c0-.5.37-.92.85-1.05a7 7 0 0 0 0-13.5A1.11 1.11 0 0 1 14 4.2v-.03c0-.6.52-1.06 1.1-.92a9 9 0 0 1 0 17.5Z" />
|
||||
<path fill="currentColor" d="M15.16 16.51c-.57.28-1.16-.2-1.16-.83v-.14c0-.43.28-.8.63-1.02a3 3 0 0 0 0-5.04c-.35-.23-.63-.6-.63-1.02v-.14c0-.63.59-1.1 1.16-.83a5 5 0 0 1 0 9.02Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function getSoundOptions(): ManaSelectOption[] {
|
||||
return defaultAudioNames()
|
||||
.map(sound => ({
|
||||
id: sound,
|
||||
label: formatSoundName(sound),
|
||||
value: sound,
|
||||
}))
|
||||
.sort((a, b) => a.label.localeCompare(b.label));
|
||||
}
|
||||
|
||||
function formatSoundName(sound: string): string {
|
||||
return sound
|
||||
.toUpperCase()
|
||||
.replace(/_/g, " ")
|
||||
.replace(/(\d+)/g, " $1");
|
||||
}
|
||||
|
||||
interface QuestNotificationSoundSelectProps {
|
||||
disabled?: boolean;
|
||||
label: string;
|
||||
onChange: (value: string | null) => void;
|
||||
onPreview: (sound: string) => void;
|
||||
options: ManaSelectOption[];
|
||||
playingSound: string | null;
|
||||
tooltip?: { position: "top" | "bottom", text: string; };
|
||||
value: string | null;
|
||||
}
|
||||
|
||||
function QuestNotificationSoundSelect({
|
||||
disabled,
|
||||
label,
|
||||
onChange,
|
||||
onPreview,
|
||||
options,
|
||||
playingSound,
|
||||
tooltip,
|
||||
value,
|
||||
}: QuestNotificationSoundSelectProps): JSX.Element {
|
||||
function formatOption(option: ManaSelectOption): ManaSelectFormattedOption {
|
||||
const sound = option.value || null;
|
||||
const isPlaying = sound != null && playingSound === sound;
|
||||
|
||||
function handlePreviewMouseDown(event: MouseEvent<HTMLButtonElement>) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
function handlePreviewClick(event: MouseEvent<HTMLButtonElement>) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
if (sound && !disabled) {
|
||||
onPreview(sound);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...option,
|
||||
trailing: sound
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
className={q("sound-preview-button", isPlaying ? "playing-audio" : undefined)}
|
||||
aria-label={`Preview ${option.label}`}
|
||||
disabled={disabled}
|
||||
onMouseDown={handlePreviewMouseDown}
|
||||
onClick={handlePreviewClick}
|
||||
>
|
||||
<SoundIcon />
|
||||
</button>
|
||||
)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsSelect
|
||||
label={label}
|
||||
options={options}
|
||||
value={value}
|
||||
selectionMode="single"
|
||||
disabled={disabled}
|
||||
clearable={true}
|
||||
fullWidth={true}
|
||||
maxOptionsVisible={7}
|
||||
placeholder="DISABLED"
|
||||
selectClassName="sound-select"
|
||||
formatOption={formatOption}
|
||||
tooltip={tooltip}
|
||||
onSelectionChange={nextValue => {
|
||||
if (nextValue != null && typeof nextValue !== "string") return;
|
||||
|
||||
onChange(nextValue ?? null);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function QuestNotificationsSetting(): JSX.Element {
|
||||
const questNotifications = settings.use([
|
||||
"newExcludedQuestAlertSound",
|
||||
"newExcludedQuestAlertVolume",
|
||||
"newQuestAlertSound",
|
||||
"newQuestAlertVolume",
|
||||
"questFetchInterval",
|
||||
"disableQuestsEverything",
|
||||
"notifyOnNewExcludedQuests",
|
||||
"notifyOnNewQuests",
|
||||
"notifyOnQuestComplete",
|
||||
"questCompletedAlertSound",
|
||||
"questCompletedAlertVolume",
|
||||
]);
|
||||
|
||||
const soundOptions = useMemo(getSoundOptions, []);
|
||||
const activePlayer = useRef<AudioPlayerInterface | null>(null);
|
||||
const [playingSound, setPlayingSound] = useState<string | null>(null);
|
||||
const disabled = questNotifications.disableQuestsEverything;
|
||||
|
||||
function clearActivePlayer(): void {
|
||||
const player = activePlayer.current;
|
||||
activePlayer.current = null;
|
||||
player?.stop();
|
||||
setPlayingSound(null);
|
||||
}
|
||||
|
||||
function previewSound(sound: string, volume: number): void {
|
||||
if (playingSound === sound) {
|
||||
clearActivePlayer();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
clearActivePlayer();
|
||||
const player = createAudioPlayer(sound, { volume: Math.max(0, Math.min(100, volume)), onEnded: clearActivePlayer });
|
||||
activePlayer.current = player;
|
||||
setPlayingSound(sound);
|
||||
player?.play();
|
||||
}
|
||||
|
||||
useEffect(() => clearActivePlayer, []);
|
||||
useEffect(() => {
|
||||
if (disabled) {
|
||||
clearActivePlayer();
|
||||
}
|
||||
}, [disabled]);
|
||||
|
||||
function updateFetchInterval(value: string | string[] | null): void {
|
||||
if (value != null && typeof value !== "string") return;
|
||||
|
||||
const interval = value == null ? 0 : Number(value);
|
||||
settings.store.questFetchInterval = interval;
|
||||
startAutoFetchingQuests(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsCard>
|
||||
<SettingsHeader> Quest Notifications </SettingsHeader>
|
||||
<SettingsDescription>Configure Quest completed and new Quest detected notifications and alerts. </SettingsDescription>
|
||||
<SettingsSubheader> Quest Completed </SettingsSubheader>
|
||||
<SettingsSubtleSwitch
|
||||
checked={questNotifications.notifyOnQuestComplete}
|
||||
disabled={disabled}
|
||||
label="Show a notification when a Quest is completed:"
|
||||
onChange={checked => { settings.store.notifyOnQuestComplete = checked; }}
|
||||
bottomSpacing="5"
|
||||
/>
|
||||
<SettingsRow>
|
||||
<SettingsRowItem className="sound-select-row-item">
|
||||
<QuestNotificationSoundSelect
|
||||
label="Play a sound when a Quest is completed:"
|
||||
disabled={disabled}
|
||||
options={soundOptions}
|
||||
value={questNotifications.questCompletedAlertSound}
|
||||
playingSound={playingSound}
|
||||
onPreview={sound => previewSound(sound, questNotifications.questCompletedAlertVolume)}
|
||||
onChange={value => { settings.store.questCompletedAlertSound = value; }}
|
||||
/>
|
||||
</SettingsRowItem>
|
||||
<SettingsRowItem className="volume-slider-row-item">
|
||||
<SettingsSlider
|
||||
label="Volume:"
|
||||
className="inline-volume-slider"
|
||||
disabled={disabled}
|
||||
value={questNotifications.questCompletedAlertVolume}
|
||||
onChange={value => { settings.store.questCompletedAlertVolume = value; }}
|
||||
/>
|
||||
</SettingsRowItem>
|
||||
</SettingsRow>
|
||||
<SettingsSubheader> New Quests Detected </SettingsSubheader>
|
||||
<SettingsSubtleSwitch
|
||||
checked={questNotifications.notifyOnNewQuests}
|
||||
disabled={disabled}
|
||||
label="Show a notification when new Quests are detected:"
|
||||
bottomSpacing="5"
|
||||
onChange={checked => {
|
||||
settings.store.notifyOnNewQuests = checked;
|
||||
startAutoFetchingQuests(true);
|
||||
}}
|
||||
/>
|
||||
<SettingsRow>
|
||||
<SettingsRowItem className="sound-select-row-item">
|
||||
<QuestNotificationSoundSelect
|
||||
label="Play a sound when new Quests are detected:"
|
||||
disabled={disabled}
|
||||
options={soundOptions}
|
||||
value={questNotifications.newQuestAlertSound}
|
||||
playingSound={playingSound}
|
||||
onPreview={sound => previewSound(sound, questNotifications.newQuestAlertVolume)}
|
||||
onChange={value => {
|
||||
settings.store.newQuestAlertSound = value;
|
||||
startAutoFetchingQuests(true);
|
||||
}}
|
||||
/>
|
||||
</SettingsRowItem>
|
||||
<SettingsRowItem className="volume-slider-row-item">
|
||||
<SettingsSlider
|
||||
label="Volume:"
|
||||
className="inline-volume-slider"
|
||||
disabled={disabled}
|
||||
value={questNotifications.newQuestAlertVolume}
|
||||
onChange={value => { settings.store.newQuestAlertVolume = value; }}
|
||||
/>
|
||||
</SettingsRowItem>
|
||||
</SettingsRow>
|
||||
<SettingsSubtleSwitch
|
||||
className="margin-top-14"
|
||||
checked={questNotifications.notifyOnNewExcludedQuests}
|
||||
disabled={disabled}
|
||||
label="Show a notification when new excluded Quests are detected:"
|
||||
bottomSpacing="5"
|
||||
onChange={checked => {
|
||||
settings.store.notifyOnNewExcludedQuests = checked;
|
||||
startAutoFetchingQuests(true);
|
||||
}}
|
||||
tooltip={{
|
||||
position: "top",
|
||||
text: "Some Quests are excluded from your available Quests list due to region or platform restrictions."
|
||||
+ "\n\nWhen enabled, Questify will fetch their Quest configs, apply your included Quest and reward type filters, print the resolved excluded Quest data to console, and show a separate notification for matching excluded Quests."
|
||||
}}
|
||||
/>
|
||||
<SettingsRow>
|
||||
<SettingsRowItem className="sound-select-row-item">
|
||||
<QuestNotificationSoundSelect
|
||||
label="Play a sound when new excluded Quests are detected:"
|
||||
disabled={disabled}
|
||||
options={soundOptions}
|
||||
value={questNotifications.newExcludedQuestAlertSound}
|
||||
playingSound={playingSound}
|
||||
onPreview={sound => previewSound(sound, questNotifications.newExcludedQuestAlertVolume)}
|
||||
onChange={value => {
|
||||
settings.store.newExcludedQuestAlertSound = value;
|
||||
startAutoFetchingQuests(true);
|
||||
}}
|
||||
tooltip={{
|
||||
position: "top",
|
||||
text: "Some Quests are excluded from your available Quests list due to region or platform restrictions."
|
||||
+ "\n\nWhen a sound is selected, Questify will fetch their Quest configs, apply your included Quest and reward type filters, print the resolved excluded Quest data to console, and play this sound for matching excluded Quests."
|
||||
}}
|
||||
/>
|
||||
</SettingsRowItem>
|
||||
<SettingsRowItem className="volume-slider-row-item">
|
||||
<SettingsSlider
|
||||
label="Volume:"
|
||||
className="inline-volume-slider"
|
||||
disabled={disabled}
|
||||
value={questNotifications.newExcludedQuestAlertVolume}
|
||||
onChange={value => { settings.store.newExcludedQuestAlertVolume = value; }}
|
||||
/>
|
||||
</SettingsRowItem>
|
||||
</SettingsRow>
|
||||
<SettingsRow>
|
||||
<SettingsRowItem>
|
||||
<SettingsSelect
|
||||
className="margin-top-12"
|
||||
label="Quest Fetch Interval:"
|
||||
options={questFetchIntervalOptions}
|
||||
value={questNotifications.questFetchInterval > 0 ? String(questNotifications.questFetchInterval) : null}
|
||||
selectionMode="single"
|
||||
disabled={disabled}
|
||||
clearable={true}
|
||||
fullWidth={true}
|
||||
placeholder="DISABLED"
|
||||
maxOptionsVisible={questFetchIntervalOptions.length}
|
||||
onSelectionChange={updateFetchInterval}
|
||||
tooltip={{
|
||||
position: "top",
|
||||
text: "Discord only fetches Quests on load and when you visit the Quests page."
|
||||
+ "\n\nThis interval periodically fetches Quests for you while the client stays open, so Quest Button indicators and new Quest alerts can stay up to date throughout the day."
|
||||
+ "\n\nThis only runs if enabled and if the Quest Button or Quest Notifications settings are configured in a way which makes fetching periodically meaningful."
|
||||
}}
|
||||
/>
|
||||
</SettingsRowItem>
|
||||
</SettingsRow>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { copyToClipboard } from "@utils/index";
|
||||
import type { Quest } from "@vencord/discord-types";
|
||||
import { Menu } from "@webpack/common";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { addIgnoredQuest, questIsIgnored, removeIgnoredQuest } from "../settings/ignoredQuests";
|
||||
import { rerenderQuests } from "../settings/rerender";
|
||||
import { canAutoCompleteQuest, getQuestAutoCompleteEntry, processQuestForAutoComplete, stopQuestAutoComplete } from "../utils/completion";
|
||||
import { q } from "../utils/ui";
|
||||
|
||||
export function QuestTileContextMenu(
|
||||
children: ReactNode[],
|
||||
props: { quest?: Quest; },
|
||||
isClaimedMenu: boolean = false,
|
||||
): void {
|
||||
const { quest } = props;
|
||||
|
||||
if (!quest) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isIgnored = questIsIgnored(quest.id);
|
||||
const isEnrolled = Boolean(quest.userStatus?.enrolledAt);
|
||||
const isAutoCompleting = getQuestAutoCompleteEntry(quest) != null;
|
||||
const canStartAutoComplete = !isClaimedMenu && isEnrolled && canAutoCompleteQuest(quest);
|
||||
|
||||
children.unshift((
|
||||
<Menu.MenuGroup>
|
||||
{!isClaimedMenu && (!isIgnored ? (
|
||||
<Menu.MenuItem
|
||||
id={q("ignore-quest")}
|
||||
label="Mark as Ignored"
|
||||
action={() => addIgnoredQuest(quest.id)}
|
||||
/>
|
||||
) : (
|
||||
<Menu.MenuItem
|
||||
id={q("unignore-quest")}
|
||||
label="Unmark as Ignored"
|
||||
action={() => removeIgnoredQuest(quest.id)}
|
||||
/>
|
||||
))}
|
||||
{isAutoCompleting ? (
|
||||
<Menu.MenuItem
|
||||
id={q("stop-auto-complete")}
|
||||
label="Stop Auto-Complete"
|
||||
action={() => {
|
||||
stopQuestAutoComplete(quest, {
|
||||
manual: true,
|
||||
preserveResume: false,
|
||||
terminalHeartbeat: true,
|
||||
});
|
||||
rerenderQuests();
|
||||
}}
|
||||
/>
|
||||
) : canStartAutoComplete ? (
|
||||
<Menu.MenuItem
|
||||
id={q("start-auto-complete")}
|
||||
label="Start Auto-Complete"
|
||||
action={() => {
|
||||
processQuestForAutoComplete(quest, {
|
||||
force: true,
|
||||
source: "manual",
|
||||
});
|
||||
rerenderQuests();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<Menu.MenuItem
|
||||
id={q("copy-quest-id")}
|
||||
label="Copy Quest ID"
|
||||
action={() => copyToClipboard(quest.id)}
|
||||
/>
|
||||
</Menu.MenuGroup>
|
||||
));
|
||||
}
|
||||
330
src/equicordplugins/questify/components/questTilesSetting.tsx
Normal file
330
src/equicordplugins/questify/components/questTilesSetting.tsx
Normal file
|
|
@ -0,0 +1,330 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { Quest } from "@vencord/discord-types";
|
||||
import { findComponentByCodeLazy } from "@webpack";
|
||||
import { QuestStore, useEffect, useMemo, useRef, useState, useStateFromStores } from "@webpack/common";
|
||||
import type { JSX, SyntheticEvent } from "react";
|
||||
|
||||
import { defaultQuestTileClaimedColorSetting, defaultQuestTileExpiredColorSetting, defaultQuestTileIgnoredColorSetting, defaultQuestTileUnclaimedColorSetting, type QuestTileColorSetting, type QuestTileGradient } from "../settings/def";
|
||||
import { rerenderQuests } from "../settings/rerender";
|
||||
import { settings } from "../settings/store";
|
||||
import { getQuestTileClasses, getQuestTileStyle } from "../utils/questTiles";
|
||||
import { q } from "../utils/ui";
|
||||
import { ManaButton, type ManaSelectOption, SettingsCard, SettingsColorPicker, SettingsDescription, SettingsHeader, SettingsRow, SettingsRowItem, SettingsSelect, SettingsSubheader } from "./shared";
|
||||
|
||||
const QuestTile = findComponentByCodeLazy(".rowIndex,trackGuildAndChannelMetadata") as React.ComponentType<{
|
||||
className?: string;
|
||||
quest: Quest;
|
||||
}>;
|
||||
|
||||
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" },
|
||||
] as const satisfies readonly { label: string, value: QuestTileGradient; }[];
|
||||
|
||||
const gradientManaOptions: ManaSelectOption[] = gradientOptions.map(({ label, value }) => ({
|
||||
id: value,
|
||||
label,
|
||||
value,
|
||||
}));
|
||||
|
||||
const preloadManaOptions: ManaSelectOption[] = [
|
||||
{ id: "true", label: "Load All Quest Assets On Page Load", value: "true" },
|
||||
{ id: "false", label: "Load Quest Assets During Page Scroll", value: "false" },
|
||||
];
|
||||
|
||||
type QuestTileColorKey =
|
||||
| "questTileUnclaimedColor"
|
||||
| "questTileClaimedColor"
|
||||
| "questTileIgnoredColor"
|
||||
| "questTileExpiredColor";
|
||||
|
||||
interface QuestTileColorOption {
|
||||
key: QuestTileColorKey;
|
||||
label: string;
|
||||
defaultValue: QuestTileColorSetting;
|
||||
}
|
||||
|
||||
const colorOptions = [
|
||||
{
|
||||
key: "questTileUnclaimedColor",
|
||||
label: "Unclaimed",
|
||||
defaultValue: defaultQuestTileUnclaimedColorSetting,
|
||||
},
|
||||
{
|
||||
key: "questTileClaimedColor",
|
||||
label: "Claimed",
|
||||
defaultValue: defaultQuestTileClaimedColorSetting,
|
||||
},
|
||||
{
|
||||
key: "questTileIgnoredColor",
|
||||
label: "Ignored",
|
||||
defaultValue: defaultQuestTileIgnoredColorSetting,
|
||||
},
|
||||
{
|
||||
key: "questTileExpiredColor",
|
||||
label: "Expired",
|
||||
defaultValue: defaultQuestTileExpiredColorSetting,
|
||||
},
|
||||
] as const satisfies readonly QuestTileColorOption[];
|
||||
|
||||
const defaultPreviewColorKey: QuestTileColorKey = "questTileUnclaimedColor";
|
||||
|
||||
function getRandomQuest(): Quest | null {
|
||||
const quests = Array.from(QuestStore.quests.values());
|
||||
return quests.length > 0 ? quests[Math.floor(Math.random() * quests.length)] : null;
|
||||
}
|
||||
|
||||
function cloneDummyQuest(quest: Quest, dummyColor: QuestTileColorSetting): Quest & { dummyColor: QuestTileColorSetting; } {
|
||||
return {
|
||||
...structuredClone(quest),
|
||||
dummyColor,
|
||||
};
|
||||
}
|
||||
|
||||
function stopDummyQuestInteraction(event: SyntheticEvent): void {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.nativeEvent.stopImmediatePropagation();
|
||||
}
|
||||
|
||||
function DummyQuestTile({
|
||||
disabled,
|
||||
dummyQuest,
|
||||
dummyGradient,
|
||||
}: {
|
||||
disabled?: boolean;
|
||||
dummyQuest: Quest & { dummyColor: QuestTileColorSetting; };
|
||||
dummyGradient: QuestTileGradient;
|
||||
}): JSX.Element {
|
||||
const blockerRef = useRef<HTMLDivElement>(null);
|
||||
const classes = getQuestTileClasses(q("dummy-quest"), dummyQuest, dummyGradient);
|
||||
const style = getQuestTileStyle(dummyQuest);
|
||||
|
||||
useEffect(() => {
|
||||
const blocker = blockerRef.current;
|
||||
|
||||
if (!blocker) return;
|
||||
|
||||
const eventNames = [
|
||||
"auxclick",
|
||||
"click",
|
||||
"contextmenu",
|
||||
"dblclick",
|
||||
"dragstart",
|
||||
"mousedown",
|
||||
"mouseup",
|
||||
"pointercancel",
|
||||
"pointerdown",
|
||||
"pointerup",
|
||||
];
|
||||
|
||||
function stopEvent(event: Event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation();
|
||||
}
|
||||
|
||||
for (const eventName of eventNames) {
|
||||
blocker.addEventListener(eventName, stopEvent, true);
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const eventName of eventNames) {
|
||||
blocker.removeEventListener(eventName, stopEvent, true);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={blockerRef}
|
||||
className={q("dummy-quest-preview", disabled ? "dimmed-settings-item" : undefined)}
|
||||
style={style}
|
||||
onAuxClickCapture={stopDummyQuestInteraction}
|
||||
onClickCapture={stopDummyQuestInteraction}
|
||||
onContextMenuCapture={stopDummyQuestInteraction}
|
||||
onDoubleClickCapture={stopDummyQuestInteraction}
|
||||
onDragStartCapture={stopDummyQuestInteraction}
|
||||
onMouseDownCapture={stopDummyQuestInteraction}
|
||||
onMouseUpCapture={stopDummyQuestInteraction}
|
||||
onPointerCancelCapture={stopDummyQuestInteraction}
|
||||
onPointerDownCapture={stopDummyQuestInteraction}
|
||||
onPointerUpCapture={stopDummyQuestInteraction}
|
||||
>
|
||||
<QuestTile
|
||||
className={classes}
|
||||
quest={dummyQuest}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DummyQuestPreview({
|
||||
disabled,
|
||||
dummyColor,
|
||||
dummyGradient,
|
||||
}: {
|
||||
disabled?: boolean;
|
||||
dummyColor: QuestTileColorSetting;
|
||||
dummyGradient: QuestTileGradient;
|
||||
}): JSX.Element | null {
|
||||
const sourceQuest = useStateFromStores([QuestStore], getRandomQuest);
|
||||
|
||||
const dummyQuest = useMemo(
|
||||
() => sourceQuest ? cloneDummyQuest(sourceQuest, dummyColor) : null,
|
||||
[dummyColor, sourceQuest]
|
||||
);
|
||||
|
||||
if (!dummyQuest) return null;
|
||||
|
||||
return (
|
||||
<DummyQuestTile
|
||||
disabled={disabled}
|
||||
dummyQuest={dummyQuest}
|
||||
dummyGradient={dummyGradient}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function QuestTilesSetting(): JSX.Element {
|
||||
const questTiles = settings.use([
|
||||
"disableQuestsEverything",
|
||||
"questTileUnclaimedColor",
|
||||
"questTileClaimedColor",
|
||||
"questTileIgnoredColor",
|
||||
"questTileExpiredColor",
|
||||
"questTileGradient",
|
||||
"questTilePreload",
|
||||
]);
|
||||
|
||||
const [previewColorKey, setPreviewColorKey] = useState<QuestTileColorKey>(defaultPreviewColorKey);
|
||||
|
||||
const disabled = questTiles.disableQuestsEverything;
|
||||
const previewColor = questTiles[previewColorKey] as QuestTileColorSetting;
|
||||
|
||||
function updateColor(key: QuestTileColorKey, nextColor: QuestTileColorSetting): void {
|
||||
setPreviewColorKey(key);
|
||||
settings.store[key] = nextColor;
|
||||
}
|
||||
|
||||
function updateColorValue(key: QuestTileColorKey, setting: QuestTileColorSetting, value: number | null): void {
|
||||
if (typeof value !== "number") return;
|
||||
|
||||
updateColor(key, {
|
||||
enabled: setting.enabled,
|
||||
color: value,
|
||||
});
|
||||
}
|
||||
|
||||
function updateColorEnabled(key: QuestTileColorKey, setting: QuestTileColorSetting, enabled: boolean): void {
|
||||
updateColor(key, {
|
||||
...setting,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
function updateGradient(value: string | string[] | null): void {
|
||||
if (typeof value !== "string") return;
|
||||
|
||||
settings.store.questTileGradient = value as QuestTileGradient;
|
||||
}
|
||||
|
||||
function updatePreload(value: string | string[] | null): void {
|
||||
if (typeof value !== "string") return;
|
||||
|
||||
const preload = value === "true";
|
||||
settings.store.questTilePreload = preload;
|
||||
rerenderQuests();
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsCard>
|
||||
<SettingsHeader> Quest Tiles </SettingsHeader>
|
||||
<SettingsDescription> Highlight Quests with optional theme colors for visibility. </SettingsDescription>
|
||||
<SettingsSubheader> Tile Behavior </SettingsSubheader>
|
||||
<SettingsRow className="quest-tile-behavior-row">
|
||||
<SettingsRowItem className="quest-tile-gradient-row-item">
|
||||
<SettingsSelect
|
||||
label="Gradient Style:"
|
||||
options={gradientManaOptions}
|
||||
value={questTiles.questTileGradient}
|
||||
selectionMode="single"
|
||||
disabled={disabled}
|
||||
fullWidth={true}
|
||||
maxOptionsVisible={gradientManaOptions.length}
|
||||
onSelectionChange={updateGradient}
|
||||
tooltip={{
|
||||
position: "top",
|
||||
text: "Intense and Default use the selected tile color in the asset gradient."
|
||||
+ "\n\nSubtle Black keeps a darker neutral gradient for contrast."
|
||||
+ "\n\nNo Gradient removes the asset gradient, which can make some Quest artwork harder to read."
|
||||
}}
|
||||
/>
|
||||
</SettingsRowItem>
|
||||
<SettingsRowItem className="quest-tile-preload-row-item">
|
||||
<SettingsSelect
|
||||
label="Asset Preload:"
|
||||
options={preloadManaOptions}
|
||||
value={String(questTiles.questTilePreload)}
|
||||
selectionMode="single"
|
||||
disabled={disabled}
|
||||
fullWidth={true}
|
||||
maxOptionsVisible={preloadManaOptions.length}
|
||||
onSelectionChange={updatePreload}
|
||||
tooltip={{
|
||||
position: "top",
|
||||
text: "Loading all assets when the Quests page opens reduces layout shifting while scrolling."
|
||||
+ "\n\nLoading during page scroll is closer to Discord's default behavior and may use less work up front."
|
||||
}}
|
||||
/>
|
||||
</SettingsRowItem>
|
||||
</SettingsRow>
|
||||
<SettingsSubheader> Tile Colors </SettingsSubheader>
|
||||
<SettingsRow className="quest-tile-color-row">
|
||||
{colorOptions.map(({ key, label }) => {
|
||||
const setting = questTiles[key] as QuestTileColorSetting;
|
||||
|
||||
return (
|
||||
<SettingsRowItem key={key} className="quest-tile-color-row-item">
|
||||
<div
|
||||
onFocusCapture={() => setPreviewColorKey(key)}
|
||||
onPointerDownCapture={() => setPreviewColorKey(key)}
|
||||
>
|
||||
<SettingsColorPicker
|
||||
label={`${label}:`}
|
||||
className={["quest-tile-color-picker", setting.enabled ? "" : "disabled-color-picker"].filter(Boolean)}
|
||||
color={setting.color}
|
||||
disabled={disabled || !setting.enabled}
|
||||
onChange={value => updateColorValue(key, setting, value)}
|
||||
showEyeDropper={true}
|
||||
/>
|
||||
</div>
|
||||
<div className={q("settings-button", "quest-tile-color-button")}>
|
||||
<ManaButton
|
||||
text={setting.enabled ? "Disable" : "Enable"}
|
||||
variant={setting.enabled ? "critical-secondary" : "primary"}
|
||||
disabled={disabled}
|
||||
fullWidth={true}
|
||||
size="sm"
|
||||
onClick={() => updateColorEnabled(key, setting, !setting.enabled)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRowItem>
|
||||
);
|
||||
})}
|
||||
</SettingsRow>
|
||||
<DummyQuestPreview
|
||||
disabled={disabled}
|
||||
dummyColor={previewColor}
|
||||
dummyGradient={questTiles.questTileGradient as QuestTileGradient}
|
||||
/>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
246
src/equicordplugins/questify/components/reorderQuestsSetting.tsx
Normal file
246
src/equicordplugins/questify/components/reorderQuestsSetting.tsx
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { JSX } from "react";
|
||||
|
||||
import { defaultQuestOrder, type QuestOrderStatus, type QuestSubsort } from "../settings/def";
|
||||
import { rerenderQuests } from "../settings/rerender";
|
||||
import { settings } from "../settings/store";
|
||||
import { type ManaSelectOption, SettingsCard, SettingsDescription, SettingsHeader, SettingsRow, SettingsRowItem, SettingsSelect, SettingsSubheader, SettingsSubtleSwitch } from "./shared";
|
||||
|
||||
const questStatusOptions = [
|
||||
{ label: "Unclaimed", value: "UNCLAIMED" },
|
||||
{ label: "Claimed", value: "CLAIMED" },
|
||||
{ label: "Ignored", value: "IGNORED" },
|
||||
{ label: "Expired", value: "EXPIRED" },
|
||||
] as const satisfies readonly { label: string, value: QuestOrderStatus; }[];
|
||||
|
||||
const questStatusManaOptions: ManaSelectOption[] = questStatusOptions.map(({ label, value }) => ({
|
||||
id: value,
|
||||
label,
|
||||
value,
|
||||
}));
|
||||
|
||||
const baseSubsortOptions = [
|
||||
{ label: "Added (Newest)", value: "Recent DESC" },
|
||||
{ label: "Added (Oldest)", value: "Recent ASC" },
|
||||
] as const satisfies readonly { label: string, value: QuestSubsort; }[];
|
||||
|
||||
const expiringSubsortOptions = [
|
||||
...baseSubsortOptions,
|
||||
{ label: "Expiring (Soonest)", value: "Expiring ASC" },
|
||||
{ label: "Expiring (Latest)", value: "Expiring DESC" },
|
||||
] as const satisfies readonly { label: string, value: QuestSubsort; }[];
|
||||
|
||||
const expiredSubsortOptions = [
|
||||
...baseSubsortOptions,
|
||||
{ label: "Expired (Most Recent)", value: "Expiring DESC" },
|
||||
{ label: "Expired (Least Recent)", value: "Expiring ASC" },
|
||||
] as const satisfies readonly { label: string, value: QuestSubsort; }[];
|
||||
|
||||
const claimedSubsortOptions = [
|
||||
...baseSubsortOptions,
|
||||
{ label: "Claimed (Most Recent)", value: "Claimed DESC" },
|
||||
{ label: "Claimed (Least Recent)", value: "Claimed ASC" },
|
||||
] as const satisfies readonly { label: string, value: QuestSubsort; }[];
|
||||
|
||||
function toManaOptions(options: readonly { label: string, value: QuestSubsort; }[]): ManaSelectOption[] {
|
||||
return options.map(({ label, value }) => ({
|
||||
id: value,
|
||||
label,
|
||||
value,
|
||||
}));
|
||||
}
|
||||
|
||||
const unclaimedSubsortManaOptions = toManaOptions(expiringSubsortOptions);
|
||||
const claimedSubsortManaOptions = toManaOptions(claimedSubsortOptions);
|
||||
const ignoredSubsortManaOptions = toManaOptions(expiringSubsortOptions);
|
||||
const expiredSubsortManaOptions = toManaOptions(expiredSubsortOptions);
|
||||
const positionLabels = ["First", "Second", "Third", "Fourth"] as const;
|
||||
const subsortTooltips = {
|
||||
unclaimedSubsort: "Completed but unclaimed Quests stay below incomplete unclaimed Quests, then this subsort is applied within those groups.",
|
||||
claimedSubsort: "Claimed Quests can be sorted by claim time or by when the Quest was added.",
|
||||
ignoredSubsort: "Ignored Quests still keep their ignored group position, then this subsort controls their order inside that group.",
|
||||
expiredSubsort: "Expired Quests can be sorted by expiration time or by when the Quest was added.",
|
||||
} as const;
|
||||
|
||||
function sanitizeQuestOrder(order: unknown): QuestOrderStatus[] {
|
||||
const validStatuses = new Set<QuestOrderStatus>(defaultQuestOrder);
|
||||
const sanitized = Array.isArray(order)
|
||||
? order.filter((status): status is QuestOrderStatus => validStatuses.has(status as QuestOrderStatus))
|
||||
: [];
|
||||
|
||||
for (const status of defaultQuestOrder) {
|
||||
if (!sanitized.includes(status)) {
|
||||
sanitized.push(status);
|
||||
}
|
||||
}
|
||||
|
||||
return sanitized.slice(0, defaultQuestOrder.length);
|
||||
}
|
||||
|
||||
export function ReorderQuestsSetting(): JSX.Element {
|
||||
const reorderQuests = settings.use([
|
||||
"disableQuestsEverything",
|
||||
"questOrder",
|
||||
"unclaimedSubsort",
|
||||
"claimedSubsort",
|
||||
"ignoredSubsort",
|
||||
"expiredSubsort",
|
||||
"rememberQuestPageSort",
|
||||
"rememberQuestPageFilters",
|
||||
]);
|
||||
|
||||
const disabled = reorderQuests.disableQuestsEverything;
|
||||
const questOrder = sanitizeQuestOrder(reorderQuests.questOrder);
|
||||
|
||||
function updateQuestOrder(index: number, value: string | string[] | null): void {
|
||||
if (typeof value !== "string") return;
|
||||
|
||||
const nextStatus = value as QuestOrderStatus;
|
||||
const nextOrder = [...questOrder];
|
||||
const previousStatus = nextOrder[index];
|
||||
const existingIndex = nextOrder.indexOf(nextStatus);
|
||||
|
||||
if (existingIndex !== -1 && existingIndex !== index) {
|
||||
nextOrder[existingIndex] = previousStatus;
|
||||
}
|
||||
|
||||
nextOrder[index] = nextStatus;
|
||||
settings.store.questOrder = nextOrder;
|
||||
rerenderQuests();
|
||||
}
|
||||
|
||||
function updateSubsort(key: "unclaimedSubsort" | "claimedSubsort" | "ignoredSubsort" | "expiredSubsort", value: string | string[] | null): void {
|
||||
if (typeof value !== "string") return;
|
||||
|
||||
settings.store[key] = value;
|
||||
rerenderQuests();
|
||||
}
|
||||
|
||||
function updateRememberSetting(key: "rememberQuestPageSort" | "rememberQuestPageFilters", checked: boolean): void {
|
||||
settings.store[key] = checked;
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsCard>
|
||||
<SettingsHeader> Reorder Quests </SettingsHeader>
|
||||
<SettingsDescription> Sort Quests by their status when the Questify sort option is selected on the Quests page. </SettingsDescription>
|
||||
<SettingsSubheader> Status Order </SettingsSubheader>
|
||||
<SettingsRow>
|
||||
{questOrder.map((status, index) => (
|
||||
<SettingsRowItem key={index}>
|
||||
<SettingsSelect
|
||||
label={`${positionLabels[index]}:`}
|
||||
options={questStatusManaOptions}
|
||||
value={status}
|
||||
selectionMode="single"
|
||||
disabled={disabled}
|
||||
fullWidth={true}
|
||||
maxOptionsVisible={questStatusManaOptions.length}
|
||||
onSelectionChange={value => updateQuestOrder(index, value)}
|
||||
tooltip={{
|
||||
position: "top",
|
||||
text: "Each status can only appear once. Selecting a status already used in another position swaps the two positions."
|
||||
}}
|
||||
/>
|
||||
</SettingsRowItem>
|
||||
))}
|
||||
</SettingsRow>
|
||||
<SettingsSubheader> Subsorts </SettingsSubheader>
|
||||
<SettingsRow>
|
||||
<SettingsRowItem>
|
||||
<SettingsSelect
|
||||
label="Unclaimed Subsort:"
|
||||
options={unclaimedSubsortManaOptions}
|
||||
value={reorderQuests.unclaimedSubsort}
|
||||
selectionMode="single"
|
||||
disabled={disabled}
|
||||
fullWidth={true}
|
||||
maxOptionsVisible={unclaimedSubsortManaOptions.length}
|
||||
onSelectionChange={value => updateSubsort("unclaimedSubsort", value)}
|
||||
tooltip={{
|
||||
position: "top",
|
||||
text: subsortTooltips.unclaimedSubsort
|
||||
}}
|
||||
/>
|
||||
</SettingsRowItem>
|
||||
<SettingsRowItem>
|
||||
<SettingsSelect
|
||||
label="Claimed Subsort:"
|
||||
options={claimedSubsortManaOptions}
|
||||
value={reorderQuests.claimedSubsort}
|
||||
selectionMode="single"
|
||||
disabled={disabled}
|
||||
fullWidth={true}
|
||||
maxOptionsVisible={claimedSubsortManaOptions.length}
|
||||
onSelectionChange={value => updateSubsort("claimedSubsort", value)}
|
||||
tooltip={{
|
||||
position: "top",
|
||||
text: subsortTooltips.claimedSubsort
|
||||
}}
|
||||
/>
|
||||
</SettingsRowItem>
|
||||
</SettingsRow>
|
||||
<SettingsRow>
|
||||
<SettingsRowItem>
|
||||
<SettingsSelect
|
||||
label="Ignored Subsort:"
|
||||
options={ignoredSubsortManaOptions}
|
||||
value={reorderQuests.ignoredSubsort}
|
||||
selectionMode="single"
|
||||
disabled={disabled}
|
||||
fullWidth={true}
|
||||
maxOptionsVisible={ignoredSubsortManaOptions.length}
|
||||
onSelectionChange={value => updateSubsort("ignoredSubsort", value)}
|
||||
tooltip={{
|
||||
position: "top",
|
||||
text: subsortTooltips.ignoredSubsort
|
||||
}}
|
||||
/>
|
||||
</SettingsRowItem>
|
||||
<SettingsRowItem>
|
||||
<SettingsSelect
|
||||
label="Expired Subsort:"
|
||||
options={expiredSubsortManaOptions}
|
||||
value={reorderQuests.expiredSubsort}
|
||||
selectionMode="single"
|
||||
disabled={disabled}
|
||||
fullWidth={true}
|
||||
maxOptionsVisible={expiredSubsortManaOptions.length}
|
||||
onSelectionChange={value => updateSubsort("expiredSubsort", value)}
|
||||
tooltip={{
|
||||
position: "top",
|
||||
text: subsortTooltips.expiredSubsort
|
||||
}}
|
||||
/>
|
||||
</SettingsRowItem>
|
||||
</SettingsRow>
|
||||
<SettingsSubheader> Quest Page Memory </SettingsSubheader>
|
||||
<SettingsSubtleSwitch
|
||||
checked={reorderQuests.rememberQuestPageSort}
|
||||
disabled={disabled}
|
||||
label="Remember the selected Quest page sort:"
|
||||
onChange={checked => updateRememberSetting("rememberQuestPageSort", checked)}
|
||||
bottomSpacing="5"
|
||||
tooltip={{
|
||||
position: "top",
|
||||
text: "When disabled, the Quests page opens with the Questify sort option each time."
|
||||
}}
|
||||
/>
|
||||
<SettingsSubtleSwitch
|
||||
checked={reorderQuests.rememberQuestPageFilters}
|
||||
disabled={disabled}
|
||||
label="Remember the selected Quest page filters:"
|
||||
onChange={checked => updateRememberSetting("rememberQuestPageFilters", checked)}
|
||||
tooltip={{
|
||||
position: "top",
|
||||
text: "When disabled, the Quests page opens without task or reward filters each time."
|
||||
}}
|
||||
/>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
32
src/equicordplugins/questify/components/settingsModal.tsx
Normal file
32
src/equicordplugins/questify/components/settingsModal.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import PluginModal from "@components/settings/tabs/plugins/PluginModal";
|
||||
import { openModal } from "@utils/modal";
|
||||
import type { Plugin } from "@utils/types";
|
||||
|
||||
import { setSettingsModalOpen } from "..";
|
||||
import { promptToRestartIfDirty } from "../settings/restartTracking";
|
||||
|
||||
export function openQuestifySettingsModal(plugin: Plugin): void {
|
||||
setSettingsModalOpen(true);
|
||||
|
||||
openModal(
|
||||
modalProps => (
|
||||
<PluginModal
|
||||
{...modalProps}
|
||||
plugin={plugin}
|
||||
onRestartNeeded={() => { }}
|
||||
/>
|
||||
),
|
||||
{
|
||||
onCloseCallback: () => {
|
||||
setSettingsModalOpen(false);
|
||||
promptToRestartIfDirty();
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
379
src/equicordplugins/questify/components/shared.tsx
Normal file
379
src/equicordplugins/questify/components/shared.tsx
Normal file
|
|
@ -0,0 +1,379 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { Card } from "@components/Card";
|
||||
import { Heading } from "@components/Heading";
|
||||
import { Paragraph } from "@components/Paragraph";
|
||||
import { findComponentByCodeLazy } from "@webpack";
|
||||
import { ColorPicker, Slider } from "@webpack/common";
|
||||
import type { ComponentProps, ComponentType, JSX, ReactNode } from "react";
|
||||
|
||||
import { q } from "../utils/ui";
|
||||
|
||||
export function SettingsCard({ children }: { children: ReactNode; }): JSX.Element {
|
||||
return (
|
||||
<Card variant="primary" className={q("setting")}>
|
||||
{children}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsHeader({ children }: { children: ReactNode; }): JSX.Element {
|
||||
return (
|
||||
<Heading className={q("setting-header")}>
|
||||
{children}
|
||||
</Heading>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsSubheader({ children, className }: { children: ReactNode; className?: string | string[]; }): JSX.Element {
|
||||
return (
|
||||
<Heading className={q("setting-subheader", className)}>
|
||||
{children}
|
||||
</Heading>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsDescription({ children }: { children: ReactNode; }): JSX.Element {
|
||||
return (
|
||||
<Paragraph className={q("setting-description")}>
|
||||
{children}
|
||||
</Paragraph>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsParagraph({ children, className }: { children: ReactNode; className?: string | string[]; }): JSX.Element {
|
||||
return (
|
||||
<Paragraph className={q("setting-paragraph", className)}>
|
||||
{children}
|
||||
</Paragraph>
|
||||
);
|
||||
}
|
||||
|
||||
function withDimmedClass(className: string | string[] | undefined, dimmed: boolean): string | string[] | undefined {
|
||||
if (!dimmed) return className;
|
||||
|
||||
return [
|
||||
...(Array.isArray(className) ? className : [className]),
|
||||
"dimmed-settings-item",
|
||||
].filter(c => c !== undefined);
|
||||
}
|
||||
|
||||
export function SettingsNotice({ children, className }: { children: ReactNode; className?: string | string[]; }): JSX.Element {
|
||||
return (
|
||||
<Paragraph className={q("notice-card", className)}>
|
||||
{children}
|
||||
</Paragraph>
|
||||
);
|
||||
}
|
||||
|
||||
export interface SettingsRowProps {
|
||||
children: ReactNode;
|
||||
className?: string | string[];
|
||||
}
|
||||
|
||||
export function SettingsRow({ children, className }: SettingsRowProps): JSX.Element {
|
||||
return (
|
||||
<div className={q("settings-row", className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface SettingsRowItemProps {
|
||||
children: ReactNode;
|
||||
className?: string | string[];
|
||||
width?: "fill" | "content";
|
||||
}
|
||||
|
||||
export function SettingsRowItem({
|
||||
children,
|
||||
className,
|
||||
width = "fill",
|
||||
}: SettingsRowItemProps): JSX.Element {
|
||||
return (
|
||||
<div className={q(
|
||||
"settings-row-item",
|
||||
width === "content" ? "settings-row-item-content" : undefined,
|
||||
className,
|
||||
)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ManaSelectOption {
|
||||
id: string;
|
||||
value: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface ManaSelectFormattedOption extends ManaSelectOption {
|
||||
description?: ReactNode;
|
||||
leading?: ReactNode;
|
||||
trailing?: ReactNode;
|
||||
}
|
||||
|
||||
export interface ManaSelectProps {
|
||||
options: ManaSelectOption[];
|
||||
value?: string | string[] | null;
|
||||
onSelectionChange?: (value: string | string[] | null) => void;
|
||||
selectionMode?: "single" | "multiple";
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
readOnly?: boolean;
|
||||
clearable?: boolean;
|
||||
fullWidth?: boolean;
|
||||
autoFocus?: boolean;
|
||||
closeOnSelect?: boolean;
|
||||
shouldFocusWrap?: boolean;
|
||||
maxOptionsVisible?: number;
|
||||
wrapTags?: boolean;
|
||||
formatOption?: (option: ManaSelectOption) => ManaSelectFormattedOption;
|
||||
name?: string;
|
||||
form?: string;
|
||||
autoComplete?: string;
|
||||
label?: string;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
export const ManaSelect = findComponentByCodeLazy('"data-mana-component":"select"') as React.ComponentType<ManaSelectProps>;
|
||||
|
||||
export interface SettingsSelectProps extends Omit<ManaSelectProps, "label"> {
|
||||
label: ReactNode;
|
||||
className?: string | string[];
|
||||
labelClassName?: string | string[];
|
||||
selectClassName?: string | string[];
|
||||
tooltip?: { position: "top" | "bottom", text: string; wider?: boolean; };
|
||||
}
|
||||
|
||||
export function SettingsSelect({
|
||||
label,
|
||||
className,
|
||||
labelClassName,
|
||||
selectClassName,
|
||||
tooltip,
|
||||
...props
|
||||
}: SettingsSelectProps): JSX.Element {
|
||||
const selectElement = (
|
||||
<div className={q("settings-select", className)}>
|
||||
<SettingsParagraph className={withDimmedClass(labelClassName, !!props.disabled)}>{label}</SettingsParagraph>
|
||||
<div className={q(selectClassName)}>
|
||||
<ManaSelect {...props} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (tooltip) {
|
||||
return (
|
||||
<SettingsTooltip text={tooltip.text} position={tooltip.position} wider={tooltip.wider}>
|
||||
{selectElement}
|
||||
</SettingsTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return selectElement;
|
||||
}
|
||||
|
||||
type ColorPickerWithOnCloseProps = ComponentProps<typeof ColorPicker> & {
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
function ColorPickerWithOnClose(props: ColorPickerWithOnCloseProps): JSX.Element {
|
||||
const LiveColorPicker = ColorPicker as ComponentType<ColorPickerWithOnCloseProps>;
|
||||
|
||||
return <LiveColorPicker {...props} />;
|
||||
}
|
||||
|
||||
export interface SettingsColorPickerProps extends ColorPickerWithOnCloseProps {
|
||||
className?: string | string[];
|
||||
label?: ReactNode;
|
||||
labelClassName?: string | string[];
|
||||
}
|
||||
|
||||
export function SettingsColorPicker({
|
||||
className,
|
||||
label,
|
||||
labelClassName,
|
||||
...props
|
||||
}: SettingsColorPickerProps): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
{label != null && <SettingsParagraph className={withDimmedClass(labelClassName, !!props.disabled)}>{label}</SettingsParagraph>}
|
||||
<div className={q("settings-color-picker", className)}>
|
||||
<ColorPickerWithOnClose {...props} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export interface SettingsSliderProps {
|
||||
className?: string | string[];
|
||||
disabled?: boolean;
|
||||
label: ReactNode;
|
||||
labelClassName?: string | string[];
|
||||
maxValue?: number;
|
||||
minValue?: number;
|
||||
onChange: (value: number) => void;
|
||||
sliderClassName?: string | string[];
|
||||
value: number;
|
||||
}
|
||||
|
||||
export function SettingsSlider({
|
||||
className,
|
||||
disabled,
|
||||
label,
|
||||
labelClassName,
|
||||
maxValue = 100,
|
||||
minValue = 0,
|
||||
onChange,
|
||||
sliderClassName,
|
||||
value,
|
||||
}: SettingsSliderProps): JSX.Element {
|
||||
return (
|
||||
<div className={q("settings-slider", className)}>
|
||||
<SettingsParagraph className={withDimmedClass(labelClassName, !!disabled)}>{label}</SettingsParagraph>
|
||||
<div className={q("settings-slider-control-container")}>
|
||||
<Slider
|
||||
minValue={minValue}
|
||||
maxValue={maxValue}
|
||||
initialValue={value}
|
||||
onValueChange={onChange}
|
||||
className={q("settings-slider-control", sliderClassName)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SwitchWithLabelProps {
|
||||
checked: boolean;
|
||||
onChange?: (checked: boolean) => void;
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface SettingsSwitchLayoutProps extends SwitchWithLabelProps {
|
||||
bottomSpacing?: "5" | "10" | "15";
|
||||
topSpacing?: "5" | "10" | "15";
|
||||
className?: string | string[];
|
||||
tooltip?: { position: "top" | "bottom", text: string; };
|
||||
}
|
||||
|
||||
const SwitchWithLabel = findComponentByCodeLazy('"data-toggleable-component":"switch"') as React.ComponentType<SwitchWithLabelProps>;
|
||||
|
||||
export type SettingsSubtleSwitchProps = Omit<SettingsSwitchLayoutProps, "description">;
|
||||
|
||||
export function SettingsSubtleSwitch(props: SettingsSubtleSwitchProps): JSX.Element {
|
||||
const switchElement = (
|
||||
<div className={q(
|
||||
"setting-subtle-switch",
|
||||
props.topSpacing ? `margin-top-${props.topSpacing}` : undefined,
|
||||
props.bottomSpacing ? `margin-bottom-${props.bottomSpacing}` : undefined,
|
||||
withDimmedClass(props.className, !!props.disabled)
|
||||
)}>
|
||||
<SwitchWithLabel {...props} />
|
||||
</div>
|
||||
);
|
||||
|
||||
if (props.tooltip) {
|
||||
return (
|
||||
<SettingsTooltip text={props.tooltip.text} aria-label={props.tooltip.text} position={props.tooltip.position}>
|
||||
{switchElement}
|
||||
</SettingsTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return switchElement;
|
||||
}
|
||||
|
||||
export const TooltipPositions = ["top", "bottom", "left", "right"] as const;
|
||||
export type TooltipPosition = typeof TooltipPositions[number];
|
||||
|
||||
export const TooltipAligns = ["start", "center", "end"] as const;
|
||||
export type TooltipAlign = typeof TooltipAligns[number];
|
||||
|
||||
export const TooltipColors = ["primary", "grey", "brand", "green", "red"] as const;
|
||||
export type TooltipColor = typeof TooltipColors[number];
|
||||
|
||||
export interface ManaTooltipProps {
|
||||
text: string | (() => React.ReactNode);
|
||||
position?: TooltipPosition;
|
||||
align?: TooltipAlign;
|
||||
color?: TooltipColor;
|
||||
spacing?: number;
|
||||
hideOnClick?: boolean;
|
||||
delay?: number;
|
||||
forceOpen?: boolean;
|
||||
shouldShow?: boolean;
|
||||
allowOverflow?: boolean;
|
||||
overflowOnly?: boolean;
|
||||
clickableOnMobile?: boolean;
|
||||
disableTooltipPointerEvents?: boolean;
|
||||
targetElementRef?: React.RefObject<HTMLElement>;
|
||||
tooltipClassName?: string;
|
||||
tooltipStyle?: React.CSSProperties;
|
||||
tooltipContentClassName?: string;
|
||||
tooltipPointerClassName?: string;
|
||||
positionKeyStemOverride?: string;
|
||||
onTooltipShow?: () => void;
|
||||
onTooltipHide?: () => void;
|
||||
onAnimationRest?: () => void;
|
||||
"aria-label"?: string | false;
|
||||
children: (props: Record<string, any>) => React.ReactNode;
|
||||
}
|
||||
|
||||
export const ManaTooltip = findComponentByCodeLazy("VoidTooltip cannot find DOM node") as React.ComponentType<ManaTooltipProps>;
|
||||
|
||||
function SettingsTooltip({
|
||||
children,
|
||||
position,
|
||||
text,
|
||||
wider,
|
||||
className
|
||||
}: {
|
||||
children: ReactNode;
|
||||
position: "top" | "bottom";
|
||||
text: string;
|
||||
wider?: boolean;
|
||||
className?: string | string[];
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div className={q("settings-tooltip-wrapper", className)}>
|
||||
<ManaTooltip
|
||||
text={text}
|
||||
position={position}
|
||||
color="brand"
|
||||
tooltipStyle={{ maxWidth: wider ? "602px" : "350px" }}
|
||||
tooltipContentClassName={q("settings-tooltip-content")}
|
||||
delay={50}
|
||||
>
|
||||
{tooltipProps => <div {...tooltipProps}>{children}</div>}
|
||||
</ManaTooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const ManaButtonVariants = ["primary", "secondary", "critical-primary", "critical-secondary", "active", "overlay-primary", "overlay-secondary", "expressive"] as const;
|
||||
export type ManaButtonVariant = typeof ManaButtonVariants[number];
|
||||
|
||||
export const ManaButtonSizes = ["xs", "sm", "md"] as const;
|
||||
export type ManaButtonSize = typeof ManaButtonSizes[number];
|
||||
|
||||
export interface ManaButtonProps {
|
||||
text?: string;
|
||||
variant?: ManaButtonVariant;
|
||||
size?: ManaButtonSize;
|
||||
disabled?: boolean;
|
||||
fullWidth?: boolean;
|
||||
onClick?: (e: React.MouseEvent) => void;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export const ManaButton = findComponentByCodeLazy('"data-mana-component":"button"') as React.ComponentType<ManaButtonProps>;
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,50 +1,83 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2025 Vendicated and contributors
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { IpcMainInvokeEvent } from "electron";
|
||||
import { BrowserWindow, type IpcMainInvokeEvent } from "electron";
|
||||
|
||||
export async function complete(_: IpcMainInvokeEvent, appID: string, authCode: string, questTarget: number): Promise<{ success: boolean; error: string | null; }> {
|
||||
const result = await authorize(appID, authCode);
|
||||
export function canOpenDevTools(event: IpcMainInvokeEvent): boolean {
|
||||
return !event.sender.isDestroyed();
|
||||
}
|
||||
|
||||
if (result.error || !result.token) {
|
||||
return { success: false, error: JSON.stringify(result.error) };
|
||||
export function openDevTools(event: IpcMainInvokeEvent): boolean {
|
||||
if (!canOpenDevTools(event)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const success = await progress(appID, result.token, questTarget);
|
||||
const window = BrowserWindow.fromWebContents(event.sender);
|
||||
|
||||
if (success.error || !success.success) {
|
||||
return { success: false, error: JSON.stringify(success.error) };
|
||||
if (event.sender.isDevToolsOpened()) {
|
||||
window?.focus();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
event.sender.openDevTools();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function complete(_: IpcMainInvokeEvent, appId: string, authCode: string, questTarget: number): Promise<{ success: boolean; error: string | null; }> {
|
||||
const authorization = await authorize(appId, authCode);
|
||||
|
||||
if (authorization.error || !authorization.token) {
|
||||
return { success: false, error: JSON.stringify(authorization.error) };
|
||||
}
|
||||
|
||||
const progressResult = await progress(appId, authorization.token, questTarget);
|
||||
|
||||
if (progressResult.error || !progressResult.success) {
|
||||
return { success: false, error: JSON.stringify(progressResult.error) };
|
||||
}
|
||||
|
||||
return { success: true, error: null };
|
||||
}
|
||||
|
||||
async function authorize(appID: string, authCode: string): Promise<{ token: string | false; error: any; }> {
|
||||
let error = null;
|
||||
async function authorize(appId: string, authCode: string): Promise<{ token: string | false; error: unknown; }> {
|
||||
let error: unknown = null;
|
||||
|
||||
const token = await fetch(`https://${appID}.discordsays.com/.proxy/acf/authorize`, {
|
||||
const token = await fetch(`https://${appId}.discordsays.com/.proxy/acf/authorize`, {
|
||||
body: JSON.stringify({ code: authCode }),
|
||||
method: "POST",
|
||||
mode: "cors",
|
||||
credentials: "include"
|
||||
}).then(res => res.json()).then(data => data.token).catch(e => { error = e; return ""; });
|
||||
credentials: "include",
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => data.token)
|
||||
.catch(e => {
|
||||
error = e;
|
||||
return false;
|
||||
});
|
||||
|
||||
return { token, error };
|
||||
}
|
||||
|
||||
async function progress(appID: string, token: string, questTarget: number): Promise<{ success: boolean; error: any; }> {
|
||||
let error = null;
|
||||
async function progress(appId: string, token: string, questTarget: number): Promise<{ success: boolean; error: unknown; }> {
|
||||
let error: unknown = null;
|
||||
|
||||
const success = await fetch(`https://${appID}.discordsays.com/.proxy/acf/quest/progress`, {
|
||||
const success = await fetch(`https://${appId}.discordsays.com/.proxy/acf/quest/progress`, {
|
||||
headers: { "x-auth-token": token },
|
||||
body: JSON.stringify({ progress: questTarget }),
|
||||
method: "POST",
|
||||
mode: "cors",
|
||||
credentials: "include"
|
||||
}).then(res => res.ok).catch(e => { error = e; return false; });
|
||||
credentials: "include",
|
||||
})
|
||||
.then(res => res.ok)
|
||||
.catch(e => {
|
||||
error = e;
|
||||
return false;
|
||||
});
|
||||
|
||||
return { success, error };
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
128
src/equicordplugins/questify/settings/def.ts
Normal file
128
src/equicordplugins/questify/settings/def.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { QuestRewardType, QuestTaskType } from "@vencord/discord-types/enums";
|
||||
|
||||
export type QuestButtonDisplayMode = "always" | "unclaimed" | "never";
|
||||
export type QuestButtonIndicatorMode = "pill" | "badge" | "both" | "none";
|
||||
export type QuestButtonAction = "open-quests" | "context-menu" | "plugin-settings" | "nothing";
|
||||
export type QuestTileGradient = "intense" | "default" | "black" | "hide";
|
||||
export type QuestOrderStatus = "UNCLAIMED" | "CLAIMED" | "IGNORED" | "EXPIRED";
|
||||
export type QuestSubsort = "Recent ASC" | "Recent DESC" | "Expiring ASC" | "Expiring DESC" | "Claimed ASC" | "Claimed DESC";
|
||||
export interface QuestTileColorSetting {
|
||||
enabled: boolean;
|
||||
color: number;
|
||||
}
|
||||
|
||||
export const defaultQuestTileUnclaimedColor = 2842239;
|
||||
export const defaultQuestTileClaimedColor = 6105983;
|
||||
export const defaultQuestTileIgnoredColor = 8334124;
|
||||
export const defaultQuestTileExpiredColor = 2368553;
|
||||
export const defaultQuestTileGradient: QuestTileGradient = "intense";
|
||||
export const defaultQuestTilePreload = true;
|
||||
export const defaultQuestTileUnclaimedColorSetting: QuestTileColorSetting = { enabled: true, color: defaultQuestTileUnclaimedColor };
|
||||
export const defaultQuestTileClaimedColorSetting: QuestTileColorSetting = { enabled: true, color: defaultQuestTileClaimedColor };
|
||||
export const defaultQuestTileIgnoredColorSetting: QuestTileColorSetting = { enabled: true, color: defaultQuestTileIgnoredColor };
|
||||
export const defaultQuestTileExpiredColorSetting: QuestTileColorSetting = { enabled: true, color: defaultQuestTileExpiredColor };
|
||||
export const defaultQuestOrder = ["UNCLAIMED", "CLAIMED", "IGNORED", "EXPIRED"] as const satisfies readonly QuestOrderStatus[];
|
||||
|
||||
export const defaultQuestButtonBadgeColor = defaultQuestTileUnclaimedColor;
|
||||
export const defaultQuestButtonDisplay: QuestButtonDisplayMode = "always";
|
||||
export const defaultQuestButtonIndicator: QuestButtonIndicatorMode = "both";
|
||||
export const defaultLeftClickAction: QuestButtonAction = "open-quests";
|
||||
export const defaultMiddleClickAction: QuestButtonAction = "plugin-settings";
|
||||
export const defaultRightClickAction: QuestButtonAction = "context-menu";
|
||||
|
||||
export const defaultDisableQuestsEverything = false;
|
||||
export const defaultDisableRelocationNotices = true;
|
||||
export const defaultDisableSponsoredBanner = false;
|
||||
export const defaultDisableAccountPanelPromo = true;
|
||||
export const defaultDisableAccountPanelQuestProgress = false;
|
||||
export const defaultDisableOrbsAndQuestsBadges = false;
|
||||
export const defaultDisableFriendsListPromo = true;
|
||||
export const defaultDisableMembersListPromo = true;
|
||||
export const defaultResumeInterruptedQuests = false;
|
||||
export const defaultAllowChangingDangerousSettings = false; // true -> Risky
|
||||
export const defaultMakeMobileVideoQuestsDesktopCompatible = false; // true -> Risky
|
||||
export const defaultCompleteVideoQuestsQuicker = false; // true -> Risky
|
||||
export const defaultAutoCompleteQuestsSimultaneously = false; // true -> Risky
|
||||
export const defaultNotifyOnQuestComplete = true;
|
||||
export const defaultNotifyOnNewQuests = true;
|
||||
export const defaultNotifyOnNewExcludedQuests = false;
|
||||
export const defaultQuestCompletedAlertSound = "bop_message1";
|
||||
export const defaultQuestCompletedAlertVolume = 100;
|
||||
|
||||
const questTaskTypes = [
|
||||
QuestTaskType.WATCH_VIDEO,
|
||||
QuestTaskType.WATCH_VIDEO_ON_MOBILE,
|
||||
QuestTaskType.ACHIEVEMENT_IN_ACTIVITY,
|
||||
QuestTaskType.ACHIEVEMENT_IN_GAME,
|
||||
QuestTaskType.PLAY_ACTIVITY,
|
||||
QuestTaskType.PLAY_ON_DESKTOP,
|
||||
QuestTaskType.PLAY_ON_DESKTOP_V2,
|
||||
QuestTaskType.STREAM_ON_DESKTOP,
|
||||
QuestTaskType.PLAY_ON_PLAYSTATION,
|
||||
QuestTaskType.PLAY_ON_XBOX,
|
||||
] as const satisfies readonly QuestTaskType[];
|
||||
|
||||
export const autoCompleteQuestTaskTypes = [
|
||||
QuestTaskType.PLAY_ON_DESKTOP,
|
||||
QuestTaskType.PLAY_ON_XBOX,
|
||||
QuestTaskType.PLAY_ON_PLAYSTATION,
|
||||
QuestTaskType.PLAY_ACTIVITY,
|
||||
QuestTaskType.WATCH_VIDEO,
|
||||
QuestTaskType.WATCH_VIDEO_ON_MOBILE,
|
||||
QuestTaskType.ACHIEVEMENT_IN_ACTIVITY,
|
||||
] as const satisfies readonly QuestTaskType[];
|
||||
|
||||
const desktopOnlyAutoCompleteQuestTypes = new Set<QuestTaskType>([
|
||||
QuestTaskType.PLAY_ON_DESKTOP,
|
||||
QuestTaskType.PLAY_ON_PLAYSTATION,
|
||||
QuestTaskType.PLAY_ON_XBOX,
|
||||
QuestTaskType.PLAY_ACTIVITY,
|
||||
]);
|
||||
|
||||
export function isDesktopCompatible(questType: QuestTaskType): boolean {
|
||||
return IS_DISCORD_DESKTOP || !desktopOnlyAutoCompleteQuestTypes.has(questType);
|
||||
}
|
||||
|
||||
export type AutoCompleteQuestTypes = Partial<Record<QuestTaskType, boolean>>;
|
||||
|
||||
export const defaultAutoCompleteQuestTypes = Object.fromEntries(
|
||||
autoCompleteQuestTaskTypes.map(questType => [questType, false])
|
||||
) as AutoCompleteQuestTypes;
|
||||
|
||||
export type QuestButtonIncludedTypes = Record<QuestTaskType | QuestRewardType, boolean>;
|
||||
|
||||
export const defaultQuestButtonIncludedTypes: QuestButtonIncludedTypes = {
|
||||
...Object.fromEntries(questTaskTypes.map(questType => [questType, true])),
|
||||
[QuestRewardType.REWARD_CODE]: true,
|
||||
[QuestRewardType.IN_GAME]: true,
|
||||
[QuestRewardType.COLLECTIBLE]: true,
|
||||
[QuestRewardType.VIRTUAL_CURRENCY]: true,
|
||||
[QuestRewardType.FRACTIONAL_PREMIUM]: true,
|
||||
} as QuestButtonIncludedTypes;
|
||||
|
||||
export const defaultQuestButtonBadgeCount = 0;
|
||||
export const defaultQuestFetchInterval = 2700;
|
||||
export const defaultNewQuestAlertSound = "discodo";
|
||||
export const defaultNewQuestAlertVolume = 100;
|
||||
export const defaultNewExcludedQuestAlertSound = null;
|
||||
export const defaultNewExcludedQuestAlertVolume = 100;
|
||||
|
||||
export const defaultUnclaimedSubsort: QuestSubsort = "Expiring ASC";
|
||||
export const defaultClaimedSubsort: QuestSubsort = "Claimed DESC";
|
||||
export const defaultIgnoredSubsort: QuestSubsort = "Recent DESC";
|
||||
export const defaultExpiredSubsort: QuestSubsort = "Expiring DESC";
|
||||
export const defaultIsOnQuestsPage = false;
|
||||
export const defaultRememberQuestPageSort = true;
|
||||
export const defaultRememberQuestPageFilters = true;
|
||||
export const defaultLastQuestPageSort = "questify";
|
||||
export const defaultLastQuestPageFilters = {} as Record<string, { group: string, filter: string; }>;
|
||||
export const ignoredQuestIDsKey = "questIDs";
|
||||
export const defaultIgnoredQuestIDs = { [ignoredQuestIDsKey]: [] } as Record<typeof ignoredQuestIDsKey, string[]>;
|
||||
export const defaultResumeQuestIDs = {} as Record<string, // key: UserID
|
||||
{ timestamp: number, questIDs: string[]; }>;
|
||||
96
src/equicordplugins/questify/settings/fetching.ts
Normal file
96
src/equicordplugins/questify/settings/fetching.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { Quest } from "@vencord/discord-types";
|
||||
|
||||
import { fetchAndAlertQuests } from "../utils/fetching";
|
||||
import { QL } from "../utils/logging";
|
||||
import { getCurrentUserId, settings } from "./store";
|
||||
|
||||
let autoFetchInterval: null | ReturnType<typeof setInterval> = null;
|
||||
const minimumAutoFetchIntervalValue = 30 * 60; // 30 minutes
|
||||
const maximumAutoFetchIntervalValue = 12 * 60 * 60; // 12 hours
|
||||
|
||||
export function autoFetchCompatible(): boolean {
|
||||
const {
|
||||
newExcludedQuestAlertSound,
|
||||
newQuestAlertSound,
|
||||
questFetchInterval,
|
||||
notifyOnNewExcludedQuests,
|
||||
notifyOnNewQuests,
|
||||
questButtonDisplay: displayMode,
|
||||
questButtonIndicator: indicatorMode
|
||||
} = settings.store;
|
||||
const fetching = !settings.store.disableQuestsEverything && questFetchInterval > 0;
|
||||
const notificationsCompatible = notifyOnNewQuests || notifyOnNewExcludedQuests || Boolean(newQuestAlertSound) || Boolean(newExcludedQuestAlertSound);
|
||||
let buttonCompatible = false;
|
||||
|
||||
if (displayMode === "always") {
|
||||
buttonCompatible = ["pill", "badge", "both"].includes(indicatorMode);
|
||||
} else if (displayMode === "unclaimed") {
|
||||
buttonCompatible = true;
|
||||
}
|
||||
|
||||
const compatible = fetching && (buttonCompatible || notificationsCompatible);
|
||||
|
||||
return compatible;
|
||||
}
|
||||
|
||||
export function startAutoFetchingQuests(force: boolean = false): void {
|
||||
if (!autoFetchCompatible()) {
|
||||
stopAutoFetchingQuests();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (autoFetchInterval) {
|
||||
if (!force) {
|
||||
return;
|
||||
}
|
||||
|
||||
stopAutoFetchingQuests();
|
||||
}
|
||||
|
||||
const { questFetchInterval } = settings.store;
|
||||
const interval = Math.min(Math.max(questFetchInterval, minimumAutoFetchIntervalValue), maximumAutoFetchIntervalValue);
|
||||
autoFetchInterval = setInterval(() => { void fetchAndAlertQuests("AUTO_FETCH"); }, interval * 1000);
|
||||
QL.info("START_AUTO_FETCHING_QUESTS", { autoFetchIntervalID: autoFetchInterval, questFetchInterval, questFetchIntervalClamped: interval });
|
||||
}
|
||||
|
||||
export function stopAutoFetchingQuests(): void {
|
||||
if (autoFetchInterval) {
|
||||
clearInterval(autoFetchInterval);
|
||||
autoFetchInterval = null;
|
||||
QL.info("STOP_AUTO_FETCHING_QUESTS");
|
||||
}
|
||||
}
|
||||
|
||||
export function resetQuestsToResume(quest?: Quest, userId?: string): void {
|
||||
const { resumeQuestIDs } = settings.store;
|
||||
const key = getCurrentUserId(userId);
|
||||
|
||||
if (!key) {
|
||||
QL.warn("RESET_QUESTS_TO_RESUME_NO_USER");
|
||||
return;
|
||||
}
|
||||
|
||||
const resumeState = resumeQuestIDs[key];
|
||||
|
||||
if (!resumeState) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!quest) {
|
||||
delete resumeQuestIDs[key];
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
resumeQuestIDs[key] = {
|
||||
timestamp: resumeState.timestamp,
|
||||
questIDs: resumeState.questIDs.filter(id => id !== quest.id),
|
||||
};
|
||||
}
|
||||
86
src/equicordplugins/questify/settings/ignoredQuests.ts
Normal file
86
src/equicordplugins/questify/settings/ignoredQuests.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { Quest } from "@vencord/discord-types";
|
||||
import { QuestStore } from "@webpack/common";
|
||||
|
||||
import type { QuestIncludedTypes } from "../utils/filtering";
|
||||
import { countIncludedUnclaimedQuests, getQuestStatus, QuestStatus } from "../utils/questState";
|
||||
import { ignoredQuestIDsKey } from "./def";
|
||||
import { rerenderQuests } from "./rerender";
|
||||
import { settings } from "./store";
|
||||
|
||||
function validateQuestBadgeCount(): void {
|
||||
const questButtonIncludedTypes = settings.store.questButtonIncludedTypes as QuestIncludedTypes;
|
||||
const quests = Array.from(QuestStore.quests.values());
|
||||
const ignoredQuestIds = getIgnoredQuestIDs();
|
||||
const count = countIncludedUnclaimedQuests(quests, ignoredQuestIds, questButtonIncludedTypes);
|
||||
|
||||
settings.store.questButtonBadgeCount = count;
|
||||
}
|
||||
|
||||
export function getIgnoredQuestIDs(): string[] {
|
||||
const { ignoredQuestIDs } = settings.store;
|
||||
const ignoredQuestIDsForKey = Array.from(ignoredQuestIDs[ignoredQuestIDsKey] ?? []);
|
||||
return ignoredQuestIDsForKey;
|
||||
}
|
||||
|
||||
function setIgnoredQuestIDs(questIDs: string[]): void {
|
||||
settings.store.ignoredQuestIDs[ignoredQuestIDsKey] = questIDs;
|
||||
}
|
||||
|
||||
export function validateIgnoredQuests(qs?: Quest[]): void {
|
||||
const currentlyIgnoredQuests = getIgnoredQuestIDs();
|
||||
const quests = qs ?? Array.from(QuestStore.quests.values());
|
||||
const excludedQuests = Array.from(QuestStore.excludedQuests.values());
|
||||
const validIgnored = Array.from(new Set<string>(currentlyIgnoredQuests.filter(id => quests.some(quest => quest.id === id) || excludedQuests.some(quest => quest.id === id))));
|
||||
|
||||
setIgnoredQuestIDs(validIgnored);
|
||||
validateQuestBadgeCount();
|
||||
rerenderQuests();
|
||||
}
|
||||
|
||||
export function resetIgnoredQuests(): void {
|
||||
setIgnoredQuestIDs([]);
|
||||
validateIgnoredQuests();
|
||||
}
|
||||
|
||||
export function addIgnoredQuest(questId: string): void {
|
||||
setIgnoredQuestIDs(Array.from(new Set([...getIgnoredQuestIDs(), questId])));
|
||||
validateIgnoredQuests();
|
||||
}
|
||||
|
||||
export function removeIgnoredQuest(questId: string): void {
|
||||
setIgnoredQuestIDs(getIgnoredQuestIDs().filter(id => id !== questId));
|
||||
validateIgnoredQuests();
|
||||
}
|
||||
|
||||
export function questIsIgnored(questId: string): boolean {
|
||||
return getIgnoredQuestIDs().includes(questId);
|
||||
}
|
||||
|
||||
export function ignoreAllQuests(): void {
|
||||
const currentlyIgnored = new Set(getIgnoredQuestIDs());
|
||||
const ignoredQuests = new Set<string>();
|
||||
|
||||
for (const quest of QuestStore.quests.values()) {
|
||||
if (
|
||||
currentlyIgnored.has(quest.id)
|
||||
|| getQuestStatus(quest, Array.from(currentlyIgnored), false) === QuestStatus.Unclaimed
|
||||
) {
|
||||
ignoredQuests.add(quest.id);
|
||||
}
|
||||
}
|
||||
|
||||
for (const quest of QuestStore.excludedQuests.values()) {
|
||||
if (currentlyIgnored.has(quest.id)) {
|
||||
ignoredQuests.add(quest.id);
|
||||
}
|
||||
}
|
||||
|
||||
setIgnoredQuestIDs(Array.from(ignoredQuests));
|
||||
validateIgnoredQuests();
|
||||
}
|
||||
29
src/equicordplugins/questify/settings/rerender.ts
Normal file
29
src/equicordplugins/questify/settings/rerender.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { React } from "@webpack/common";
|
||||
|
||||
let questRerenderVersion = 0;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
function getSnapshot(): number {
|
||||
return questRerenderVersion;
|
||||
}
|
||||
|
||||
export function rerenderQuests(): void {
|
||||
questRerenderVersion++;
|
||||
listeners.forEach(listener => listener());
|
||||
}
|
||||
|
||||
export function useQuestRerender(): number {
|
||||
return React.useSyncExternalStore(subscribe, getSnapshot);
|
||||
}
|
||||
65
src/equicordplugins/questify/settings/restartTracking.ts
Normal file
65
src/equicordplugins/questify/settings/restartTracking.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { SettingsStore } from "@api/Settings";
|
||||
import type { DefinedSettings, SettingsDefinition } from "@utils/types";
|
||||
|
||||
import { Alerts } from "../utils/ui";
|
||||
|
||||
type RestartTrackingSettings = Pick<DefinedSettings<SettingsDefinition>, "def" | "pluginName">;
|
||||
|
||||
let restartDirty = false;
|
||||
let didAttachRestartListeners = false;
|
||||
const restartListenerCleanups: (() => void)[] = [];
|
||||
|
||||
function getRestartSettingPaths(settings: RestartTrackingSettings): string[] {
|
||||
return Object.entries(settings.def)
|
||||
.filter(([, definition]) => definition.restartNeeded)
|
||||
.map(([key]) => `plugins.${settings.pluginName}.${key}`);
|
||||
}
|
||||
|
||||
export function initializeRestartTracking(settings: RestartTrackingSettings): void {
|
||||
if (didAttachRestartListeners) {
|
||||
return;
|
||||
}
|
||||
|
||||
didAttachRestartListeners = true;
|
||||
|
||||
for (const path of getRestartSettingPaths(settings)) {
|
||||
const markRestartDirty = () => { restartDirty = true; };
|
||||
|
||||
SettingsStore.addChangeListener(path, markRestartDirty);
|
||||
restartListenerCleanups.push(() => SettingsStore.removeChangeListener(path, markRestartDirty));
|
||||
}
|
||||
}
|
||||
|
||||
export function disposeRestartTracking(): void {
|
||||
for (const cleanup of restartListenerCleanups.splice(0)) {
|
||||
cleanup();
|
||||
}
|
||||
|
||||
didAttachRestartListeners = false;
|
||||
}
|
||||
|
||||
export function isRestartDirty(): boolean {
|
||||
return restartDirty;
|
||||
}
|
||||
|
||||
export function promptToRestartIfDirty(): boolean {
|
||||
if (!restartDirty) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Alerts.show({
|
||||
title: "Restart Required",
|
||||
body: "A change you've made to Questify's settings requires a restart.",
|
||||
confirmText: "Restart",
|
||||
cancelText: "Later",
|
||||
onConfirm: () => location.reload(),
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
386
src/equicordplugins/questify/settings/store.ts
Normal file
386
src/equicordplugins/questify/settings/store.ts
Normal file
|
|
@ -0,0 +1,386 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { definePluginSettings, PlainSettings, SettingsStore } from "@api/Settings";
|
||||
import ErrorBoundary from "@components/ErrorBoundary";
|
||||
import { OptionType } from "@utils/types";
|
||||
import { UserStore } from "@webpack/common";
|
||||
|
||||
import { QuestButtonSetting } from "../components/questButtonSettings";
|
||||
import { QuestFeaturesSetting } from "../components/questFeaturesSetting";
|
||||
import { QuestNotificationsSetting } from "../components/questNotificationsSetting";
|
||||
import { QuestTilesSetting } from "../components/questTilesSetting";
|
||||
import { ReorderQuestsSetting } from "../components/reorderQuestsSetting";
|
||||
import { defaultAllowChangingDangerousSettings, defaultAutoCompleteQuestsSimultaneously, defaultAutoCompleteQuestTypes, defaultClaimedSubsort, defaultCompleteVideoQuestsQuicker, defaultDisableAccountPanelPromo, defaultDisableAccountPanelQuestProgress, defaultDisableFriendsListPromo, defaultDisableMembersListPromo, defaultDisableOrbsAndQuestsBadges, defaultDisableQuestsEverything, defaultDisableRelocationNotices, defaultDisableSponsoredBanner, defaultExpiredSubsort, defaultIgnoredQuestIDs, defaultIgnoredSubsort, defaultIsOnQuestsPage, defaultLastQuestPageFilters, defaultLastQuestPageSort, defaultLeftClickAction, defaultMakeMobileVideoQuestsDesktopCompatible, defaultMiddleClickAction, defaultNewExcludedQuestAlertSound, defaultNewExcludedQuestAlertVolume, defaultNewQuestAlertSound, defaultNewQuestAlertVolume, defaultNotifyOnNewExcludedQuests, defaultNotifyOnNewQuests, defaultNotifyOnQuestComplete, defaultQuestButtonBadgeColor, defaultQuestButtonBadgeCount, defaultQuestButtonDisplay, defaultQuestButtonIncludedTypes, defaultQuestButtonIndicator, defaultQuestCompletedAlertSound, defaultQuestCompletedAlertVolume, defaultQuestFetchInterval, defaultQuestOrder, defaultQuestTileClaimedColorSetting, defaultQuestTileExpiredColorSetting, defaultQuestTileGradient, defaultQuestTileIgnoredColorSetting, defaultQuestTilePreload, defaultQuestTileUnclaimedColorSetting, defaultRememberQuestPageFilters, defaultRememberQuestPageSort, defaultResumeInterruptedQuests, defaultResumeQuestIDs, defaultRightClickAction, defaultUnclaimedSubsort, type QuestButtonAction, type QuestButtonDisplayMode, type QuestButtonIncludedTypes, type QuestButtonIndicatorMode, type QuestOrderStatus } from "./def";
|
||||
|
||||
export function getCurrentUserId(userId?: string): string | null {
|
||||
return userId ?? UserStore.getCurrentUser()?.id ?? null;
|
||||
}
|
||||
|
||||
const MIGRATION_TARGET = 1;
|
||||
const CURRENT_SETTINGS = PlainSettings.plugins.Questify;
|
||||
|
||||
if (CURRENT_SETTINGS) {
|
||||
let migrationVersion = CURRENT_SETTINGS.migrationVersion ?? 0;
|
||||
|
||||
// 0 -> 1: Reset Settings
|
||||
if (migrationVersion === 0) {
|
||||
PlainSettings.plugins.Questify = { enabled: CURRENT_SETTINGS.enabled, migrationVersion: 1 };
|
||||
migrationVersion = 1;
|
||||
}
|
||||
|
||||
if (migrationVersion !== CURRENT_SETTINGS.migrationVersion) {
|
||||
SettingsStore.markAsChanged();
|
||||
}
|
||||
}
|
||||
|
||||
export const settings = definePluginSettings({
|
||||
migrationVersion: {
|
||||
type: OptionType.NUMBER,
|
||||
description: "The current migration version of the settings.",
|
||||
default: MIGRATION_TARGET,
|
||||
hidden: true,
|
||||
},
|
||||
questFeatures: {
|
||||
type: OptionType.COMPONENT,
|
||||
component: ErrorBoundary.wrap(QuestFeaturesSetting) as any,
|
||||
description: "Select which Quest features to disable.",
|
||||
},
|
||||
disableQuestsEverything: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Disable all Quest features.",
|
||||
default: defaultDisableQuestsEverything,
|
||||
restartNeeded: true,
|
||||
hidden: true,
|
||||
},
|
||||
disableRelocationNotices: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Disable Quest relocation notices in the Discovery page.",
|
||||
default: defaultDisableRelocationNotices,
|
||||
restartNeeded: true,
|
||||
hidden: true,
|
||||
},
|
||||
disableSponsoredBanner: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Disable the sponsored banner on the Quest page.",
|
||||
default: defaultDisableSponsoredBanner,
|
||||
restartNeeded: true,
|
||||
hidden: true,
|
||||
},
|
||||
disableAccountPanelPromo: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Disable the promoted Quest popup above your account panel.",
|
||||
default: defaultDisableAccountPanelPromo,
|
||||
restartNeeded: true,
|
||||
hidden: true,
|
||||
},
|
||||
disableAccountPanelQuestProgress: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Disable active and completed Quest progress above your account panel.",
|
||||
default: defaultDisableAccountPanelQuestProgress,
|
||||
restartNeeded: true,
|
||||
hidden: true,
|
||||
},
|
||||
disableOrbsAndQuestsBadges: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Disable the Quest badge on user profiles.",
|
||||
default: defaultDisableOrbsAndQuestsBadges,
|
||||
restartNeeded: true,
|
||||
hidden: true,
|
||||
},
|
||||
disableFriendsListPromo: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Disable the promotion of Quests for games played by friends.",
|
||||
default: defaultDisableFriendsListPromo,
|
||||
restartNeeded: true,
|
||||
hidden: true,
|
||||
},
|
||||
disableMembersListPromo: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Disable the actively playing icon in members list items.",
|
||||
default: defaultDisableMembersListPromo,
|
||||
restartNeeded: true,
|
||||
hidden: true,
|
||||
},
|
||||
allowChangingDangerousSettings: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Allow changing dangerous settings.",
|
||||
default: defaultAllowChangingDangerousSettings,
|
||||
hidden: true,
|
||||
},
|
||||
resumeInterruptedQuests: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Resume auto-completing Quests after a reload or restart.",
|
||||
default: defaultResumeInterruptedQuests,
|
||||
restartNeeded: true,
|
||||
hidden: true,
|
||||
},
|
||||
makeMobileVideoQuestsDesktopCompatible: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Make mobile-only Video Quests compatible with desktop.",
|
||||
default: defaultMakeMobileVideoQuestsDesktopCompatible,
|
||||
restartNeeded: true,
|
||||
hidden: true,
|
||||
},
|
||||
autoCompleteQuestsSimultaneously: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Complete Quests simultaneously rather than sequentially.",
|
||||
default: defaultAutoCompleteQuestsSimultaneously,
|
||||
restartNeeded: true,
|
||||
hidden: true,
|
||||
},
|
||||
completeVideoQuestsQuicker: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Use Discord's progress leeway and elapsed enrollment time for Video Quest auto-completion.",
|
||||
default: defaultCompleteVideoQuestsQuicker,
|
||||
restartNeeded: true,
|
||||
hidden: true,
|
||||
},
|
||||
autoCompleteQuestTypes: {
|
||||
type: OptionType.CUSTOM,
|
||||
description: "Which types of Quests to auto-complete in the background.",
|
||||
default: defaultAutoCompleteQuestTypes,
|
||||
restartNeeded: true,
|
||||
hidden: true,
|
||||
},
|
||||
questButton: {
|
||||
type: OptionType.COMPONENT,
|
||||
component: ErrorBoundary.wrap(QuestButtonSetting) as any,
|
||||
description: "Customize the Quest button in the server list.",
|
||||
},
|
||||
questButtonDisplay: {
|
||||
type: OptionType.CUSTOM,
|
||||
description: "Which display type to use for the Quest button in the server list.",
|
||||
default: defaultQuestButtonDisplay as QuestButtonDisplayMode,
|
||||
restartNeeded: true,
|
||||
hidden: true,
|
||||
},
|
||||
questButtonIncludedTypes: {
|
||||
type: OptionType.CUSTOM,
|
||||
description: "Which reward types and Quest types to include when displaying Quest counts on the Quest button.",
|
||||
default: defaultQuestButtonIncludedTypes as QuestButtonIncludedTypes,
|
||||
hidden: true,
|
||||
},
|
||||
questButtonIndicator: {
|
||||
type: OptionType.CUSTOM,
|
||||
description: "Which display type to use for the unclaimed indicator on the Quest button in the server list.",
|
||||
default: defaultQuestButtonIndicator as QuestButtonIndicatorMode,
|
||||
hidden: true,
|
||||
},
|
||||
questButtonBadgeCount: {
|
||||
type: OptionType.NUMBER,
|
||||
description: "The current number of relevant unclaimed Quests.",
|
||||
default: defaultQuestButtonBadgeCount,
|
||||
hidden: true,
|
||||
},
|
||||
questButtonBadgeColor: {
|
||||
type: OptionType.NUMBER | OptionType.CUSTOM,
|
||||
description: "The color of the Quest button badge in the server list.",
|
||||
default: defaultQuestButtonBadgeColor as number | null,
|
||||
hidden: true,
|
||||
},
|
||||
questButtonLeftClickAction: {
|
||||
type: OptionType.CUSTOM,
|
||||
description: "The action to perform when left-clicking the Quest button in the server list.",
|
||||
default: defaultLeftClickAction as QuestButtonAction,
|
||||
hidden: true,
|
||||
},
|
||||
questButtonMiddleClickAction: {
|
||||
type: OptionType.CUSTOM,
|
||||
description: "The action to perform when middle-clicking the Quest button in the server list.",
|
||||
default: defaultMiddleClickAction as QuestButtonAction,
|
||||
hidden: true,
|
||||
},
|
||||
questButtonRightClickAction: {
|
||||
type: OptionType.CUSTOM,
|
||||
description: "The action to perform when right-clicking the Quest button in the server list.",
|
||||
default: defaultRightClickAction as QuestButtonAction,
|
||||
hidden: true,
|
||||
},
|
||||
questNotifications: {
|
||||
type: OptionType.COMPONENT,
|
||||
component: ErrorBoundary.wrap(QuestNotificationsSetting) as any,
|
||||
description: "Configure Quest completed and new Quest detected notifications.",
|
||||
},
|
||||
notifyOnQuestComplete: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Show a notification when a Quest is completed.",
|
||||
default: defaultNotifyOnQuestComplete,
|
||||
hidden: true,
|
||||
},
|
||||
notifyOnNewQuests: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Show a notification when new Quests are detected.",
|
||||
default: defaultNotifyOnNewQuests,
|
||||
hidden: true,
|
||||
},
|
||||
notifyOnNewExcludedQuests: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Show a notification when new excluded Quests are detected.",
|
||||
default: defaultNotifyOnNewExcludedQuests,
|
||||
hidden: true,
|
||||
},
|
||||
questCompletedAlertSound: {
|
||||
type: OptionType.STRING | OptionType.CUSTOM,
|
||||
description: "The sound to play when a Quest is completed.",
|
||||
default: defaultQuestCompletedAlertSound as string | null,
|
||||
hidden: true,
|
||||
},
|
||||
questCompletedAlertVolume: {
|
||||
type: OptionType.NUMBER,
|
||||
description: "The volume for the Quest completed alert sound.",
|
||||
default: defaultQuestCompletedAlertVolume,
|
||||
hidden: true,
|
||||
},
|
||||
questFetchInterval: {
|
||||
type: OptionType.NUMBER,
|
||||
description: "The interval in seconds to fetch Quests from Discord.",
|
||||
default: defaultQuestFetchInterval,
|
||||
hidden: true,
|
||||
},
|
||||
newQuestAlertSound: {
|
||||
type: OptionType.STRING | OptionType.CUSTOM,
|
||||
description: "The sound to play when new Quests are detected.",
|
||||
default: defaultNewQuestAlertSound as string | null,
|
||||
hidden: true,
|
||||
},
|
||||
newQuestAlertVolume: {
|
||||
type: OptionType.NUMBER,
|
||||
description: "The volume for the new Quest alert sound.",
|
||||
default: defaultNewQuestAlertVolume,
|
||||
hidden: true,
|
||||
},
|
||||
newExcludedQuestAlertSound: {
|
||||
type: OptionType.STRING | OptionType.CUSTOM,
|
||||
description: "The sound to play when new excluded Quests are detected.",
|
||||
default: defaultNewExcludedQuestAlertSound as string | null,
|
||||
hidden: true,
|
||||
},
|
||||
newExcludedQuestAlertVolume: {
|
||||
type: OptionType.NUMBER,
|
||||
description: "The volume for the new excluded Quest alert sound.",
|
||||
default: defaultNewExcludedQuestAlertVolume,
|
||||
hidden: true,
|
||||
},
|
||||
questTiles: {
|
||||
type: OptionType.COMPONENT,
|
||||
component: ErrorBoundary.wrap(QuestTilesSetting) as any,
|
||||
description: "Customize the appearance of Quest tiles in the Quests page.",
|
||||
},
|
||||
questTileUnclaimedColor: {
|
||||
type: OptionType.CUSTOM,
|
||||
description: "The color of unclaimed Quest tiles in the Quests page.",
|
||||
default: { ...defaultQuestTileUnclaimedColorSetting },
|
||||
hidden: true,
|
||||
},
|
||||
questTileClaimedColor: {
|
||||
type: OptionType.CUSTOM,
|
||||
description: "The color of claimed Quest tiles in the Quests page.",
|
||||
default: { ...defaultQuestTileClaimedColorSetting },
|
||||
hidden: true,
|
||||
},
|
||||
questTileIgnoredColor: {
|
||||
type: OptionType.CUSTOM,
|
||||
description: "The color of ignored Quest tiles in the Quests page.",
|
||||
default: { ...defaultQuestTileIgnoredColorSetting },
|
||||
hidden: true,
|
||||
},
|
||||
questTileExpiredColor: {
|
||||
type: OptionType.CUSTOM,
|
||||
description: "The color of expired Quest tiles in the Quests page.",
|
||||
default: { ...defaultQuestTileExpiredColorSetting },
|
||||
hidden: true,
|
||||
},
|
||||
questTileGradient: {
|
||||
type: OptionType.STRING,
|
||||
description: "Style of the gradient used in the Quest tiles.",
|
||||
default: defaultQuestTileGradient,
|
||||
hidden: true,
|
||||
},
|
||||
questTilePreload: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Attempt to preload the assets for the Quest tiles.",
|
||||
default: defaultQuestTilePreload,
|
||||
hidden: true,
|
||||
},
|
||||
reorderQuests: {
|
||||
type: OptionType.COMPONENT,
|
||||
description: "Sort Quests by their status.",
|
||||
component: ErrorBoundary.wrap(ReorderQuestsSetting) as any,
|
||||
},
|
||||
questOrder: {
|
||||
type: OptionType.CUSTOM,
|
||||
description: "Sort order for Quest status groups.",
|
||||
default: Array.from(defaultQuestOrder) as QuestOrderStatus[],
|
||||
hidden: true,
|
||||
},
|
||||
unclaimedSubsort: {
|
||||
type: OptionType.STRING,
|
||||
description: "Subsort method for unclaimed Quests.",
|
||||
default: defaultUnclaimedSubsort,
|
||||
hidden: true,
|
||||
},
|
||||
claimedSubsort: {
|
||||
type: OptionType.STRING,
|
||||
description: "Subsort method for claimed Quests.",
|
||||
default: defaultClaimedSubsort,
|
||||
hidden: true,
|
||||
},
|
||||
ignoredSubsort: {
|
||||
type: OptionType.STRING,
|
||||
description: "Subsort method for ignored Quests.",
|
||||
default: defaultIgnoredSubsort,
|
||||
hidden: true,
|
||||
},
|
||||
expiredSubsort: {
|
||||
type: OptionType.STRING,
|
||||
description: "Subsort method for expired Quests.",
|
||||
default: defaultExpiredSubsort,
|
||||
hidden: true,
|
||||
},
|
||||
isOnQuestsPage: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Whether the user is currently on the Quests page.",
|
||||
default: defaultIsOnQuestsPage,
|
||||
hidden: true,
|
||||
},
|
||||
rememberQuestPageSort: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Remember the last used sort on the Quests page.",
|
||||
default: defaultRememberQuestPageSort,
|
||||
hidden: true,
|
||||
},
|
||||
rememberQuestPageFilters: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Remember the last used filters on the Quests page.",
|
||||
default: defaultRememberQuestPageFilters,
|
||||
hidden: true,
|
||||
},
|
||||
lastQuestPageSort: {
|
||||
type: OptionType.STRING,
|
||||
description: "Remember the last used sort on the Quests page.",
|
||||
default: defaultLastQuestPageSort,
|
||||
hidden: true,
|
||||
},
|
||||
lastQuestPageFilters: {
|
||||
type: OptionType.CUSTOM,
|
||||
description: "Remember the last used filters on the Quests page.",
|
||||
default: defaultLastQuestPageFilters,
|
||||
hidden: true,
|
||||
},
|
||||
ignoredQuestIDs: {
|
||||
type: OptionType.CUSTOM,
|
||||
description: "An array of Quest IDs that are ignored.",
|
||||
default: defaultIgnoredQuestIDs,
|
||||
hidden: true,
|
||||
},
|
||||
resumeQuestIDs: {
|
||||
type: OptionType.CUSTOM,
|
||||
description: "An array of Quest IDs that are being or are queued to be auto-completed in the background.",
|
||||
default: defaultResumeQuestIDs,
|
||||
hidden: true,
|
||||
},
|
||||
});
|
||||
|
|
@ -1,209 +1,550 @@
|
|||
/* Plugin Settings */
|
||||
/* Miscellaneous */
|
||||
|
||||
/* Fix to overwrite bad CSS in callTimer/alignedChatInputFix.css */
|
||||
[class*="panels"] [class*="questProgressWrapper"] [class*="outer"] > [class*="inner"] {
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
/* Fix to overwrite bad CSS in callTimer/alignedChatInputFix.css */
|
||||
[class*="panels"] [class*="questProgressWrapper"] > div {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
/* Fix oversized confetti blocking Quest buttons. */
|
||||
[class*="rewardDescriptionContainer"] [class*="progressWrapper"] > [class*="confetti"] {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Fix Questify button text clipping. */
|
||||
.questify-quest-item-restyle button [class*="buttonChildren"],
|
||||
.questify-quest-item-restyle button span {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* Settings */
|
||||
|
||||
.questify-setting {
|
||||
border-radius: 3px;
|
||||
border-left: 3px solid var(--text-strong);
|
||||
padding: 8px;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.questify-settings-divider-small {
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.questify-settings-divider-medium {
|
||||
margin: 15px 0;
|
||||
}
|
||||
|
||||
.questify-settings-divider-large {
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.questify-dimmed-settings-item {
|
||||
filter: brightness(0.5);
|
||||
}
|
||||
|
||||
.questify-setting-header {
|
||||
font-size: 19px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.questify-setting-subheader {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
margin: 15px 0 5px;
|
||||
}
|
||||
|
||||
.questify-setting-paragraph {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
margin: 5px 0;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.questify-setting-description {
|
||||
margin-bottom: 15px;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.questify-setting-description-multi-paragraph {
|
||||
line-height: 0.2em;
|
||||
}
|
||||
|
||||
[class*="clickTrapContainer"] > div > div:has(.questify-settings-tooltip-content) {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.questify-settings-tooltip-content {
|
||||
text-align: justify;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.questify-setting-switch {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.questify-setting-switch [class*="container_"] [class*="labelContainer_"] label {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.questify-setting-subtle-switch [class*="container_"] [class*="labelContainer_"] label,
|
||||
.questify-setting-switch [class*="container_"] [class*="labelContainer_"] div {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.questify-notice-card {
|
||||
margin: 12px 0;
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
line-height: 0.2em;
|
||||
}
|
||||
|
||||
.questify-notice-card-red {
|
||||
border-left: 3px solid var(--status-danger);
|
||||
background-color: color-mix(in srgb, var(--status-danger) 12%, transparent);
|
||||
}
|
||||
|
||||
.questify-notice-card-blue {
|
||||
border-left: 3px solid var(--background-brand);
|
||||
background-color: color-mix(in srgb, var(--background-brand) 12%, transparent);
|
||||
}
|
||||
|
||||
.questify-notice-card-solo {
|
||||
margin: 12px 0 0;
|
||||
}
|
||||
|
||||
.questify-settings-button > button[data-mana-component="button"] {
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.questify-settings-button > button[data-mana-component="button"] span {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.questify-setting [data-floating-ui-focusable][class*="selectDropdown"],
|
||||
.questify-setting [data-mana-component="select-input-field"] > [class*="selectField"],
|
||||
.questify-setting [data-mana-component="select-input-field"] [class*="tagList"] > div {
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.questify-inline-volume-slider {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.questify-sound-preview-button {
|
||||
color: var(--interactive-normal);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.questify-sound-preview-button:hover,
|
||||
.questify-sound-preview-button.questify-playing-audio {
|
||||
background: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
.questify-sound-preview-button:hover {
|
||||
color: var(--interactive-active);
|
||||
}
|
||||
|
||||
.questify-sound-preview-button.questify-playing-audio {
|
||||
color: var(--red-500);
|
||||
}
|
||||
|
||||
.questify-sound-preview-button:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.questify-sound-select [class*="selectedIcon"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.questify-no-top-margin,
|
||||
.questify-margin-top-0 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.questify-margin-top-1 {
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.questify-margin-top-2 {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.questify-margin-top-3 {
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.questify-margin-top-4 {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.questify-margin-top-5 {
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.questify-margin-top-6 {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.questify-margin-top-7 {
|
||||
margin-top: 7px;
|
||||
}
|
||||
|
||||
.questify-margin-top-8 {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.questify-margin-top-9 {
|
||||
margin-top: 9px;
|
||||
}
|
||||
|
||||
.questify-margin-top-10 {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.questify-margin-top-11 {
|
||||
margin-top: 11px;
|
||||
}
|
||||
|
||||
.questify-margin-top-12 {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.questify-margin-top-13 {
|
||||
margin-top: 13px;
|
||||
}
|
||||
|
||||
.questify-margin-top-14 {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.questify-margin-top-15 {
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.questify-no-bottom-margin,
|
||||
.questify-margin-bottom-0 {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.questify-margin-bottom-1 {
|
||||
margin-bottom: 1px;
|
||||
}
|
||||
|
||||
.questify-margin-bottom-2 {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.questify-margin-bottom-3 {
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.questify-margin-bottom-4 {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.questify-margin-bottom-5 {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.questify-margin-bottom-6 {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.questify-margin-bottom-7 {
|
||||
margin-bottom: 7px;
|
||||
}
|
||||
|
||||
.questify-margin-bottom-8 {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.questify-setting-divider {
|
||||
margin: 0;
|
||||
.questify-margin-bottom-9 {
|
||||
margin-bottom: 9px;
|
||||
}
|
||||
|
||||
.questify-setting {
|
||||
border-left: 3px solid var(--text-strong);
|
||||
padding: 3px 0 3px 8px;
|
||||
border-radius: 3px;
|
||||
user-select: text;
|
||||
.questify-margin-bottom-10 {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.questify-main-inline-group {
|
||||
.questify-margin-bottom-11 {
|
||||
margin-bottom: 11px;
|
||||
}
|
||||
|
||||
.questify-margin-bottom-12 {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.questify-margin-bottom-13 {
|
||||
margin-bottom: 13px;
|
||||
}
|
||||
|
||||
.questify-margin-bottom-14 {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.questify-margin-bottom-15 {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.questify-settings-row {
|
||||
margin-top: 4px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
gap: 4px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.questify-sub-inline-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
.questify-settings-row-item {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.questify-inline-group-item {
|
||||
flex: 1;
|
||||
.questify-settings-row-item-content {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.questify-form-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.questify-form-subtitle {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin-top: 4px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.questify-form-subtitle-spacier {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.questify-form-description {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.questify-form-warning {
|
||||
margin: 12px 0 8px;
|
||||
padding: 10px 12px;
|
||||
border-left: 3px solid var(--status-danger);
|
||||
border-radius: 4px;
|
||||
background-color: color-mix(in srgb, var(--status-danger) 12%, transparent);
|
||||
}
|
||||
|
||||
.questify-select > div {
|
||||
border-radius: 3px;
|
||||
min-height: 43px;
|
||||
border: 1px solid var(--border-normal);
|
||||
}
|
||||
|
||||
.questify-affixless-select-popout-option {
|
||||
grid-template-areas: "content selectedIcon" !important;
|
||||
grid-template-columns: 1fr auto !important;
|
||||
}
|
||||
|
||||
.questify-setting [class*="comboBoxInputContainer"] {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.questify-volume-slider-container {
|
||||
margin: 8px 0 0;
|
||||
.questify-settings-slider-control-container {
|
||||
margin: 4px 0 0;
|
||||
background-color: var(--input-background-default, var(--input-background));
|
||||
padding: 7px 12px 7px 7px;
|
||||
padding: 0 12px 0 4px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--border-normal);
|
||||
}
|
||||
|
||||
.questify-button,
|
||||
.questify-sub-inline-group [class*="swatch"] {
|
||||
height: 38px;
|
||||
min-height: 38px;
|
||||
width: 80px;
|
||||
min-width: 80px;
|
||||
padding: 2px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.questify-alert-icon {
|
||||
flex: 0;
|
||||
place-content: center center;
|
||||
background-color: var(--input-background-default, var(--input-background));
|
||||
border-radius: 3px;
|
||||
padding: 0 10px;
|
||||
color: var(--text-default);
|
||||
border: 1px solid var(--border-normal);
|
||||
min-height: 43px;
|
||||
min-height: 40px;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transition: color 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.questify-alert-icon.questify-playing-audio {
|
||||
color: var(--red-500);
|
||||
transition: color 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.questify-button [class*="contents"] {
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
.questify-button-red {
|
||||
background-color: var(--red-500) !important;
|
||||
}
|
||||
|
||||
.questify-button-red:hover {
|
||||
filter: brightness(0.75);
|
||||
transition: filter 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.questify-button-blue {
|
||||
background-color: var(--blue-500) !important;
|
||||
}
|
||||
|
||||
.questify-button-blue:hover {
|
||||
filter: brightness(0.75);
|
||||
transition: filter 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
#questify-dummy-quest-button {
|
||||
border-left: 5px solid var(--text-strong);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.questify-text-input,
|
||||
.questify-text-area {
|
||||
margin-top: 4px;
|
||||
.questify-settings-slider-control {
|
||||
margin: 0 4px;
|
||||
width: 100%;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.questify-text-area {
|
||||
height: 150px;
|
||||
.questify-sound-select-row-item {
|
||||
flex: 0 1 67%;
|
||||
}
|
||||
|
||||
.questify-color-picker-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
.questify-volume-slider-row-item {
|
||||
flex: 0 1 33%;
|
||||
}
|
||||
|
||||
.questify-color-picker-group {
|
||||
/* Quest Tiles */
|
||||
|
||||
.questify-quest-tile-gradient-row-item {
|
||||
flex: 0 1 42%;
|
||||
}
|
||||
|
||||
.questify-quest-tile-preload-row-item {
|
||||
flex: 0 1 58%;
|
||||
}
|
||||
|
||||
.questify-quest-tile-color-row {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.questify-quest-tile-color-row-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.questify-color-picker-with-buttons {
|
||||
background-color: var(--input-background-default, var(--input-background));
|
||||
border: 1px solid var(--border-normal);
|
||||
border-radius: 3px;
|
||||
padding: 8px;
|
||||
.questify-settings-color-picker.questify-quest-tile-color-picker {
|
||||
display: flex;
|
||||
height: 38px;
|
||||
min-height: 38px;
|
||||
min-width: 0;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.questify-color-picker-with-buttons [class*="swatch"],
|
||||
.questify-color-picker-with-buttons [class*="colorSwatch"] {
|
||||
border-radius: 3px;
|
||||
.questify-settings-color-picker.questify-quest-tile-color-picker > [class*="colorSwatch"] {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
margin-bottom: 4px;
|
||||
height: 100%;
|
||||
min-height: 38px;
|
||||
}
|
||||
|
||||
.questify-wide-button {
|
||||
.questify-settings-color-picker.questify-quest-tile-color-picker [class*="swatch"],
|
||||
.questify-settings-color-picker.questify-quest-tile-color-picker [class*="Swatch"] {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
min-height: 38px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.questify-flex-65 {
|
||||
flex: 6;
|
||||
.questify-settings-color-picker.questify-disabled-color-picker {
|
||||
filter: brightness(0.5);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.questify-flex-35 {
|
||||
flex: 4;
|
||||
.questify-quest-tile-color-button {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.questify-inline-code-block {
|
||||
display: inline-block;
|
||||
padding: 0 4px;
|
||||
border-radius: 4px;
|
||||
background-color: var(--background-code);
|
||||
border: 1px solid var(--border-normal);
|
||||
font-family: var(--font-code);
|
||||
font-size: 90%;
|
||||
.questify-dummy-quest-preview {
|
||||
cursor: default;
|
||||
margin-top: 12px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.questify-dummy-quest {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.questify-dummy-quest:active {
|
||||
/* Background of Quest tile. */
|
||||
.questify-quest-item-restyle > :where(div) {
|
||||
background-color: var(--questify-color) !important;
|
||||
}
|
||||
|
||||
/* Normal button text + SVG button text color. */
|
||||
.questify-quest-item-restyle > :nth-child(2) button > *,
|
||||
.questify-quest-item-restyle > :nth-child(2) button > * > * > span {
|
||||
color: var(--questify-quest-name);
|
||||
}
|
||||
|
||||
/* Watch again button SVG color. */
|
||||
.questify-quest-item-restyle > :nth-child(2) button > div > div > svg path {
|
||||
fill: var(--questify-quest-name);
|
||||
}
|
||||
|
||||
/* Allows glowing reward previews which aren't confined to their own square video frame to overflow. */
|
||||
.questify-quest-item-restyle > div > div[class*="rewardDescriptionContainer"] {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* Quest name color. */
|
||||
.questify-quest-item-restyle > div > div[class*="rewardDescriptionContainer"] > div[class*="textContainer"] > [class*="eyebrow"] {
|
||||
color: var(--questify-quest-name) !important;
|
||||
}
|
||||
|
||||
/* Reward title + Orbs reward title text color. */
|
||||
.questify-quest-item-restyle > div > div[class*="rewardDescriptionContainer"] > div[class*="textContainer"] > div[class*="text-md"],
|
||||
.questify-quest-item-restyle > div > div[class*="rewardDescriptionContainer"] > div[class*="textContainer"] > div[class*="text-md"] > span {
|
||||
color: var(--questify-reward-title) !important;
|
||||
}
|
||||
|
||||
/* Quest description text color. */
|
||||
.questify-quest-item-restyle > div > div[class*="rewardDescriptionContainer"] > div[class*="textContainer"] > div[class*="text-sm"] {
|
||||
color: var(--questify-reward-description) !important;
|
||||
}
|
||||
|
||||
.questify-quest-item-restyle [class*="placeholderElement"] {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* Promoted by XYZ text color. */
|
||||
.questify-quest-item-restyle > div[class*="container"] > div[class*="positionContentOverBackground"] > div[class*="contents"] > div[class*="bottomRow"] > div[class*="text-sm"] {
|
||||
color: var(--questify-reward-description) !important;
|
||||
}
|
||||
|
||||
.questify-quest-item-contrast-logo > div[class*="container"] > div[class*="positionContentOverBackground"] > div[class*="contents"] > div[class*="partnerBranding"] > img {
|
||||
filter: drop-shadow(0 0 10px rgb(0 0 0 / 80%));
|
||||
}
|
||||
|
||||
.questify-quest-item-intense-gradient > :nth-child(1) [class*="overlay"] {
|
||||
background: linear-gradient(to top,
|
||||
var(--questify-color) 0,
|
||||
hsl(from var(--questify-color) h s l / 100%) 20.5%,
|
||||
hsl(from var(--questify-color) h s l / 99.5%) 23.33%,
|
||||
hsl(from var(--questify-color) h s l / 98%) 26.66%,
|
||||
hsl(from var(--questify-color) h s l / 97%) 30%,
|
||||
hsl(from var(--questify-color) h s l / 90%) 40%,
|
||||
hsl(from var(--questify-color) h s l / 77%) 50%,
|
||||
hsl(from var(--questify-color) h s l / 0%) 100%);
|
||||
}
|
||||
|
||||
.questify-quest-item-default-gradient > :nth-child(1) [class*="overlay"] {
|
||||
background: linear-gradient(to top,
|
||||
var(--questify-color) 0,
|
||||
hsl(from var(--questify-color) h s l / 95%) 20.31%,
|
||||
hsl(from var(--questify-color) h s l / 0%) 100%);
|
||||
}
|
||||
|
||||
.questify-quest-item-black-gradient > :nth-child(1) [class*="overlay"] {
|
||||
background: linear-gradient(to top,
|
||||
hsl(0deg 0% 0% / 80%) 10%,
|
||||
hsl(0deg 0% 0% / 65%) 20%,
|
||||
hsl(0deg 0% 0% / 50%) 50%,
|
||||
hsl(0deg 0% 0% / 10%) 75%,
|
||||
hsl(0deg 0% 0% / 0%) 90%);
|
||||
}
|
||||
|
||||
.questify-quest-item-hide-gradient > :nth-child(1) [class*="overlay"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.questify-quest-item-restyle > :nth-child(2) [class*="textOverflowBlur"] {
|
||||
background: linear-gradient(transparent 50%, var(--questify-color) 100%);
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
|
||||
.questify-quest-item-restyle > :nth-child(2) > div button {
|
||||
background-color: var(--questify-button-normal) !important;
|
||||
}
|
||||
|
||||
.questify-quest-item-restyle > :nth-child(2) > div button:not([disabled]):hover {
|
||||
background-color: var(--questify-button-hover) !important;
|
||||
transition: background-color 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.questify-quest-item-restyle > :nth-child(1) [class*="container"] {
|
||||
border-top-left-radius: 8px;
|
||||
border-top-right-radius: 8px;
|
||||
}
|
||||
|
||||
.questify-quest-item-restyle > :nth-child(2) [class*="container"] {
|
||||
border-bottom-left-radius: 8px;
|
||||
border-bottom-right-radius: 8px;
|
||||
}
|
||||
|
||||
div[id^="quest-tile-"] div[class^="confetti__"] {
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Quest Button */
|
||||
|
||||
#questify-dummy-quest-button {
|
||||
margin-right: -12px;
|
||||
margin-left: 5px;
|
||||
border-left: 5px solid var(--text-strong);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.questify-settings-color-picker.questify-quest-button-color-picker {
|
||||
display: flex;
|
||||
height: 38px;
|
||||
min-height: 38px;
|
||||
min-width: 0;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.questify-settings-color-picker.questify-quest-button-color-picker > [class*="colorSwatch"] {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 38px;
|
||||
}
|
||||
|
||||
.questify-settings-color-picker.questify-quest-button-color-picker [class*="swatch"],
|
||||
.questify-settings-color-picker.questify-quest-button-color-picker [class*="Swatch"] {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 38px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.questify-quest-button-pill {
|
||||
|
|
@ -245,126 +586,3 @@
|
|||
.questify-quest-button-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Background of Quest tile. */
|
||||
.questify-quest-item-restyle > div {
|
||||
background-color: var(--questify-color) !important;
|
||||
}
|
||||
|
||||
/* Normal button text + SVG button text color. */
|
||||
.questify-quest-item-restyle > :nth-child(2) button > div,
|
||||
.questify-quest-item-restyle > :nth-child(2) button > div > div > span {
|
||||
color: var(--questify-quest-name);
|
||||
}
|
||||
|
||||
/* Watch again button SVG color. */
|
||||
.questify-quest-item-restyle > :nth-child(2) button > div > div > svg path {
|
||||
fill: var(--questify-quest-name);
|
||||
}
|
||||
|
||||
/* Allows glowing reward previews which aren't confined to their own
|
||||
square video frame to overflow so the glow doesn't get cut off. */
|
||||
.questify-quest-item-restyle > div > div[class*="rewardDescriptionContainer"] {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* Quest name color. */
|
||||
.questify-quest-item-restyle > div > div[class*="rewardDescriptionContainer"] > div[class*="textContainer"] > [class*="eyebrow"] {
|
||||
color: var(--questify-quest-name) !important;
|
||||
}
|
||||
|
||||
/* Reward title + Orbs reward title text color. */
|
||||
.questify-quest-item-restyle > div > div[class*="rewardDescriptionContainer"] > div[class*="textContainer"] > div[class*="text-md"],
|
||||
.questify-quest-item-restyle > div > div[class*="rewardDescriptionContainer"] > div[class*="textContainer"] > div[class*="text-md"] > span {
|
||||
color: var(--questify-reward-title) !important;
|
||||
}
|
||||
|
||||
/* Quest description text color. */
|
||||
.questify-quest-item-restyle > div > div[class*="rewardDescriptionContainer"] > div[class*="textContainer"] > div[class*="text-sm"] {
|
||||
color: var(--questify-reward-description) !important;
|
||||
}
|
||||
|
||||
/* Hides the gray placeholder elements which distract from the restyles. */
|
||||
.questify-quest-item-restyle [class*="placeholderElement"] {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* Promoted by XYZ text color. */
|
||||
.questify-quest-item-restyle > div[class*="container"] > div[class*="positionContentOverBackground"] > div[class*="contents"] > div[class*="bottomRow"] > div[class*="text-sm"] {
|
||||
color: var(--questify-reward-description) !important;
|
||||
}
|
||||
|
||||
/* Drop shadow for >87.5% luminance (near-white) restyle gradients so logos are legible. */
|
||||
.questify-quest-item-contrast-logo > div[class*="container"] > div[class*="positionContentOverBackground"] > div[class*="contents"] > div[class*="partnerBranding"] > img {
|
||||
filter: drop-shadow(0 0 10px rgb(0 0 0 / 80%));
|
||||
}
|
||||
|
||||
/* Increased gradient intensity with restyle color. */
|
||||
.questify-quest-item-intense-gradient > :nth-child(1) [class*="overlay"] {
|
||||
background: linear-gradient(to top,
|
||||
var(--questify-color) 0,
|
||||
hsl(from var(--questify-color) h s l / 100%) 20.5%,
|
||||
hsl(from var(--questify-color) h s l / 99.5%) 23.33%,
|
||||
hsl(from var(--questify-color) h s l / 98%) 26.66%,
|
||||
hsl(from var(--questify-color) h s l / 97%) 30%,
|
||||
hsl(from var(--questify-color) h s l / 90%) 40%,
|
||||
hsl(from var(--questify-color) h s l / 77%) 50%,
|
||||
hsl(from var(--questify-color) h s l / 0%) 100%);
|
||||
}
|
||||
|
||||
/* Default gradient intensity with restyle color. */
|
||||
.questify-quest-item-default-gradient > :nth-child(1) [class*="overlay"] {
|
||||
background: linear-gradient(to top,
|
||||
var(--questify-color) 0,
|
||||
hsl(from var(--questify-color) h s l / 95%) 20.31%,
|
||||
hsl(from var(--questify-color) h s l / 0%) 100%);
|
||||
}
|
||||
|
||||
/* Subtle black gradient. */
|
||||
.questify-quest-item-black-gradient > :nth-child(1) [class*="overlay"] {
|
||||
background: linear-gradient(to top,
|
||||
hsl(0deg 0% 0% / 80%) 10%,
|
||||
hsl(0deg 0% 0% / 65%) 20%,
|
||||
hsl(0deg 0% 0% / 50%) 50%,
|
||||
hsl(0deg 0% 0% / 10%) 75%,
|
||||
hsl(0deg 0% 0% / 0%) 90%);
|
||||
}
|
||||
|
||||
/* Hides the gradient altogether. */
|
||||
.questify-quest-item-hide-gradient > :nth-child(1) [class*="overlay"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Matches the text overlay which darkens the text of long
|
||||
descriptions until hovered with the chosen restyle color. */
|
||||
.questify-quest-item-restyle > :nth-child(2) [class*="textOverflowBlur"] {
|
||||
background: linear-gradient(transparent 50%, var(--questify-color) 100%);
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
|
||||
/* Quest tile button color. */
|
||||
.questify-quest-item-restyle > :nth-child(2) > div button {
|
||||
background-color: var(--questify-button-normal) !important;
|
||||
}
|
||||
|
||||
.questify-quest-item-restyle > :nth-child(2) > div button:not([disabled]):hover {
|
||||
background-color: var(--questify-button-hover) !important;
|
||||
transition: background-color 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
/* Quest tile border radius. */
|
||||
|
||||
.questify-quest-item-restyle > :nth-child(1) [class*="container"] {
|
||||
border-top-left-radius: 8px;
|
||||
border-top-right-radius: 8px;
|
||||
}
|
||||
|
||||
.questify-quest-item-restyle > :nth-child(2) [class*="container"] {
|
||||
border-bottom-left-radius: 8px;
|
||||
border-bottom-right-radius: 8px;
|
||||
}
|
||||
|
||||
/* Prevent completion confetti from covering quest buttons */
|
||||
div[id^="quest-tile-"] div[class^="confetti__"] {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
|
|
|||
1205
src/equicordplugins/questify/utils/completion.ts
Normal file
1205
src/equicordplugins/questify/utils/completion.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,381 +0,0 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2025 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import ErrorBoundary from "@components/ErrorBoundary";
|
||||
import { findComponentByCodeLazy } from "@webpack";
|
||||
import { SearchableSelect, useState } from "@webpack/common";
|
||||
import { JSX } from "react";
|
||||
|
||||
import { settings } from "../settings";
|
||||
import { formatLowerBadge } from "./misc";
|
||||
|
||||
// Requires GuildlessServerListItemComponent export patch in index.tsx
|
||||
// GuildlessServerListItem's built-in pill does not support unread state.
|
||||
export const GuildlessServerListItemComponent = findComponentByCodeLazy("tooltip:", "lowerBadgeSize:");
|
||||
export const GuildedServerListItemPillComponent = findComponentByCodeLazy('"pill":"empty"');
|
||||
export const ServerListItemLowerBadgeComponent = findComponentByCodeLazy("BADGE_NOTIFICATION_BACKGROUND", "let{count:");
|
||||
export const ServerListItemUpperBadgeComponent = findComponentByCodeLazy("{icon:", ".ROUND,disableColor");
|
||||
export const RadioGroup = findComponentByCodeLazy(",{label:", ",description:", ",required:", ",errorMessage:", ",children:");
|
||||
export const QuestTile = findComponentByCodeLazy(".rowIndex,trackGuildAndChannelMetadata");
|
||||
|
||||
export class ActiveQuestIntervalsMap extends Map<string, { progressTimeout: NodeJS.Timeout; rerenderTimeout: NodeJS.Timeout; progress: number; type: string; }> {
|
||||
set(key: string, value: { progressTimeout: NodeJS.Timeout; rerenderTimeout: NodeJS.Timeout; progress: number; type: string; }): this {
|
||||
const { resumeQuestIDs } = settings.store;
|
||||
|
||||
settings.store.resumeQuestIDs = {
|
||||
watch: resumeQuestIDs.watch.filter((id: string) => id !== key),
|
||||
play: resumeQuestIDs.play.filter((id: string) => id !== key),
|
||||
achievement: resumeQuestIDs.achievement.filter((id: string) => id !== key),
|
||||
};
|
||||
|
||||
settings.store.resumeQuestIDs[value.type].push(key);
|
||||
|
||||
return super.set(key, value);
|
||||
}
|
||||
|
||||
delete(key: string): boolean {
|
||||
const { resumeQuestIDs } = settings.store;
|
||||
|
||||
settings.store.resumeQuestIDs = {
|
||||
watch: resumeQuestIDs.watch.filter((id: string) => id !== key),
|
||||
play: resumeQuestIDs.play.filter((id: string) => id !== key),
|
||||
achievement: resumeQuestIDs.achievement.filter((id: string) => id !== key),
|
||||
};
|
||||
|
||||
return super.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
export enum QuestRewardType {
|
||||
UNKNOWN = 0,
|
||||
REWARD_CODE = 1,
|
||||
IN_GAME = 2,
|
||||
COLLECTIBLE = 3,
|
||||
VIRTUAL_CURRENCY = 4,
|
||||
FRACTIONAL_PREMIUM = 5
|
||||
}
|
||||
|
||||
export enum QuestStatus {
|
||||
Claimed = "CLAIMED",
|
||||
Unclaimed = "UNCLAIMED",
|
||||
Ignored = "IGNORED",
|
||||
Expired = "EXPIRED",
|
||||
Unknown = "UNKNOWN"
|
||||
}
|
||||
|
||||
export interface FetchingQuestsSettingProps {
|
||||
interval: number | null;
|
||||
alert: string | null;
|
||||
}
|
||||
|
||||
export interface RadioOption {
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface SelectOption {
|
||||
label: string;
|
||||
value: string | number;
|
||||
}
|
||||
|
||||
export interface DynamicDropdownSettingOption {
|
||||
label: string;
|
||||
value: string;
|
||||
selected: boolean;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
export interface RGB {
|
||||
r: number;
|
||||
g: number;
|
||||
b: number;
|
||||
}
|
||||
|
||||
export type ExcludedQuestMap = Map<string, ExcludedQuest>;
|
||||
|
||||
export type QuestMap = Map<string, Quest>;
|
||||
|
||||
export interface ExcludedQuest {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export enum QuestTaskType {
|
||||
WATCH_VIDEO = "WATCH_VIDEO",
|
||||
WATCH_VIDEO_ON_MOBILE = "WATCH_VIDEO_ON_MOBILE",
|
||||
PLAY_ON_DESKTOP = "PLAY_ON_DESKTOP",
|
||||
PLAY_ON_XBOX = "PLAY_ON_XBOX",
|
||||
PLAY_ON_PLAYSTATION = "PLAY_ON_PLAYSTATION",
|
||||
PLAY_ACTIVITY = "PLAY_ACTIVITY",
|
||||
ACHIEVEMENT_IN_ACTIVITY = "ACHIEVEMENT_IN_ACTIVITY"
|
||||
}
|
||||
|
||||
export interface QuestTask {
|
||||
type: QuestTaskType;
|
||||
target: number;
|
||||
}
|
||||
|
||||
export interface Quest {
|
||||
questifyNumber: number;
|
||||
id: string;
|
||||
config: {
|
||||
application: {
|
||||
id: string;
|
||||
name: string;
|
||||
},
|
||||
startsAt: string;
|
||||
expiresAt: string;
|
||||
messages: {
|
||||
questName: string;
|
||||
};
|
||||
rewardsConfig: {
|
||||
rewards: {
|
||||
type: QuestRewardType;
|
||||
orbQuantity?: number;
|
||||
messages?: {
|
||||
nameWithArticle: string;
|
||||
};
|
||||
}[];
|
||||
},
|
||||
taskConfigV2: {
|
||||
tasks: {
|
||||
WATCH_VIDEO?: {
|
||||
type: QuestTaskType.WATCH_VIDEO;
|
||||
target: number;
|
||||
},
|
||||
WATCH_VIDEO_ON_MOBILE?: {
|
||||
type: QuestTaskType.WATCH_VIDEO_ON_MOBILE;
|
||||
target: number;
|
||||
},
|
||||
PLAY_ON_DESKTOP?: {
|
||||
type: QuestTaskType.PLAY_ON_DESKTOP;
|
||||
target: number;
|
||||
},
|
||||
PLAY_ON_XBOX?: {
|
||||
type: QuestTaskType.PLAY_ON_XBOX;
|
||||
target: number;
|
||||
},
|
||||
PLAY_ON_PLAYSTATION?: {
|
||||
type: QuestTaskType.PLAY_ON_PLAYSTATION;
|
||||
target: number;
|
||||
},
|
||||
PLAY_ACTIVITY?: {
|
||||
type: QuestTaskType.PLAY_ACTIVITY;
|
||||
target: number;
|
||||
};
|
||||
ACHIEVEMENT_IN_ACTIVITY?: {
|
||||
type: QuestTaskType.ACHIEVEMENT_IN_ACTIVITY;
|
||||
target: number;
|
||||
eventName: "progress";
|
||||
applications: {
|
||||
id: string;
|
||||
}[];
|
||||
};
|
||||
};
|
||||
};
|
||||
},
|
||||
userStatus: null | {
|
||||
userId: string;
|
||||
claimedAt: string | null;
|
||||
completedAt: string | null;
|
||||
enrolledAt: string | null;
|
||||
progress: null | {
|
||||
WATCH_VIDEO?: {
|
||||
eventName: QuestTaskType.WATCH_VIDEO;
|
||||
value: number;
|
||||
},
|
||||
WATCH_VIDEO_ON_MOBILE?: {
|
||||
eventName: QuestTaskType.WATCH_VIDEO_ON_MOBILE;
|
||||
value: number;
|
||||
},
|
||||
PLAY_ON_DESKTOP?: {
|
||||
eventName: QuestTaskType.PLAY_ON_DESKTOP;
|
||||
value: number;
|
||||
},
|
||||
PLAY_ON_XBOX?: {
|
||||
eventName: QuestTaskType.PLAY_ON_XBOX;
|
||||
value: number;
|
||||
},
|
||||
PLAY_ON_PLAYSTATION?: {
|
||||
eventName: QuestTaskType.PLAY_ON_PLAYSTATION;
|
||||
value: number;
|
||||
},
|
||||
PLAY_ACTIVITY?: {
|
||||
eventName: QuestTaskType.PLAY_ACTIVITY;
|
||||
value: number;
|
||||
},
|
||||
};
|
||||
},
|
||||
dummyColor?: number | null;
|
||||
}
|
||||
|
||||
export function SoundIcon(height: number, width: number, className?: string): JSX.Element {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
height={height}
|
||||
width={width}
|
||||
fill="none"
|
||||
className={className}
|
||||
>
|
||||
<path fill="currentColor" d="M12 3a1 1 0 0 0-1-1h-.06a1 1 0 0 0-.74.32L5.92 7H3a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h2.92l4.28 4.68a1 1 0 0 0 .74.32H11a1 1 0 0 0 1-1V3ZM15.1 20.75c-.58.14-1.1-.33-1.1-.92v-.03c0-.5.37-.92.85-1.05a7 7 0 0 0 0-13.5A1.11 1.11 0 0 1 14 4.2v-.03c0-.6.52-1.06 1.1-.92a9 9 0 0 1 0 17.5Z" className=""></path><path fill="currentColor" d="M15.16 16.51c-.57.28-1.16-.2-1.16-.83v-.14c0-.43.28-.8.63-1.02a3 3 0 0 0 0-5.04c-.35-.23-.63-.6-.63-1.02v-.14c0-.63.59-1.1 1.16-.83a5 5 0 0 1 0 9.02Z"></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
export function QuestIcon(height: number, width: number, className?: string): JSX.Element {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
height={height}
|
||||
width={width}
|
||||
fill="none"
|
||||
className={className}
|
||||
>
|
||||
<path fill="currentColor" d="M7.5 21.7a8.95 8.95 0 0 1 9 0 1 1 0 0 0 1-1.73c-.6-.35-1.24-.64-1.9-.87.54-.3 1.05-.65 1.52-1.07a3.98 3.98 0 0 0 5.49-1.8.77.77 0 0 0-.24-.95 3.98 3.98 0 0 0-2.02-.76A4 4 0 0 0 23 10.47a.76.76 0 0 0-.71-.71 4.06 4.06 0 0 0-1.6.22 3.99 3.99 0 0 0 .54-5.35.77.77 0 0 0-.95-.24c-.75.36-1.37.95-1.77 1.67V6a4 4 0 0 0-4.9-3.9.77.77 0 0 0-.6.72 4 4 0 0 0 3.7 4.17c.89 1.3 1.3 2.95 1.3 4.51 0 3.66-2.75 6.5-6 6.5s-6-2.84-6-6.5c0-1.56.41-3.21 1.3-4.51A4 4 0 0 0 11 2.82a.77.77 0 0 0-.6-.72 4.01 4.01 0 0 0-4.9 3.96A4.02 4.02 0 0 0 3.73 4.4a.77.77 0 0 0-.95.24 3.98 3.98 0 0 0 .55 5.35 4 4 0 0 0-1.6-.22.76.76 0 0 0-.72.71l-.01.28a4 4 0 0 0 2.65 3.77c-.75.06-1.45.33-2.02.76-.3.22-.4.62-.24.95a4 4 0 0 0 5.49 1.8c.47.42.98.78 1.53 1.07-.67.23-1.3.52-1.91.87a1 1 0 1 0 1 1.73Z" ></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ServerListItemUpperBadgeProps {
|
||||
icon?: (() => JSX.Element),
|
||||
color?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ServerListItemUpperBadge(props: ServerListItemUpperBadgeProps): JSX.Element {
|
||||
return (
|
||||
<ServerListItemUpperBadgeComponent {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export interface ServerListItemLowerBadgeProps {
|
||||
count: number;
|
||||
className?: string;
|
||||
color?: string;
|
||||
style?: React.CSSProperties;
|
||||
maxDigits?: number;
|
||||
}
|
||||
|
||||
export function ServerListItemLowerBadge(props: ServerListItemLowerBadgeProps): JSX.Element {
|
||||
return (
|
||||
<ServerListItemLowerBadgeComponent
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export interface GuildlessServerListItemProps {
|
||||
id?: string;
|
||||
className?: string;
|
||||
icon?: JSX.Element;
|
||||
tooltip?: string;
|
||||
showPill?: boolean;
|
||||
isVisible?: boolean;
|
||||
isSelected?: boolean;
|
||||
hasUnread?: boolean;
|
||||
lowerBadgeProps?: ServerListItemLowerBadgeProps;
|
||||
upperBadgeProps?: ServerListItemUpperBadgeProps;
|
||||
onClick?: ((e: React.MouseEvent) => void);
|
||||
onContextMenu?: ((e: React.MouseEvent) => void);
|
||||
onMouseDown?: ((e: React.MouseEvent) => void);
|
||||
}
|
||||
|
||||
export function GuildlessServerListItem({
|
||||
id,
|
||||
className = "vc-server-list-item",
|
||||
icon,
|
||||
tooltip,
|
||||
showPill = true,
|
||||
isVisible,
|
||||
isSelected,
|
||||
hasUnread,
|
||||
lowerBadgeProps,
|
||||
upperBadgeProps,
|
||||
onClick,
|
||||
onContextMenu,
|
||||
onMouseDown
|
||||
}: GuildlessServerListItemProps): JSX.Element {
|
||||
const baseClasses = className.split(" ") || ["vc-server-list-item"];
|
||||
const mainContainerClass = `${baseClasses.join(" ")}-container`;
|
||||
const iconContainerClass = `${baseClasses.join(" ")}-icon-container`;
|
||||
const buttonContainerClass = `${baseClasses.join(" ")}-server-list-button-container`;
|
||||
const pillContainerClass = `${baseClasses.join(" ")}-pill-container`;
|
||||
const buttonClass = `${baseClasses.join(" ")}-button`;
|
||||
const pillClass = `${baseClasses.join(" ")}-pill`;
|
||||
const pillClassSelected = `${pillClass} selected`;
|
||||
const pillClassHovered = `${pillClass} hovered`;
|
||||
const lowerBadgeClass = `${baseClasses.join(" ")}-lower-badge`;
|
||||
const upperBadgeClass = `${baseClasses.join(" ")}-upper-badge`;
|
||||
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const visible = isVisible ?? true;
|
||||
const selected = isSelected ?? false;
|
||||
const unread = hasUnread ?? false;
|
||||
const lowerBadgeData = lowerBadgeProps ? { ...lowerBadgeProps, className: lowerBadgeProps.className ?? lowerBadgeClass } : null;
|
||||
const lowerBadgeSize = lowerBadgeData ? { width: formatLowerBadge(lowerBadgeData.count, lowerBadgeData.maxDigits)[1] } : null;
|
||||
|
||||
const lowerBadge = !lowerBadgeData || lowerBadgeData.count === 0 ? null : ServerListItemLowerBadge({ ...lowerBadgeData, style: { ...(lowerBadgeData.style || {}), ...(lowerBadgeSize || {}) } });
|
||||
const upperBadgeData = upperBadgeProps ? { ...upperBadgeProps, className: upperBadgeProps.className ?? upperBadgeClass } : null;
|
||||
const upperBadge = upperBadgeData ? ServerListItemUpperBadge(upperBadgeData) : null;
|
||||
|
||||
const wrappedIcon = icon ? (
|
||||
<div className={iconContainerClass}>
|
||||
{icon}
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
const componentProps: Record<string, any> = {
|
||||
...(wrappedIcon && { icon: () => wrappedIcon }),
|
||||
...(tooltip !== undefined && { tooltip }),
|
||||
...(onClick !== undefined && { onClick }),
|
||||
...(onMouseDown !== undefined && { onMouseDown }),
|
||||
...(onContextMenu !== undefined && { onContextMenu }),
|
||||
// This one styles the backdrop of the badge while the one passed as a prop to the badge styles the badge itself.
|
||||
...(lowerBadge && lowerBadgeSize ? { lowerBadgeSize: lowerBadgeSize } : {}),
|
||||
...(lowerBadge ? { lowerBadge: lowerBadge } : {}),
|
||||
...(upperBadge ? { upperBadge: upperBadge } : {}),
|
||||
};
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
{visible && (
|
||||
<div
|
||||
{...(id !== undefined ? { id } : {})}
|
||||
className={mainContainerClass}
|
||||
>
|
||||
<div className={pillContainerClass} >
|
||||
<GuildedServerListItemPillComponent
|
||||
unread={unread && showPill}
|
||||
selected={selected && showPill}
|
||||
hovered={hovered && showPill}
|
||||
className={selected ? pillClassSelected : hovered ? pillClassHovered : pillClass}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={buttonContainerClass}
|
||||
>
|
||||
<GuildlessServerListItemComponent
|
||||
showPill={false}
|
||||
selected={selected}
|
||||
className={buttonClass}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
{...componentProps}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
export interface DynamicDropdownProps extends React.ComponentProps<typeof SearchableSelect> {
|
||||
feedback?: string;
|
||||
maxOptionsVisible?: number;
|
||||
}
|
||||
|
||||
export const DynamicDropdown: React.FC<DynamicDropdownProps> = props => {
|
||||
return <SearchableSelect {...props} />;
|
||||
};
|
||||
217
src/equicordplugins/questify/utils/fetching.ts
Normal file
217
src/equicordplugins/questify/utils/fetching.ts
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { playAudio } from "@api/AudioPlayer";
|
||||
import { showNotification } from "@api/Notifications";
|
||||
import { sleep } from "@utils/misc";
|
||||
import type { PluginNative } from "@utils/types";
|
||||
import type { Quest } from "@vencord/discord-types";
|
||||
import { findByCodeLazy, findStoreLazy } from "@webpack";
|
||||
import { QuestStore, RestAPI } from "@webpack/common";
|
||||
import { NavigationRouter } from "@webpack/common/utils";
|
||||
|
||||
import { questIsIgnored } from "../settings/ignoredQuests";
|
||||
import { settings } from "../settings/store";
|
||||
import { getNewQuests, normalizeQuestName, type QuestIncludedTypes, questMatchesIncludedTypes } from "./filtering";
|
||||
import { QL } from "./logging";
|
||||
import { QUEST_PAGE } from "./ui";
|
||||
|
||||
export const AuthorizedAppsStore = findStoreLazy("AuthorizedAppsStore");
|
||||
const QuestifyNative = VencordNative?.pluginHelpers?.Questify as PluginNative<typeof import("../native")> | undefined;
|
||||
|
||||
export function snakeToCamel(obj: any): any {
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map(snakeToCamel);
|
||||
}
|
||||
|
||||
if (obj && typeof obj === "object") {
|
||||
return Object.fromEntries(
|
||||
Object.entries(obj).map(([key, value]) => [
|
||||
key.replace(/_([a-z])/g, (_, c) => c.toUpperCase()),
|
||||
snakeToCamel(value),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
export const fetchAndDispatchQuests = findByCodeLazy("QUESTS_FETCH_CURRENT_QUESTS_BEGIN");
|
||||
const parseQuestConfig = findByCodeLazy("config).with({config_version:");
|
||||
const formatQuestData = findByCodeLazy("config),userStatus:null==");
|
||||
|
||||
async function fetchQuestById(questId: string): Promise<Quest | null> {
|
||||
try {
|
||||
const { body } = await RestAPI.get({ url: `/quests/${questId}`, retries: 3 });
|
||||
const valid = !!parseQuestConfig({ config: body });
|
||||
|
||||
if (!valid) {
|
||||
QL.warn("FETCH_QUEST_BY_ID_INVALID_BODY", { questId, body });
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return formatQuestData(body);
|
||||
} catch (error: unknown) {
|
||||
QL.warn("FETCH_QUEST_BY_ID_FAILED", { questId, error });
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchExcludedQuestConfigs(questIds: string[]): Promise<Quest[]> {
|
||||
const quests: Quest[] = [];
|
||||
|
||||
for (const [index, questId] of questIds.entries()) {
|
||||
if (index > 0) {
|
||||
await sleep(1000);
|
||||
}
|
||||
|
||||
const quest = await fetchQuestById(questId);
|
||||
|
||||
if (quest) {
|
||||
quests.push(quest);
|
||||
}
|
||||
}
|
||||
|
||||
return quests;
|
||||
}
|
||||
|
||||
export function canOpenDevToolsWindow(): boolean {
|
||||
return typeof QuestifyNative?.canOpenDevTools === "function"
|
||||
&& typeof QuestifyNative?.openDevTools === "function";
|
||||
}
|
||||
|
||||
export async function openDevToolsWindow(): Promise<boolean> {
|
||||
if (!canOpenDevToolsWindow()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const native = QuestifyNative!;
|
||||
|
||||
return await native.canOpenDevTools()
|
||||
? native.openDevTools()
|
||||
: false;
|
||||
}
|
||||
|
||||
function getQuestNotificationText(quests: Quest[], excluded: boolean): { title: string; body: string; } {
|
||||
const firstQuest = quests[0];
|
||||
const firstQuestName = normalizeQuestName(firstQuest);
|
||||
|
||||
if (quests.length === 1) {
|
||||
return {
|
||||
title: excluded ? "New Excluded Quest Detected!" : "New Quest Detected!",
|
||||
body: excluded
|
||||
? `The excluded ${firstQuestName} Quest was detected. ID: ${firstQuest.id}`
|
||||
: `The ${firstQuestName} Quest is now available.`
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: excluded ? "New Excluded Quests Detected!" : "New Quests Detected!",
|
||||
body: excluded
|
||||
? `${quests.length} new excluded Quests were detected. Check the console for their Quest IDs.`
|
||||
: `${quests.length} new Quests are now available.`
|
||||
};
|
||||
}
|
||||
|
||||
function notifyNewQuests(quests: Quest[], excluded: boolean): void {
|
||||
if (quests.length === 0) return;
|
||||
|
||||
const firstQuest = quests[0];
|
||||
const { title, body } = getQuestNotificationText(quests, excluded);
|
||||
const onClick = excluded
|
||||
? canOpenDevToolsWindow() ? openDevToolsWindow : undefined
|
||||
: () => NavigationRouter.transitionTo(`${QUEST_PAGE}#${firstQuest.id}`);
|
||||
|
||||
showNotification({
|
||||
title,
|
||||
body,
|
||||
dismissOnClick: true,
|
||||
onClick
|
||||
});
|
||||
|
||||
QL.log("NOTIFY_NEW_QUESTS", { excluded, quests });
|
||||
}
|
||||
|
||||
export async function fetchAndAlertQuests(source: string): Promise<Quest[] | null> {
|
||||
const alertSound = settings.store.newQuestAlertSound;
|
||||
const alertVolume = settings.store.newQuestAlertVolume;
|
||||
const excludedAlertSound = settings.store.newExcludedQuestAlertSound;
|
||||
const excludedAlertVolume = settings.store.newExcludedQuestAlertVolume;
|
||||
const shouldFetchExcludedQuests = settings.store.notifyOnNewExcludedQuests || Boolean(excludedAlertSound);
|
||||
const currentQuests = Array.from(QuestStore.quests.values());
|
||||
const currentExcludedQuestIds = new Set(Array.from(QuestStore.excludedQuests.values()).map(quest => quest.id));
|
||||
const includedTypes = settings.store.questButtonIncludedTypes as QuestIncludedTypes;
|
||||
|
||||
await fetchAndDispatchQuests();
|
||||
await sleep(1000);
|
||||
|
||||
const nextQuests = Array.from(QuestStore.quests.values());
|
||||
|
||||
if (!nextQuests || currentQuests.length === 0) {
|
||||
return nextQuests;
|
||||
}
|
||||
|
||||
const newQuests = getNewQuests(currentQuests, nextQuests);
|
||||
const newExcludedQuestIds = shouldFetchExcludedQuests
|
||||
? Array.from(QuestStore.excludedQuests.values())
|
||||
.map(quest => quest.id)
|
||||
.filter(questId => !currentExcludedQuestIds.has(questId))
|
||||
: [];
|
||||
|
||||
if (newQuests.length === 0 && newExcludedQuestIds.length === 0) {
|
||||
return nextQuests;
|
||||
}
|
||||
|
||||
const newExcludedQuests = newExcludedQuestIds.length > 0 ? await fetchExcludedQuestConfigs(newExcludedQuestIds) : [];
|
||||
const newIncludedQuests = newQuests.filter(quest => questMatchesIncludedTypes(quest, includedTypes) && !questIsIgnored(quest.id));
|
||||
const newIncludedExcludedQuests = newExcludedQuests.filter(quest => questMatchesIncludedTypes(quest, includedTypes) && !questIsIgnored(quest.id));
|
||||
const shouldAlert = Boolean(alertSound) && newIncludedQuests.length > 0;
|
||||
const shouldAlertExcluded = Boolean(excludedAlertSound) && newIncludedExcludedQuests.length > 0;
|
||||
const shouldNotify = settings.store.notifyOnNewQuests && newIncludedQuests.length > 0;
|
||||
const shouldNotifyExcluded = settings.store.notifyOnNewExcludedQuests && newIncludedExcludedQuests.length > 0;
|
||||
|
||||
QL.info("FETCH_AND_ALERT_QUESTS_NEW_QUESTS", {
|
||||
source,
|
||||
newQuestCount: newQuests.length,
|
||||
newQuests: newIncludedQuests,
|
||||
matchedQuestCount: newIncludedQuests.length + newIncludedExcludedQuests.length,
|
||||
...(shouldFetchExcludedQuests ? {
|
||||
newExcludedQuestCount: newExcludedQuestIds.length,
|
||||
newExcludedQuests: newIncludedExcludedQuests,
|
||||
matchedExcludedQuestCount: newIncludedExcludedQuests.length,
|
||||
} : {}),
|
||||
shouldAlert,
|
||||
shouldAlertExcluded,
|
||||
shouldNotify,
|
||||
shouldNotifyExcluded,
|
||||
});
|
||||
|
||||
if (shouldAlert) {
|
||||
playAudio(
|
||||
alertSound!,
|
||||
{ volume: Math.max(0, Math.min(100, alertVolume)) }
|
||||
);
|
||||
}
|
||||
|
||||
if (shouldAlertExcluded) {
|
||||
playAudio(
|
||||
excludedAlertSound!,
|
||||
{ volume: Math.max(0, Math.min(100, excludedAlertVolume)) }
|
||||
);
|
||||
}
|
||||
|
||||
if (shouldNotify) {
|
||||
notifyNewQuests(newIncludedQuests, false);
|
||||
}
|
||||
|
||||
if (shouldNotifyExcluded) {
|
||||
notifyNewQuests(newIncludedExcludedQuests, true);
|
||||
}
|
||||
|
||||
return nextQuests;
|
||||
}
|
||||
28
src/equicordplugins/questify/utils/filtering.ts
Normal file
28
src/equicordplugins/questify/utils/filtering.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { Quest } from "@vencord/discord-types";
|
||||
import { QuestRewardType, QuestTaskType } from "@vencord/discord-types/enums";
|
||||
|
||||
export type QuestIncludedTypes = Record<QuestTaskType | QuestRewardType, boolean>;
|
||||
|
||||
export function getNewQuests(currentQuests: Quest[], nextQuests: Quest[]): Quest[] {
|
||||
const currentQuestIds = new Set(currentQuests.map(quest => quest.id));
|
||||
|
||||
return nextQuests.filter(quest => !currentQuestIds.has(quest.id));
|
||||
}
|
||||
|
||||
export function questMatchesIncludedTypes(quest: Quest, includedTypes: QuestIncludedTypes): boolean {
|
||||
const rewardTypeAllowed = quest.config.rewardsConfig.rewards.some(reward => Boolean(includedTypes[reward.type]));
|
||||
const taskTypeAllowed = Object.values(quest.config.taskConfigV2.tasks).some(task => Boolean(includedTypes[task.type]));
|
||||
|
||||
return rewardTypeAllowed && taskTypeAllowed;
|
||||
}
|
||||
|
||||
export function normalizeQuestName(quest: Quest): string {
|
||||
const normalized = quest.config.messages.questName.trim().toUpperCase();
|
||||
return normalized.endsWith("QUEST") ? normalized.slice(0, -5).trim() : normalized;
|
||||
}
|
||||
79
src/equicordplugins/questify/utils/logging.ts
Normal file
79
src/equicordplugins/questify/utils/logging.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { showNotification } from "@api/Notifications";
|
||||
import { Logger } from "@utils/Logger";
|
||||
import type { Quest } from "@vencord/discord-types";
|
||||
import { NavigationRouter } from "@webpack/common/utils";
|
||||
|
||||
import { normalizeQuestName } from "./filtering";
|
||||
import { QUEST_PAGE } from "./ui";
|
||||
|
||||
const LOG = new Logger("Questify");
|
||||
|
||||
export function getFormattedNow(): string {
|
||||
return new Date().toLocaleString(undefined, {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: true,
|
||||
}).replace(",", "");
|
||||
}
|
||||
|
||||
function getQuestifyLogPrefix(source: string): string {
|
||||
return `[${getFormattedNow()}] [${source}]\n`;
|
||||
}
|
||||
|
||||
function emitQuestifyLog(level: string, source: string, ...args: unknown[]): void {
|
||||
const prefix = args.length ? getQuestifyLogPrefix(source) : getQuestifyLogPrefix(source).trim();
|
||||
|
||||
switch (level) {
|
||||
case "debug":
|
||||
LOG.debug(prefix, ...args);
|
||||
|
||||
return;
|
||||
case "error":
|
||||
LOG.error(prefix, ...args);
|
||||
|
||||
return;
|
||||
case "info":
|
||||
LOG.info(prefix, ...args);
|
||||
|
||||
return;
|
||||
case "warn":
|
||||
LOG.warn(prefix, ...args);
|
||||
|
||||
return;
|
||||
default:
|
||||
LOG.log(prefix, ...args);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
export const QL = {
|
||||
log(source: string, ...args: unknown[]): void { emitQuestifyLog("log", source, ...args); },
|
||||
info(source: string, ...args: unknown[]): void { emitQuestifyLog("info", source, ...args); },
|
||||
warn(source: string, ...args: unknown[]): void { emitQuestifyLog("warn", source, ...args); },
|
||||
error(source: string, ...args: unknown[]): void { emitQuestifyLog("error", source, ...args); },
|
||||
debug(source: string, ...args: unknown[]): void { emitQuestifyLog("debug", source, ...args); },
|
||||
};
|
||||
|
||||
export function notifyQuestCompletion(quest?: Quest): void {
|
||||
if (!quest) return;
|
||||
|
||||
showNotification({
|
||||
title: "Quest Completed!",
|
||||
body: `The ${normalizeQuestName(quest)} Quest has completed.`,
|
||||
dismissOnClick: true,
|
||||
onClick: () => NavigationRouter.transitionTo(`${QUEST_PAGE}#${quest.id}`)
|
||||
});
|
||||
|
||||
QL.log("NOTIFY_QUEST_COMPLETION", { quest });
|
||||
}
|
||||
|
|
@ -1,490 +0,0 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2025 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { classNameFactory } from "@utils/css";
|
||||
import { Logger } from "@utils/Logger";
|
||||
import { findByCodeLazy, findByPropsLazy } from "@webpack";
|
||||
import { FluxDispatcher, RestAPI, UserStore } from "@webpack/common";
|
||||
|
||||
import { activeQuestIntervals } from "..";
|
||||
import { questIsIgnored, settings } from "../settings";
|
||||
import { Quest, QuestStatus, QuestTask, QuestTaskType, RGB } from "./components";
|
||||
|
||||
export const q = classNameFactory("questify-");
|
||||
export const QuestifyLogger = new Logger("Questify");
|
||||
export const QuestsStore = findByPropsLazy("getQuest");
|
||||
export const reportProgress = findByCodeLazy(".QUESTS_VIDEO_PROGRESS(");
|
||||
export const sendHeartbeat = findByCodeLazy(".QUESTS_HEARTBEAT(");
|
||||
export const questPath = "/quest-home";
|
||||
export const videoQuestLeeway = 24;
|
||||
export const leftClick = 0;
|
||||
export const middleClick = 1;
|
||||
export const rightClick = 2;
|
||||
|
||||
type HeartbeatDispatchResult =
|
||||
| { type: "success"; userStatus: Quest["userStatus"]; }
|
||||
| { type: "failure"; error: unknown; }
|
||||
| { type: "timeout"; };
|
||||
|
||||
export function setIgnoredQuestIDs(questIDs: string[], userId?: string): void {
|
||||
const currentUserID = userId ?? UserStore.getCurrentUser()?.id;
|
||||
if (!currentUserID) return;
|
||||
const { ignoredQuestProfile } = settings.store;
|
||||
const key = ignoredQuestProfile === "shared" ? "shared" : currentUserID;
|
||||
settings.store.ignoredQuestIDs[key] = questIDs;
|
||||
}
|
||||
|
||||
export function getIgnoredQuestIDs(userId?: string): string[] {
|
||||
const currentUserID = userId ?? UserStore.getCurrentUser()?.id;
|
||||
if (!currentUserID) return [];
|
||||
const { ignoredQuestIDs, ignoredQuestProfile } = settings.store;
|
||||
const key = ignoredQuestProfile === "shared" ? "shared" : currentUserID;
|
||||
ignoredQuestIDs[key] ??= [];
|
||||
return ignoredQuestIDs[key];
|
||||
}
|
||||
|
||||
export function getQuestTask(quest: Quest): QuestTask {
|
||||
const tasks = quest.config.taskConfigV2?.tasks;
|
||||
|
||||
return (tasks.PLAY_ON_DESKTOP ||
|
||||
tasks.PLAY_ON_XBOX ||
|
||||
tasks.PLAY_ON_PLAYSTATION ||
|
||||
tasks.PLAY_ACTIVITY ||
|
||||
tasks.WATCH_VIDEO ||
|
||||
tasks.WATCH_VIDEO_ON_MOBILE ||
|
||||
tasks.ACHIEVEMENT_IN_ACTIVITY) as QuestTask;
|
||||
}
|
||||
|
||||
export function getQuestProgress(quest: Quest, task: QuestTask) {
|
||||
const intervalData = activeQuestIntervals.get(quest.id);
|
||||
|
||||
if (intervalData) { return intervalData.progress; }
|
||||
|
||||
const progressMap = quest.userStatus?.progress;
|
||||
|
||||
if (!progressMap) { return null; }
|
||||
|
||||
if (task.type === QuestTaskType.WATCH_VIDEO || task.type === QuestTaskType.WATCH_VIDEO_ON_MOBILE) {
|
||||
const watchProgress = progressMap.WATCH_VIDEO?.value;
|
||||
const mobileProgress = progressMap.WATCH_VIDEO_ON_MOBILE?.value;
|
||||
|
||||
if (watchProgress !== undefined || mobileProgress !== undefined) {
|
||||
return Math.max(watchProgress ?? 0, mobileProgress ?? 0);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return progressMap[task.type]?.value ?? null;
|
||||
}
|
||||
|
||||
export function getQuestTarget(task: QuestTask) {
|
||||
const isWatch = task.type === QuestTaskType.WATCH_VIDEO || task.type === QuestTaskType.WATCH_VIDEO_ON_MOBILE;
|
||||
const { completeVideoQuestsQuicker } = settings.store;
|
||||
const raw = task.target;
|
||||
const adjusted = Math.max(0, raw - (isWatch && completeVideoQuestsQuicker ? videoQuestLeeway : 0));
|
||||
return { raw, adjusted };
|
||||
}
|
||||
|
||||
export function getQuestStatus(quest: Quest, checkIgnored: boolean = true): QuestStatus {
|
||||
const completedQuest = quest.userStatus?.completedAt;
|
||||
const claimedQuest = quest.userStatus?.claimedAt;
|
||||
const expiredQuest = new Date(quest.config.expiresAt) < new Date();
|
||||
const questIgnored = questIsIgnored(quest.id);
|
||||
|
||||
if (claimedQuest) {
|
||||
return QuestStatus.Claimed;
|
||||
} else if (checkIgnored && questIgnored && (!expiredQuest || completedQuest)) {
|
||||
return QuestStatus.Ignored;
|
||||
} else if (completedQuest || !expiredQuest) {
|
||||
return QuestStatus.Unclaimed;
|
||||
} else if (expiredQuest) {
|
||||
return QuestStatus.Expired;
|
||||
}
|
||||
|
||||
return QuestStatus.Unknown;
|
||||
}
|
||||
|
||||
export function refreshQuest(quest: Quest): Quest {
|
||||
return QuestsStore.getQuest(quest.id) as Quest ?? quest;
|
||||
}
|
||||
|
||||
export function isSoundAllowed(url: string): Promise<boolean> {
|
||||
return VencordNative.csp.isDomainAllowed(url, ["img-src"]);
|
||||
}
|
||||
|
||||
export function snakeToCamel(obj: any): any {
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map(snakeToCamel);
|
||||
} else if (obj && typeof obj === "object") {
|
||||
return Object.fromEntries(
|
||||
Object.entries(obj).map(([key, value]) => [
|
||||
key.replace(/_([a-z])/g, (_, c) => c.toUpperCase()),
|
||||
snakeToCamel(value)
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
export function getFormattedNow(): string {
|
||||
return new Date().toLocaleString(undefined, {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: true
|
||||
}).replace(",", "");
|
||||
}
|
||||
|
||||
export function decimalToRGB(decimal: number): RGB {
|
||||
const r = (decimal >> 16) & 0xFF;
|
||||
const g = (decimal >> 8) & 0xFF;
|
||||
const b = decimal & 0xFF;
|
||||
return { r, g, b };
|
||||
}
|
||||
|
||||
export function adjustRGB(rgb: RGB, shift: number): RGB {
|
||||
const color = { ...rgb };
|
||||
|
||||
color.r = Math.max(0, Math.min(255, color.r + shift));
|
||||
color.g = Math.max(0, Math.min(255, color.g + shift));
|
||||
color.b = Math.max(0, Math.min(255, color.b + shift));
|
||||
|
||||
return color;
|
||||
}
|
||||
|
||||
export function isDarkish(rgb: RGB, threshold: number = 0.5): boolean {
|
||||
const luminance = (0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b) / 255;
|
||||
return luminance < threshold;
|
||||
}
|
||||
|
||||
export function validCommaSeparatedList(
|
||||
value: string,
|
||||
allowedTerms: string[] = [],
|
||||
allowEmpty: boolean = true,
|
||||
mustIncludeAll: boolean = false,
|
||||
noDuplicates: boolean = true,
|
||||
caseSensitive: boolean = false
|
||||
): boolean {
|
||||
const trimmedValue = value.trim();
|
||||
|
||||
if (!trimmedValue) {
|
||||
return allowEmpty;
|
||||
}
|
||||
|
||||
function normalize(str: string): string {
|
||||
return caseSensitive ? str.trim() : str.trim().toLowerCase();
|
||||
}
|
||||
|
||||
const allowed = new Set(allowedTerms.map(term => normalize(term)));
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (let term of trimmedValue.split(",")) {
|
||||
term = normalize(term);
|
||||
|
||||
if (!term || !allowed.has(term)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (noDuplicates && seen.has(term)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
seen.add(term);
|
||||
}
|
||||
|
||||
return !mustIncludeAll || (allowed.size === seen.size);
|
||||
}
|
||||
|
||||
export function normalizeQuestName(name: string): string {
|
||||
const normalized = name.trim().toUpperCase();
|
||||
return normalized.endsWith("QUEST") ? normalized.slice(0, -5).trim() : normalized;
|
||||
}
|
||||
|
||||
export async function fetchAndDispatchQuests(source?: string, logger?: Logger): Promise<Quest[] | null> {
|
||||
try {
|
||||
const { body } = snakeToCamel(await RestAPI.get({ url: "/quests/@me", retries: 3 }));
|
||||
|
||||
FluxDispatcher.dispatch({
|
||||
type: "QUESTS_FETCH_CURRENT_QUESTS_SUCCESS",
|
||||
excludedQuests: body.excludedQuests,
|
||||
questEnrollmentBlockedUntil: body.questEnrollmentBlockedUntil,
|
||||
quests: body.quests,
|
||||
...(source !== undefined && { source }),
|
||||
});
|
||||
|
||||
return body.quests;
|
||||
} catch (e) {
|
||||
logger?.warn(`[${getFormattedNow()}] Failed to fetch and dispatch Quests:`, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitUntilEnrolled(quest: Quest, timeout: number = 30000, interval: number = 500, logger?: Logger): Promise<boolean> {
|
||||
const questName = normalizeQuestName(quest.config.messages.questName);
|
||||
const start = Date.now();
|
||||
|
||||
if (quest.userStatus?.enrolledAt) {
|
||||
logger?.info(`[${getFormattedNow()}] Quest ${questName} is already enrolled.`);
|
||||
return true;
|
||||
} else if (quest.userStatus?.completedAt) {
|
||||
logger?.warn(`[${getFormattedNow()}] Cannot enroll in completed Quest ${questName}.`);
|
||||
return false;
|
||||
} else {
|
||||
logger?.info(`[${getFormattedNow()}] Waiting for enrollment in Quest ${questName}...`);
|
||||
}
|
||||
|
||||
while (!quest.userStatus?.enrolledAt && (Date.now() - start) < timeout) {
|
||||
await new Promise(resolve => setTimeout(resolve, interval));
|
||||
quest = refreshQuest(quest);
|
||||
}
|
||||
|
||||
if (quest.userStatus?.enrolledAt) {
|
||||
logger?.info(`[${getFormattedNow()}] Successfully waited for enrollment in Quest ${questName}.`);
|
||||
return true;
|
||||
} else {
|
||||
logger?.warn(`[${getFormattedNow()}] Timeout waiting for enrollment in Quest ${questName}.`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function reportVideoQuestProgress(quest: Quest, currentProgress: number, logger?: Logger, options?: { attempts?: number, delay?: number; }): Promise<boolean> {
|
||||
const questName = normalizeQuestName(quest.config.messages.questName);
|
||||
|
||||
if (!quest.userStatus?.enrolledAt) {
|
||||
logger?.warn(`[${getFormattedNow()}] Cannot report progress for unenrolled Quest ${questName}.`);
|
||||
return false;
|
||||
} else if (quest.userStatus?.completedAt) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const maxAttempts = options?.attempts ?? 1;
|
||||
const delay = options?.delay ?? 2500;
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
await reportProgress(quest.id, currentProgress);
|
||||
|
||||
logger?.info(`[${getFormattedNow()}] Quest ${questName} progress reported: ${currentProgress.toFixed(6)} seconds.`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger?.error(`[${getFormattedNow()}] Failed to report progress for Quest ${questName} on attempt ${attempt}/${maxAttempts}:`, error);
|
||||
|
||||
if (attempt < maxAttempts) {
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger?.warn(`[${getFormattedNow()}] Exhausted all ${maxAttempts} attempts to report progress for Quest ${questName}.`);
|
||||
return false;
|
||||
}
|
||||
|
||||
function getPlayGameProgressValue(userStatus: Quest["userStatus"]): number | null {
|
||||
const progressMap = userStatus?.progress;
|
||||
|
||||
if (!progressMap) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const progressPlayType = progressMap.PLAY_ON_DESKTOP?.value
|
||||
?? progressMap.PLAY_ON_XBOX?.value
|
||||
?? progressMap.PLAY_ON_PLAYSTATION?.value
|
||||
?? progressMap.PLAY_ACTIVITY?.value;
|
||||
|
||||
return progressPlayType ?? null;
|
||||
}
|
||||
|
||||
function waitForHeartbeatDispatchResult(questId: string, timeoutMs: number): Promise<HeartbeatDispatchResult> {
|
||||
return new Promise(resolve => {
|
||||
const successEvent = "QUESTS_SEND_HEARTBEAT_SUCCESS";
|
||||
const failureEvent = "QUESTS_SEND_HEARTBEAT_FAILURE";
|
||||
const timeoutId = setTimeout(() => {
|
||||
cleanup();
|
||||
resolve({ type: "timeout" });
|
||||
}, timeoutMs);
|
||||
|
||||
function cleanup() {
|
||||
clearTimeout(timeoutId);
|
||||
FluxDispatcher.unsubscribe(successEvent, onSuccess);
|
||||
FluxDispatcher.unsubscribe(failureEvent, onFailure);
|
||||
}
|
||||
|
||||
function onSuccess(data: { questId?: string; userStatus?: Quest["userStatus"]; }) {
|
||||
if (data.questId !== questId) {
|
||||
return;
|
||||
}
|
||||
|
||||
cleanup();
|
||||
resolve({ type: "success", userStatus: data.userStatus ?? null });
|
||||
}
|
||||
|
||||
function onFailure(data: { questId?: string; error?: unknown; }) {
|
||||
if (data.questId !== questId) {
|
||||
return;
|
||||
}
|
||||
|
||||
cleanup();
|
||||
resolve({ type: "failure", error: data.error });
|
||||
}
|
||||
|
||||
FluxDispatcher.subscribe(successEvent, onSuccess);
|
||||
FluxDispatcher.subscribe(failureEvent, onFailure);
|
||||
});
|
||||
}
|
||||
|
||||
export async function reportPlayGameQuestProgress(quest: Quest, terminal: boolean, logger?: Logger, options?: { attempts?: number, delay?: number; timeout?: number; applicationId?: string; streamKey?: string; }): Promise<{ progress: number | null; completed: boolean; }> {
|
||||
const questName = normalizeQuestName(quest.config.messages.questName);
|
||||
|
||||
if (!quest.userStatus?.enrolledAt) {
|
||||
logger?.warn(`[${getFormattedNow()}] Cannot send heartbeat for unenrolled Quest ${questName}.`);
|
||||
return { progress: null, completed: false };
|
||||
} else if (quest.userStatus?.completedAt && !terminal) {
|
||||
return { progress: null, completed: true };
|
||||
}
|
||||
|
||||
const maxAttempts = options?.attempts ?? 1;
|
||||
const delay = options?.delay ?? 2500;
|
||||
const timeout = options?.timeout ?? 10000;
|
||||
const streamKey = options?.streamKey;
|
||||
const applicationId = options?.applicationId ?? quest.config.application.id;
|
||||
const questPlayType = 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;
|
||||
|
||||
if (!questPlayType) {
|
||||
logger?.warn(`[${getFormattedNow()}] Could not recognize the Quest type for Quest ${questName}.`);
|
||||
return { progress: null, completed: false };
|
||||
}
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
const dispatchResultPromise = waitForHeartbeatDispatchResult(quest.id, timeout);
|
||||
|
||||
await sendHeartbeat({
|
||||
questId: quest.id,
|
||||
streamKey,
|
||||
applicationId,
|
||||
terminal,
|
||||
});
|
||||
|
||||
const dispatchResult = await dispatchResultPromise;
|
||||
|
||||
if (dispatchResult.type === "failure") {
|
||||
logger?.error(`[${getFormattedNow()}] Failed to send heartbeat for Quest ${questName} on attempt ${attempt}/${maxAttempts}:`, dispatchResult.error);
|
||||
|
||||
if (attempt < maxAttempts) {
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (dispatchResult.type === "timeout") {
|
||||
logger?.warn(`[${getFormattedNow()}] Timed out waiting for heartbeat dispatch for Quest ${questName} on attempt ${attempt}/${maxAttempts}.`);
|
||||
|
||||
if (attempt < maxAttempts) {
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
const updatedQuest = refreshQuest(quest);
|
||||
const progress = getPlayGameProgressValue(dispatchResult.userStatus) ?? getQuestProgress(updatedQuest, questPlayType) ?? 0;
|
||||
const completed = !!dispatchResult.userStatus?.completedAt || !!updatedQuest.userStatus?.completedAt;
|
||||
|
||||
logger?.info(`[${getFormattedNow()}] Heartbeat sent for Quest ${questName} with progress: ${progress}/${questPlayType.target}.`);
|
||||
return { progress, completed };
|
||||
} catch (error) {
|
||||
logger?.error(`[${getFormattedNow()}] Failed to send heartbeat for Quest ${questName} on attempt ${attempt}/${maxAttempts}:`, error);
|
||||
|
||||
if (attempt < maxAttempts) {
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger?.warn(`[${getFormattedNow()}] Exhausted all ${maxAttempts} attempts to send heartbeat for Quest ${questName}.`);
|
||||
return { progress: null, completed: false };
|
||||
}
|
||||
|
||||
export function getBadgeSize(value: string, negative: boolean): number {
|
||||
const numChars = value.length;
|
||||
const subtract = negative ? 3 : 0;
|
||||
|
||||
if (numChars === 1) {
|
||||
return 16;
|
||||
} else if (numChars === 2) {
|
||||
return 21;
|
||||
} else {
|
||||
return (21 + (numChars - 2) * 8) - subtract;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatLowerBadge(value: number, maxDigits: number = 4): [string, number] {
|
||||
const isNegative = value < 0;
|
||||
const absValue = Math.abs(value);
|
||||
|
||||
if (maxDigits <= 0) {
|
||||
const str = isNegative ? `-${absValue}` : `${absValue}`;
|
||||
return [str, getBadgeSize(str, isNegative)];
|
||||
}
|
||||
|
||||
const absStr = String(absValue);
|
||||
const numDigits = absStr.length;
|
||||
|
||||
if (numDigits <= maxDigits) {
|
||||
const str = isNegative ? `-${absValue}` : `${absValue}`;
|
||||
return [str, getBadgeSize(str, isNegative)];
|
||||
}
|
||||
|
||||
if (absValue < 1000) {
|
||||
if (maxDigits === 1) {
|
||||
if (absValue < 100) {
|
||||
const str = isNegative ? "<-9" : "9+";
|
||||
return [str, getBadgeSize(str, isNegative)];
|
||||
} else {
|
||||
const str = isNegative ? "<-99" : "99+";
|
||||
return [str, getBadgeSize(str, isNegative)];
|
||||
}
|
||||
} else if (maxDigits === 2) {
|
||||
const str = isNegative ? "<-99" : "99+";
|
||||
return [str, getBadgeSize(str, isNegative)];
|
||||
}
|
||||
}
|
||||
|
||||
let num = absValue;
|
||||
const units = ["K", "M", "B"];
|
||||
let unit = "";
|
||||
|
||||
for (let i = 0; i < units.length; i++) {
|
||||
const nextValue = Math.floor(num / 1000);
|
||||
|
||||
if (nextValue === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (i === units.length - 1 && nextValue >= 1000) {
|
||||
num = 999;
|
||||
unit = units[i];
|
||||
break;
|
||||
}
|
||||
|
||||
num = nextValue;
|
||||
unit = units[i];
|
||||
}
|
||||
|
||||
if (isNegative) {
|
||||
const str = `<-${num}${unit}`;
|
||||
return [str, getBadgeSize(str, true)];
|
||||
} else {
|
||||
const str = `${num}${unit}+`;
|
||||
return [str, getBadgeSize(str, false)];
|
||||
}
|
||||
}
|
||||
321
src/equicordplugins/questify/utils/questState.ts
Normal file
321
src/equicordplugins/questify/utils/questState.ts
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { Quest } from "@vencord/discord-types";
|
||||
import { QuestTaskType } from "@vencord/discord-types/enums";
|
||||
import { QuestStore } from "@webpack/common";
|
||||
|
||||
import { ignoredQuestIDsKey } from "../settings/def";
|
||||
import { settings } from "../settings/store";
|
||||
import { getActiveAutoCompletes, getAutoCompleteQuestTarget, getQuestAutoCompleteEntry } from "./completion";
|
||||
import { type QuestIncludedTypes, questMatchesIncludedTypes } from "./filtering";
|
||||
|
||||
export interface QuestTask {
|
||||
type: QuestTaskType;
|
||||
target: number;
|
||||
applications?: { id: string; }[];
|
||||
}
|
||||
|
||||
export enum QuestStatus {
|
||||
Claimed = "CLAIMED",
|
||||
Unclaimed = "UNCLAIMED",
|
||||
Ignored = "IGNORED",
|
||||
Expired = "EXPIRED",
|
||||
Unknown = "UNKNOWN"
|
||||
}
|
||||
|
||||
const showcaseSwitchLeewaySeconds = 3;
|
||||
let showcasedAutoCompleteQuestId: string | null = null;
|
||||
|
||||
const questProgressTaskPriority = [
|
||||
QuestTaskType.WATCH_VIDEO,
|
||||
QuestTaskType.WATCH_VIDEO_ON_MOBILE,
|
||||
QuestTaskType.ACHIEVEMENT_IN_ACTIVITY,
|
||||
QuestTaskType.ACHIEVEMENT_IN_GAME,
|
||||
QuestTaskType.PLAY_ACTIVITY,
|
||||
QuestTaskType.PLAY_ON_DESKTOP,
|
||||
QuestTaskType.PLAY_ON_DESKTOP_V2,
|
||||
QuestTaskType.STREAM_ON_DESKTOP,
|
||||
QuestTaskType.PLAY_ON_PLAYSTATION,
|
||||
QuestTaskType.PLAY_ON_XBOX,
|
||||
] as const satisfies readonly QuestTaskType[];
|
||||
|
||||
interface QuestPanelPercentCompleteOptions {
|
||||
quest?: Quest | null;
|
||||
percentCompleteText?: string;
|
||||
}
|
||||
|
||||
interface QuestPanelPercentCompleteResult {
|
||||
percentComplete: number;
|
||||
percentCompleteText?: string;
|
||||
}
|
||||
|
||||
interface QuestProgressEntry {
|
||||
eventName?: QuestTaskType;
|
||||
heartbeat?: { lastBeatAt?: string | null; } | null;
|
||||
updatedAt?: string | null;
|
||||
}
|
||||
|
||||
export function refreshQuest(quest: Quest): Quest {
|
||||
return QuestStore.getQuest(quest.id) ?? quest;
|
||||
}
|
||||
|
||||
export function isVideoQuestTask(taskType: QuestTaskType): boolean {
|
||||
return taskType === QuestTaskType.WATCH_VIDEO || taskType === QuestTaskType.WATCH_VIDEO_ON_MOBILE;
|
||||
}
|
||||
|
||||
function getQuestTaskByType(quest: Quest, taskType: QuestTaskType): QuestTask | null {
|
||||
const task = quest.config.taskConfigV2?.tasks[taskType] as QuestTask | undefined;
|
||||
|
||||
return task ?? null;
|
||||
}
|
||||
|
||||
function getVideoQuestTask(quest: Quest): QuestTask | null {
|
||||
return getQuestTaskByType(quest, QuestTaskType.WATCH_VIDEO)
|
||||
?? getQuestTaskByType(quest, QuestTaskType.WATCH_VIDEO_ON_MOBILE);
|
||||
}
|
||||
|
||||
function getProgressTimestamp(progress: QuestProgressEntry): number {
|
||||
const timestamp = progress.heartbeat?.lastBeatAt ?? progress.updatedAt;
|
||||
const time = timestamp ? new Date(timestamp).getTime() : 0;
|
||||
|
||||
return Number.isFinite(time) ? time : 0;
|
||||
}
|
||||
|
||||
function getLatestProgressTask(quest: Quest): QuestTask | null {
|
||||
const progressEntries = Object.entries(quest.userStatus?.progress ?? {}) as [QuestTaskType, QuestProgressEntry][];
|
||||
|
||||
progressEntries.sort(([, a], [, b]) => getProgressTimestamp(b) - getProgressTimestamp(a));
|
||||
|
||||
for (const [fallbackTaskType, progress] of progressEntries) {
|
||||
const task = getQuestTaskByType(quest, progress.eventName ?? fallbackTaskType);
|
||||
|
||||
if (task) {
|
||||
return task;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getQuestProgressTask(quest: Quest): QuestTask | null {
|
||||
if (!quest.config.taskConfigV2?.tasks) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const progressTask = getLatestProgressTask(quest) ?? getVideoQuestTask(quest);
|
||||
|
||||
if (progressTask) {
|
||||
return progressTask;
|
||||
}
|
||||
|
||||
for (const taskType of questProgressTaskPriority) {
|
||||
const task = getQuestTaskByType(quest, taskType);
|
||||
|
||||
if (task) {
|
||||
return task;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getQuestStoredProgress(quest: Quest, task: QuestTask): number | null {
|
||||
if (quest.userStatus?.completedAt) {
|
||||
return task.target;
|
||||
}
|
||||
|
||||
const progressMap = quest.userStatus?.progress;
|
||||
|
||||
if (!progressMap) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isVideoQuestTask(task.type)) {
|
||||
const watchProgress = progressMap.WATCH_VIDEO?.value;
|
||||
const mobileProgress = progressMap.WATCH_VIDEO_ON_MOBILE?.value;
|
||||
|
||||
return watchProgress !== undefined || mobileProgress !== undefined
|
||||
? Math.max(watchProgress ?? 0, mobileProgress ?? 0)
|
||||
: null;
|
||||
}
|
||||
|
||||
return progressMap[task.type]?.value ?? null;
|
||||
}
|
||||
|
||||
function getCurrentIgnoredQuestIds(): string[] {
|
||||
return Array.from(settings.store.ignoredQuestIDs[ignoredQuestIDsKey] ?? []);
|
||||
}
|
||||
|
||||
function getAutoCompleteShowcaseQuest(): Quest | null {
|
||||
let bestQuest: Quest | null = null;
|
||||
let bestTimeRemaining = Infinity;
|
||||
let currentQuest: Quest | null = null;
|
||||
let currentTimeRemaining = Infinity;
|
||||
|
||||
for (const entry of getActiveAutoCompletes()) {
|
||||
const quest = QuestStore.getQuest(entry.questId);
|
||||
|
||||
if (!quest) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const progress = entry.progress ?? 0;
|
||||
const { adjusted: target } = getAutoCompleteQuestTarget(entry.task);
|
||||
const timeRemaining = Math.max(0, target - progress);
|
||||
|
||||
if (entry.questId === showcasedAutoCompleteQuestId) {
|
||||
currentQuest = quest;
|
||||
currentTimeRemaining = timeRemaining;
|
||||
}
|
||||
|
||||
if (timeRemaining < bestTimeRemaining) {
|
||||
bestQuest = quest;
|
||||
bestTimeRemaining = timeRemaining;
|
||||
}
|
||||
}
|
||||
|
||||
if (!bestQuest) {
|
||||
showcasedAutoCompleteQuestId = null;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (currentQuest && bestQuest.id !== currentQuest.id && bestTimeRemaining >= currentTimeRemaining - showcaseSwitchLeewaySeconds) {
|
||||
return currentQuest;
|
||||
}
|
||||
|
||||
showcasedAutoCompleteQuestId = bestQuest.id;
|
||||
|
||||
return bestQuest;
|
||||
}
|
||||
|
||||
function getMostRecentlyCompletedUnclaimedQuest(): Quest | null {
|
||||
return Array.from(QuestStore.quests.values())
|
||||
.filter(quest => (
|
||||
Boolean(quest.userStatus?.completedAt)
|
||||
&& getQuestStatus(quest, getCurrentIgnoredQuestIds()) === QuestStatus.Unclaimed
|
||||
))
|
||||
.sort((a, b) => {
|
||||
const aTime = new Date(a.userStatus?.completedAt ?? 0).getTime();
|
||||
const bTime = new Date(b.userStatus?.completedAt ?? 0).getTime();
|
||||
|
||||
return bTime - aTime;
|
||||
})[0] ?? null;
|
||||
}
|
||||
|
||||
export function getQuestPanelOverride(quest: Quest | null): Quest | null {
|
||||
const panelState = settings.use(["disableQuestsEverything", "disableAccountPanelPromo", "disableAccountPanelQuestProgress"]);
|
||||
|
||||
if (panelState.disableQuestsEverything) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (panelState.disableAccountPanelPromo && panelState.disableAccountPanelQuestProgress) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (panelState.disableAccountPanelQuestProgress) {
|
||||
return quest;
|
||||
}
|
||||
|
||||
const nextQuest = getAutoCompleteShowcaseQuest() ?? getMostRecentlyCompletedUnclaimedQuest();
|
||||
|
||||
return nextQuest ?? (panelState.disableAccountPanelPromo ? null : quest);
|
||||
}
|
||||
|
||||
export function shouldForceQuestPanelVisible(quest: Quest | null): boolean {
|
||||
if (!quest || settings.store.disableQuestsEverything || settings.store.disableAccountPanelQuestProgress) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return getQuestAutoCompleteEntry(refreshQuest(quest)) !== null;
|
||||
}
|
||||
|
||||
export function getQuestPanelPercentComplete({
|
||||
quest,
|
||||
percentCompleteText,
|
||||
}: QuestPanelPercentCompleteOptions): QuestPanelPercentCompleteResult | null {
|
||||
if (!quest) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const refreshedQuest = refreshQuest(quest);
|
||||
const activeAutoComplete = getQuestAutoCompleteEntry(refreshedQuest);
|
||||
const task: QuestTask | null = activeAutoComplete?.task ?? getQuestProgressTask(refreshedQuest);
|
||||
|
||||
if (!task) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const questTarget = activeAutoComplete
|
||||
? getAutoCompleteQuestTarget(task)
|
||||
.adjusted
|
||||
: task.target;
|
||||
const questProgress = activeAutoComplete?.progress ?? getQuestStoredProgress(refreshedQuest, task);
|
||||
|
||||
if (!questTarget || questProgress === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const percentComplete = Math.min(1, questProgress / questTarget);
|
||||
|
||||
if (!percentCompleteText) {
|
||||
return { percentComplete };
|
||||
}
|
||||
|
||||
return {
|
||||
percentComplete,
|
||||
percentCompleteText: `${Math.floor(percentComplete * 100)}%`,
|
||||
};
|
||||
}
|
||||
|
||||
export function getQuestStatus(
|
||||
quest: Quest,
|
||||
ignoredQuestIds: ReadonlyArray<string>,
|
||||
checkIgnored: boolean = true,
|
||||
): QuestStatus {
|
||||
const completedQuest = quest.userStatus?.completedAt;
|
||||
const claimedQuest = quest.userStatus?.claimedAt;
|
||||
const expiredQuest = new Date(quest.config.expiresAt) < new Date();
|
||||
const questIgnored = ignoredQuestIds.includes(quest.id);
|
||||
|
||||
if (claimedQuest) {
|
||||
return QuestStatus.Claimed;
|
||||
}
|
||||
|
||||
if (checkIgnored && questIgnored && (!expiredQuest || completedQuest)) {
|
||||
return QuestStatus.Ignored;
|
||||
}
|
||||
|
||||
if (completedQuest || !expiredQuest) {
|
||||
return QuestStatus.Unclaimed;
|
||||
}
|
||||
|
||||
if (expiredQuest) {
|
||||
return QuestStatus.Expired;
|
||||
}
|
||||
|
||||
return QuestStatus.Unknown;
|
||||
}
|
||||
|
||||
export function countIncludedUnclaimedQuests(
|
||||
quests: Quest[],
|
||||
ignoredQuestIds: ReadonlyArray<string>,
|
||||
includedTypes: QuestIncludedTypes,
|
||||
): number {
|
||||
let count = 0;
|
||||
|
||||
for (const quest of quests) {
|
||||
const questStatus = getQuestStatus(quest, ignoredQuestIds);
|
||||
|
||||
if (questMatchesIncludedTypes(quest, includedTypes) && questStatus === QuestStatus.Unclaimed) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
365
src/equicordplugins/questify/utils/questTiles.ts
Normal file
365
src/equicordplugins/questify/utils/questTiles.ts
Normal file
|
|
@ -0,0 +1,365 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { Quest, QuestTaskWatchVideo, QuestTaskWatchVideoOnMobile } from "@vencord/discord-types";
|
||||
import { QuestTaskType } from "@vencord/discord-types/enums";
|
||||
|
||||
import { defaultClaimedSubsort, defaultExpiredSubsort, defaultIgnoredSubsort, defaultQuestOrder, defaultUnclaimedSubsort, type QuestOrderStatus, type QuestSubsort, type QuestTileColorSetting, type QuestTileGradient } from "../settings/def";
|
||||
import { getIgnoredQuestIDs } from "../settings/ignoredQuests";
|
||||
import { settings } from "../settings/store";
|
||||
import { getQuestStatus, QuestStatus } from "./questState";
|
||||
import { adjustRGB, decimalToRGB, isDarkish, q, type RGB } from "./ui";
|
||||
|
||||
type QuestGroupKey = "claimed" | "expired" | "ignored" | "unclaimed" | "unknown";
|
||||
|
||||
export const desktopVideoCompatibilityQuestIds = new Set<string>();
|
||||
|
||||
interface QuestTileColorSettings {
|
||||
questTileUnclaimedColor: QuestTileColorSetting;
|
||||
questTileClaimedColor: QuestTileColorSetting;
|
||||
questTileIgnoredColor: QuestTileColorSetting;
|
||||
questTileExpiredColor: QuestTileColorSetting;
|
||||
}
|
||||
|
||||
const customQuestTileClasses = [
|
||||
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"),
|
||||
];
|
||||
|
||||
const sortFallbacks = {
|
||||
unclaimed: defaultUnclaimedSubsort,
|
||||
claimed: defaultClaimedSubsort,
|
||||
ignored: defaultIgnoredSubsort,
|
||||
expired: defaultExpiredSubsort,
|
||||
} as const satisfies Record<string, QuestSubsort>;
|
||||
|
||||
const validSubsorts = new Set<QuestSubsort>([
|
||||
"Recent ASC",
|
||||
"Recent DESC",
|
||||
"Expiring ASC",
|
||||
"Expiring DESC",
|
||||
"Claimed ASC",
|
||||
"Claimed DESC",
|
||||
]);
|
||||
|
||||
function getTileColorSetting(status: QuestStatus, colors: QuestTileColorSettings): QuestTileColorSetting | null {
|
||||
switch (status) {
|
||||
case QuestStatus.Claimed:
|
||||
return colors.questTileClaimedColor;
|
||||
case QuestStatus.Unclaimed:
|
||||
return colors.questTileUnclaimedColor;
|
||||
case QuestStatus.Ignored:
|
||||
return colors.questTileIgnoredColor;
|
||||
case QuestStatus.Expired:
|
||||
return colors.questTileExpiredColor;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getQuestTileColor(quest: Quest & { dummyColor?: QuestTileColorSetting; }, colors: QuestTileColorSettings): number | null {
|
||||
const questStatus = getQuestStatus(quest, getIgnoredQuestIDs());
|
||||
const setting = quest.dummyColor ?? getTileColorSetting(questStatus, colors);
|
||||
|
||||
if (!setting?.enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return setting.color;
|
||||
}
|
||||
|
||||
function getGradientClass(gradient: QuestTileGradient): string | null {
|
||||
if (gradient === "black") return q("quest-item-black-gradient");
|
||||
if (gradient === "hide") return q("quest-item-hide-gradient");
|
||||
if (gradient === "default") return q("quest-item-default-gradient");
|
||||
|
||||
return q("quest-item-intense-gradient");
|
||||
}
|
||||
|
||||
export function getQuestTileClasses(originalClasses: string, quest: Quest & { dummyColor?: QuestTileColorSetting; }, gradientOverride?: QuestTileGradient): string {
|
||||
const questTiles = settings.use([
|
||||
"disableQuestsEverything",
|
||||
"ignoredQuestIDs",
|
||||
"questTileUnclaimedColor",
|
||||
"questTileClaimedColor",
|
||||
"questTileIgnoredColor",
|
||||
"questTileExpiredColor",
|
||||
"questTileGradient",
|
||||
]);
|
||||
|
||||
const baseClasses = originalClasses
|
||||
.split(" ")
|
||||
.filter(cls => cls && !customQuestTileClasses.includes(cls));
|
||||
const colors: QuestTileColorSettings = {
|
||||
questTileUnclaimedColor: questTiles.questTileUnclaimedColor as QuestTileColorSetting,
|
||||
questTileClaimedColor: questTiles.questTileClaimedColor as QuestTileColorSetting,
|
||||
questTileIgnoredColor: questTiles.questTileIgnoredColor as QuestTileColorSetting,
|
||||
questTileExpiredColor: questTiles.questTileExpiredColor as QuestTileColorSetting,
|
||||
};
|
||||
const color = !questTiles.disableQuestsEverything
|
||||
? getQuestTileColor(quest, colors)
|
||||
: null;
|
||||
|
||||
if (color == null) {
|
||||
return baseClasses.join(" ");
|
||||
}
|
||||
|
||||
const returnClasses = [...baseClasses, q("quest-item-restyle")];
|
||||
const gradient = gradientOverride ?? questTiles.questTileGradient as QuestTileGradient;
|
||||
const gradientClass = getGradientClass(gradient);
|
||||
|
||||
if (gradientClass != null) {
|
||||
returnClasses.push(gradientClass);
|
||||
}
|
||||
|
||||
if (gradient !== "black" && gradient !== "hide" && !isDarkish(decimalToRGB(color), 0.875)) {
|
||||
returnClasses.push(q("quest-item-contrast-logo"));
|
||||
}
|
||||
|
||||
return returnClasses.join(" ");
|
||||
}
|
||||
|
||||
export function getQuestTileStyle(quest: (Quest & { dummyColor?: QuestTileColorSetting; }) | null): Record<string, string> {
|
||||
const questTiles = settings.use([
|
||||
"disableQuestsEverything",
|
||||
"ignoredQuestIDs",
|
||||
"questTileUnclaimedColor",
|
||||
"questTileClaimedColor",
|
||||
"questTileIgnoredColor",
|
||||
"questTileExpiredColor",
|
||||
]);
|
||||
|
||||
const style: Record<string, string> = {};
|
||||
const colors: QuestTileColorSettings = {
|
||||
questTileUnclaimedColor: questTiles.questTileUnclaimedColor as QuestTileColorSetting,
|
||||
questTileClaimedColor: questTiles.questTileClaimedColor as QuestTileColorSetting,
|
||||
questTileIgnoredColor: questTiles.questTileIgnoredColor as QuestTileColorSetting,
|
||||
questTileExpiredColor: questTiles.questTileExpiredColor as QuestTileColorSetting,
|
||||
};
|
||||
const themeColor = quest && !questTiles.disableQuestsEverything
|
||||
? getQuestTileColor(quest, colors)
|
||||
: null;
|
||||
|
||||
if (themeColor == null) return style;
|
||||
|
||||
const rgb = decimalToRGB(themeColor);
|
||||
const darkish = isDarkish(rgb);
|
||||
const sign = darkish ? 1 : -1;
|
||||
const questNameColor = adjustRGB(rgb, 200 * sign);
|
||||
const rewardTitleColor = adjustRGB(rgb, 150 * sign);
|
||||
const rewardDescriptionColor = adjustRGB(rgb, 100 * sign);
|
||||
const buttonNormalColor = adjustRGB(rgb, 50 * sign);
|
||||
const buttonHoverColor = adjustRGB(rgb, 75 * sign);
|
||||
|
||||
function toRGB(value: RGB): string {
|
||||
return `rgb(${value.r}, ${value.g}, ${value.b})`;
|
||||
}
|
||||
|
||||
style["--questify-color"] = toRGB(rgb);
|
||||
style["--questify-quest-name"] = toRGB(questNameColor);
|
||||
style["--questify-reward-title"] = toRGB(rewardTitleColor);
|
||||
style["--questify-reward-description"] = toRGB(rewardDescriptionColor);
|
||||
style["--questify-button-normal"] = toRGB(buttonNormalColor);
|
||||
style["--questify-button-hover"] = toRGB(buttonHoverColor);
|
||||
|
||||
return style;
|
||||
}
|
||||
|
||||
function createSortFunction(subsort: QuestSubsort): (a: Quest, b: Quest) => number {
|
||||
switch (subsort) {
|
||||
case "Recent ASC":
|
||||
return (a, b) => new Date(a.config.startsAt).getTime() - new Date(b.config.startsAt).getTime();
|
||||
case "Recent DESC":
|
||||
return (a, b) => new Date(b.config.startsAt).getTime() - new Date(a.config.startsAt).getTime();
|
||||
case "Expiring ASC":
|
||||
return (a, b) => new Date(a.config.expiresAt).getTime() - new Date(b.config.expiresAt).getTime();
|
||||
case "Expiring DESC":
|
||||
return (a, b) => new Date(b.config.expiresAt).getTime() - new Date(a.config.expiresAt).getTime();
|
||||
case "Claimed ASC":
|
||||
return (a, b) => new Date(a.userStatus?.claimedAt || 0).getTime() - new Date(b.userStatus?.claimedAt || 0).getTime();
|
||||
case "Claimed DESC":
|
||||
return (a, b) => new Date(b.userStatus?.claimedAt || 0).getTime() - new Date(a.userStatus?.claimedAt || 0).getTime();
|
||||
}
|
||||
}
|
||||
|
||||
function getValidSubsort(value: unknown, fallback: QuestSubsort): QuestSubsort {
|
||||
return validSubsorts.has(value as QuestSubsort) ? value as QuestSubsort : fallback;
|
||||
}
|
||||
|
||||
function getValidQuestOrder(value: unknown): QuestOrderStatus[] {
|
||||
const validStatuses = new Set<QuestOrderStatus>(defaultQuestOrder);
|
||||
const configuredOrder = Array.isArray(value)
|
||||
? value
|
||||
: defaultQuestOrder;
|
||||
const order = configuredOrder.filter((status): status is QuestOrderStatus => validStatuses.has(status as QuestOrderStatus));
|
||||
|
||||
for (const status of defaultQuestOrder) {
|
||||
if (!order.includes(status)) {
|
||||
order.push(status);
|
||||
}
|
||||
}
|
||||
|
||||
return order;
|
||||
}
|
||||
|
||||
function injectDesktopVideoQuestTasks(quests: Quest[]): void {
|
||||
for (const quest of quests) {
|
||||
const tasks = quest.config.taskConfigV2?.tasks;
|
||||
const mobileVideoTask = tasks?.[QuestTaskType.WATCH_VIDEO_ON_MOBILE] as QuestTaskWatchVideoOnMobile | undefined;
|
||||
|
||||
if (!tasks || !mobileVideoTask || tasks[QuestTaskType.WATCH_VIDEO]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const desktopVideoTask: QuestTaskWatchVideo = {
|
||||
...mobileVideoTask,
|
||||
type: QuestTaskType.WATCH_VIDEO,
|
||||
};
|
||||
|
||||
const reorderedTasks: typeof tasks = {};
|
||||
|
||||
for (const [taskType, task] of Object.entries(tasks) as [QuestTaskType, typeof tasks[QuestTaskType]][]) {
|
||||
if (taskType === QuestTaskType.WATCH_VIDEO_ON_MOBILE) {
|
||||
reorderedTasks[QuestTaskType.WATCH_VIDEO] = desktopVideoTask;
|
||||
}
|
||||
|
||||
reorderedTasks[taskType] = task;
|
||||
}
|
||||
|
||||
quest.config.taskConfigV2.tasks = reorderedTasks;
|
||||
desktopVideoCompatibilityQuestIds.add(quest.id);
|
||||
}
|
||||
}
|
||||
|
||||
export function hasInjectedDesktopVideoCompatibility(quest?: Quest | string | null): boolean {
|
||||
return !quest ? false : desktopVideoCompatibilityQuestIds.has(typeof quest === "string" ? quest : quest.id);
|
||||
}
|
||||
|
||||
export function sortQuests(quests: Quest[], skip?: boolean): Quest[] {
|
||||
const questSorting = settings.use([
|
||||
"disableQuestsEverything",
|
||||
"ignoredQuestIDs",
|
||||
"makeMobileVideoQuestsDesktopCompatible",
|
||||
"completeVideoQuestsQuicker",
|
||||
"questOrder",
|
||||
"unclaimedSubsort",
|
||||
"claimedSubsort",
|
||||
"ignoredSubsort",
|
||||
"expiredSubsort",
|
||||
"autoCompleteQuestTypes",
|
||||
]);
|
||||
|
||||
if (questSorting.disableQuestsEverything) {
|
||||
return quests;
|
||||
}
|
||||
|
||||
if (questSorting.makeMobileVideoQuestsDesktopCompatible || !!questSorting.autoCompleteQuestTypes.WATCH_VIDEO_ON_MOBILE) {
|
||||
injectDesktopVideoQuestTasks(quests);
|
||||
}
|
||||
|
||||
if (skip) {
|
||||
return quests;
|
||||
}
|
||||
|
||||
const ignoredQuestIds = getIgnoredQuestIDs();
|
||||
const questGroups: Record<QuestGroupKey, Quest[]> = {
|
||||
claimed: [],
|
||||
expired: [],
|
||||
ignored: [],
|
||||
unclaimed: [],
|
||||
unknown: [],
|
||||
};
|
||||
|
||||
for (const quest of quests) {
|
||||
switch (getQuestStatus(quest, ignoredQuestIds)) {
|
||||
case QuestStatus.Claimed:
|
||||
questGroups.claimed.push(quest);
|
||||
break;
|
||||
case QuestStatus.Unclaimed:
|
||||
questGroups.unclaimed.push(quest);
|
||||
break;
|
||||
case QuestStatus.Expired:
|
||||
questGroups.expired.push(quest);
|
||||
break;
|
||||
case QuestStatus.Ignored:
|
||||
questGroups.ignored.push(quest);
|
||||
break;
|
||||
default:
|
||||
questGroups.unknown.push(quest);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const unclaimedSortFunction = createSortFunction(getValidSubsort(questSorting.unclaimedSubsort, sortFallbacks.unclaimed));
|
||||
|
||||
questGroups.unclaimed.sort((a, b) => {
|
||||
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(getValidSubsort(questSorting.claimedSubsort, sortFallbacks.claimed)));
|
||||
questGroups.ignored.sort(createSortFunction(getValidSubsort(questSorting.ignoredSubsort, sortFallbacks.ignored)));
|
||||
questGroups.expired.sort(createSortFunction(getValidSubsort(questSorting.expiredSubsort, sortFallbacks.expired)));
|
||||
|
||||
return [
|
||||
...getValidQuestOrder(questSorting.questOrder).flatMap(status => questGroups[status.toLowerCase() as QuestGroupKey]),
|
||||
...questGroups.unknown,
|
||||
];
|
||||
}
|
||||
|
||||
export function shouldPreloadQuestAssets(): boolean {
|
||||
return !settings.store.disableQuestsEverything && settings.store.questTilePreload;
|
||||
}
|
||||
|
||||
export function getLastSortChoice(): string | null {
|
||||
const { rememberQuestPageSort, lastQuestPageSort } = settings.store;
|
||||
|
||||
return rememberQuestPageSort ? lastQuestPageSort : "questify";
|
||||
}
|
||||
|
||||
export function setLastSortChoice(sort: string): void {
|
||||
settings.store.lastQuestPageSort = sort || "questify";
|
||||
}
|
||||
|
||||
function getFilterChoiceKey({ group, filter }: { group: string; filter: string; }): string {
|
||||
return JSON.stringify([group, filter]);
|
||||
}
|
||||
|
||||
export function getLastFilterChoices(): { group: string, filter: string; }[] | null {
|
||||
const { rememberQuestPageFilters, lastQuestPageFilters } = settings.store;
|
||||
|
||||
return rememberQuestPageFilters
|
||||
? Object.values(lastQuestPageFilters).map(item => JSON.parse(JSON.stringify(item)))
|
||||
: null;
|
||||
}
|
||||
|
||||
export function setLastFilterChoices(filters: { group: string, filter: string; }[] | null): void {
|
||||
if (!filters?.length) {
|
||||
settings.store.lastQuestPageFilters = {};
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!filters.every(filter => filter?.group && filter?.filter)) {
|
||||
return;
|
||||
}
|
||||
|
||||
settings.store.lastQuestPageFilters = JSON.parse(JSON.stringify(filters)).reduce((acc, item) => {
|
||||
acc[getFilterChoiceKey(item)] = item;
|
||||
|
||||
return acc;
|
||||
}, {} as Record<string, { group: string, filter: string; }>);
|
||||
}
|
||||
158
src/equicordplugins/questify/utils/ui.ts
Normal file
158
src/equicordplugins/questify/utils/ui.ts
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { classNameFactory } from "@utils/css";
|
||||
import { findByPropsLazy } from "@webpack";
|
||||
|
||||
import type { QuestButtonDisplayMode, QuestButtonIndicatorMode } from "../settings/def";
|
||||
|
||||
export interface RGB {
|
||||
r: number;
|
||||
g: number;
|
||||
b: number;
|
||||
}
|
||||
|
||||
export const QUEST_PAGE = "/quest-home";
|
||||
export const q = classNameFactory("questify-");
|
||||
export const leftClick = 0;
|
||||
export const middleClick = 1;
|
||||
export const rightClick = 2;
|
||||
|
||||
export function decimalToRGB(decimal: number): RGB {
|
||||
return {
|
||||
r: (decimal >> 16) & 0xff,
|
||||
g: (decimal >> 8) & 0xff,
|
||||
b: decimal & 0xff,
|
||||
};
|
||||
}
|
||||
|
||||
export function adjustRGB(rgb: RGB, shift: number): RGB {
|
||||
return {
|
||||
r: Math.max(0, Math.min(255, rgb.r + shift)),
|
||||
g: Math.max(0, Math.min(255, rgb.g + shift)),
|
||||
b: Math.max(0, Math.min(255, rgb.b + shift)),
|
||||
};
|
||||
}
|
||||
|
||||
export function isDarkish(rgb: RGB, threshold: number = 0.5): boolean {
|
||||
const luminance = (0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b) / 255;
|
||||
|
||||
return luminance < threshold;
|
||||
}
|
||||
|
||||
export function canShowBadge(mode: QuestButtonIndicatorMode): boolean {
|
||||
return mode === "badge" || mode === "both";
|
||||
}
|
||||
|
||||
export function canShowPill(mode: QuestButtonIndicatorMode): boolean {
|
||||
return mode === "pill" || mode === "both";
|
||||
}
|
||||
|
||||
export function canShowButton(mode: QuestButtonDisplayMode): boolean {
|
||||
return mode === "always" || mode === "unclaimed";
|
||||
}
|
||||
|
||||
function getBadgeSize(value: string, negative: boolean): number {
|
||||
const numChars = value.length;
|
||||
const subtract = negative ? 3 : 0;
|
||||
|
||||
if (numChars === 1) {
|
||||
return 16;
|
||||
}
|
||||
|
||||
if (numChars === 2) {
|
||||
return 21;
|
||||
}
|
||||
|
||||
return (21 + (numChars - 2) * 8) - subtract;
|
||||
}
|
||||
|
||||
export function formatLowerBadge(value: number, maxDigits: number = 4): [string, number] {
|
||||
const isNegative = value < 0;
|
||||
const absValue = Math.abs(value);
|
||||
|
||||
if (maxDigits <= 0) {
|
||||
const formatted = isNegative ? `-${absValue}` : `${absValue}`;
|
||||
|
||||
return [formatted, getBadgeSize(formatted, isNegative)];
|
||||
}
|
||||
|
||||
const absStr = String(absValue);
|
||||
|
||||
if (absStr.length <= maxDigits) {
|
||||
const formatted = isNegative ? `-${absValue}` : `${absValue}`;
|
||||
|
||||
return [formatted, getBadgeSize(formatted, isNegative)];
|
||||
}
|
||||
|
||||
if (absValue < 1000) {
|
||||
if (maxDigits === 1) {
|
||||
const formatted = absValue < 100
|
||||
? (isNegative ? "<-9" : "9+")
|
||||
: (isNegative ? "<-99" : "99+");
|
||||
|
||||
return [formatted, getBadgeSize(formatted, isNegative)];
|
||||
}
|
||||
|
||||
if (maxDigits === 2) {
|
||||
const formatted = isNegative ? "<-99" : "99+";
|
||||
|
||||
return [formatted, getBadgeSize(formatted, isNegative)];
|
||||
}
|
||||
}
|
||||
|
||||
let reducedValue = absValue;
|
||||
let unit = "";
|
||||
const units = ["K", "M", "B"];
|
||||
|
||||
for (let index = 0; index < units.length; index++) {
|
||||
const nextValue = Math.floor(reducedValue / 1000);
|
||||
|
||||
if (nextValue === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (index === units.length - 1 && nextValue >= 1000) {
|
||||
reducedValue = 999;
|
||||
unit = units[index];
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
reducedValue = nextValue;
|
||||
unit = units[index];
|
||||
}
|
||||
|
||||
const formatted = isNegative ? `<-${reducedValue}${unit}` : `${reducedValue}${unit}+`;
|
||||
|
||||
return [formatted, getBadgeSize(formatted, isNegative)];
|
||||
}
|
||||
|
||||
export interface AlertOptions {
|
||||
title: string;
|
||||
body: React.ReactNode;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
confirmVariant?: "primary" | "critical-primary" | "expressive";
|
||||
onConfirm?: () => void;
|
||||
onCancel?: () => void;
|
||||
onCloseCallback?: () => void;
|
||||
contextKey?: string;
|
||||
}
|
||||
|
||||
export interface AlertsType {
|
||||
show: (options: AlertOptions) => void;
|
||||
close: () => void;
|
||||
confirm: (options: AlertOptions) => Promise<boolean>;
|
||||
}
|
||||
|
||||
const AlertsModule = findByPropsLazy("show", "close", "confirm");
|
||||
|
||||
export const Alerts: AlertsType = {
|
||||
show: options => AlertsModule.show(options),
|
||||
close: () => AlertsModule.close(),
|
||||
confirm: options => AlertsModule.confirm(options),
|
||||
};
|
||||
Loading…
Add table
Reference in a new issue