Add FakeProfileThemesAndEffects back

This commit is contained in:
thororen1234 2024-10-19 15:49:41 -04:00
parent 79a44729a0
commit 857e4ae872
13 changed files with 981 additions and 0 deletions

View file

@ -250,6 +250,7 @@ function isPluginFile({ name }: { name: string; }) {
plugins.push(data);
if (readme) readmes[data.name] = readme;
})
.sort()
));
const data = JSON.stringify(plugins);

View file

@ -250,6 +250,7 @@ function isPluginFile({ name }: { name: string; }) {
plugins.push(data);
if (readme) readmes[data.name] = readme;
})
.sort()
));
const data = JSON.stringify(plugins);

View file

@ -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 (
<>
<CustomizationSection title="FPTE Builder">
<div style={{ display: "flex", justifyContent: "space-between" }}>
<BuilderColorButton
label="Primary"
color={primaryColor}
setColor={setPrimaryColor}
suggestedColors={avatarColors}
/>
<BuilderColorButton
label="Accent"
color={accentColor}
setColor={setAccentColor}
suggestedColors={avatarColors}
/>
<BuilderButton
label="Effect"
tooltip={effect?.title}
selectedStyle={effect ? {
background: `top / cover url(${effect.thumbnailPreviewSrc}), top / cover url(/assets/f328a6f8209d4f1f5022.png)`
} : undefined}
buttonProps={{
onClick() {
openProfileEffectModal(effect?.id, setEffect, guild);
}
}}
/>
<div
style={{
display: "flex",
alignItems: "center",
flexDirection: "column"
}}
>
<Button
size={Button.Sizes.SMALL}
onClick={() => {
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
</Button>
<Button
look={Button.Looks.LINK}
color={Button.Colors.PRIMARY}
size={Button.Sizes.SMALL}
style={primaryColor === null && accentColor === null && !effect ? { visibility: "hidden" } : undefined}
onClick={() => {
setPrimaryColor(null);
setAccentColor(null);
setEffect(null);
}}
>
Reset
</Button>
</div>
</div>
</CustomizationSection>
<Switch
value={preview}
onChange={setPreview}
>
FPTE Builder Preview
</Switch>
<Switch
value={buildLegacy}
note="Will use more characters"
onChange={setBuildLegacy}
>
Build backwards compatible FPTE
</Switch>
</>
);
}

View file

@ -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) => (
<Tooltip text={tooltip} shouldShow={!!tooltip}>
{tooltipProps => (
<div style={{ width: "60px" }}>
<div
{...tooltipProps}
{...buttonProps}
aria-label={label}
role="button"
tabIndex={0}
style={{
...selectedStyle ?? { border: "2px dashed var(--header-secondary)" },
display: "grid",
placeItems: "center",
height: "60px",
borderRadius: "4px",
cursor: "pointer"
}}
>
{!selectedStyle && (
<svg
fill="var(--header-secondary)"
width="40%"
height="40%"
viewBox="0 0 144 144"
>
<path d="M144 64H80V0H64v64H0v16h64v64h16V80h64Z" />
</svg>
)}
</div>
{!!label && (
<Text
color="header-secondary"
variant="text-xs/normal"
tag="div"
style={{
marginTop: "4px",
textAlign: "center"
}}
>
{label}
</Text>
)}
</div>
)}
</Tooltip>
);

View file

@ -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<BuilderButtonProps, "label">, Pick<CustomColorPickerProps, "suggestedColors"> {
color: number | null;
setColor: (color: number | null) => void;
}
export const BuilderColorButton = ({ label, color, setColor, suggestedColors }: BuilderColorButtonProps) => (
<Popout
position="bottom"
renderPopout={() => (
<CustomColorPicker
value={color}
onChange={setColor}
showEyeDropper={true}
suggestedColors={suggestedColors}
/>
)}
>
{popoutProps => {
const hexColor = color ? "#" + color.toString(16).padStart(6, "0") : undefined;
return (
<BuilderButton
label={label}
tooltip={hexColor}
selectedStyle={hexColor ? { background: hexColor } : undefined}
buttonProps={popoutProps}
/>
);
}}
</Popout>
);

View file

@ -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<CustomizationSectionProps>
= findByCodeLazy(".customizationSectionBackground");
export const tokens: {
unsafe_rawColors: Record<string, Parameters<useToken>[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<CustomColorPickerProps>(".customColorPicker");
interface ProfileEffectModalProps extends ModalProps {
analyticsLocations?: string[] | undefined;
guild?: Guild | null | undefined;
initialSelectedEffectId?: string | undefined;
onApply: (effect: ProfileEffectConfig | null) => void;
}
let ProfileEffectModal: FunctionComponent<ProfileEffectModalProps> = () => 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 => (
<ProfileEffectModal
{...modalProps}
initialSelectedEffectId={initialEffectId}
guild={guild}
onApply={onApply}
/>
));
}

View file

@ -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 = () => (
<Forms.FormSection>
<Forms.FormTitle tag="h3">Usage</Forms.FormTitle>
<Forms.FormText>
After enabling this plugin, you will see custom theme colors and effects in the profiles of others using this plugin.
<div className={Margins.top8}>
<b>To set your own profile theme colors and effect:</b>
</div>
<ol
className={Margins.bottom8}
style={{ listStyle: "decimal", paddingLeft: "40px" }}
>
<li>Go to your profile settings</li>
<li>Use the FPTE Builder to choose your profile theme colors and effect</li>
<li>Click the "Copy FPTE" button</li>
<li>Paste the invisible text anywhere in your About Me</li>
</ol>
</Forms.FormText>
</Forms.FormSection>
);

View file

@ -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 : <Builder guild={guild} />,
onApply(_effectId?: string) { },
set ProfileEffectModal(comp: Parameters<typeof setProfileEffectModal>[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<string, any>[]) => 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
});

View file

@ -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;
}

View file

@ -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";

View file

@ -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<ProfileEffectProperties>) => ProfileEffectRecordInstance;
} = findByCodeLazy(",this.type=", ".PROFILE_EFFECT");
export type ProfileEffectProperties = Omit<ProfileEffectRecordInstance, "type">;
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,
}

View file

@ -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<string, any>) {
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;
}
}
}

View file

@ -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;
}