From a53e43fc70e654f672fd6b5e3c246b4e7ccc1935 Mon Sep 17 00:00:00 2001 From: everestmcarthur <280397636+everestmcarthur@users.noreply.github.com> Date: Sat, 1 Aug 2026 11:49:06 -0400 Subject: [PATCH] Add FakeProfileThemesAndEffects fork with confirmed crash fix; expand Key Inspector --- README.md | 3 +- build.mjs | 17 +- .../fake-profile-themes-and-effects/LICENSE | 116 +++++++++ .../manifest.json | 13 + .../src/index.ts | 51 ++++ .../src/lib/flux.ts | 4 + .../src/lib/fpte.ts | 226 ++++++++++++++++++ .../src/lib/haptics.ts | 12 + .../src/lib/reactNativeRenderTree.ts | 165 +++++++++++++ .../src/lib/records.ts | 25 ++ .../lib/stores/CollectiblesPurchaseStore.ts | 55 +++++ .../src/lib/stores/ProfileEffectStore.ts | 54 +++++ .../src/lib/stores/index.ts | 12 + .../src/lib/utils.ts | 17 ++ .../src/patches/index.ts | 7 + .../src/patches/patchGetPurchase.ts | 12 + .../src/patches/patchGetUserProfile.ts | 60 +++++ .../src/patches/patchGuildProfileEditForm.tsx | 40 ++++ .../src/patches/patchNitroUpsellCard.tsx | 37 +++ .../patches/patchUseProfileEffectSections.ts | 30 +++ .../src/patches/patchUseProfileTheme.ts | 144 +++++++++++ .../src/patches/patchUserProfileEditForm.tsx | 38 +++ .../actionSheets/EffectPickerActionSheet.tsx | 104 ++++++++ .../FallbackEffectPickerActionSheet.tsx | 214 +++++++++++++++++ .../src/ui/actionSheets/index.ts | 29 +++ .../src/ui/actionSheets/showColorPicker.ts | 12 + .../src/ui/actionSheets/showEffectPicker.tsx | 36 +++ .../src/ui/assets.ts | 17 ++ .../src/ui/color.ts | 74 ++++++ .../src/ui/components/Builder.tsx | 188 +++++++++++++++ .../src/ui/components/BuilderButton.tsx | 63 +++++ .../src/ui/components/Button.ts | 26 ++ .../src/ui/components/Icon.ts | 15 ++ .../src/ui/components/StaticEffect.tsx | 34 +++ .../src/ui/components/Text.ts | 19 ++ .../src/ui/components/forms.ts | 108 +++++++++ .../src/ui/components/index.ts | 28 +++ .../src/ui/length.ts | 18 ++ .../src/ui/pages/Settings.tsx | 39 +++ .../src/ui/pages/index.ts | 1 + .../src/ui/toasts.ts | 13 + plugins/key-inspector/src/lib/apiTree.ts | 79 ++++++ plugins/key-inspector/src/ui/Settings.tsx | 25 ++ site/meta.json | 18 +- 44 files changed, 2295 insertions(+), 3 deletions(-) create mode 100644 plugins/fake-profile-themes-and-effects/LICENSE create mode 100644 plugins/fake-profile-themes-and-effects/manifest.json create mode 100644 plugins/fake-profile-themes-and-effects/src/index.ts create mode 100644 plugins/fake-profile-themes-and-effects/src/lib/flux.ts create mode 100644 plugins/fake-profile-themes-and-effects/src/lib/fpte.ts create mode 100644 plugins/fake-profile-themes-and-effects/src/lib/haptics.ts create mode 100644 plugins/fake-profile-themes-and-effects/src/lib/reactNativeRenderTree.ts create mode 100644 plugins/fake-profile-themes-and-effects/src/lib/records.ts create mode 100644 plugins/fake-profile-themes-and-effects/src/lib/stores/CollectiblesPurchaseStore.ts create mode 100644 plugins/fake-profile-themes-and-effects/src/lib/stores/ProfileEffectStore.ts create mode 100644 plugins/fake-profile-themes-and-effects/src/lib/stores/index.ts create mode 100644 plugins/fake-profile-themes-and-effects/src/lib/utils.ts create mode 100644 plugins/fake-profile-themes-and-effects/src/patches/index.ts create mode 100644 plugins/fake-profile-themes-and-effects/src/patches/patchGetPurchase.ts create mode 100644 plugins/fake-profile-themes-and-effects/src/patches/patchGetUserProfile.ts create mode 100644 plugins/fake-profile-themes-and-effects/src/patches/patchGuildProfileEditForm.tsx create mode 100644 plugins/fake-profile-themes-and-effects/src/patches/patchNitroUpsellCard.tsx create mode 100644 plugins/fake-profile-themes-and-effects/src/patches/patchUseProfileEffectSections.ts create mode 100644 plugins/fake-profile-themes-and-effects/src/patches/patchUseProfileTheme.ts create mode 100644 plugins/fake-profile-themes-and-effects/src/patches/patchUserProfileEditForm.tsx create mode 100644 plugins/fake-profile-themes-and-effects/src/ui/actionSheets/EffectPickerActionSheet.tsx create mode 100644 plugins/fake-profile-themes-and-effects/src/ui/actionSheets/FallbackEffectPickerActionSheet.tsx create mode 100644 plugins/fake-profile-themes-and-effects/src/ui/actionSheets/index.ts create mode 100644 plugins/fake-profile-themes-and-effects/src/ui/actionSheets/showColorPicker.ts create mode 100644 plugins/fake-profile-themes-and-effects/src/ui/actionSheets/showEffectPicker.tsx create mode 100644 plugins/fake-profile-themes-and-effects/src/ui/assets.ts create mode 100644 plugins/fake-profile-themes-and-effects/src/ui/color.ts create mode 100644 plugins/fake-profile-themes-and-effects/src/ui/components/Builder.tsx create mode 100644 plugins/fake-profile-themes-and-effects/src/ui/components/BuilderButton.tsx create mode 100644 plugins/fake-profile-themes-and-effects/src/ui/components/Button.ts create mode 100644 plugins/fake-profile-themes-and-effects/src/ui/components/Icon.ts create mode 100644 plugins/fake-profile-themes-and-effects/src/ui/components/StaticEffect.tsx create mode 100644 plugins/fake-profile-themes-and-effects/src/ui/components/Text.ts create mode 100644 plugins/fake-profile-themes-and-effects/src/ui/components/forms.ts create mode 100644 plugins/fake-profile-themes-and-effects/src/ui/components/index.ts create mode 100644 plugins/fake-profile-themes-and-effects/src/ui/length.ts create mode 100644 plugins/fake-profile-themes-and-effects/src/ui/pages/Settings.tsx create mode 100644 plugins/fake-profile-themes-and-effects/src/ui/pages/index.ts create mode 100644 plugins/fake-profile-themes-and-effects/src/ui/toasts.ts create mode 100644 plugins/key-inspector/src/lib/apiTree.ts diff --git a/README.md b/README.md index 62049fe..d21a548 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ To install: open Discord with Revenge installed, go to **Settings → Plugins**, | Staff Tags | Rebuilt | OWNER/ADMIN/STAFF/MOD-style tags next to members, computed from real server permissions. Per-tag text, colors, gradients, and visibility. | `https://rp.jarviscli.dev/staff-tags/install/` | | RoleColorEverywhere | Revived | A member's top role color shown in mentions, the typing indicator, voice channel names, member list role headers, and optionally message text. | `https://rp.jarviscli.dev/role-color-everywhere/install/` | | PronounDB | Revived | Shows a user's pronouns in their profile, if they've set them at pronoundb.org (nothing to configure in the plugin itself). | `https://rp.jarviscli.dev/pronoun-db/install/` | +| FakeProfileThemesAndEffects | Revived | Profile theming and profile effects without Nitro. Fork of [shipwr3ckd's FPTE](https://github.com/shipwr3ckd/FPTE) - fixed a crash on every profile-editor open, confirmed via on-device diagnostics. | `https://rp.jarviscli.dev/fake-profile-themes-and-effects/install/` | | BetterTimestamps | Revived | Calendar-relative, fully relative, ISO 8601, or a custom moment.js timestamp format, for both messages and day dividers. | `https://rp.jarviscli.dev/better-timestamps/install/` | | CopyRoleColor | Revived | Long-press a role pill to copy its color as hex. | `https://rp.jarviscli.dev/copy-role-color/install/` | @@ -40,7 +41,7 @@ To install: open Discord with Revenge installed, go to **Settings → Plugins**, | Plugin | Status | Description | Install link | | --- | --- | --- | --- | | ViewRaw | Revived | See a message's raw JSON via long-press - searchable, syntax-highlighted, and copyable. Fork of [bwlok's ViewRaw](https://github.com/bwlok/revenge-plugins/tree/master/plugins/ViewRaw), GPLv3. | `https://rp.jarviscli.dev/view-raw/install/` | -| Key Inspector | New | One-tap scan of every internal key this repo's plugins depend on, with a copyable report - for diagnosing crashes entirely from a phone. | `https://rp.jarviscli.dev/key-inspector/install/` | +| Key Inspector | New | One-tap scan of every internal key this repo's plugins depend on, plus a full dump of Revenge's plugin API tree - for diagnosing crashes entirely from a phone. | `https://rp.jarviscli.dev/key-inspector/install/` | "Revived" means the plugin previously existed elsewhere, stopped working after a Discord/API update, and has been rebuilt here. "Rebuilt" means it's a from-scratch reimplementation of a previously-broken plugin with expanded features. "New" means diff --git a/build.mjs b/build.mjs index ceebed9..601d24d 100644 --- a/build.mjs +++ b/build.mjs @@ -18,7 +18,14 @@ const extensions = [".js", ".jsx", ".mjs", ".ts", ".tsx", ".cts", ".mts"]; /** @type import("rollup").InputPluginOption */ const plugins = [ alias({ - entries: [{ find: "@shared", replacement: resolvePath("./shared") }], + entries: [ + { find: "@shared", replacement: resolvePath("./shared") }, + // FPTE's own internal aliases, namespaced to avoid colliding with any other plugin + // that might want a generic "@lib"/"@ui" of its own. + { find: "@fpte/lib", replacement: resolvePath("./plugins/fake-profile-themes-and-effects/src/lib") }, + { find: "@fpte/ui", replacement: resolvePath("./plugins/fake-profile-themes-and-effects/src/ui") }, + { find: "@fpte/patches", replacement: resolvePath("./plugins/fake-profile-themes-and-effects/src/patches") }, + ], }), nodeResolve({ extensions: [".mjs", ".js", ".json", ".node", ".ts", ".tsx"] }), commonjs(), @@ -70,6 +77,13 @@ for (const plug of await readdir("./plugins")) { const bundle = await rollup({ input: `./plugins/${plug}/${manifest.main}`, onwarn: () => {}, + // Most plugins only ever import React/ReactNative via @vendetta/metro/common, which + // resolves to nothing on disk and so is treated as external automatically - but a + // couple of ported plugins import the bare "react"/"react-native" packages directly + // (like their own original build configs expected), and those DO exist in + // node_modules, so without this they'd actually get bundled - including react-native's + // real source, which contains Flow syntax this toolchain can't parse. + external: ["react", "react-native"], plugins, }); @@ -79,6 +93,7 @@ for (const plug of await readdir("./plugins")) { if (id.startsWith("@vendetta")) return id.substring(1).replace(/\//g, "."); const map = { react: "window.React", + "react-native": "vendetta.metro.common.ReactNative", }; return map[id] || null; diff --git a/plugins/fake-profile-themes-and-effects/LICENSE b/plugins/fake-profile-themes-and-effects/LICENSE new file mode 100644 index 0000000..670154e --- /dev/null +++ b/plugins/fake-profile-themes-and-effects/LICENSE @@ -0,0 +1,116 @@ +CC0 1.0 Universal + +Statement of Purpose + +The laws of most jurisdictions throughout the world automatically confer +exclusive Copyright and Related Rights (defined below) upon the creator and +subsequent owner(s) (each and all, an "owner") of an original work of +authorship and/or a database (each, a "Work"). + +Certain owners wish to permanently relinquish those rights to a Work for the +purpose of contributing to a commons of creative, cultural and scientific +works ("Commons") that the public can reliably and without fear of later +claims of infringement build upon, modify, incorporate in other works, reuse +and redistribute as freely as possible in any form whatsoever and for any +purposes, including without limitation commercial purposes. These owners may +contribute to the Commons to promote the ideal of a free culture and the +further production of creative, cultural and scientific works, or to gain +reputation or greater distribution for their Work in part through the use and +efforts of others. + +For these and/or other purposes and motivations, and without any expectation +of additional consideration or compensation, the person associating CC0 with a +Work (the "Affirmer"), to the extent that he or she is an owner of Copyright +and Related Rights in the Work, voluntarily elects to apply CC0 to the Work +and publicly distribute the Work under its terms, with knowledge of his or her +Copyright and Related Rights in the Work and the meaning and intended legal +effect of CC0 on those rights. + +1. Copyright and Related Rights. A Work made available under CC0 may be +protected by copyright and related or neighboring rights ("Copyright and +Related Rights"). Copyright and Related Rights include, but are not limited +to, the following: + + i. the right to reproduce, adapt, distribute, perform, display, communicate, + and translate a Work; + + ii. moral rights retained by the original author(s) and/or performer(s); + + iii. publicity and privacy rights pertaining to a person's image or likeness + depicted in a Work; + + iv. rights protecting against unfair competition in regards to a Work, + subject to the limitations in paragraph 4(a), below; + + v. rights protecting the extraction, dissemination, use and reuse of data in + a Work; + + vi. database rights (such as those arising under Directive 96/9/EC of the + European Parliament and of the Council of 11 March 1996 on the legal + protection of databases, and under any national implementation thereof, + including any amended or successor version of such directive); and + + vii. other similar, equivalent or corresponding rights throughout the world + based on applicable law or treaty, and any national implementations thereof. + +2. Waiver. To the greatest extent permitted by, but not in contravention of, +applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and +unconditionally waives, abandons, and surrenders all of Affirmer's Copyright +and Related Rights and associated claims and causes of action, whether now +known or unknown (including existing as well as future claims and causes of +action), in the Work (i) in all territories worldwide, (ii) for the maximum +duration provided by applicable law or treaty (including future time +extensions), (iii) in any current or future medium and for any number of +copies, and (iv) for any purpose whatsoever, including without limitation +commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes +the Waiver for the benefit of each member of the public at large and to the +detriment of Affirmer's heirs and successors, fully intending that such Waiver +shall not be subject to revocation, rescission, cancellation, termination, or +any other legal or equitable action to disrupt the quiet enjoyment of the Work +by the public as contemplated by Affirmer's express Statement of Purpose. + +3. Public License Fallback. Should any part of the Waiver for any reason be +judged legally invalid or ineffective under applicable law, then the Waiver +shall be preserved to the maximum extent permitted taking into account +Affirmer's express Statement of Purpose. In addition, to the extent the Waiver +is so judged Affirmer hereby grants to each affected person a royalty-free, +non transferable, non sublicensable, non exclusive, irrevocable and +unconditional license to exercise Affirmer's Copyright and Related Rights in +the Work (i) in all territories worldwide, (ii) for the maximum duration +provided by applicable law or treaty (including future time extensions), (iii) +in any current or future medium and for any number of copies, and (iv) for any +purpose whatsoever, including without limitation commercial, advertising or +promotional purposes (the "License"). The License shall be deemed effective as +of the date CC0 was applied by Affirmer to the Work. Should any part of the +License for any reason be judged legally invalid or ineffective under +applicable law, such partial invalidity or ineffectiveness shall not +invalidate the remainder of the License, and in such case Affirmer hereby +affirms that he or she will not (i) exercise any of his or her remaining +Copyright and Related Rights in the Work or (ii) assert any associated claims +and causes of action with respect to the Work, in either case contrary to +Affirmer's express Statement of Purpose. + +4. Limitations and Disclaimers. + + a. No trademark or patent rights held by Affirmer are waived, abandoned, + surrendered, licensed or otherwise affected by this document. + + b. Affirmer offers the Work as-is and makes no representations or warranties + of any kind concerning the Work, express, implied, statutory or otherwise, + including without limitation warranties of title, merchantability, fitness + for a particular purpose, non infringement, or the absence of latent or + other defects, accuracy, or the present or absence of errors, whether or not + discoverable, all to the greatest extent permissible under applicable law. + + c. Affirmer disclaims responsibility for clearing rights of other persons + that may apply to the Work or any use thereof, including without limitation + any person's Copyright and Related Rights in the Work. Further, Affirmer + disclaims responsibility for obtaining any necessary consents, permissions + or other rights required for any use of the Work. + + d. Affirmer understands and acknowledges that Creative Commons is not a + party to this document and has no duty or obligation with respect to this + CC0 or use of the Work. + +For more information, please see + diff --git a/plugins/fake-profile-themes-and-effects/manifest.json b/plugins/fake-profile-themes-and-effects/manifest.json new file mode 100644 index 0000000..4b9fa02 --- /dev/null +++ b/plugins/fake-profile-themes-and-effects/manifest.json @@ -0,0 +1,13 @@ +{ + "name": "FakeProfileThemesAndEffects", + "description": "Profile theming and profile effects without Nitro, by hiding the color/effect data in your About Me using invisible zero-width characters.", + "authors": [ + { "name": "everestmcarthur" }, + { "name": "ryan" }, + { "name": "シグマ siguma" } + ], + "main": "src/index.ts", + "vendetta": { + "icon": "ic_profile_24px" + } +} diff --git a/plugins/fake-profile-themes-and-effects/src/index.ts b/plugins/fake-profile-themes-and-effects/src/index.ts new file mode 100644 index 0000000..f6d74ea --- /dev/null +++ b/plugins/fake-profile-themes-and-effects/src/index.ts @@ -0,0 +1,51 @@ +import { logger } from "@vendetta"; +import { applyPatches } from "@shared/lib/patcher"; +import { FluxDispatcher } from "@fpte/lib/flux"; +import { UserProfileStore, UserStore } from "@fpte/lib/stores"; +import { + patchGetPurchase, + patchGetUserProfile, + patchUseProfileEffectSections, + patchUseProfileTheme, + patchUserProfileEditForm, + patchNitroUpsellCard +} from "@fpte/patches"; +import { Settings } from "@fpte/ui/pages"; + +/** Updates the profile theme and effect used by YouScreen and BottomTabBar. */ +function updateProfileThemeAndEffect() { + const user = UserStore.getCurrentUser(); + if (!user) return; + const user_profile = UserProfileStore.getUserProfile(user.id); + if (!user_profile) return; + FluxDispatcher.dispatch({ + type: "USER_PROFILE_FETCH_SUCCESS", + user, + user_profile, + connected_accounts: user_profile.connectedAccounts + }); +} + +let unpatchAll: () => void = () => {}; + +export default { + onLoad() { + unpatchAll = applyPatches("FakeProfileThemesAndEffects", logger, { + "purchase check": patchGetPurchase, + "user profile data": patchGetUserProfile, + "profile effect sections": () => { + const unpatches = patchUseProfileEffectSections(); + return () => unpatches.forEach((u) => u()); + }, + "profile theme": patchUseProfileTheme, + "profile edit form": patchUserProfileEditForm, + "nitro upsell card": patchNitroUpsellCard + }); + updateProfileThemeAndEffect(); + }, + onUnload() { + unpatchAll(); + updateProfileThemeAndEffect(); + }, + settings: Settings +}; diff --git a/plugins/fake-profile-themes-and-effects/src/lib/flux.ts b/plugins/fake-profile-themes-and-effects/src/lib/flux.ts new file mode 100644 index 0000000..dd53bfb --- /dev/null +++ b/plugins/fake-profile-themes-and-effects/src/lib/flux.ts @@ -0,0 +1,4 @@ +import type { FluxDispatcher as $FluxDispatcher } from "@vencord/discord-types"; +import { FluxDispatcher as Dispatcher } from "@vendetta/metro/common"; + +export const FluxDispatcher = Dispatcher as $FluxDispatcher; diff --git a/plugins/fake-profile-themes-and-effects/src/lib/fpte.ts b/plugins/fake-profile-themes-and-effects/src/lib/fpte.ts new file mode 100644 index 0000000..43ad996 --- /dev/null +++ b/plugins/fake-profile-themes-and-effects/src/lib/fpte.ts @@ -0,0 +1,226 @@ +/* eslint-disable @stylistic/brace-style */ + +/** 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 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) { + return String.fromCodePoint(...[...`[#${primary.toString(16)},#${accent.toString(16)}]`] + .map(c => c.codePointAt(0)! + STARTING_CODEPOINT)); +} + +/** + * 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 String.fromCodePoint(STARTING_CODEPOINT); + 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 String.fromCodePoint(STARTING_CODEPOINT); + 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; +} + +/** + * Detects if a string contains an FPTE substring based on the presence of + * FPTE codepoints or the FPTE delimiter. + * @param str The string to check. + * @returns True if the string contains FPTE codepoints or delimiters, false otherwise. + */ +export function hasFPTE(str: string): boolean { + for (const char of str) { + const cp = char.codePointAt(0)!; + if ( + (cp >= STARTING_CODEPOINT && cp <= ENDING_CODEPOINT) || // FPTE codepoint range + cp === DELIMITER_CODEPOINT // delimiter zero-width space + ) { + return true; + } + } + return false; +} + +export function stripFPTE(str: string) { + return [...str] + .filter(ch => { + const cp = ch.codePointAt(0)!; + return (cp < STARTING_CODEPOINT || cp > ENDING_CODEPOINT) && cp !== DELIMITER_CODEPOINT; + }) + .join("") + .trim(); + } \ No newline at end of file diff --git a/plugins/fake-profile-themes-and-effects/src/lib/haptics.ts b/plugins/fake-profile-themes-and-effects/src/lib/haptics.ts new file mode 100644 index 0000000..0fe16f7 --- /dev/null +++ b/plugins/fake-profile-themes-and-effects/src/lib/haptics.ts @@ -0,0 +1,12 @@ +import { findByProps } from "@vendetta/metro"; + +// Must use `as` or else `undefined` is lost due to a TS bug +const hapticsModule = findByProps("triggerHapticFeedback") as Record | undefined; + +export type FeebackKey = "IMPACT_LIGHT" | "IMPACT_MEDIUM" | "IMPACT_HEAVY" | "NOTIFICATION_ERROR" | "DRAG_AND_DROP_START" | "DRAG_AND_DROP_END" | "DRAG_AND_DROP_MOVE"; + +export const HapticFeebackTypes: Record + = hapticsModule?.HapticFeedbackTypes ?? {}; + +export const triggerHapticFeedback: (feedbackType?: number | undefined) => void + = hapticsModule?.triggerHapticFeedback ?? (() => undefined); diff --git a/plugins/fake-profile-themes-and-effects/src/lib/reactNativeRenderTree.ts b/plugins/fake-profile-themes-and-effects/src/lib/reactNativeRenderTree.ts new file mode 100644 index 0000000..1bbeb1a --- /dev/null +++ b/plugins/fake-profile-themes-and-effects/src/lib/reactNativeRenderTree.ts @@ -0,0 +1,165 @@ +import type React from "react"; + +import { isIterable, isNonNullObject, type OmitCallSignature } from "@fpte/lib/utils"; + +export namespace RN { + export type Fragment

= Element; + + export interface ConsumerProps { + children: (value: T) => Node; + } + + export interface ProviderProps { + value: T; + children?: Node; + } + + export type ConsumerType = React.Context & OmitCallSignature>>; + + export interface ProviderType extends OmitCallSignature>> { + _context: React.Context; + } + + export interface ForwardRefType extends OmitCallSignature> { + render: React.ForwardRefRenderFunction; + } + + export interface MemoType = ElementType> extends OmitCallSignature>> { + readonly type: T; + } + + export interface Component extends Omit, "render"> { + render: () => Node; + } + + export interface ComponentClass

extends OmitCallSignature> { + new (props: P, context?: any): Component; + } + + export interface FunctionComponent

extends OmitCallSignature> { + (props: P, context?: any): Node; + } + + export type ComponentType

= ComponentClass

| FunctionComponent

; + + export type ElementType

= ComponentType

| symbol | ConsumerType | ProviderType | ForwardRefType | MemoType>; + + export interface Element

{ + type: T; + props: P; + key: React.Key | null; + } + + export type Node = Iterable | Element | string | number | boolean | null | undefined; + + export type PropsWithChildren

= P & { children: Node; }; + + export type ComponentProps = T extends ElementType ? P : object; + + export type ComponentPropsWithRef = + T extends React.ComponentClass + ? React.PropsWithoutRef

& React.RefAttributes> + : React.PropsWithRef>; +} + +export function isElement(arg: RN.Node): arg is RN.Element { + return isNonNullObject(arg) && "type" in arg; +} + +export function isElementWithChildren(arg: T | RN.Element): arg is RN.Element { + return "children" in arg.props; +} + +export function isProviderType(arg: Exclude): arg is RN.ProviderType { + return "_context" in arg; +} + +export function isForwardRefType(arg: Exclude): arg is RN.ForwardRefType { + return "render" in arg; +} + +export function isMemoType(arg: Exclude): arg is RN.MemoType { + return "type" in arg; +} + +/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ +/** @see {@link https://github.com/facebook/react-native/blob/d724007364c4315e2cabace0fc6eae6e6212431d/packages/react-native/Libraries/Renderer/implementations/ReactNativeRenderer-dev.js#L3130-L3212} */ +export function getComponentNameFromType(type: RN.ElementType) { + if (typeof type === "symbol") + return Symbol.keyFor(type) || null; + if (typeof type === "function") + return type.displayName || type.name || null; + if (isProviderType(type)) + return type._context.displayName || null; + if (type.displayName) + return type.displayName; + if (isForwardRefType(type)) + return type.render.displayName || type.render.name || null; + if (isMemoType(type)) + return getComponentNameFromType(type.type); + return null; +} +/* eslint-enable @typescript-eslint/prefer-nullish-coalescing */ + +export function findElementInTree( + tree: RN.Node, + filter: (element: RN.Element) => element is T, + maxDepth?: number | undefined +): T | null; +export function findElementInTree( + tree: RN.Node, + filter: (element: RN.Element) => unknown, + maxDepth?: number | undefined +): RN.Element | null; +export function findElementInTree( + tree: RN.Node, + filter: (element: RN.Element) => unknown, + maxDepth = 200 +): RN.Element | null { + if (isNonNullObject(tree)) { + if (isIterable(tree)) { + if (maxDepth > 0) { + for (const node of tree) { + const foundElement = findElementInTree(node, filter, maxDepth - 1); + if (foundElement) return foundElement; + } + } + } else { + if (filter(tree)) return tree; + if (isElementWithChildren(tree)) + return findElementInTree(tree.props.children, filter, maxDepth - 1); + } + } + return null; +} + +export function findParentInTree( + tree: RN.Node, + filter: (children: RN.Node) => children is T, + maxDepth?: number | undefined +): RN.Element<{ children: T; }> | null; +export function findParentInTree( + tree: RN.Node, + filter: (children: RN.Node) => unknown, + maxDepth?: number | undefined +): RN.Element | null; +export function findParentInTree( + tree: RN.Node, + filter: (children: RN.Node) => unknown, + maxDepth = 200 +): RN.Element | null { + if (isNonNullObject(tree)) { + if (isIterable(tree)) { + if (maxDepth > 0) { + for (const node of tree) { + const foundParent = findParentInTree(node, filter, maxDepth - 1); + if (foundParent) return foundParent; + } + } + } else if (isElementWithChildren(tree)) { + if (filter(tree.props.children)) return tree; + return findParentInTree(tree.props.children, filter, maxDepth - 1); + } + } + return null; +} diff --git a/plugins/fake-profile-themes-and-effects/src/lib/records.ts b/plugins/fake-profile-themes-and-effects/src/lib/records.ts new file mode 100644 index 0000000..84ec9fb --- /dev/null +++ b/plugins/fake-profile-themes-and-effects/src/lib/records.ts @@ -0,0 +1,25 @@ +import type { ImmutableRecord } from "@vencord/discord-types"; +import { findByName } from "@vendetta/metro"; + +export const ProfileEffectRecord: typeof $ProfileEffectRecord = findByName("ProfileEffectRecord"); + +export type ProfileEffectRecordOwnProperties = Pick<$ProfileEffectRecord, "id" | "skuId" | "type">; + +export type ProfileEffectProperties = Omit; + +declare class $ProfileEffectRecord extends ImmutableRecord { + constructor(profileEffectProperties: ProfileEffectProperties); + + static fromServer(profileEffectFromServer: { id: string; sku_id: string; }): $ProfileEffectRecord; + + id: string; + skuId: string; + type: CollectiblesItemType.PROFILE_EFFECT; +} + +export const enum CollectiblesItemType { + AVATAR_DECORATION = 0, + PROFILE_EFFECT = 1, + NONE = 100, + BUNDLE = 1_000, +} diff --git a/plugins/fake-profile-themes-and-effects/src/lib/stores/CollectiblesPurchaseStore.ts b/plugins/fake-profile-themes-and-effects/src/lib/stores/CollectiblesPurchaseStore.ts new file mode 100644 index 0000000..867bf7a --- /dev/null +++ b/plugins/fake-profile-themes-and-effects/src/lib/stores/CollectiblesPurchaseStore.ts @@ -0,0 +1,55 @@ +import type { ExtractAction, FluxAction, FluxStore } from "@vencord/discord-types"; +import { findByStoreName } from "@vendetta/metro"; +import type tinycolor from "tinycolor2"; + +export const CollectiblesPurchaseStore: $CollectiblesPurchaseStore + = findByStoreName("CollectiblesPurchaseStore"); + +export type CollectiblesPurchaseStoreAction = ExtractAction; + +declare class $CollectiblesPurchaseStore extends FluxStore { + static displayName: "CollectiblesPurchaseStore"; + + get claimError(): Error | undefined; + get fetchError(): Error | undefined; + getPurchase(skuId: string | null | undefined): Purchase | undefined; + get isClaiming(): boolean; + get isFetching(): boolean; + get purchases(): Map; +} + +export interface Purchase { + banner: string; + categorySkuId: string; + items: { + asset: string; + id: string; + label: string; + skuId: string; + type: number; + }[]; + name: string; + premiumType: number | null; + prices: { + countryPrices: { + countryCode: string; + prices: { + amount: number; + currency: string; + exponent: number; + }[]; + }; + }[]; + purchaseType: number; + purchasedAt: Date; + skuId: string; + storeListingId: string; + styles: { + backgroundColors: [typeof tinycolor, typeof tinycolor]; + buttonColors: [typeof tinycolor, typeof tinycolor]; + confettiColors: (typeof tinycolor)[]; + }; + summary: string; + type: number; + unpublishedAt: Date | null; +} diff --git a/plugins/fake-profile-themes-and-effects/src/lib/stores/ProfileEffectStore.ts b/plugins/fake-profile-themes-and-effects/src/lib/stores/ProfileEffectStore.ts new file mode 100644 index 0000000..a67ac8c --- /dev/null +++ b/plugins/fake-profile-themes-and-effects/src/lib/stores/ProfileEffectStore.ts @@ -0,0 +1,54 @@ +import type { ExtractAction, FluxAction, FluxStore } from "@vencord/discord-types"; +import { findByStoreName } from "@vendetta/metro"; + +import type { CollectiblesItemType } from "@fpte/lib/records"; + +export const ProfileEffectStore: $ProfileEffectStore + = findByStoreName("ProfileEffectStore"); + +export type ProfileEffectStoreAction = ExtractAction; + +declare class $ProfileEffectStore extends FluxStore { + static displayName: "ProfileEffectStore"; + + canFetch(): boolean; + get fetchError(): Error | undefined; + getProfileEffectById(effectId: string): ProfileEffect | undefined; + hasFetched(): boolean; + get isFetching(): boolean; + get profileEffects(): ProfileEffect[]; + get tryItOutId(): string | null; +} + +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; +} diff --git a/plugins/fake-profile-themes-and-effects/src/lib/stores/index.ts b/plugins/fake-profile-themes-and-effects/src/lib/stores/index.ts new file mode 100644 index 0000000..79a4806 --- /dev/null +++ b/plugins/fake-profile-themes-and-effects/src/lib/stores/index.ts @@ -0,0 +1,12 @@ +import type { + UserProfileStore as $UserProfileStore, + UserStore as $UserStore +} from "@vencord/discord-types"; +import { findByStoreName } from "@vendetta/metro"; + +export * from "./CollectiblesPurchaseStore"; +export * from "./ProfileEffectStore"; + +export const UserProfileStore: $UserProfileStore = findByStoreName("UserProfileStore"); + +export const UserStore: $UserStore = findByStoreName("UserStore"); diff --git a/plugins/fake-profile-themes-and-effects/src/lib/utils.ts b/plugins/fake-profile-themes-and-effects/src/lib/utils.ts new file mode 100644 index 0000000..85f4bf1 --- /dev/null +++ b/plugins/fake-profile-themes-and-effects/src/lib/utils.ts @@ -0,0 +1,17 @@ +declare const emptyObjectSymbol: unique symbol; + +export interface EmptyObject { + [emptyObjectSymbol]?: never; +} + +export type OmitCallSignature = Pick; + +export type StringDict = { [K in V as Uppercase]: V }; + +export function isIterable(arg: T | Iterable): arg is Iterable { + return typeof (arg as Iterable)[Symbol.iterator] === "function"; +} + +export function isNonNullObject(arg: T | T & object): arg is T & object { + return arg !== null && typeof arg === "object"; +} diff --git a/plugins/fake-profile-themes-and-effects/src/patches/index.ts b/plugins/fake-profile-themes-and-effects/src/patches/index.ts new file mode 100644 index 0000000..3c2c6c7 --- /dev/null +++ b/plugins/fake-profile-themes-and-effects/src/patches/index.ts @@ -0,0 +1,7 @@ +export { patchGetPurchase } from "./patchGetPurchase"; +export { patchGetUserProfile } from "./patchGetUserProfile"; +//export { patchGuildProfileEditForm } from "./patchGuildProfileEditForm"; +export { patchUseProfileEffectSections } from "./patchUseProfileEffectSections"; +export { patchUseProfileTheme } from "./patchUseProfileTheme"; +export { patchUserProfileEditForm } from "./patchUserProfileEditForm"; +export { patchNitroUpsellCard } from "./patchNitroUpsellCard"; \ No newline at end of file diff --git a/plugins/fake-profile-themes-and-effects/src/patches/patchGetPurchase.ts b/plugins/fake-profile-themes-and-effects/src/patches/patchGetPurchase.ts new file mode 100644 index 0000000..7627e9f --- /dev/null +++ b/plugins/fake-profile-themes-and-effects/src/patches/patchGetPurchase.ts @@ -0,0 +1,12 @@ +import { instead } from "@vendetta/patcher"; + +import { CollectiblesPurchaseStore } from "@fpte/lib/stores"; +import { previewUserId } from "@fpte/patches/patchUseProfileTheme"; + +export const patchGetPurchase = () => instead( + "getPurchase", + CollectiblesPurchaseStore, + (args: unknown[], origFunc: (...args: any[]) => unknown) => previewUserId + ? { purchasedAt: new Date() } + : origFunc(args) +); diff --git a/plugins/fake-profile-themes-and-effects/src/patches/patchGetUserProfile.ts b/plugins/fake-profile-themes-and-effects/src/patches/patchGetUserProfile.ts new file mode 100644 index 0000000..a466f23 --- /dev/null +++ b/plugins/fake-profile-themes-and-effects/src/patches/patchGetUserProfile.ts @@ -0,0 +1,60 @@ +import type { UserProfile } from "@vencord/discord-types"; +import { after } from "@vendetta/patcher"; +import { storage } from "@vendetta/plugin"; + +import { decodeColor, decodeColorsLegacy, decodeEffect, extractFPTE } from "@fpte/lib/fpte"; +import { UserProfileStore } from "@fpte/lib/stores"; + +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 = profile.profileEffectID = id.toString(); + profile.premiumType = 2; + } +} + +export const patchGetUserProfile = () => after("getUserProfile", UserProfileStore, (_args: unknown[], profile: UserProfile | undefined) => { + if (!profile || profile.profileFetchFailed) return profile; + + if (storage.prioritizeNitro) { + if (profile.themeColors) { + if (!(profile.profileEffectId || 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 || 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; +}); diff --git a/plugins/fake-profile-themes-and-effects/src/patches/patchGuildProfileEditForm.tsx b/plugins/fake-profile-themes-and-effects/src/patches/patchGuildProfileEditForm.tsx new file mode 100644 index 0000000..3a45d63 --- /dev/null +++ b/plugins/fake-profile-themes-and-effects/src/patches/patchGuildProfileEditForm.tsx @@ -0,0 +1,40 @@ +import { findByName } from "@vendetta/metro"; +import { after } from "@vendetta/patcher"; +import { storage } from "@vendetta/plugin"; +import React from "react"; + +import { findParentInTree, getComponentNameFromType, isElement, type RN } from "@fpte/lib/reactNativeRenderTree"; +import { Builder } from "@fpte/ui/components"; + +const funcParent = findByName("GuildProfileEditForm", false); + +export const patchGuildProfileEditForm = () => after("default", funcParent, (_args: unknown[], tree: RN.Node) => { + if (storage.hideBuilder) return tree; + + console.log("[GuildProfileEditForm] Render tree:", tree); + console.log("[GuildProfileEditForm] Direct children:", tree?.props?.children); + + let guildId: string | undefined; + const parent = findParentInTree(tree, (children): children is RN.Node[] => + Array.isArray(children) && children.some(child => { + if (isElement(child)) { + const name = getComponentNameFromType(child.type); + console.log("[GuildProfileEditForm] Child component:", name, child.props); + if (name === "EditGuildIdentityBio") { + guildId = (child.props as any).displayProfile?.guildId; + return true; + } + } + return false; + }) + ); + + if (parent) { + console.log("[GuildProfileEditForm] Found parent:", parent); + parent.props.children.splice(2, 0, ); + } else { + console.warn("[GuildProfileEditForm] Parent not found."); + } + + return tree; +}); \ No newline at end of file diff --git a/plugins/fake-profile-themes-and-effects/src/patches/patchNitroUpsellCard.tsx b/plugins/fake-profile-themes-and-effects/src/patches/patchNitroUpsellCard.tsx new file mode 100644 index 0000000..512f5dc --- /dev/null +++ b/plugins/fake-profile-themes-and-effects/src/patches/patchNitroUpsellCard.tsx @@ -0,0 +1,37 @@ +import { findByName } from "@vendetta/metro"; +import { after } from "@vendetta/patcher"; +import { isElement, getComponentNameFromType, type RN } from "@fpte/lib/reactNativeRenderTree"; + +const funcParent = findByName("UserProfileEditForm", false); + +function removeNitroCards(node: RN.Node): RN.Node | null { + if (!node) return null; + + if ( + isElement(node) && + getComponentNameFromType(node.type) === "UserProfilePremiumUpsellCard" + ) { + return null; + } + + if (node.props?.children) { + let children = node.props.children; + if (!Array.isArray(children)) children = [children]; + + const filteredChildren = children + .map(removeNitroCards) + .filter((child): child is RN.Node => child !== null); + + node.props.children = filteredChildren.length === 1 ? filteredChildren[0] : filteredChildren; + } + + return node; +} + +export const patchNitroUpsellCard = () => { + if (!funcParent) return; + + after("default", funcParent, (_args: unknown[], tree: RN.Node) => { + return removeNitroCards(tree) || tree; + }); +}; \ No newline at end of file diff --git a/plugins/fake-profile-themes-and-effects/src/patches/patchUseProfileEffectSections.ts b/plugins/fake-profile-themes-and-effects/src/patches/patchUseProfileEffectSections.ts new file mode 100644 index 0000000..68b654b --- /dev/null +++ b/plugins/fake-profile-themes-and-effects/src/patches/patchUseProfileEffectSections.ts @@ -0,0 +1,30 @@ +import { findByPropsAll } from "@vendetta/metro"; +import { after } from "@vendetta/patcher"; + +import { ProfileEffectRecord } from "@fpte/lib/records"; +import { type ProfileEffect, ProfileEffectStore } from "@fpte/lib/stores"; +import { previewUserId } from "@fpte/patches/patchUseProfileTheme"; + +const useSectionModules = findByPropsAll("NONE_ITEM"); + +let prevProfileEffects: ProfileEffect[]; + +let prevSections: Record[]; + +export const patchUseProfileEffectSections = () => useSectionModules.map( + sectionModule => after("default", sectionModule, (_args: unknown[], origSections: typeof prevSections) => { + if (previewUserId) { + const currProfileEffects = ProfileEffectStore.profileEffects; + if (prevProfileEffects !== currProfileEffects) { + origSections.splice(1); + origSections[0]!.items.splice(1); + ProfileEffectStore.profileEffects.forEach(effect => { + origSections[0]!.items.push(new ProfileEffectRecord(effect)); + }); + prevSections = origSections; + } else + origSections = prevSections; + } + return origSections; + }) +); diff --git a/plugins/fake-profile-themes-and-effects/src/patches/patchUseProfileTheme.ts b/plugins/fake-profile-themes-and-effects/src/patches/patchUseProfileTheme.ts new file mode 100644 index 0000000..4ac729c --- /dev/null +++ b/plugins/fake-profile-themes-and-effects/src/patches/patchUseProfileTheme.ts @@ -0,0 +1,144 @@ +import type { DisplayProfile, ProfileThemeColors, UserRecord } from "@vencord/discord-types"; +import { findByName } from "@vendetta/metro"; +import { after } from "@vendetta/patcher"; +import { useState } from "react"; + +import { FluxDispatcher } from "@fpte/lib/flux"; +import type { ProfileEffectConfig } from "@fpte/lib/stores"; +import { getProfileTheme, type Theme } from "@fpte/ui/color"; + +function updatePreview() { + FluxDispatcher.dispatch({ type: "USER_SETTINGS_ACCOUNT_SUBMIT_SUCCESS" }); +} + +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; +} + +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; +} + +// TEMP +// eslint-disable-next-line @typescript-eslint/no-unused-vars +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; +} + +export let previewUserId: string | undefined; + +export function setPreviewUserId(userId: typeof previewUserId) { + previewUserId = userId; +} + +export const patchUseProfileTheme = (() => { + let funcParent = findByName("useProfileTheme", false); + if (funcParent) + return () => after( + "default", + funcParent, + (([options]: [UseProfileThemeOptions], profileTheme: ProfileTheme) => { + const { user } = options; + if ( + (user != null && user.id === previewUserId + || "pendingThemeColors" in options) + && showPreview + ) { + if (primaryColor !== null) { + profileTheme.theme = getProfileTheme(primaryColor); + profileTheme.primaryColor = primaryColor; + profileTheme.secondaryColor = accentColor ?? primaryColor; + } else if (accentColor !== null) { + profileTheme.theme = getProfileTheme(accentColor); + profileTheme.primaryColor = accentColor; + profileTheme.secondaryColor = accentColor; + } + } + return profileTheme; + }) as any + ); + + funcParent = findByName("useProfileThemeColors", false); + if (funcParent) + return () => after( + "default", + funcParent, + (([user, _displayProfile, previewProps]: UseProfileThemeColorsArgs, origRet: PartialProfileThemeColors) => { + if ( + (user != null && user.id === previewUserId + || previewProps) + && showPreview + ) { + if (primaryColor !== null) + return [primaryColor, accentColor ?? primaryColor]; + if (accentColor !== null) + return [accentColor, accentColor]; + } + return origRet; + }) as any + ); + + return () => () => true; +})(); + +interface UseProfileThemeOptions { + user?: UserRecord | null | undefined; + displayProfile?: DisplayProfile | null | undefined; + pendingThemeColors: ProfileThemeColors | undefined; + pendingAvatar?: UserRecord["avatar"] | undefined; + isPreview?: boolean | null | undefined; +} + +interface ProfileTheme { + theme: Theme; + primaryColor: number | null; + secondaryColor: number | null; +} + +type UseProfileThemeColorsArgs = [ + user: UserRecord | null | undefined, + displayProfile: DisplayProfile | null | undefined, + previewProps?: { + pendingThemeColors: ProfileThemeColors | undefined; + pendingAvatar?: UserRecord["avatar"] | undefined; + isPreview?: boolean; + } | undefined +]; + +type PartialProfileThemeColors = [primaryColor: number | null, accentColor: number | null]; diff --git a/plugins/fake-profile-themes-and-effects/src/patches/patchUserProfileEditForm.tsx b/plugins/fake-profile-themes-and-effects/src/patches/patchUserProfileEditForm.tsx new file mode 100644 index 0000000..b10d84f --- /dev/null +++ b/plugins/fake-profile-themes-and-effects/src/patches/patchUserProfileEditForm.tsx @@ -0,0 +1,38 @@ +import { findByName } from "@vendetta/metro"; +import { after } from "@vendetta/patcher"; +import { storage } from "@vendetta/plugin"; +import React from "react"; + +import { findParentInTree, getComponentNameFromType, isElement, type RN } from "@fpte/lib/reactNativeRenderTree"; +import { Builder } from "@fpte/ui/components"; + +const funcParent = findByName("UserProfileEditForm", false); + +export const patchUserProfileEditForm = () => + after("default", funcParent, (_args: unknown[], tree: RN.Node) => { + if (storage.hideBuilder) return tree; + + const parent = findParentInTree(tree, (children): children is RN.Node[] => + Array.isArray(children) && + children.some(child => + isElement(child) && + getComponentNameFromType(child.type) === "UserProfileEditFormTextField" + ) + ); + + if (parent) { + const index = parent.props.children.reduce((acc, child, i) => { + if ( + isElement(child) && + getComponentNameFromType(child.type) === "UserProfileEditFormTextField" + ) acc.push(i); + return acc; + }, []); + + if (index.length >= 3) { + parent.props.children.splice(index[2] + 1, 0, ); + } + } + + return tree; + }); \ No newline at end of file diff --git a/plugins/fake-profile-themes-and-effects/src/ui/actionSheets/EffectPickerActionSheet.tsx b/plugins/fake-profile-themes-and-effects/src/ui/actionSheets/EffectPickerActionSheet.tsx new file mode 100644 index 0000000..5959dbc --- /dev/null +++ b/plugins/fake-profile-themes-and-effects/src/ui/actionSheets/EffectPickerActionSheet.tsx @@ -0,0 +1,104 @@ +import type { UserRecord } from "@vencord/discord-types"; +import { findByName } from "@vendetta/metro"; +import { storage } from "@vendetta/plugin"; +import React, { type ReactElement, type ReactNode, useMemo } from "react"; + +import { findElementInTree, findParentInTree, getComponentNameFromType, type RN } from "@fpte/lib/reactNativeRenderTree"; +import { ProfileEffectRecord } from "@fpte/lib/records"; +import type { ProfileEffect, ProfileEffectConfig } from "@fpte/lib/stores"; +import { setPreviewUserId } from "@fpte/patches/patchUseProfileTheme"; +import { FallbackEffectPickerActionSheet } from "@fpte/ui/actionSheets"; +import { ThemeContextProvider, useThemeContext } from "@fpte/ui/color"; + +export interface EffectPickerActionSheetProps { + effects: ProfileEffect[]; + onSelect: (effect: ProfileEffectConfig | null) => void; + user: UserRecord; + currentEffectId?: string | undefined; +} + +const EffectPicker: RN.FunctionComponent | undefined = findByName("EditProfileEffectActionSheet"); + +let lastGoodTree: RN.Node; + +function PatchedEffectPickerActionSheet(props: EffectPickerActionSheetProps) { + const { currentEffectId, effects, onSelect, user } = props; + + const tree = EffectPicker!({ user }); + + const themeContext = useThemeContext(); + + const effectRecords = useMemo(() => effects.map(effect => ({ items: new ProfileEffectRecord(effect) })), [effects]); + + if (storage.forceFallbackEffectPicker) + return ; + + let isLegacyEffectPicker = false; + const effectPickerInner: RN.Element | null = findElementInTree(tree, element => { + if (getComponentNameFromType(element.type) === "EditProfileEffectInner") + return true; + if ( + "profileEffects" in element.props + && "selectedProfileEffect" in element.props + && typeof (element.props as any).setSelectedProfileEffect === "function" + ) return isLegacyEffectPicker = true; + return false; + }); + if (!effectPickerInner) { + if (lastGoodTree) return lastGoodTree as ReactElement; + return ; + } + + const applyButton: RN.Element | null = findElementInTree(tree, element => getComponentNameFromType(element.type) === "Button"); + if (!applyButton) { + if (lastGoodTree) return lastGoodTree as ReactElement; + return ; + } + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (isLegacyEffectPicker) { + if (effectPickerInner.props.selectedProfileEffect === undefined) + effectPickerInner.props.setSelectedProfileEffect(props.currentEffectId + ? { id: props.currentEffectId } + : null); + effectPickerInner.props.profileEffects = props.effects; + + const profilePreview = findParentInTree(tree, children => + Array.isArray(children) && children.some(child => + getComponentNameFromType(child.type) === "DisplayBanner")); + if (profilePreview) { + const baseProvider = tree as RN.Element; + + profilePreview.props.children = ( + + ); + + baseProvider.props.theme = themeContext.theme; + baseProvider.props.primaryColor = themeContext.primaryColor; + baseProvider.props.secondaryColor = themeContext.secondaryColor; + } + } else { + if (effectPickerInner.props.selectedProfileEffect === undefined) + effectPickerInner.props.setSelectedProfileEffect(currentEffectId + ? new ProfileEffectRecord({ id: currentEffectId } as any) + : null); + effectPickerInner.props.purchases = effectRecords; + } + + setPreviewUserId(user.id); + applyButton.props.onPress = () => { + setPreviewUserId(undefined); + onSelect(effects.find(effect => effect.id === effectPickerInner.props.selectedProfileEffect?.id)?.config ?? null); + }; + + return (lastGoodTree = tree) as ReactElement; +} + +export const EffectPickerActionSheet = EffectPicker + ? PatchedEffectPickerActionSheet + : FallbackEffectPickerActionSheet; diff --git a/plugins/fake-profile-themes-and-effects/src/ui/actionSheets/FallbackEffectPickerActionSheet.tsx b/plugins/fake-profile-themes-and-effects/src/ui/actionSheets/FallbackEffectPickerActionSheet.tsx new file mode 100644 index 0000000..8bc5eb6 --- /dev/null +++ b/plugins/fake-profile-themes-and-effects/src/ui/actionSheets/FallbackEffectPickerActionSheet.tsx @@ -0,0 +1,214 @@ +import { chunk } from "lodash"; +import React, { type ReactNode, useContext, useMemo, useState } from "react"; +import { View, type ViewStyle } from "react-native"; + +import { HapticFeebackTypes, triggerHapticFeedback } from "@fpte/lib/haptics"; +import type { ProfileEffect } from "@fpte/lib/stores"; +import { BottomSheet, BottomSheetScrollView, type EffectPickerActionSheetProps } from "@fpte/ui/actionSheets"; +import { IMG_NONE } from "@fpte/ui/assets"; +import { resolveSemanticColorSafe, useThemeContext } from "@fpte/ui/color"; +import { Button, FlashList, Icon, PressableOpacity, StaticEffect, Text } from "@fpte/ui/components"; +import { Radius, SafeAreaContext, Spacing, useWindowDimensions } from "@fpte/ui/length"; + +const ROW_SIZE = 3; + +interface ItemProps { + label: string; + isSelected: boolean; + size: number; + colors: [bgColor: string, itemColor: string, selectedColor: string]; + onPress: () => void; + style?: ViewStyle | undefined; + children: Exclude; +} + +function Item({ label, isSelected, size, colors, onPress, style, children }: ItemProps) { + const [bgColor, itemColor, selectedColor] = colors; + + return ( + { + triggerHapticFeedback(HapticFeebackTypes.IMPACT_LIGHT); + onPress(); + }} + style={[ + { + height: size, + width: size, + overflow: "hidden", + backgroundColor: itemColor, + borderColor: bgColor, + borderRadius: Radius.sm, + borderWidth: 2 + }, + isSelected && { borderColor: selectedColor }, + style + ]} + > + {children} + + ); +} + +export type FallbackEffectPickerActionSheetProps = Pick; + +export function FallbackEffectPickerActionSheet({ currentEffectId, effects, onSelect }: FallbackEffectPickerActionSheetProps) { + const [selectedId, setSelectedId] = useState(currentEffectId); + const [itemSize, setItemSize] = useState(0); + + const { theme } = useThemeContext(); + const colors: ItemProps["colors"] = useMemo(() => [ + resolveSemanticColorSafe(theme, ["BACKGROUND_PRIMARY", "BACKGROUND_BASE_LOWER", "BACKGROUND_BASE_LOW"], "#1E1F22"), + resolveSemanticColorSafe(theme, ["BACKGROUND_FLOATING", "MOBILE_FLOATINGBAR_BACKGROUND", "BACKGROUND_BASE_LOWER"], "#2B2D31"), + resolveSemanticColorSafe(theme, ["BUTTON_OUTLINE_BRAND_BORDER_ACTIVE", "BORDER_FOCUS"], "#5865F2") + ], [theme]); + + const windowDimensions = useWindowDimensions(); + const safeArea = useContext(SafeAreaContext); + + const effectRows = useMemo(() => { + const effectChunks: (ProfileEffect | null | undefined)[][] = chunk([null, ...effects], ROW_SIZE); + const lastChunk = effectChunks[effectChunks.length - 1]!; + while (lastChunk.length < 3) lastChunk.push(undefined); + return effectChunks; + }, [effects]); + + return ( + + + + + {currentEffectId ? "Change Effect" : "Add Profile Effect"} + + + + {effects.find(effect => effect.id === selectedId)?.config.title ?? "None"} + + + + } + contentContainerStyle={{ paddingHorizontal: Spacing.PX_4 }} + data={effectRows} + extraData={selectedId} + renderItem={({ item }) => ( + + {item.map(effect => effect + ? ( + { setSelectedId(effect.id); }} + > + + + ) + : effect === null + ? ( + { setSelectedId(undefined); }} + style={{ + alignItems: "center", + justifyContent: "center" + }} + > + + + None + + + ) + : ( + + ) + )} + + )} + onLayout={event => { setItemSize((event.nativeEvent.layout.width - 64) / ROW_SIZE); }} + /> + + + +