* Rewrite.

* Improve select sorting & dummy quest picking.

* Improvement + typo.

* Map fix.

* Misc.

* Remove remnants of previous version.

* Fix pill bumping.

* Pill height fix.

* Gradient improvement + README.

* Misc.

* Only alert if QuestsStore was already populated.

* Remove query selector.

* Misc.

* Simplify conditional class.

* Hook order fix.

* Patch fixes.

* Stop audio output on settings close.

* Find fixes.

* Fixes.

* Null alert fix.

* Fetch source distinction.

* Add README image.

* Use proper JSX.

* Remove needless use of Dispatch and simplify prop passing.

* Remove unnecessary functions.

* Fix pill jumpiness.

* Fix pill CSS.

* Fix button CSS.

* Remove audio delay.

* Reorder improvements + immediately apply settings.

* Cleanup subsort options.

* Unclaimed badge bug fix.

* README update.

* Unclaimed badge bug fix (again).

* Legacy CSS cleanup.

* Add option for completing mobile quests on desktop.

* Squash settings.

* Reviewed CSS changes.

* Reviewed CSS changes.

* CSS regression fix.

* Add option for completing video quests in the background.

* README update.

* Merge redundant settings.

* Code style nitpicks.

* Add option for completing game quests in the background.

* Removing unnecessary log.

* Add Desktop client check.

* Misc.

* Add preface that game quest option is desktop only.

* Semantics.

* Better i18n syntax.

* Prevent duplicate game heartbeats when launching game being background completed + logger fixes.

* Prevent delay on near-complete quest.

* Fix dropdown bug and also hide orbs badge if setting enabled.

* Fix hardcoded classes.

* Make alert icon color transition smooth.

* Label improvements + patch improvements.

* Import fix.

* Flux change.

* Comments.

* Fix console logs.

* Adjust authors.

* Move to equicordplugins dir.

* Fixes and improvements.

* Improve patch.

* Improve progress display above user panel.

* Fix dynamic label being overwritten by built-in filter.

* Semantics.

* Patch update.
This commit is contained in:
Etorix 2025-08-01 07:36:37 -07:00 • committed by GitHub
parent 10ecbfbece
commit e3f6212689
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 3867 additions and 0 deletions

1
.gitignore vendored
View file

@ -21,6 +21,7 @@ lerna-debug.log*
.pnpm-debug.log* .pnpm-debug.log*
*.tsbuildinfo *.tsbuildinfo
.modules/
src/userplugins src/userplugins
ExtensionCache/ ExtensionCache/

View file

@ -0,0 +1,69 @@
# Questify
Enhance your Quest experience with a suite of features, or disable them entirely if they're not your thing.
![An example of the Restyle and Reorder features.](https://github.com/user-attachments/assets/452ba894-d0e0-46d5-894a-af901c42d8e5)
## Features
1. Quest Features
- Modify specific Quest features.
- Allows for disabling the following Quest related features:
- Everything — All of the below is disabled.
- Discovery Tab — Quests tab becomes hidden.
- Fetching Quests — Will prevent fetching Quests resulting in a blank Quests page.
- Badge on User Profiles — Hides the profile badge which doubles as a shortcut to the Quests page.
- Popup Above User Panel — Hides the new Quest promotion that appears above your profile panel.
- Gift Inventory Relocation Notice — Hides the text in the gift inventory which doubles as a shortcut to the Quests page.
- Friends List Active Now Promotion — Hides the promotion that displays when your friends play a game which has an active Quest.
- Allows for modifying the following Quest related features:
- Complete Video Quests in Background — Click the watch button on video Quests and wait for the video duration to pass. The first click will start the Quest without opening the video modal. Subsequent clicks will show the modal as normal.
- Make Mobile Quests Desktop Compatible — Make mobile-only video Quests completable on Desktop.
2. Quests Button
- Show a Quest button in the server list with an optional indicator for unclaimed Quests.
- Button Visibility
- Always — Always display the button.
- Unclaimed — Only display the button when you have unclaimed Quests.
- Never — Never display the button.
- Unclaimed Indicator
- Pill — Shows an "unread" pill when there are unclaimed Quests.
- Badge — Shows the number of unclaimed Quests as a "ping" badge.
- Both — Both of the above.
- None — Show no indication of unclaimed Quests.
- Badge Color
- Use the color picker to pick a custom badge color or use the default Discord ping badge color by clicking `Disable`.
- Left / Middle / Right Click Actions
- Open Quests — Navigate to the Quests tab in Discovery.
- Plugin Settings — Open the plugin settings.
- Context Menu — Show the options `Mark All Ignored`, `Reset Ignored List`, and `Fetch Quests`.
- Nothing — Do nothing.
3. Fetching Quests
- Configure how often to fetch Quests from Discord and set up alerts for new Quests.
- By default, Discord only fetches Quests on load and when visiting the Quests page. This means that without a fetch interval defined below, this plugin will become unaware of new Quests added throughout the day.
- This relies on the Quest Button being enabled and set to either `Unclaimed`, or set to `Always` with unclaimed `Pill`, `Badge`, or `Both` indicators enabled. Otherwise, there is no reason to periodically fetch Quests.
- Also, if `Fetching Quests` is blocked in the `Quest Features` setting, this will not work.
- Fetch Interval
- Any time between 30 minutes and 12 hours. Defaults to every 45 minutes.
- Alert Sound
- Pick a default Discord sound to play when new Quests are fetched, or provide a custom audio URL. The audio must be hosted on a domain whitelisted by the Vencord CSP, such as `catbox`.
- If the provided URL is not on a supported domain, you will receive an error. If it *is* on a supported domain, but the audio file is invalid, it will silently fail. To ensure your audio file plays fine, click the audio preview button in the plugin settings.
4. Restyle Quests
- Highlight Quests with optional theme colors for visibility.
- Unclaimed / Claimed / Ignored / Expired
- Use the color picker to pick a custom Quest tile color or use the default Discord coloring by clicking `Disable`.
- Gradient Style
- Intense Restyle Gradient — A more intense gradient than default in order to hide hard edges under the gradient.
- Default Restyle Gradient — The default gradient intensity with the restyle color.
- Subtle Black Gradient — A much less intense black gradient for contrast with the Quest assets.
- No Gradient — Remove the gradient altogether. May cause the Quest assets to be difficult to see.
- Asset Preload
- Load All Quest Assets On Page Load — As soon as the Quest page is opened, load all Quest assets on the page.
- Load Quest Assets During Page Scroll — Keep the default behavior of loading Quest assets as they come into view.
5. Reorder Quests
- Sort Quests by their status.
- Comma-separated list must contain all of: `UNCLAIMED, CLAIMED, IGNORED, EXPIRED`.
- Subsort status groups by Quest added date, expiring date, or claimed date.
6. Ignored Quests
- A list of Quest names to exclude from the `Unclaimed Indicator`.
- One Quest name per line. Names must match the spelling displayed on the Quests page.
- Alternatively, click the three dots on a Quest tile and select the `Mark as Ignored` option.
- This list is automatically cleaned of removed Quests as new Quests come in and as Quests are completed.

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,340 @@
/* Plugin Settings */
.questify-setting-divider {
margin: 0;
}
.questify-setting {
border-left: 3px solid var(--header-primary);
padding: 3px 0 3px 8px;
border-radius: 3px;
user-select: text;
}
.questify-main-inline-group {
margin-top: 4px;
display: flex;
gap: 12px;
}
.questify-sub-inline-group {
display: flex;
gap: 8px;
}
.questify-inline-group-item {
flex: 1;
}
.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-select {
border-radius: 3px !important; /* Necessary !important */
margin-top: 8px;
min-height: 45px;
}
.questify-inline-group-item .questify-select {
margin-top: 0;
}
.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);
border-radius: 3px;
padding: 0 10px;
color: var(--text-default);
border: 1px solid var(--border-normal);
min-height: 43px;
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; /* Necessary !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; /* Necessary !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(--header-primary);
border-radius: 4px;
}
.questify-text-input,
.questify-text-area {
margin-top: 4px;
width: 100%;
border-radius: 3px;
}
.questify-text-area {
height: 150px;
}
.questify-color-picker-container {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-top: 8px;
}
.questify-color-picker-group {
display: flex;
flex-direction: column;
}
.questify-color-picker-with-buttons {
background-color: var(--input-background);
border: 1px solid var(--border-normal);
border-radius: 3px;
padding: 8px;
}
.questify-color-picker-with-buttons [class^="swatch"],
.questify-color-picker-with-buttons [class^="colorSwatch"] {
border-radius: 3px;
width: 100%;
margin-bottom: 4px;
}
.questify-wide-button {
width: 100%;
flex: 1;
}
.questify-flex-65 {
flex: 6;
}
.questify-flex-35 {
flex: 4;
}
.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 {
margin-top: 8px;
}
.questify-dummy-quest:active {
pointer-events: none;
user-select: none;
}
.questify-quest-button-pill {
position: absolute;
top: 0;
left: 0;
height: 100%;
pointer-events: none;
}
.questify-quest-button-pill>[class^="item"] {
will-change: transform;
max-height: 8px;
min-height: 8px;
transition: max-height 0.2s ease-in-out;
}
.questify-quest-button-pill.hovered>[class^="item"] {
max-height: 20px;
}
.questify-quest-button-pill.selected>[class^="item"] {
max-height: 40px;
}
.questify-quest-button-server-list-button-container {
position: relative;
}
.questify-quest-button-icon-container {
pointer-events: none;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.questify-quest-button-container {
position: relative;
}
/* Background of bottom half of Quest tile. */
.questify-quest-item-restyle>div {
background-color: var(--questify-color);
}
/* 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"]>h2[class^="eyebrow"] {
color: var(--questify-quest-name) !important; /* Necessary !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; /* Necessary !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; /* Necessary !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; /* Necessary !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; /* Necessary !important */
}
.questify-quest-item-restyle> :nth-child(2)> div> button:not([disabled]):hover {
background-color: var(--questify-button-hover) !important; /* Necessary !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;
}

View file

@ -0,0 +1,302 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { ErrorBoundary } from "@components/index";
import { findComponentByCodeLazy } from "@webpack";
import { SearchableSelect, useState } from "@webpack/common";
import { JSX } from "react";
import { formatLowerBadge } from "./misc";
// 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("STATUS_DANGER,disableColor:");
export const ServerListItemUpperBadgeComponent = findComponentByCodeLazy("STATUS_DANGER,shape:", "iconBadge,");
export const RadioGroup = findComponentByCodeLazy("radioItemIconClassName,");
export const QuestTile = findComponentByCodeLazy(".rowIndex,trackGuildAndChannelMetadata");
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 QuestMap = Map<string, Quest>;
export interface Quest {
questifyNumber: number;
id: string;
config: {
application: {
id: string;
name: string;
},
startsAt: string;
expiresAt: string;
messages: {
questName: string;
};
taskConfigV2?: {
tasks: {
WATCH_VIDEO?: {
type: "WATCH_VIDEO";
target: number;
},
WATCH_VIDEO_ON_MOBILE?: {
type: "WATCH_VIDEO_ON_MOBILE";
target: number;
},
PLAY_ON_DESKTOP?: {
type: "PLAY_ON_DESKTOP";
target: number;
},
PLAY_ON_XBOX?: {
type: "PLAY_ON_XBOX";
target: number;
},
PLAY_ON_PLAYSTATION?: {
type: "PLAY_ON_PLAYSTATION";
target: number;
},
PLAY_ACTIVITY?: {
type: "PLAY_ACTIVITY";
target: number;
};
};
};
},
userStatus: null | {
claimedAt: string | null;
completedAt: string | null;
enrolledAt: string | null;
progress: null | {
WATCH_VIDEO?: {
eventName: "WATCH_VIDEO";
value: number;
},
WATCH_VIDEO_ON_MOBILE?: {
eventName: "WATCH_VIDEO_ON_MOBILE";
value: number;
},
PLAY_ON_DESKTOP?: {
eventName: "PLAY_ON_DESKTOP";
value: number;
},
PLAY_ON_XBOX?: {
eventName: "PLAY_ON_XBOX";
value: number;
},
PLAY_ON_PLAYSTATION?: {
eventName: "PLAY_ON_PLAYSTATION";
value: number;
},
PLAY_ACTIVITY?: {
eventName: "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;
}
export const DynamicDropdown: React.FC<DynamicDropdownProps> = props => {
return <SearchableSelect {...props} />;
};

View file

@ -0,0 +1,339 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { classNameFactory } from "@api/Styles";
import { Logger } from "@utils/Logger";
import { findByCodeLazy, findStoreLazy } from "@webpack";
import { FluxDispatcher, RestAPI } from "@webpack/common";
import { questIsIgnored } from "../settings";
import { Quest, QuestStatus, RGB } from "./components";
export const q = classNameFactory("questify-");
export const QuestifyLogger = new Logger("Questify");
// Only takes an audio name or URL. Plays instantly.
export const PlayAudio = findByCodeLazy("Unable to find sound for pack name:");
// Takes an audio name or URL, volume, and a callback function. Plays when play() is called.
const AudioPlayerConstructor = findByCodeLazy("sound has no duration");
export function AudioPlayer(name: string, volume: number = 1, callback?: () => void): any { return new AudioPlayerConstructor(name, null, volume, "default", callback || (() => { })); }
export const QuestsStore = findStoreLazy("QuestsStore");
export const questPath = "/discovery/quests";
export const leftClick = 0;
export const middleClick = 1;
export const rightClick = 2;
export function getQuestStatus(quest: Quest, checkIgnored: boolean = true): QuestStatus {
const questName = normalizeQuestName(quest.config.messages.questName);
const completedQuest = quest.userStatus?.completedAt;
const claimedQuest = quest.userStatus?.claimedAt;
const expiredQuest = new Date(quest.config.expiresAt) < new Date();
const questIgnored = questIsIgnored(questName);
if (claimedQuest) {
return QuestStatus.Claimed;
} else if (checkIgnored && questIgnored) {
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): 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;
}
try {
const response = await RestAPI.post({
url: `/quests/${quest.id}/video-progress`,
body: { timestamp: currentProgress }
});
if (!response || !response.body) {
logger?.warn(`[${getFormattedNow()}] No response body received while reporting video progress for Quest ${questName}.`);
return false;
}
logger?.info(`[${getFormattedNow()}] Quest ${questName} progress reported: ${currentProgress} seconds.`);
return true;
} catch (error) {
logger?.error(`[${getFormattedNow()}] Failed to report progress for Quest ${questName}:`, error);
return false;
}
}
export async function reportPlayGameQuestProgress(quest: Quest, terminal: boolean, logger?: Logger): Promise<{ progress: number | null; }> {
const questName = normalizeQuestName(quest.config.messages.questName);
if (!quest.userStatus?.enrolledAt) {
logger?.warn(`[${getFormattedNow()}] Cannot send heartbeat for unenrolled Quest ${questName}.`);
return { progress: null };
} else if (quest.userStatus?.completedAt) {
return { progress: null };
}
try {
const response = await RestAPI.post({
url: `/quests/${quest.id}/heartbeat`,
body: {
stream_key: `call:${quest.id}:1`,
terminal
}
});
if (!response || !response.body) {
logger?.warn(`[${getFormattedNow()}] No response body received while sending heartbeat for Quest ${questName}.`);
return { progress: null };
}
const { body } = snakeToCamel(response);
const progressPlayType = body.progress.PLAY_ON_DESKTOP || body.progress.PLAY_ON_XBOX || body.progress.PLAY_ON_PLAYSTATION || body.progress.PLAY_ACTIVITY;
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;
const progress = progressPlayType?.value || 1;
if (!questPlayType) {
logger?.warn(`[${getFormattedNow()}] Could not recognize the quest type for Quest ${questName}.`);
return { progress: null };
}
logger?.info(`[${getFormattedNow()}] Heartbeat sent for Quest ${questName} with progress: ${progress}/${questPlayType.target}.`);
return { progress };
} catch (error) {
logger?.error(`[${getFormattedNow()}] Failed to send heartbeat for Quest ${questName}:`, error);
return { progress: null };
}
}
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)];
}
}