diff --git a/src/equicordplugins/channelTabs/components/ChannelTab.tsx b/src/equicordplugins/channelTabs/components/ChannelTab.tsx index 5660eec76..5bec9dc1a 100644 --- a/src/equicordplugins/channelTabs/components/ChannelTab.tsx +++ b/src/equicordplugins/channelTabs/components/ChannelTab.tsx @@ -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
setIsHovered(true)} diff --git a/src/equicordplugins/questify/components/questButton.tsx b/src/equicordplugins/questify/components/questButton.tsx new file mode 100644 index 000000000..460ec03ab --- /dev/null +++ b/src/equicordplugins/questify/components/questButton.tsx @@ -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) => void; +} + +export function QuestIcon(height: number, width: number, className?: string): JSX.Element { + return ( + + + + ); +} + +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 ; +} + +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 + : ( + + ); + const icon = ( +
+ {QuestIcon(26, 26)} +
+ ); + + return ( + + {visible && ( +
+
+ +
+
+ icon} + tooltip="Quests" + showPill={false} + selected={selected} + className={buttonClass} + onClick={onPress} + onMouseDown={onPress} + onContextMenu={onPress} + onMouseEnter={() => setHovered(true)} + onMouseLeave={() => setHovered(false)} + {...(lowerBadge ? { lowerBadge, lowerBadgeSize } : {})} + /> +
+
+ )} +
+ ); +} + +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, + 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 ( + + + + { void fetchAndAlertQuests("MANUAL_FETCH"); }} + disabled={fetchQuestsDisabled} + /> + + ); +} + +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): 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, () => ); + } + } + + return ( + 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): 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, () => ); + } + } + + return ( + + ); +} diff --git a/src/equicordplugins/questify/components/questButtonSettings.tsx b/src/equicordplugins/questify/components/questButtonSettings.tsx new file mode 100644 index 000000000..f53ff07bb --- /dev/null +++ b/src/equicordplugins/questify/components/questButtonSettings.tsx @@ -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(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 ( + + + + Quest Button + Show a Quest Button in the server list with an optional indicator for unclaimed and unignored Quests. + + + + + + Button Behavior + + + updateQuestButtonAction("questButtonLeftClickAction", value)} + /> + + + updateQuestButtonAction("questButtonMiddleClickAction", value)} + /> + + + updateQuestButtonAction("questButtonRightClickAction", value)} + /> + + + + + + + + + + + + + + Quest Relevancy + 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." + }} + /> + 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." + }} + /> + + ); +} diff --git a/src/equicordplugins/questify/components/questFeaturesSetting.tsx b/src/equicordplugins/questify/components/questFeaturesSetting.tsx new file mode 100644 index 000000000..a388c1c86 --- /dev/null +++ b/src/equicordplugins/questify/components/questFeaturesSetting.tsx @@ -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; + +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 ( +
+ +
+ ); +} + +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 ( + + Quest Features + Modify how Quests behave to enhance or remove functionality. + Disable Features + + + Modify Features + c !== undefined)}> + + Discord has began issuing warnings to users of scripts or plugins that modify how Quests are completed, which is against their Terms of Service. + +
+ + The warnings appear limited to threat of loss of access to Quests or their rewards, but Discord may escalate at any time. + +
+ + Due to the various methods Discord uses to track users, there's no way to realistically evade detection. If you proceed, understand that Discord likely will detect it at some point. + +
+ + Use the following toggle to access potentially dangerous settings at your own risk. + + updateDangerousAccess(!questFeatures.allowChangingDangerousSettings)} + /> + {questFeatures.allowChangingDangerousSettings && <> + 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." + }} + /> + 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." + }} + /> + 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." + }} + /> + 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." + }} + /> + + } +
+
+ ); +} diff --git a/src/equicordplugins/questify/components/questNotificationsSetting.tsx b/src/equicordplugins/questify/components/questNotificationsSetting.tsx new file mode 100644 index 000000000..b52581945 --- /dev/null +++ b/src/equicordplugins/questify/components/questNotificationsSetting.tsx @@ -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 ( + + + + + ); +} + +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) { + event.preventDefault(); + event.stopPropagation(); + } + + function handlePreviewClick(event: MouseEvent) { + event.preventDefault(); + event.stopPropagation(); + + if (sound && !disabled) { + onPreview(sound); + } + } + + return { + ...option, + trailing: sound + ? ( + + ) + : undefined, + }; + } + + return ( + { + 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(null); + const [playingSound, setPlayingSound] = useState(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 ( + + Quest Notifications + Configure Quest completed and new Quest detected notifications and alerts. + Quest Completed + { settings.store.notifyOnQuestComplete = checked; }} + bottomSpacing="5" + /> + + + previewSound(sound, questNotifications.questCompletedAlertVolume)} + onChange={value => { settings.store.questCompletedAlertSound = value; }} + /> + + + { settings.store.questCompletedAlertVolume = value; }} + /> + + + New Quests Detected + { + settings.store.notifyOnNewQuests = checked; + startAutoFetchingQuests(true); + }} + /> + + + previewSound(sound, questNotifications.newQuestAlertVolume)} + onChange={value => { + settings.store.newQuestAlertSound = value; + startAutoFetchingQuests(true); + }} + /> + + + { settings.store.newQuestAlertVolume = value; }} + /> + + + { + 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." + }} + /> + + + 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." + }} + /> + + + { settings.store.newExcludedQuestAlertVolume = value; }} + /> + + + + + 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." + }} + /> + + + + ); +} diff --git a/src/equicordplugins/questify/components/questTileContextMenu.tsx b/src/equicordplugins/questify/components/questTileContextMenu.tsx new file mode 100644 index 000000000..ec1cb048f --- /dev/null +++ b/src/equicordplugins/questify/components/questTileContextMenu.tsx @@ -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(( + + {!isClaimedMenu && (!isIgnored ? ( + addIgnoredQuest(quest.id)} + /> + ) : ( + removeIgnoredQuest(quest.id)} + /> + ))} + {isAutoCompleting ? ( + { + stopQuestAutoComplete(quest, { + manual: true, + preserveResume: false, + terminalHeartbeat: true, + }); + rerenderQuests(); + }} + /> + ) : canStartAutoComplete ? ( + { + processQuestForAutoComplete(quest, { + force: true, + source: "manual", + }); + rerenderQuests(); + }} + /> + ) : null} + copyToClipboard(quest.id)} + /> + + )); +} diff --git a/src/equicordplugins/questify/components/questTilesSetting.tsx b/src/equicordplugins/questify/components/questTilesSetting.tsx new file mode 100644 index 000000000..ec6e98e62 --- /dev/null +++ b/src/equicordplugins/questify/components/questTilesSetting.tsx @@ -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(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 ( +
+ +
+ ); +} + +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 ( + + ); +} + +export function QuestTilesSetting(): JSX.Element { + const questTiles = settings.use([ + "disableQuestsEverything", + "questTileUnclaimedColor", + "questTileClaimedColor", + "questTileIgnoredColor", + "questTileExpiredColor", + "questTileGradient", + "questTilePreload", + ]); + + const [previewColorKey, setPreviewColorKey] = useState(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 ( + + Quest Tiles + Highlight Quests with optional theme colors for visibility. + Tile Behavior + + + + + + + + + Tile Colors + + {colorOptions.map(({ key, label }) => { + const setting = questTiles[key] as QuestTileColorSetting; + + return ( + +
setPreviewColorKey(key)} + onPointerDownCapture={() => setPreviewColorKey(key)} + > + updateColorValue(key, setting, value)} + showEyeDropper={true} + /> +
+
+ updateColorEnabled(key, setting, !setting.enabled)} + /> +
+
+ ); + })} +
+ +
+ ); +} diff --git a/src/equicordplugins/questify/components/reorderQuestsSetting.tsx b/src/equicordplugins/questify/components/reorderQuestsSetting.tsx new file mode 100644 index 000000000..a39c618ee --- /dev/null +++ b/src/equicordplugins/questify/components/reorderQuestsSetting.tsx @@ -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(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 ( + + Reorder Quests + Sort Quests by their status when the Questify sort option is selected on the Quests page. + Status Order + + {questOrder.map((status, index) => ( + + 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." + }} + /> + + ))} + + Subsorts + + + updateSubsort("unclaimedSubsort", value)} + tooltip={{ + position: "top", + text: subsortTooltips.unclaimedSubsort + }} + /> + + + updateSubsort("claimedSubsort", value)} + tooltip={{ + position: "top", + text: subsortTooltips.claimedSubsort + }} + /> + + + + + updateSubsort("ignoredSubsort", value)} + tooltip={{ + position: "top", + text: subsortTooltips.ignoredSubsort + }} + /> + + + updateSubsort("expiredSubsort", value)} + tooltip={{ + position: "top", + text: subsortTooltips.expiredSubsort + }} + /> + + + Quest Page Memory + updateRememberSetting("rememberQuestPageSort", checked)} + bottomSpacing="5" + tooltip={{ + position: "top", + text: "When disabled, the Quests page opens with the Questify sort option each time." + }} + /> + updateRememberSetting("rememberQuestPageFilters", checked)} + tooltip={{ + position: "top", + text: "When disabled, the Quests page opens without task or reward filters each time." + }} + /> + + ); +} diff --git a/src/equicordplugins/questify/components/settingsModal.tsx b/src/equicordplugins/questify/components/settingsModal.tsx new file mode 100644 index 000000000..8fc8ce838 --- /dev/null +++ b/src/equicordplugins/questify/components/settingsModal.tsx @@ -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 => ( + { }} + /> + ), + { + onCloseCallback: () => { + setSettingsModalOpen(false); + promptToRestartIfDirty(); + } + } + ); +} diff --git a/src/equicordplugins/questify/components/shared.tsx b/src/equicordplugins/questify/components/shared.tsx new file mode 100644 index 000000000..dbdedd0eb --- /dev/null +++ b/src/equicordplugins/questify/components/shared.tsx @@ -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 ( + + {children} + + ); +} + +export function SettingsHeader({ children }: { children: ReactNode; }): JSX.Element { + return ( + + {children} + + ); +} + +export function SettingsSubheader({ children, className }: { children: ReactNode; className?: string | string[]; }): JSX.Element { + return ( + + {children} + + ); +} + +export function SettingsDescription({ children }: { children: ReactNode; }): JSX.Element { + return ( + + {children} + + ); +} + +export function SettingsParagraph({ children, className }: { children: ReactNode; className?: string | string[]; }): JSX.Element { + return ( + + {children} + + ); +} + +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 ( + + {children} + + ); +} + +export interface SettingsRowProps { + children: ReactNode; + className?: string | string[]; +} + +export function SettingsRow({ children, className }: SettingsRowProps): JSX.Element { + return ( +
+ {children} +
+ ); +} + +export interface SettingsRowItemProps { + children: ReactNode; + className?: string | string[]; + width?: "fill" | "content"; +} + +export function SettingsRowItem({ + children, + className, + width = "fill", +}: SettingsRowItemProps): JSX.Element { + return ( +
+ {children} +
+ ); +} + +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; + +export interface SettingsSelectProps extends Omit { + 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 = ( +
+ {label} +
+ +
+
+ ); + + if (tooltip) { + return ( + + {selectElement} + + ); + } + + return selectElement; +} + +type ColorPickerWithOnCloseProps = ComponentProps & { + onClose?: () => void; +}; + +function ColorPickerWithOnClose(props: ColorPickerWithOnCloseProps): JSX.Element { + const LiveColorPicker = ColorPicker as ComponentType; + + return ; +} + +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 && {label}} +
+ +
+ + ); +} + +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 ( +
+ {label} +
+ +
+
+ ); +} + +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; + +export type SettingsSubtleSwitchProps = Omit; + +export function SettingsSubtleSwitch(props: SettingsSubtleSwitchProps): JSX.Element { + const switchElement = ( +
+ +
+ ); + + if (props.tooltip) { + return ( + + {switchElement} + + ); + } + + 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; + tooltipClassName?: string; + tooltipStyle?: React.CSSProperties; + tooltipContentClassName?: string; + tooltipPointerClassName?: string; + positionKeyStemOverride?: string; + onTooltipShow?: () => void; + onTooltipHide?: () => void; + onAnimationRest?: () => void; + "aria-label"?: string | false; + children: (props: Record) => React.ReactNode; +} + +export const ManaTooltip = findComponentByCodeLazy("VoidTooltip cannot find DOM node") as React.ComponentType; + +function SettingsTooltip({ + children, + position, + text, + wider, + className +}: { + children: ReactNode; + position: "top" | "bottom"; + text: string; + wider?: boolean; + className?: string | string[]; +}): JSX.Element { + return ( +
+ + {tooltipProps =>
{children}
} +
+
+ ); +} + +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; diff --git a/src/equicordplugins/questify/index.tsx b/src/equicordplugins/questify/index.tsx index 70d86f715..2593ebd23 100644 --- a/src/equicordplugins/questify/index.tsx +++ b/src/equicordplugins/questify/index.tsx @@ -1,1428 +1,216 @@ /* * 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 "./styles.css"; -import { showNotification } from "@api/Notifications"; -import { plugins } from "@api/PluginManager"; +import { playAudio } from "@api/AudioPlayer"; import { addServerListElement, removeServerListElement, ServerListRenderPosition } from "@api/ServerList"; -import { migratePluginToSettings, Settings } from "@api/Settings"; -import ErrorBoundary from "@components/ErrorBoundary"; -import { openPluginModal } from "@components/settings"; +import { Settings } from "@api/Settings"; +import { ErrorBoundary } from "@components/index"; import { EquicordDevs } from "@utils/constants"; -import { copyToClipboard } from "@utils/index"; -import definePlugin, { PluginNative, StartAt } from "@utils/types"; -import { findStoreLazy, onceReady } from "@webpack"; -import { ContextMenuApi, Menu, NavigationRouter, RestAPI, useEffect, useState } from "@webpack/common"; -import { JSX } from "react"; +import definePlugin, { StartAt } from "@utils/types"; +import type { Quest, QuestUserStatus } from "@vencord/discord-types"; +import { findComponentByCodeLazy, onceReady } from "@webpack"; +import { QuestStore } from "@webpack/common"; +import type { JSX } from "react"; -import { addIgnoredQuest, addRerenderCallback, autoFetchCompatible, fetchAndAlertQuests, maximumAutoFetchIntervalValue, minimumAutoFetchIntervalValue, questIsIgnored, removeIgnoredQuest, rerenderQuests, settings, startAutoFetchingQuests, stopAutoFetchingQuests, validateAndOverwriteIgnoredQuests } from "./settings"; -import { ActiveQuestIntervalsMap, ExcludedQuestMap, GuildlessServerListItem, Quest, QuestIcon, QuestMap, QuestStatus, QuestTaskType, RGB } from "./utils/components"; -import { adjustRGB, decimalToRGB, fetchAndDispatchQuests, formatLowerBadge, getFormattedNow, getIgnoredQuestIDs, getQuestProgress, getQuestStatus, getQuestTarget, getQuestTask, isDarkish, leftClick, middleClick, normalizeQuestName, q, QuestifyLogger, questPath, QuestsStore, refreshQuest, reportPlayGameQuestProgress, reportVideoQuestProgress, rightClick, setIgnoredQuestIDs, waitUntilEnrolled } from "./utils/misc"; +import { disguiseHomeButton, QuestButton, showQuestButton } from "./components/questButton"; +import { QuestTileContextMenu } from "./components/questTileContextMenu"; +import { resetQuestsToResume, startAutoFetchingQuests, stopAutoFetchingQuests } from "./settings/fetching"; +import { validateIgnoredQuests } from "./settings/ignoredQuests"; +import { rerenderQuests, useQuestRerender } from "./settings/rerender"; +import { disposeRestartTracking, initializeRestartTracking, promptToRestartIfDirty } from "./settings/restartTracking"; +import { settings } from "./settings/store"; +import { canAutoCompleteQuest, getActiveAutoCompletes, getQuestAutoCompleteProgress, getQuestButtonProps, getQuestPanelSubtitleText, hasEnabledAutoCompleteQuestTypes, processQuestForAutoComplete, resumeInterruptedAutoCompletes, setHeartbeatStackTracePatchSucceeded, setVideoProgressStackTracePatchSucceeded, stopAllAutoCompletes, stopAutoCompletesForRunningGames, stopQuestAutoComplete } from "./utils/completion"; +import { canOpenDevToolsWindow, fetchAndDispatchQuests, openDevToolsWindow, snakeToCamel } from "./utils/fetching"; +import { normalizeQuestName } from "./utils/filtering"; +import { notifyQuestCompletion, QL } from "./utils/logging"; +import { getQuestPanelOverride, getQuestPanelPercentComplete, shouldForceQuestPanelVisible } from "./utils/questState"; +import { getLastFilterChoices, getLastSortChoice, getQuestTileClasses, getQuestTileStyle, setLastFilterChoices, setLastSortChoice, shouldPreloadQuestAssets, sortQuests } from "./utils/questTiles"; +import { formatLowerBadge, QUEST_PAGE } from "./utils/ui"; -const AuthorizedAppsStore = findStoreLazy("AuthorizedAppsStore"); -let initialQuestDataFetched = false; -const QuestifyNative = VencordNative.pluginHelpers.Questify as PluginNative; -const patchedMobileQuests = new Set(); -const manuallyStoppedQuestIDs = new Set(); -export const activeQuestIntervals = new ActiveQuestIntervalsMap(); +let settingsModalOpen = false; -function questMenuUnignoreAllClicked(): void { - validateAndOverwriteIgnoredQuests([]); +export function setSettingsModalOpen(open: boolean): void { + settingsModalOpen = open; } -function questMenuIgnoreAllClicked(): void { - const quests = (QuestsStore.quests as QuestMap); - const excludedQuests = (QuestsStore.excludedQuests as ExcludedQuestMap); - const ignoredQuestsSet = new Set(); - const ignoredQuestIDs = getIgnoredQuestIDs(); - - for (const quest of quests.values()) { - const questID = quest.id; - const questStatus = getQuestStatus(quest, false); - - if (questStatus === QuestStatus.Unclaimed || ignoredQuestIDs.includes(questID)) { - ignoredQuestsSet.add(questID); - } - } - - for (const quest of excludedQuests.values()) { - if (ignoredQuestIDs.includes(quest.id)) { - ignoredQuestsSet.add(quest.id); - } - } - - setIgnoredQuestIDs(Array.from(ignoredQuestsSet)); - settings.store.unclaimedUnignoredQuests = 0; +function getSettingsModalOpen(): boolean { + return settingsModalOpen; } -function showQuestsButton(questButtonDisplay: string, unclaimedUnignoredQuests: number, onQuestsPage: boolean): boolean { - const canShow = questButtonDisplay !== "never"; - const alwaysShow = questButtonDisplay === "always"; - return canShow && (alwaysShow || !!unclaimedUnignoredQuests || onQuestsPage); +export let initialQuestDataFetched = false; +let isSwitchingAccount = false; +let didAttemptAutoCompleteResume = false; +const notifiedCompletedQuests = new Set(); + +function setOnQuestsPage(force?: boolean): void { + settings.store.isOnQuestsPage = force ?? (window.location.pathname === QUEST_PAGE); } -export function QuestButton(): JSX.Element { - const { questButtonDisplay, questButtonUnclaimed, questButtonBadgeColor, unclaimedUnignoredQuests, onQuestsPage } = settings.use(["questButtonDisplay", "questButtonUnclaimed", "questButtonBadgeColor", "unclaimedUnignoredQuests", "onQuestsPage"]); - const questButtonBadgeColorRGB = questButtonBadgeColor === null ? null : decimalToRGB(questButtonBadgeColor); +function startPerAccountTasks(source: string): void { + const startedAt = Date.now(); - function handleClick(event: React.MouseEvent) { - // ListItem does not support onAuxClick, so we have to listen for mousedown events. - // Ignore left and right clicks sent via mousedown events to prevent double events. - if (event.type === "mousedown" && event.button !== middleClick) { - return; - } + setOnQuestsPage(); + startAutoFetchingQuests(); + resumeAutoCompletesIfReady(); + fetchAndDispatchQuests(); - event.preventDefault(); - event.stopPropagation(); + QL.info(`START_TASKS-${source.toUpperCase()}`, { startedAt }); +} - let todo: string | null = null; +function stopPerAccountTasks(source: string, preserveResume: boolean = true): void { + const stoppedAt = Date.now(); - if (event.button === middleClick) { - todo = settings.store.questButtonMiddleClickAction; - } else if (event.button === rightClick) { - todo = settings.store.questButtonRightClickAction; - } else if (event.button === leftClick) { - todo = settings.store.questButtonLeftClickAction; - } + setOnQuestsPage(); + stopAutoFetchingQuests(); + notifiedCompletedQuests.clear(); + stopAllAutoCompletes({ manual: false, preserveResume, terminalHeartbeat: true }); - if (todo === "open-quests") { - NavigationRouter.transitionTo(questPath); - } else if (todo === "plugin-settings") { - openPluginModal(plugins.Questify); - } else if (todo === "context-menu") { - ContextMenuApi.openContextMenu(event, () => ( - - - - fetchAndAlertQuests("Questify-ManualFetch", QuestifyLogger)} - /> - - )); - } + QL.info(`STOP_TASKS-${source.toUpperCase()}`, { stoppedAt }); +} + +function resumeAutoCompletesIfReady(): void { + if (didAttemptAutoCompleteResume || !initialQuestDataFetched) { + return; } - const lowerBadgeProps = { - count: !["badge", "both"].includes(questButtonUnclaimed) ? 0 : unclaimedUnignoredQuests, - maxDigits: 2, - ...(questButtonBadgeColorRGB ? { color: `rgb(${questButtonBadgeColorRGB.r}, ${questButtonBadgeColorRGB.g}, ${questButtonBadgeColorRGB.b})` } : {}), - ...(questButtonBadgeColorRGB ? { style: { color: isDarkish(questButtonBadgeColorRGB) ? "white" : "black" } } : {}) - }; + didAttemptAutoCompleteResume = true; + resumeInterruptedAutoCompletes(); +} + +const Button = findComponentByCodeLazy("BUTTON_LOADING_STARTED_LABEL)),"); + +function enrolledIncompleteButton(args: { quest: Quest, size: string; }): JSX.Element | null { + const props = getQuestButtonProps({ quest: args.quest }); + + if (!props) { + return null; + } return ( - + +