From 857e4ae872bcc7ef249cdc5de7061bc578f47a9b Mon Sep 17 00:00:00 2001 From: thororen1234 <78185467+thororen1234@users.noreply.github.com> Date: Sat, 19 Oct 2024 15:49:41 -0400 Subject: [PATCH] Add FakeProfileThemesAndEffects back --- scripts/generateEquicordPluginList.ts | 1 + scripts/generatePluginList.ts | 1 + .../components/Builder.tsx | 109 ++++++++++ .../components/BuilderButton.tsx | 63 ++++++ .../components/BuilderColorButton.tsx | 41 ++++ .../components/index.tsx | 99 +++++++++ .../components/settingsAboutComponent.tsx | 29 +++ .../fakeProfileThemesAndEffects/index.tsx | 205 ++++++++++++++++++ .../fakeProfileThemesAndEffects/lib/ftpe.ts | 205 ++++++++++++++++++ .../fakeProfileThemesAndEffects/lib/index.ts | 10 + .../lib/profileEffects.ts | 72 ++++++ .../lib/profilePreview.ts | 77 +++++++ .../lib/userProfile.ts | 69 ++++++ 13 files changed, 981 insertions(+) create mode 100644 src/equicordplugins/fakeProfileThemesAndEffects/components/Builder.tsx create mode 100644 src/equicordplugins/fakeProfileThemesAndEffects/components/BuilderButton.tsx create mode 100644 src/equicordplugins/fakeProfileThemesAndEffects/components/BuilderColorButton.tsx create mode 100644 src/equicordplugins/fakeProfileThemesAndEffects/components/index.tsx create mode 100644 src/equicordplugins/fakeProfileThemesAndEffects/components/settingsAboutComponent.tsx create mode 100644 src/equicordplugins/fakeProfileThemesAndEffects/index.tsx create mode 100644 src/equicordplugins/fakeProfileThemesAndEffects/lib/ftpe.ts create mode 100644 src/equicordplugins/fakeProfileThemesAndEffects/lib/index.ts create mode 100644 src/equicordplugins/fakeProfileThemesAndEffects/lib/profileEffects.ts create mode 100644 src/equicordplugins/fakeProfileThemesAndEffects/lib/profilePreview.ts create mode 100644 src/equicordplugins/fakeProfileThemesAndEffects/lib/userProfile.ts diff --git a/scripts/generateEquicordPluginList.ts b/scripts/generateEquicordPluginList.ts index b806d7a9..943da452 100644 --- a/scripts/generateEquicordPluginList.ts +++ b/scripts/generateEquicordPluginList.ts @@ -250,6 +250,7 @@ function isPluginFile({ name }: { name: string; }) { plugins.push(data); if (readme) readmes[data.name] = readme; }) + .sort() )); const data = JSON.stringify(plugins); diff --git a/scripts/generatePluginList.ts b/scripts/generatePluginList.ts index 5c4efc83..957e8678 100644 --- a/scripts/generatePluginList.ts +++ b/scripts/generatePluginList.ts @@ -250,6 +250,7 @@ function isPluginFile({ name }: { name: string; }) { plugins.push(data); if (readme) readmes[data.name] = readme; }) + .sort() )); const data = JSON.stringify(plugins); diff --git a/src/equicordplugins/fakeProfileThemesAndEffects/components/Builder.tsx b/src/equicordplugins/fakeProfileThemesAndEffects/components/Builder.tsx new file mode 100644 index 00000000..349e0a22 --- /dev/null +++ b/src/equicordplugins/fakeProfileThemesAndEffects/components/Builder.tsx @@ -0,0 +1,109 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { copyWithToast } from "@utils/misc"; +import { Button, showToast, Switch, UserStore, useState, useToken } from "@webpack/common"; +import type { Guild } from "discord-types/general"; + +import { buildFPTE, useAccentColor, usePrimaryColor, useProfileEffect, useShowPreview } from "../lib"; +import { BuilderButton, BuilderColorButton, CustomizationSection, openProfileEffectModal, tokens, useAvatarColors } from "."; + +export interface BuilderProps { + guild?: Guild | undefined; +} + +export function Builder({ guild }: BuilderProps) { + const [primaryColor, setPrimaryColor] = usePrimaryColor(null); + const [accentColor, setAccentColor] = useAccentColor(null); + const [effect, setEffect] = useProfileEffect(null); + const [preview, setPreview] = useShowPreview(true); + const [buildLegacy, setBuildLegacy] = useState(false); + + const avatarColors = useAvatarColors( + UserStore.getCurrentUser()?.getAvatarURL(guild?.id, 80), + useToken(tokens.unsafe_rawColors.PRIMARY_530).hex(), + false + ); + + return ( + <> + + + + + + + { + const strToCopy = buildFPTE(primaryColor ?? -1, accentColor ?? -1, effect?.id ?? "", buildLegacy); + if (strToCopy) + copyWithToast(strToCopy, "FPTE copied to clipboard!"); + else + showToast("FPTE Builder is empty; nothing to copy!"); + }} + > + Copy FPTE + + { + setPrimaryColor(null); + setAccentColor(null); + setEffect(null); + }} + > + Reset + + + + + + FPTE Builder Preview + + + Build backwards compatible FPTE + + > + ); +} diff --git a/src/equicordplugins/fakeProfileThemesAndEffects/components/BuilderButton.tsx b/src/equicordplugins/fakeProfileThemesAndEffects/components/BuilderButton.tsx new file mode 100644 index 00000000..9da8049b --- /dev/null +++ b/src/equicordplugins/fakeProfileThemesAndEffects/components/BuilderButton.tsx @@ -0,0 +1,63 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { Text, Tooltip } from "@webpack/common"; +import type { ComponentProps } from "react"; + +export interface BuilderButtonProps { + label?: string | undefined; + tooltip?: string | undefined; + selectedStyle?: ComponentProps<"div">["style"]; + buttonProps?: ComponentProps<"div"> | undefined; +} + +export const BuilderButton = ({ label, tooltip, selectedStyle, buttonProps }: BuilderButtonProps) => ( + + {tooltipProps => ( + + + {!selectedStyle && ( + + + + )} + + {!!label && ( + + {label} + + )} + + )} + +); diff --git a/src/equicordplugins/fakeProfileThemesAndEffects/components/BuilderColorButton.tsx b/src/equicordplugins/fakeProfileThemesAndEffects/components/BuilderColorButton.tsx new file mode 100644 index 00000000..e29357a5 --- /dev/null +++ b/src/equicordplugins/fakeProfileThemesAndEffects/components/BuilderColorButton.tsx @@ -0,0 +1,41 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { Popout } from "@webpack/common"; + +import { BuilderButton, type BuilderButtonProps, CustomColorPicker, type CustomColorPickerProps } from "."; + +export interface BuilderColorButtonProps extends Pick, Pick { + color: number | null; + setColor: (color: number | null) => void; +} + +export const BuilderColorButton = ({ label, color, setColor, suggestedColors }: BuilderColorButtonProps) => ( + ( + + )} + > + {popoutProps => { + const hexColor = color ? "#" + color.toString(16).padStart(6, "0") : undefined; + + return ( + + ); + }} + +); diff --git a/src/equicordplugins/fakeProfileThemesAndEffects/components/index.tsx b/src/equicordplugins/fakeProfileThemesAndEffects/components/index.tsx new file mode 100644 index 00000000..b08aa00b --- /dev/null +++ b/src/equicordplugins/fakeProfileThemesAndEffects/components/index.tsx @@ -0,0 +1,99 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { type ModalProps, openModal } from "@utils/modal"; +import { extractAndLoadChunksLazy, findByCodeLazy, findByPropsLazy, findComponentByCodeLazy } from "@webpack"; +import type { useToken } from "@webpack/types"; +import type { Guild } from "discord-types/general"; +import type { ComponentType, FunctionComponent, PropsWithChildren, ReactNode } from "react"; + +import type { ProfileEffectConfig } from "../lib"; + +export * from "./Builder"; +export * from "./BuilderButton"; +export * from "./BuilderColorButton"; +export * from "./settingsAboutComponent"; + +export interface CustomizationSectionProps extends PropsWithChildren { + borderType?: FeatureBorderType | undefined; + className?: string | undefined; + description?: ReactNode; + disabled?: boolean | undefined /* = false */; + errors?: string[] | undefined; + forcedDivider?: boolean | undefined /* = false */; + hasBackground?: boolean | undefined /* = false */; + hideDivider?: boolean | undefined /* = false */; + showBorder?: boolean | undefined /* = false */; + showPremiumIcon?: boolean | undefined /* = false */; + title?: ReactNode; + titleIcon?: ReactNode; + titleId?: string | undefined; +} + +// Original name: FeatureBorderTypes +export const enum FeatureBorderType { + LIMITED = "limited", + PREMIUM = "premium", +} + +export const CustomizationSection: ComponentType + = findByCodeLazy(".customizationSectionBackground"); + +export const tokens: { + unsafe_rawColors: Record[0]>; +} = findByPropsLazy("unsafe_rawColors", "modules"); + +export const useAvatarColors: ( + avatarURL: string | null | undefined, + fallbackColor: string, + desaturateColors?: boolean | undefined /* = true */ +) => [string, string, ...string[]] = findByCodeLazy(".palette[", ".desaturateUserColors"); + +export interface CustomColorPickerProps { + className?: string | undefined; + eagerUpdate?: boolean | undefined /* = false */; + footer?: ReactNode; + middle?: ReactNode; + onChange: (color: number) => void; + onClose?: (() => void) | undefined; + showEyeDropper?: boolean | undefined /* = false */; + suggestedColors?: string[] | null | undefined; + wrapperComponentType?: ComponentType | null | undefined; + value?: string | number | null | undefined; +} + +export const CustomColorPicker = findComponentByCodeLazy(".customColorPicker"); + +interface ProfileEffectModalProps extends ModalProps { + analyticsLocations?: string[] | undefined; + guild?: Guild | null | undefined; + initialSelectedEffectId?: string | undefined; + onApply: (effect: ProfileEffectConfig | null) => void; +} + +let ProfileEffectModal: FunctionComponent = () => null; + +export function setProfileEffectModal(comp: typeof ProfileEffectModal) { + ProfileEffectModal = comp; +} + +const requireProfileEffectModal = extractAndLoadChunksLazy(["initialSelectedEffectId:", ".openModalLazy"]); + +export async function openProfileEffectModal( + initialEffectId: ProfileEffectModalProps["initialSelectedEffectId"], + onApply: ProfileEffectModalProps["onApply"], + guild?: ProfileEffectModalProps["guild"] +) { + await requireProfileEffectModal(); + openModal(modalProps => ( + + )); +} diff --git a/src/equicordplugins/fakeProfileThemesAndEffects/components/settingsAboutComponent.tsx b/src/equicordplugins/fakeProfileThemesAndEffects/components/settingsAboutComponent.tsx new file mode 100644 index 00000000..7f8b1f58 --- /dev/null +++ b/src/equicordplugins/fakeProfileThemesAndEffects/components/settingsAboutComponent.tsx @@ -0,0 +1,29 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { Margins } from "@utils/margins"; +import { Forms } from "@webpack/common"; + +export const settingsAboutComponent = () => ( + + Usage + + After enabling this plugin, you will see custom theme colors and effects in the profiles of others using this plugin. + + To set your own profile theme colors and effect: + + + Go to your profile settings + Use the FPTE Builder to choose your profile theme colors and effect + Click the "Copy FPTE" button + Paste the invisible text anywhere in your About Me + + + +); diff --git a/src/equicordplugins/fakeProfileThemesAndEffects/index.tsx b/src/equicordplugins/fakeProfileThemesAndEffects/index.tsx new file mode 100644 index 00000000..ac6b3b25 --- /dev/null +++ b/src/equicordplugins/fakeProfileThemesAndEffects/index.tsx @@ -0,0 +1,205 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { definePluginSettings } from "@api/Settings"; +import { EquicordDevs } from "@utils/constants"; +import { canonicalizeMatch, canonicalizeReplace } from "@utils/patches"; +import definePlugin, { OptionType } from "@utils/types"; +import { useMemo } from "@webpack/common"; + +import { Builder, type BuilderProps, setProfileEffectModal, settingsAboutComponent } from "./components"; +import { ProfileEffectRecord, ProfileEffectStore } from "./lib/profileEffects"; +import { profilePreviewHook } from "./lib/profilePreview"; +import { decodeAboutMeFPTEHook } from "./lib/userProfile"; + +function replaceHelper( + string: string, + replaceArgs: readonly (readonly [searchRegExp: RegExp, replaceString: string])[] +) { + let result = string; + for (const [searchRegExp, replaceString] of replaceArgs) { + const beforeReplace = result; + result = result.replace( + canonicalizeMatch(searchRegExp), + canonicalizeReplace(replaceString, "FakeProfileThemesAndEffects") + ); + if (beforeReplace === result) + throw new Error("Replace had no effect: " + searchRegExp); + } + return result; +} + +export const settings = definePluginSettings({ + prioritizeNitro: { + description: "Source to prioritize", + type: OptionType.SELECT, + options: [ + { label: "Nitro", value: true }, + { label: "About Me", value: false, default: true } + ] + }, + hideBuilder: { + description: "Hide the FPTE Builder in the User Profile and Server Profiles settings pages", + type: OptionType.BOOLEAN, + default: false + } +}); + +export default definePlugin({ + name: "FakeProfileThemesAndEffects", + description: "Allows profile theming and the usage of profile effects by hiding the colors and effect ID in your About Me using invisible, zero-width characters", + authors: [EquicordDevs.ryan], + patches: [ + // Patches UserProfileStore.getUserProfile + { + find: '"UserProfileStore"', + replacement: { + match: /([{}]getUserProfile\([^)]*\){return) ?([^}]+)/, + replace: "$1 $self.decodeAboutMeFPTEHook($2)" + } + }, + // Patches ProfileCustomizationPreview + { + find: ".EDIT_PROFILE_BANNER})", + replacement: { + match: /:function\(\){return (\i)}.+function \1\((\i)\){/, + replace: "$&$self.profilePreviewHook($2);" + } + }, + // Adds the FPTE Builder to the User Profile settings page + { + find: '"DefaultCustomizationSections"', + replacement: { + match: /\.sectionsContainer,.*?children:\[/, + replace: "$&$self.addFPTEBuilder()," + } + }, + // Adds the FPTE Builder to the Server Profiles settings page + { + find: '"guild should not be null"', + replacement: { + match: /\.sectionsContainer,.*?children:\[(?=.+?[{,]guild:(\i))/, + replace: "$&$self.addFPTEBuilder($1)," + } + }, + // ProfileEffectModal + { + find: "initialSelectedProfileEffectId:", + group: true, + replacement: [ + // Modal root + { + match: /(function (\i)\([^)]*\){(?:.(?!function |}$))*\.ModalRoot,(?:.(?!function |}$))*}).*(?=})/, + replace: (match, func, funcName) => `${match}(()=>{$self.ProfileEffectModal=${funcName};` + + replaceHelper(func, [ + // Required for the profile preview to show profile effects + [ + /(?<=[{,]purchases:.+?}=).+?(?=,\i=|,{\i:|;)/, + "{isFetching:!1,categories:new Map,purchases:$self.usePurchases()}" + ] + ]) + + "})()" + }, + // Modal content + { + match: /(function \i\([^)]*\){(?:.(?!function ))*\.ModalContent,(?:.(?!function ))*}).*(?=}\))/, + replace: (match, func) => match + replaceHelper(func, [ + // Required to show the apply button + [ + /(?<=[{,]purchase:.+?}=).+?(?=,\i=|,{\i:|;)/, + "{purchase:{purchasedAt:new Date}}" + ], + // Replaces the profile effect list with the modified version + [ + /(?<=\.jsxs?\)\()[^,]+(?=,{(?:(?:.(?!\.jsxs?\)))+,)?onSelect:)/, + "$self.ProfileEffectSelection" + ], + // Replaces the apply profile effect function with the modified version + [ + /(?<=[{,]onApply:).*?\)\((\i).*?(?=,\i:|}\))/, + "()=>$self.onApply($1)" + ], + // Required to show the apply button + [ + /(?<=[{,]canUseCollectibles:).+?(?=,\i:|}\))/, + "!0" + ], + // Required to enable the apply button + [ + /(?<=[{,]disableApplyButton:).+?(?=,\i:|}\))/, + "!1" + ] + ]) + } + ] + }, + // ProfileEffectSelection + { + find: ".presetEffectBackground", + replacement: { + match: /function\(\i,(\i),.+[,;}]\1\.\i=([^=].+?})(?=;|}$).*(?=}$)/, + replace: (match, _, func) => `${match};$self.ProfileEffectSelection=` + + replaceHelper(func, [ + // Removes the "Exclusive to Nitro" and "Preview The Shop" sections + // Adds every profile effect to the "Your Decorations" section and removes the "Shop" button + [ + /(?<=[ ,](\i)=).+?(?=(?:,\i=|,{\i:|;).+?:\1\.map\()/, + "$self.useProfileEffectSections($&)" + ] + ]) + } + }, + // Patches ProfileEffectPreview + { + find: ".effectDescriptionContainer", + replacement: { + // Add back removed "forProfileEffectModal" property + match: /(?<=[{,])(?=pendingProfileEffectId:)/, + replace: "forProfileEffectModal:!0," + } + } + ], + + addFPTEBuilder: (guild?: BuilderProps["guild"]) => settings.store.hideBuilder ? null : , + + onApply(_effectId?: string) { }, + set ProfileEffectModal(comp: Parameters[0]) { + setProfileEffectModal(props => { + this.onApply = effectId => { + props.onApply(effectId ? ProfileEffectStore.getProfileEffectById(effectId)!.config : null); + props.onClose(); + }; + return comp(props); + }); + }, + + ProfileEffectSelection: () => null, + + usePurchases: () => useMemo( + () => new Map(ProfileEffectStore.profileEffects.map(effect => [ + effect.id, + { items: new ProfileEffectRecord(effect) } + ])), + [ProfileEffectStore.profileEffects] + ), + + useProfileEffectSections: (origSections: Record[]) => useMemo( + () => { + origSections.splice(1); + origSections[0].items.splice(1); + for (const effect of ProfileEffectStore.profileEffects) + origSections[0].items.push(new ProfileEffectRecord(effect)); + return origSections; + }, + [ProfileEffectStore.profileEffects] + ), + + settings, + replaceHelper, + settingsAboutComponent, + decodeAboutMeFPTEHook, + profilePreviewHook +}); diff --git a/src/equicordplugins/fakeProfileThemesAndEffects/lib/ftpe.ts b/src/equicordplugins/fakeProfileThemesAndEffects/lib/ftpe.ts new file mode 100644 index 00000000..54412ff4 --- /dev/null +++ b/src/equicordplugins/fakeProfileThemesAndEffects/lib/ftpe.ts @@ -0,0 +1,205 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +/** The FPTE delimiter codepoint (codepoint of zero-width space). */ +const DELIMITER_CODEPOINT = 0x200B; +/** The FPTE delimiter (zero-width space). */ +const DELIMITER = String.fromCodePoint(DELIMITER_CODEPOINT); +/** The FPTE radix (number of default-ignorable codepoints in the SSP plane). */ +const RADIX = 0x1000; +/** The FPTE starting codepoint (first codepoint in the SSP plane). */ +const STARTING_CODEPOINT = 0xE0000; +/** The FPTE starting character (first character in the SSP plane). */ +const STARTING = String.fromCodePoint(STARTING_CODEPOINT); +/** The FPTE ending codepoint (last default-ignorable codepoint in the SSP plane). */ +const ENDING_CODEPOINT = STARTING_CODEPOINT + RADIX - 1; + +/** + * Builds a theme color string in the legacy format: `[#primary,#accent]`, where primary and accent are + * 24-bit colors as base-16 strings, with each codepoint of the string offset by +{@link STARTING_CODEPOINT}. + * @param primary The 24-bit primary color. + * @param accent The 24-bit accent color. + * @returns The built legacy-format theme color string. + */ +export function encodeColorsLegacy(primary: number, accent: number) { + let str = ""; + for (const char of `[#${primary.toString(16)},#${accent.toString(16)}]`) + str += String.fromCodePoint(char.codePointAt(0)! + STARTING_CODEPOINT); + return str; +} + +/** + * Extracts the theme colors from a legacy-format string. + * @param str The legacy-format string to extract the theme colors from. + * @returns The profile theme colors. Colors will be -1 if not found. + * @see {@link encodeColorsLegacy} + */ +export function decodeColorsLegacy(str: string): [primaryColor: number, accentColor: number] { + const [primary, accent] = str.matchAll(/(?<=#)[\dA-Fa-f]{1,6}/g); + return [primary ? parseInt(primary[0], 16) : -1, accent ? parseInt(accent[0], 16) : -1]; +} + +/** + * Converts a 24-bit color to a base-{@link RADIX} string with each codepoint offset by +{@link STARTING_CODEPOINT}. + * @param color The 24-bit color to be converted. + * @returns The converted base-{@link RADIX} string with +{@link STARTING_CODEPOINT} offset. + */ +export function encodeColor(color: number) { + if (color === 0) return STARTING; + let str = ""; + for (; color > 0; color = Math.trunc(color / RADIX)) + str = String.fromCodePoint(color % RADIX + STARTING_CODEPOINT) + str; + return str; +} + +/** + * Converts a no-offset base-{@link RADIX} string to a 24-bit color. + * @param str The no-offset base-{@link RADIX} string to be converted. + * @returns The converted 24-bit color. + * Will be -1 if `str` is empty and -2 if the color is greater than the maximum 24-bit color, 0xFFFFFF. + */ +export function decodeColor(str: string) { + if (!str) return -1; + let color = 0; + for (let i = 0; i < str.length; i++) { + if (color > 0xFFF_FFF) return -2; + color += str.codePointAt(i)! * RADIX ** (str.length - 1 - i); + } + return color; +} + +/** + * Converts an effect ID to a base-{@link RADIX} string with each code point offset by +{@link STARTING_CODEPOINT}. + * @param id The effect ID to be converted. + * @returns The converted base-{@link RADIX} string with +{@link STARTING_CODEPOINT} offset. + */ +export function encodeEffect(id: bigint) { + if (id === 0n) return STARTING; + let str = ""; + for (; id > 0n; id /= BigInt(RADIX)) + str = String.fromCodePoint(Number(id % BigInt(RADIX)) + STARTING_CODEPOINT) + str; + return str; +} + +/** + * Converts a no-offset base-{@link RADIX} string to an effect ID. + * @param str The no-offset base-{@link RADIX} string to be converted. + * @returns The converted effect ID. + * Will be -1n if `str` is empty and -2n if the color is greater than the maximum effect ID. + */ +export function decodeEffect(str: string) { + if (!str) return -1n; + let id = 0n; + for (let i = 0; i < str.length; i++) { + if (id >= 10_000_000_000_000_000_000n) return -2n; + id += BigInt(str.codePointAt(i)!) * BigInt(RADIX) ** BigInt(str.length - 1 - i); + } + return id; +} + +/** + * Builds a FPTE string containing the given primary/accent colors and effect ID. If the FPTE Builder is NOT set to backwards + * compatibility mode, the primary and accent colors will be converted to base-{@link RADIX} before they are encoded. + * @param primary The primary profile theme color. Must be negative if unset. + * @param accent The accent profile theme color. Must be negative if unset. + * @param effect The profile effect ID. Must be empty if unset. + * @param legacy Whether the primary and accent colors should be legacy encoded. + * @returns The built FPTE string. Will be empty if the given colors and effect are all unset. + */ +export function buildFPTE(primary: number, accent: number, effect: string, legacy: boolean) { + /** The FPTE string to be returned. */ + let fpte = ""; + + // If the FPTE Builder is set to backwards compatibility mode, + // the primary and accent colors, if set, will be legacy encoded. + if (legacy) { + // Legacy FPTE strings must include both the primary and accent colors, even if they are the same. + + if (primary >= 0) { + // If both the primary and accent colors are set, they will be legacy encoded and added to the + // string; otherwise, if the accent color is unset, the primary color will be used in its place. + if (accent >= 0) + fpte = encodeColorsLegacy(primary, accent); + else + fpte = encodeColorsLegacy(primary, primary); + + // If the effect ID is set, it will be encoded and added to the string prefixed by one delimiter. + if (effect) + fpte += DELIMITER + encodeEffect(BigInt(effect)); + + return fpte; + } + + // Since the primary color is unset, the accent color, if set, will be used in its place. + if (accent >= 0) { + fpte = encodeColorsLegacy(accent, accent); + + // If the effect ID is set, it will be encoded and added to the string prefixed by one delimiter. + if (effect) + fpte += DELIMITER + encodeEffect(BigInt(effect)); + + return fpte; + } + } + // If the primary color is set, it will be encoded and added to the string. + else if (primary >= 0) { + fpte = encodeColor(primary); + + // If the accent color is set and different from the primary color, it + // will be encoded and added to the string prefixed by one delimiter. + if (accent >= 0 && primary !== accent) { + fpte += DELIMITER + encodeColor(accent); + + // If the effect ID is set, it will be encoded and added to the string prefixed by one delimiter. + if (effect) + fpte += DELIMITER + encodeEffect(BigInt(effect)); + + return fpte; + } + } + // If only the accent color is set, it will be encoded and added to the string. + else if (accent >= 0) + fpte = encodeColor(accent); + + // Since either the primary/accent colors are the same, both are unset, or just one is set, only one color will be added + // to the string; therefore, the effect ID, if set, will be encoded and added to the string prefixed by two delimiters. + if (effect) + fpte += DELIMITER + DELIMITER + encodeEffect(BigInt(effect)); + + return fpte; +} + +/** + * Extracts the delimiter-separated values of the first FPTE substring in a string. + * @param str The string to be searched for a FPTE substring. + * @returns An array of the found FPTE substring's extracted values. Values will be empty if not found. + */ +export function extractFPTE(str: string) { + /** The array of extracted FPTE values to be returned. */ + const fpte: [maybePrimaryOrLegacy: string, maybeAccentOrEffect: string, maybeEffect: string] = ["", "", ""]; + /** The current index of {@link fpte} getting extracted. */ + let i = 0; + + for (const char of str) { + /** The current character's codepoint. */ + const cp = char.codePointAt(0)!; + + // If the current character is a delimiter, then the current index of fpte has been completed. + if (cp === DELIMITER_CODEPOINT) { + // If the current index of fpte is the last, then the extraction is done. + if (i >= 2) break; + i++; // Start extracting the next index of fpte. + } + // If the current character is not a delimiter but a valid FPTE + // character, it will be added to the current index of fpte. + else if (cp >= STARTING_CODEPOINT && cp <= ENDING_CODEPOINT) + fpte[i] += String.fromCodePoint(cp - STARTING_CODEPOINT); + // If an FPTE string has been found and its end has been reached, then the extraction is done. + else if (i > 0 || fpte[0]) break; + } + + return fpte; +} diff --git a/src/equicordplugins/fakeProfileThemesAndEffects/lib/index.ts b/src/equicordplugins/fakeProfileThemesAndEffects/lib/index.ts new file mode 100644 index 00000000..88f85ff6 --- /dev/null +++ b/src/equicordplugins/fakeProfileThemesAndEffects/lib/index.ts @@ -0,0 +1,10 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +export * from "./ftpe"; +export * from "./profileEffects"; +export * from "./profilePreview"; +export * from "./userProfile"; diff --git a/src/equicordplugins/fakeProfileThemesAndEffects/lib/profileEffects.ts b/src/equicordplugins/fakeProfileThemesAndEffects/lib/profileEffects.ts new file mode 100644 index 00000000..bb789f3e --- /dev/null +++ b/src/equicordplugins/fakeProfileThemesAndEffects/lib/profileEffects.ts @@ -0,0 +1,72 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { findByCodeLazy, findStoreLazy } from "@webpack"; +import type { FluxStore } from "@webpack/types"; +import type { SnakeCasedProperties } from "type-fest"; + +export const ProfileEffectStore: FluxStore & { + canFetch: () => boolean; + getProfileEffectById: (effectId: string) => ProfileEffect | undefined; + hasFetched: () => boolean; + readonly fetchError: Error | undefined; + readonly isFetching: boolean; + readonly profileEffects: ProfileEffect[]; + readonly tryItOutId: string | null; +} = findStoreLazy("ProfileEffectStore"); + +export const ProfileEffectRecord: { + new(profileEffectProperties: ProfileEffectProperties): ProfileEffectRecordInstance; + fromServer: (profileEffectFromServer: SnakeCasedProperties) => ProfileEffectRecordInstance; +} = findByCodeLazy(",this.type=", ".PROFILE_EFFECT"); + +export type ProfileEffectProperties = Omit; + +export interface ProfileEffectRecordInstance { + id: string; + skuId: string; + type: CollectiblesItemType.PROFILE_EFFECT; +} + +export interface ProfileEffect { + config: ProfileEffectConfig; + id: string; + skuId: string; +} + +export interface ProfileEffectConfig { + accessibilityLabel: string; + animationType: number; + description: string; + effects: { + duartion: number; + height: number; + loop: boolean; + loopDelay: number; + position: { + x: number; + y: number; + }; + src: string; + start: number; + width: number; + zIndex: number; + }[]; + id: string; + reducedMotionSrc: string; + sku_id: string; + staticFrameSrc?: string; + thumbnailPreviewSrc: string; + title: string; + type: CollectiblesItemType.PROFILE_EFFECT; +} + +export const enum CollectiblesItemType { + AVATAR_DECORATION = 0, + PROFILE_EFFECT = 1, + NONE = 100, + BUNDLE = 1_000, +} diff --git a/src/equicordplugins/fakeProfileThemesAndEffects/lib/profilePreview.ts b/src/equicordplugins/fakeProfileThemesAndEffects/lib/profilePreview.ts new file mode 100644 index 00000000..8a0a4dce --- /dev/null +++ b/src/equicordplugins/fakeProfileThemesAndEffects/lib/profilePreview.ts @@ -0,0 +1,77 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { FluxDispatcher, useState } from "@webpack/common"; + +import type { ProfileEffectConfig } from "./profileEffects"; + +function updatePreview() { + FluxDispatcher.dispatch({ type: "USER_SETTINGS_ACCOUNT_SUBMIT_SUCCESS" }); +} + +let primaryColor: number | null = null; +export function usePrimaryColor(initialState: typeof primaryColor) { + const [state, setState] = useState(() => primaryColor = initialState); + return [ + state, + (color: typeof primaryColor) => { + setState(primaryColor = color); + if (showPreview) updatePreview(); + } + ] as const; +} + +let accentColor: number | null = null; +export function useAccentColor(initialState: typeof accentColor) { + const [state, setState] = useState(() => accentColor = initialState); + return [ + state, + (color: typeof accentColor) => { + setState(accentColor = color); + if (showPreview) updatePreview(); + } + ] as const; +} + +let profileEffect: ProfileEffectConfig | null = null; +export function useProfileEffect(initialState: typeof profileEffect) { + const [state, setState] = useState(() => profileEffect = initialState); + return [ + state, + (effect: typeof profileEffect) => { + setState(profileEffect = effect); + if (showPreview) updatePreview(); + } + ] as const; +} + +let showPreview = true; +export function useShowPreview(initialState: typeof showPreview) { + const [state, setState] = useState(() => showPreview = initialState); + return [ + state, + (preview: typeof showPreview) => { + setState(showPreview = preview); + updatePreview(); + } + ] as const; +} + +export function profilePreviewHook(props: Record) { + if (showPreview) { + if (primaryColor !== null) { + props.pendingThemeColors = [primaryColor, accentColor ?? primaryColor]; + props.canUsePremiumCustomization = true; + } else if (accentColor !== null) { + props.pendingThemeColors = [accentColor, accentColor]; + props.canUsePremiumCustomization = true; + } + if (!props.forProfileEffectModal && profileEffect) { + props.pendingProfileEffectId = profileEffect.id; + props.canUsePremiumCustomization = true; + } + } +} diff --git a/src/equicordplugins/fakeProfileThemesAndEffects/lib/userProfile.ts b/src/equicordplugins/fakeProfileThemesAndEffects/lib/userProfile.ts new file mode 100644 index 00000000..bd91c81f --- /dev/null +++ b/src/equicordplugins/fakeProfileThemesAndEffects/lib/userProfile.ts @@ -0,0 +1,69 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { settings } from ".."; +import { decodeColor, decodeColorsLegacy, decodeEffect, extractFPTE } from "."; + +export interface UserProfile { + bio: string; + premiumType: number | null | undefined; + profileEffectId: string | undefined; + themeColors: [primaryColor: number, accentColor: number] | undefined; +} + +function updateProfileThemeColors(profile: UserProfile, primary: number, accent: number) { + if (primary > -1) { + profile.themeColors = [primary, accent > -1 ? accent : primary]; + profile.premiumType = 2; + } else if (accent > -1) { + profile.themeColors = [accent, accent]; + profile.premiumType = 2; + } +} + +function updateProfileEffectId(profile: UserProfile, id: bigint) { + if (id > -1n) { + profile.profileEffectId = id.toString(); + profile.premiumType = 2; + } +} + +export function decodeAboutMeFPTEHook(profile?: UserProfile) { + if (!profile) return profile; + + if (settings.store.prioritizeNitro) { + if (profile.themeColors) { + if (!profile.profileEffectId) { + const fpte = extractFPTE(profile.bio); + if (decodeColor(fpte[0]) === -2) + updateProfileEffectId(profile, decodeEffect(fpte[1])); + else + updateProfileEffectId(profile, decodeEffect(fpte[2])); + } + return profile; + } else if (profile.profileEffectId) { + const fpte = extractFPTE(profile.bio); + const primaryColor = decodeColor(fpte[0]); + if (primaryColor === -2) + updateProfileThemeColors(profile, ...decodeColorsLegacy(fpte[0])); + else + updateProfileThemeColors(profile, primaryColor, decodeColor(fpte[1])); + return profile; + } + } + + const fpte = extractFPTE(profile.bio); + const primaryColor = decodeColor(fpte[0]); + if (primaryColor === -2) { + updateProfileThemeColors(profile, ...decodeColorsLegacy(fpte[0])); + updateProfileEffectId(profile, decodeEffect(fpte[1])); + } else { + updateProfileThemeColors(profile, primaryColor, decodeColor(fpte[1])); + updateProfileEffectId(profile, decodeEffect(fpte[2])); + } + + return profile; +}