Add FakeProfileThemesAndEffects fork with confirmed crash fix; expand Key Inspector
This commit is contained in:
parent
c39745c925
commit
a53e43fc70
44 changed files with 2295 additions and 3 deletions
|
|
@ -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
|
||||
|
|
|
|||
17
build.mjs
17
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;
|
||||
|
|
|
|||
116
plugins/fake-profile-themes-and-effects/LICENSE
Normal file
116
plugins/fake-profile-themes-and-effects/LICENSE
Normal file
|
|
@ -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
|
||||
<http://creativecommons.org/publicdomain/zero/1.0/>
|
||||
13
plugins/fake-profile-themes-and-effects/manifest.json
Normal file
13
plugins/fake-profile-themes-and-effects/manifest.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
51
plugins/fake-profile-themes-and-effects/src/index.ts
Normal file
51
plugins/fake-profile-themes-and-effects/src/index.ts
Normal file
|
|
@ -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
|
||||
};
|
||||
4
plugins/fake-profile-themes-and-effects/src/lib/flux.ts
Normal file
4
plugins/fake-profile-themes-and-effects/src/lib/flux.ts
Normal file
|
|
@ -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;
|
||||
226
plugins/fake-profile-themes-and-effects/src/lib/fpte.ts
Normal file
226
plugins/fake-profile-themes-and-effects/src/lib/fpte.ts
Normal file
|
|
@ -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();
|
||||
}
|
||||
12
plugins/fake-profile-themes-and-effects/src/lib/haptics.ts
Normal file
12
plugins/fake-profile-themes-and-effects/src/lib/haptics.ts
Normal file
|
|
@ -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<string, any> | 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<FeebackKey, number>
|
||||
= hapticsModule?.HapticFeedbackTypes ?? {};
|
||||
|
||||
export const triggerHapticFeedback: (feedbackType?: number | undefined) => void
|
||||
= hapticsModule?.triggerHapticFeedback ?? (() => undefined);
|
||||
|
|
@ -0,0 +1,165 @@
|
|||
import type React from "react";
|
||||
|
||||
import { isIterable, isNonNullObject, type OmitCallSignature } from "@fpte/lib/utils";
|
||||
|
||||
export namespace RN {
|
||||
export type Fragment<P extends object = object> = Element<P, symbol>;
|
||||
|
||||
export interface ConsumerProps<T = unknown> {
|
||||
children: (value: T) => Node;
|
||||
}
|
||||
|
||||
export interface ProviderProps<T = unknown> {
|
||||
value: T;
|
||||
children?: Node;
|
||||
}
|
||||
|
||||
export type ConsumerType<T = unknown> = React.Context<T> & OmitCallSignature<React.NamedExoticComponent<ConsumerProps<T>>>;
|
||||
|
||||
export interface ProviderType<T = unknown> extends OmitCallSignature<React.ProviderExoticComponent<ProviderProps<T>>> {
|
||||
_context: React.Context<T>;
|
||||
}
|
||||
|
||||
export interface ForwardRefType<T = unknown, P extends object = object> extends OmitCallSignature<React.ForwardRefExoticComponent<P>> {
|
||||
render: React.ForwardRefRenderFunction<T, P>;
|
||||
}
|
||||
|
||||
export interface MemoType<T extends ElementType<any> = ElementType> extends OmitCallSignature<React.NamedExoticComponent<ComponentPropsWithRef<T>>> {
|
||||
readonly type: T;
|
||||
}
|
||||
|
||||
export interface Component<P, S> extends Omit<React.Component<P, S>, "render"> {
|
||||
render: () => Node;
|
||||
}
|
||||
|
||||
export interface ComponentClass<P extends object = object, S = React.ComponentState> extends OmitCallSignature<React.ComponentClass<P, S>> {
|
||||
new (props: P, context?: any): Component<P, S>;
|
||||
}
|
||||
|
||||
export interface FunctionComponent<P extends object = object> extends OmitCallSignature<React.FunctionComponent<P>> {
|
||||
(props: P, context?: any): Node;
|
||||
}
|
||||
|
||||
export type ComponentType<P extends object = object> = ComponentClass<P> | FunctionComponent<P>;
|
||||
|
||||
export type ElementType<P extends object = object> = ComponentType<P> | symbol | ConsumerType | ProviderType | ForwardRefType<unknown, P> | MemoType<ElementType<P>>;
|
||||
|
||||
export interface Element<P extends object = object, T extends ElementType = ElementType> {
|
||||
type: T;
|
||||
props: P;
|
||||
key: React.Key | null;
|
||||
}
|
||||
|
||||
export type Node = Iterable<Node> | Element | string | number | boolean | null | undefined;
|
||||
|
||||
export type PropsWithChildren<P extends object = object> = P & { children: Node; };
|
||||
|
||||
export type ComponentProps<T extends ElementType> = T extends ElementType<infer P> ? P : object;
|
||||
|
||||
export type ComponentPropsWithRef<T extends ElementType> =
|
||||
T extends React.ComponentClass<infer P>
|
||||
? React.PropsWithoutRef<P> & React.RefAttributes<InstanceType<T>>
|
||||
: React.PropsWithRef<ComponentProps<T>>;
|
||||
}
|
||||
|
||||
export function isElement(arg: RN.Node): arg is RN.Element {
|
||||
return isNonNullObject(arg) && "type" in arg;
|
||||
}
|
||||
|
||||
export function isElementWithChildren<T extends RN.Element>(arg: T | RN.Element<RN.PropsWithChildren>): arg is RN.Element<RN.PropsWithChildren> {
|
||||
return "children" in arg.props;
|
||||
}
|
||||
|
||||
export function isProviderType(arg: Exclude<RN.ElementType, symbol>): arg is RN.ProviderType {
|
||||
return "_context" in arg;
|
||||
}
|
||||
|
||||
export function isForwardRefType(arg: Exclude<RN.ElementType, symbol>): arg is RN.ForwardRefType {
|
||||
return "render" in arg;
|
||||
}
|
||||
|
||||
export function isMemoType(arg: Exclude<RN.ElementType, symbol>): 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<T extends RN.Element>(
|
||||
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<T extends RN.Node>(
|
||||
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<RN.PropsWithChildren> | null;
|
||||
export function findParentInTree(
|
||||
tree: RN.Node,
|
||||
filter: (children: RN.Node) => unknown,
|
||||
maxDepth = 200
|
||||
): RN.Element<RN.PropsWithChildren> | 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;
|
||||
}
|
||||
25
plugins/fake-profile-themes-and-effects/src/lib/records.ts
Normal file
25
plugins/fake-profile-themes-and-effects/src/lib/records.ts
Normal file
|
|
@ -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<ProfileEffectRecordOwnProperties, "type">;
|
||||
|
||||
declare class $ProfileEffectRecord extends ImmutableRecord<ProfileEffectRecordOwnProperties> {
|
||||
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,
|
||||
}
|
||||
|
|
@ -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<FluxAction, "COLLECTIBLES_CLAIM" | "COLLECTIBLES_CLAIM_FAILURE" | "COLLECTIBLES_CLAIM_SUCCESS" | "COLLECTIBLES_PURCHASES_FETCH" | "COLLECTIBLES_PURCHASES_FETCH_FAILURE" | "COLLECTIBLES_PURCHASES_FETCH_SUCCESS" | "LOGOUT">;
|
||||
|
||||
declare class $CollectiblesPurchaseStore extends FluxStore<CollectiblesPurchaseStoreAction> {
|
||||
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<string, Purchase>;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
|
@ -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<FluxAction, "LOGOUT" | "PROFILE_EFFECTS_SET_TRY_IT_OUT" | "USER_PROFILE_EFFECTS_FETCH" | "USER_PROFILE_EFFECTS_FETCH_FAILURE" | "USER_PROFILE_EFFECTS_FETCH_SUCCESS">;
|
||||
|
||||
declare class $ProfileEffectStore extends FluxStore<ProfileEffectStoreAction> {
|
||||
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;
|
||||
}
|
||||
|
|
@ -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");
|
||||
17
plugins/fake-profile-themes-and-effects/src/lib/utils.ts
Normal file
17
plugins/fake-profile-themes-and-effects/src/lib/utils.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
declare const emptyObjectSymbol: unique symbol;
|
||||
|
||||
export interface EmptyObject {
|
||||
[emptyObjectSymbol]?: never;
|
||||
}
|
||||
|
||||
export type OmitCallSignature<T> = Pick<T, keyof T>;
|
||||
|
||||
export type StringDict<V extends string> = { [K in V as Uppercase<K>]: V };
|
||||
|
||||
export function isIterable<T extends object, V = any>(arg: T | Iterable<V>): arg is Iterable<V> {
|
||||
return typeof (arg as Iterable<V>)[Symbol.iterator] === "function";
|
||||
}
|
||||
|
||||
export function isNonNullObject<T>(arg: T | T & object): arg is T & object {
|
||||
return arg !== null && typeof arg === "object";
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
@ -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)
|
||||
);
|
||||
|
|
@ -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<false>, 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<false>, 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;
|
||||
});
|
||||
|
|
@ -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, <Builder guildId={guildId} />);
|
||||
} else {
|
||||
console.warn("[GuildProfileEditForm] Parent not found.");
|
||||
}
|
||||
|
||||
return tree;
|
||||
});
|
||||
|
|
@ -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;
|
||||
});
|
||||
};
|
||||
|
|
@ -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<string, any>[];
|
||||
|
||||
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;
|
||||
})
|
||||
);
|
||||
|
|
@ -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];
|
||||
|
|
@ -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<number[]>((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, <Builder />);
|
||||
}
|
||||
}
|
||||
|
||||
return tree;
|
||||
});
|
||||
|
|
@ -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 <FallbackEffectPickerActionSheet {...props} />;
|
||||
|
||||
let isLegacyEffectPicker = false;
|
||||
const effectPickerInner: RN.Element<any> | 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 <FallbackEffectPickerActionSheet {...props} />;
|
||||
}
|
||||
|
||||
const applyButton: RN.Element<any> | null = findElementInTree(tree, element => getComponentNameFromType(element.type) === "Button");
|
||||
if (!applyButton) {
|
||||
if (lastGoodTree) return lastGoodTree as ReactElement;
|
||||
return <FallbackEffectPickerActionSheet {...props} />;
|
||||
}
|
||||
|
||||
// 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<any>;
|
||||
|
||||
profilePreview.props.children = (
|
||||
<ThemeContextProvider
|
||||
theme={baseProvider.props.theme}
|
||||
primaryColor={baseProvider.props.primaryColor}
|
||||
secondaryColor={baseProvider.props.secondaryColor}
|
||||
children={profilePreview.props.children as ReactNode}
|
||||
/>
|
||||
);
|
||||
|
||||
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;
|
||||
|
|
@ -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<ReactNode, number | string>;
|
||||
}
|
||||
|
||||
function Item({ label, isSelected, size, colors, onPress, style, children }: ItemProps) {
|
||||
const [bgColor, itemColor, selectedColor] = colors;
|
||||
|
||||
return (
|
||||
<PressableOpacity
|
||||
accessibilityLabel={label}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ selected: isSelected }}
|
||||
disabled={isSelected}
|
||||
onPress={() => {
|
||||
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}
|
||||
</PressableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
export type FallbackEffectPickerActionSheetProps = Pick<EffectPickerActionSheetProps, "currentEffectId" | "effects" | "onSelect">;
|
||||
|
||||
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 (
|
||||
<BottomSheet
|
||||
transparentHeader={true}
|
||||
scrollable={true}
|
||||
startExpanded={true}
|
||||
startHeight={windowDimensions.height - safeArea.top}
|
||||
>
|
||||
<BottomSheetScrollView
|
||||
scrollsToTop={false}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
paddingBottom: 88
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
variant="redesign/heading-18/bold"
|
||||
color="header-primary"
|
||||
style={{ margin: Spacing.PX_16 }}
|
||||
>
|
||||
{currentEffectId ? "Change Effect" : "Add Profile Effect"}
|
||||
</Text>
|
||||
<View
|
||||
style={{
|
||||
width: "72%",
|
||||
minHeight: 38
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
variant="heading-md/bold"
|
||||
color="header-primary"
|
||||
style={{ textAlign: "center" }}
|
||||
>
|
||||
{effects.find(effect => effect.id === selectedId)?.config.title ?? "None"}
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
width: "92%",
|
||||
marginTop: 3
|
||||
}}
|
||||
>
|
||||
<FlashList
|
||||
accessibilityLabel="Profile Effect Selection Section"
|
||||
numColumns={1}
|
||||
estimatedItemSize={98}
|
||||
ItemSeparatorComponent={() => <View style={{ height: Spacing.PX_16 }} />}
|
||||
contentContainerStyle={{ paddingHorizontal: Spacing.PX_4 }}
|
||||
data={effectRows}
|
||||
extraData={selectedId}
|
||||
renderItem={({ item }) => (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
paddingHorizontal: Spacing.PX_16
|
||||
}}
|
||||
>
|
||||
{item.map(effect => effect
|
||||
? (
|
||||
<Item
|
||||
label={effect.config.accessibilityLabel}
|
||||
isSelected={effect.id === selectedId}
|
||||
size={itemSize}
|
||||
colors={colors}
|
||||
onPress={() => { setSelectedId(effect.id); }}
|
||||
>
|
||||
<StaticEffect effect={effect.config} />
|
||||
</Item>
|
||||
)
|
||||
: effect === null
|
||||
? (
|
||||
<Item
|
||||
label="None"
|
||||
isSelected={!selectedId}
|
||||
size={itemSize}
|
||||
colors={colors}
|
||||
onPress={() => { setSelectedId(undefined); }}
|
||||
style={{
|
||||
alignItems: "center",
|
||||
justifyContent: "center"
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
source={IMG_NONE}
|
||||
size={Icon.Sizes.LARGE}
|
||||
/>
|
||||
<Text
|
||||
variant="text-sm/medium"
|
||||
color="header-primary"
|
||||
style={{ marginTop: Spacing.PX_4 }}
|
||||
>
|
||||
None
|
||||
</Text>
|
||||
</Item>
|
||||
)
|
||||
: (
|
||||
<View
|
||||
style={{
|
||||
width: itemSize,
|
||||
height: itemSize
|
||||
}}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
onLayout={event => { setItemSize((event.nativeEvent.layout.width - 64) / ROW_SIZE); }}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</BottomSheetScrollView>
|
||||
<Button
|
||||
text="Apply"
|
||||
textStyle={{ fontSize: 16 }}
|
||||
onPress={() => { onSelect(effects.find(effect => effect.id === selectedId)?.config ?? null); }}
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
height: 48,
|
||||
marginHorizontal: 36,
|
||||
marginBottom: Spacing.PX_48,
|
||||
borderRadius: Radius.round
|
||||
}}
|
||||
/>
|
||||
</BottomSheet>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import { findByProps } from "@vendetta/metro";
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
|
||||
export * from "./EffectPickerActionSheet";
|
||||
export * from "./FallbackEffectPickerActionSheet";
|
||||
export * from "./showColorPicker";
|
||||
export * from "./showEffectPicker";
|
||||
|
||||
export const BottomSheet: ComponentType<Record<string, any>>
|
||||
= (findByProps("BottomSheet") as Record<string, any> | undefined)?.BottomSheet
|
||||
?? (findByProps("ActionSheet") as Record<string, any> | undefined)?.ActionSheet
|
||||
?? (() => { throw new Error("FakeProfileThemesAndEffects threw an error to avoid an otherwise-inevitable, unrecoverable freeze."); });
|
||||
|
||||
export const BottomSheetScrollView: ComponentType<any>
|
||||
= (findByProps("BottomSheetScrollView") as any)?.BottomSheetScrollView ?? (() => null);
|
||||
|
||||
// Must use `as` or else `undefined` is lost due to a TS bug
|
||||
const actionSheetModule = findByProps("showActionSheet") as Record<string, any> | undefined;
|
||||
|
||||
export const showActionSheet: (props: {
|
||||
content: ReactNode;
|
||||
key: string;
|
||||
impressionName?: string | undefined;
|
||||
impressionProperties?: object | undefined;
|
||||
backdropKind?: string | undefined;
|
||||
}) => void = actionSheetModule?.showActionSheet ?? (() => undefined);
|
||||
|
||||
export const hideActionSheet: (key?: string | undefined) => void
|
||||
= actionSheetModule?.default?.hideActionSheet ?? (() => undefined);
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
import { findByName } from "@vendetta/metro";
|
||||
|
||||
const $showColorPicker = findByName("showCustomColorPickerActionSheet") ?? (() => undefined);
|
||||
|
||||
export function showColorPicker(props: {
|
||||
color?: number | null | undefined;
|
||||
onSelect?: ((color: number) => void) | undefined;
|
||||
suggestedColors?: string[] | undefined;
|
||||
}) {
|
||||
props.color ??= 0;
|
||||
return $showColorPicker(props);
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import React from "react";
|
||||
|
||||
import { FluxDispatcher } from "@fpte/lib/flux";
|
||||
import { type ProfileEffectConfig, ProfileEffectStore, UserStore } from "@fpte/lib/stores";
|
||||
import { setPreviewUserId } from "@fpte/patches/patchUseProfileTheme";
|
||||
import { EffectPickerActionSheet, hideActionSheet, showActionSheet } from "@fpte/ui/actionSheets";
|
||||
|
||||
const SHEET_KEY = "__FPTE__";
|
||||
|
||||
export function showEffectPicker(
|
||||
onSelect: (effect: ProfileEffectConfig | null) => void,
|
||||
currentEffectId?: string | undefined
|
||||
) {
|
||||
function onClose(action: any) {
|
||||
if (action.key === SHEET_KEY) {
|
||||
FluxDispatcher.unsubscribe("HIDE_ACTION_SHEET", onClose);
|
||||
setPreviewUserId(undefined);
|
||||
}
|
||||
}
|
||||
FluxDispatcher.subscribe("HIDE_ACTION_SHEET", onClose);
|
||||
|
||||
showActionSheet({
|
||||
content: (
|
||||
<EffectPickerActionSheet
|
||||
effects={ProfileEffectStore.profileEffects}
|
||||
onSelect={effect => {
|
||||
onSelect(effect);
|
||||
hideActionSheet(SHEET_KEY);
|
||||
}}
|
||||
user={UserStore.getCurrentUser()!}
|
||||
currentEffectId={currentEffectId}
|
||||
/>
|
||||
),
|
||||
key: SHEET_KEY
|
||||
});
|
||||
}
|
||||
17
plugins/fake-profile-themes-and-effects/src/ui/assets.ts
Normal file
17
plugins/fake-profile-themes-and-effects/src/ui/assets.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { findByProps } from "@vendetta/metro";
|
||||
import { getAssetIDByName } from "@vendetta/ui/assets";
|
||||
import type { ImageSourcePropType } from "react-native";
|
||||
|
||||
export const IMG_NONE = getAssetIDByName("img_none");
|
||||
|
||||
export const PROFILE_EFFECT_WH_RATIO: number
|
||||
= (findByProps("DEFAULT_PROFILE_EFFECT_WH_RATIO") as Record<string, any> | undefined)?.DEFAULT_PROFILE_EFFECT_WH_RATIO
|
||||
?? 45 / 88;
|
||||
|
||||
export const SAMPLE_PROFILE_SMALL: ImageSourcePropType
|
||||
= (getAssetIDByName("sample-profile-small") as number | undefined)
|
||||
?? { uri: "https://discordapp.com/assets/f328a6f8209d4f1f5022.png" };
|
||||
|
||||
export const TOAST_TYPE_COPY = getAssetIDByName("toast_copy_link");
|
||||
|
||||
export const TOAST_TYPE_FAILURE = getAssetIDByName("Small");
|
||||
74
plugins/fake-profile-themes-and-effects/src/ui/color.ts
Normal file
74
plugins/fake-profile-themes-and-effects/src/ui/color.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { find, findByProps } from "@vendetta/metro";
|
||||
import { semanticColors as _semanticColors } from "@vendetta/ui";
|
||||
import type { ComponentType, PropsWithChildren } from "react";
|
||||
|
||||
import type { EmptyObject } from "@fpte/lib/utils";
|
||||
|
||||
export const semanticColors: Record<string, EmptyObject> = _semanticColors;
|
||||
|
||||
export const resolveSemanticColor: (theme: Theme, semanticColor: EmptyObject) => string
|
||||
= find(m => m.default?.internal?.resolveSemanticColor)?.default.internal.resolveSemanticColor
|
||||
?? find(m => m.meta?.resolveSemanticColor)?.meta.resolveSemanticColor
|
||||
?? (() => undefined);
|
||||
|
||||
/**
|
||||
* Discord renames semantic color tokens across versions - confirmed via live on-device diagnostics
|
||||
* that HEADER_SECONDARY, BACKGROUND_PRIMARY, BACKGROUND_FLOATING, and TEXT_NORMAL have all been
|
||||
* removed on at least one current build, which is exactly what crashed this plugin's profile editor
|
||||
* every time it rendered. This tries each candidate token name in order and never throws.
|
||||
*/
|
||||
export function resolveSemanticColorSafe(theme: Theme, tokenNames: string[], fallbackHex: string): string {
|
||||
for (const name of tokenNames) {
|
||||
const token = semanticColors?.[name];
|
||||
if (!token) continue;
|
||||
try {
|
||||
const resolved = resolveSemanticColor(theme, token);
|
||||
if (resolved) return resolved;
|
||||
} catch {
|
||||
// try the next candidate
|
||||
}
|
||||
}
|
||||
return fallbackHex;
|
||||
}
|
||||
|
||||
export const useAvatarColors: (
|
||||
avatarUrl: string,
|
||||
fillerColor: string,
|
||||
desaturateColors?: boolean | undefined /* = true */
|
||||
) => string[]
|
||||
= (findByProps("useAvatarColors") as Record<string, any> | undefined)?.useAvatarColors
|
||||
?? (() => undefined);
|
||||
|
||||
export type Theme = "dark" | "light" | "midnight" | "darker";
|
||||
|
||||
export const getProfileTheme: <T extends number | null | undefined>(primaryColor: T) => T extends number ? Theme : null
|
||||
= findByProps("getProfileTheme").getProfileTheme;
|
||||
|
||||
export interface ThemeContext {
|
||||
theme: Theme;
|
||||
primaryColor: number | null;
|
||||
secondaryColor: number | null;
|
||||
gradient: {
|
||||
id: number;
|
||||
theme: Theme;
|
||||
colors: {
|
||||
stop: number;
|
||||
token: string;
|
||||
}[];
|
||||
angle: number;
|
||||
midpointPercentage: number;
|
||||
getName: () => string;
|
||||
} | null;
|
||||
flags: number;
|
||||
key: string;
|
||||
}
|
||||
|
||||
export const useThemeContext: () => ThemeContext
|
||||
= (findByProps("useThemeContext") as Record<string, any> | undefined)?.useThemeContext
|
||||
?? (() => ({}));
|
||||
|
||||
export type ThemeContextProviderProps = PropsWithChildren<Partial<ThemeContext>>;
|
||||
|
||||
export const ThemeContextProvider: ComponentType<ThemeContextProviderProps>
|
||||
= (findByProps("ThemeContextProvider") as Record<string, any> | undefined)?.ThemeContextProvider
|
||||
?? (({ children }) => children);
|
||||
|
|
@ -0,0 +1,188 @@
|
|||
import { showToast } from "@vendetta/ui/toasts";
|
||||
import { findByProps } from "@vendetta/metro";
|
||||
import React, { useMemo, useState, useEffect } from "react";
|
||||
import { View, Text } from "react-native";
|
||||
import { buildFPTE, hasFPTE, stripFPTE } from "@fpte/lib/fpte";
|
||||
import { type ProfileEffectConfig, UserStore, UserProfileStore } from "@fpte/lib/stores";
|
||||
import { useAccentColor, usePrimaryColor, useShowPreview } from "@fpte/patches/patchUseProfileTheme";
|
||||
import { showColorPicker, showEffectPicker } from "@fpte/ui/actionSheets";
|
||||
import { resolveSemanticColorSafe, useAvatarColors, useThemeContext } from "@fpte/ui/color";
|
||||
import { BuilderButton, Button, StaticEffect } from "@fpte/ui/components";
|
||||
import { FormCardSection, FormSwitchRow } from "@fpte/ui/components/forms";
|
||||
|
||||
const UserProfileActionCreators = findByProps("saveProfileChanges");
|
||||
|
||||
export interface BuilderProps {
|
||||
guildId?: string | undefined;
|
||||
}
|
||||
|
||||
export function Builder({ guildId }: BuilderProps) {
|
||||
const [primaryColor, setPrimaryColor] = usePrimaryColor(null);
|
||||
const [accentColor, setAccentColor] = useAccentColor(null);
|
||||
const [effect, setEffect] = useState<ProfileEffectConfig | null>(null);
|
||||
const [preview, setPreview] = useShowPreview(true);
|
||||
const [buildLegacy, setBuildLegacy] = useState(false);
|
||||
const { theme } = useThemeContext();
|
||||
const [fgColor, fillerColor] = useMemo(
|
||||
() => [
|
||||
resolveSemanticColorSafe(theme, ["HEADER_SECONDARY", "TEXT_MUTED", "TEXT_SUBTLE"], "#B5BAC1"),
|
||||
resolveSemanticColorSafe(theme, ["BACKGROUND_ACCENT", "BACKGROUND_BASE_LOWER", "BACKGROUND_BASE_LOW"], "#1E1F22")
|
||||
],
|
||||
[theme]
|
||||
);
|
||||
const avatarColors = useAvatarColors(
|
||||
UserStore.getCurrentUser()?.getAvatarURL(guildId, 80) ?? "",
|
||||
fillerColor,
|
||||
false
|
||||
);
|
||||
const [bio, setBio] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const currentUser = UserStore.getCurrentUser();
|
||||
if (!currentUser) return;
|
||||
const profile = UserProfileStore.getUserProfile(currentUser.id);
|
||||
if (!profile) return;
|
||||
setBio(profile.bio ?? null);
|
||||
}, []);
|
||||
|
||||
const fpteActive = bio !== null && hasFPTE(bio);
|
||||
const hasSelection = primaryColor !== null || accentColor !== null || effect !== null;
|
||||
|
||||
const fpteString = buildFPTE(
|
||||
primaryColor ?? -1,
|
||||
accentColor ?? -1,
|
||||
effect?.id ?? "",
|
||||
buildLegacy
|
||||
);
|
||||
|
||||
function applyFPTE() {
|
||||
const currentUser = UserStore.getCurrentUser();
|
||||
if (!currentUser) return;
|
||||
|
||||
let newBio = bio ?? "";
|
||||
|
||||
if (fpteActive && !hasSelection) {
|
||||
newBio = stripFPTE(newBio);
|
||||
try {
|
||||
UserProfileActionCreators.saveProfileChanges({
|
||||
...UserProfileStore.getUserProfile(currentUser.id),
|
||||
bio: newBio,
|
||||
});
|
||||
setBio(newBio);
|
||||
showToast("FPTE removed!");
|
||||
} catch (err) {
|
||||
showToast("Failed to update bio!");
|
||||
console.error(err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fpteString) return;
|
||||
|
||||
if (hasFPTE(newBio)) {
|
||||
newBio = stripFPTE(newBio);
|
||||
}
|
||||
if (newBio.length > 0) newBio += " ";
|
||||
newBio += fpteString;
|
||||
|
||||
try {
|
||||
UserProfileActionCreators.saveProfileChanges({
|
||||
...UserProfileStore.getUserProfile(currentUser.id),
|
||||
bio: newBio,
|
||||
});
|
||||
setBio(newBio);
|
||||
showToast("FPTE applied!");
|
||||
} catch (err) {
|
||||
showToast("Failed to update bio!");
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
const buttonText = fpteActive && !hasSelection ? "Remove FPTE" : "Apply FPTE";
|
||||
|
||||
const applyButtonVisible = hasSelection || fpteActive;
|
||||
|
||||
return (
|
||||
<FormCardSection
|
||||
title={
|
||||
<View style={{ flexDirection: "row", alignItems: "center" }}>
|
||||
<Text style={{ fontSize: 16, color: "#FFFFFF" }}>FPTE Builder</Text>
|
||||
<Text
|
||||
style={{
|
||||
color: fpteActive ? "#4CAF50" : "#F44336",
|
||||
fontSize: 17,
|
||||
marginLeft: 8,
|
||||
}}
|
||||
>
|
||||
{fpteActive ? "Active" : "Inactive"}
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
cardStyle={{ backgroundColor: "transparent" }}
|
||||
>
|
||||
<View style={{ flexDirection: "row", justifyContent: "space-between" }}>
|
||||
<BuilderButton
|
||||
fgColor={fgColor}
|
||||
label="Primary"
|
||||
bgColor={primaryColor}
|
||||
onPress={() =>
|
||||
showColorPicker({
|
||||
color: primaryColor,
|
||||
onSelect: setPrimaryColor,
|
||||
suggestedColors: avatarColors,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<BuilderButton
|
||||
fgColor={fgColor}
|
||||
label="Accent"
|
||||
bgColor={accentColor}
|
||||
onPress={() =>
|
||||
showColorPicker({
|
||||
color: accentColor,
|
||||
onSelect: setAccentColor,
|
||||
suggestedColors: avatarColors,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<BuilderButton fgColor={fgColor} label="Effect" onPress={() => showEffectPicker(setEffect, effect?.id)}>
|
||||
{effect && <StaticEffect effect={effect} style={{ width: "140%", height: "100%" }} />}
|
||||
</BuilderButton>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
marginLeft: 12,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
text={buttonText}
|
||||
size={Button.Sizes.SMALL}
|
||||
onPress={applyFPTE}
|
||||
style={{ marginBottom: 6, paddingHorizontal: 12, opacity: applyButtonVisible ? 1 : 0 }}
|
||||
pointerEvents={applyButtonVisible ? "auto" : "none"}
|
||||
/>
|
||||
<Button
|
||||
text="Reset"
|
||||
look={Button.Looks.LINK}
|
||||
color={Button.Colors.TRANSPARENT}
|
||||
size={Button.Sizes.SMALL}
|
||||
{...(!hasSelection ? { pointerEvents: "none", style: { opacity: 0 } } : {})}
|
||||
onPress={() => {
|
||||
setPrimaryColor(null);
|
||||
setAccentColor(null);
|
||||
setEffect(null);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
{/* <FormSwitchRow label="FPTE Builder Preview" value={preview} onValueChange={setPreview} />
|
||||
<FormSwitchRow
|
||||
label="Build backwards compatible FPTE"
|
||||
subLabel="Will use more characters"
|
||||
value={buildLegacy}
|
||||
onValueChange={setBuildLegacy}
|
||||
/> */}
|
||||
</FormCardSection>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
import React, { type ReactNode } from "react";
|
||||
import { type ColorValue, View } from "react-native";
|
||||
|
||||
import { Path, PressableOpacity, Svg, Text } from "@fpte/ui/components";
|
||||
import { Radius, Spacing } from "@fpte/ui/length";
|
||||
|
||||
export interface BuilderButtonProps {
|
||||
label?: string | undefined;
|
||||
fgColor: ColorValue;
|
||||
bgColor?: number | null | undefined;
|
||||
onPress?: (() => void) | undefined;
|
||||
children?: Exclude<ReactNode, number | string>;
|
||||
}
|
||||
|
||||
export const BuilderButton = ({ label, fgColor, bgColor, onPress, children }: BuilderButtonProps) => (
|
||||
<View style={{ width: 50 }}>
|
||||
<PressableOpacity
|
||||
accessibilityLabel={label}
|
||||
accessibilityRole="button"
|
||||
onPress={onPress}
|
||||
style={[
|
||||
{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: 50,
|
||||
overflow: "hidden",
|
||||
borderColor: fgColor,
|
||||
borderStyle: "dashed",
|
||||
borderWidth: 2,
|
||||
borderRadius: Radius.xs
|
||||
},
|
||||
bgColor != null && {
|
||||
backgroundColor: "#" + bgColor.toString(16).padStart(6, "0"),
|
||||
borderStyle: "solid"
|
||||
},
|
||||
!!children && { borderWidth: 0 }
|
||||
]}
|
||||
>
|
||||
{children ?? (bgColor == null && (
|
||||
<Svg
|
||||
fill={fgColor}
|
||||
width="40%"
|
||||
height="40%"
|
||||
viewBox="0 0 144 144"
|
||||
>
|
||||
<Path d="M144 64H80V0H64v64H0v16h64v64h16V80h64Z" />
|
||||
</Svg>
|
||||
))}
|
||||
</PressableOpacity>
|
||||
{!!label && (
|
||||
<Text
|
||||
variant="text-sm/normal"
|
||||
style={{
|
||||
marginTop: Spacing.PX_4,
|
||||
textAlign: "center"
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import { Button as $Button } from "@vendetta/ui/components";
|
||||
import type { ComponentType } from "react";
|
||||
import type { PressableProps, TextProps } from "react-native";
|
||||
|
||||
import type { StringDict } from "@fpte/lib/utils";
|
||||
|
||||
export type ButtonLook = "filled" | "link" | "outlined";
|
||||
|
||||
export type ButtonColor = "brand" | "red" | "green" | "primary" | "transparent" | "grey" | "lightgrey" | "white" | "link";
|
||||
|
||||
export type ButtonSize = "xsmall" | "small" | "medium" | "large";
|
||||
|
||||
export interface ButtonProps extends Pick<PressableProps, "onPress" | "style"> {
|
||||
disabled?: boolean | undefined;
|
||||
text?: string | null | undefined;
|
||||
look?: ButtonLook | undefined;
|
||||
color?: ButtonColor | undefined;
|
||||
size?: ButtonSize | undefined;
|
||||
textStyle?: TextProps["style"];
|
||||
}
|
||||
|
||||
export const Button: ComponentType<ButtonProps> & {
|
||||
Looks: StringDict<ButtonLook>;
|
||||
Colors: StringDict<ButtonColor>;
|
||||
Sizes: StringDict<ButtonSize>;
|
||||
} = $Button as any;
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import { findByProps } from "@vendetta/metro";
|
||||
import type { ComponentType } from "react";
|
||||
import type { ColorValue, ImageProps } from "react-native";
|
||||
|
||||
export type SizeKey = "EXTRA_SMALL_10" | "EXTRA_SMALL" | "SMALL" | "SMALL_20" | "MEDIUM" | "LARGE" | "CUSTOM" | "REFRESH_SMALL_16" | "SMALL_14";
|
||||
|
||||
export interface IconProps extends Pick<ImageProps, "accessible" | "accessibilityLabel" | "resizeMode" | "source" | "style"> {
|
||||
size?: string | undefined;
|
||||
color?: ColorValue | undefined;
|
||||
disableColor?: boolean | undefined;
|
||||
}
|
||||
|
||||
export const Icon: ComponentType<IconProps> & {
|
||||
Sizes: Record<SizeKey, string>;
|
||||
} = (findByProps("IconSizes") as Record<string, any> | undefined)?.default ?? (() => null);
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
import React from "react";
|
||||
import { Image, View, type ViewProps } from "react-native";
|
||||
|
||||
import type { ProfileEffectConfig } from "@fpte/lib/stores";
|
||||
import { PROFILE_EFFECT_WH_RATIO, SAMPLE_PROFILE_SMALL } from "@fpte/ui/assets";
|
||||
|
||||
export interface StaticEffectProps {
|
||||
effect: ProfileEffectConfig;
|
||||
style?: ViewProps["style"];
|
||||
}
|
||||
|
||||
export const StaticEffect = ({ effect, style }: StaticEffectProps) => (
|
||||
<View style={style}>
|
||||
<Image
|
||||
resizeMode="cover"
|
||||
source={SAMPLE_PROFILE_SMALL}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%"
|
||||
}}
|
||||
/>
|
||||
<Image
|
||||
alt={effect.accessibilityLabel}
|
||||
resizeMethod="resize"
|
||||
resizeMode="cover"
|
||||
source={{ uri: effect.thumbnailPreviewSrc }}
|
||||
style={{
|
||||
position: "absolute",
|
||||
width: "100%",
|
||||
aspectRatio: PROFILE_EFFECT_WH_RATIO
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,108 @@
|
|||
import { Forms } from "@vendetta/ui/components";
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
import type { ColorValue, PressableProps, ViewProps } from "react-native";
|
||||
|
||||
import type { TextProps } from "@fpte/ui/components/Text";
|
||||
|
||||
interface CardProps {
|
||||
start?: boolean | undefined;
|
||||
end?: boolean | undefined;
|
||||
shadow?: string | undefined;
|
||||
border?: string | undefined;
|
||||
variant?: string | undefined;
|
||||
}
|
||||
|
||||
export interface ViewCardProps extends CardProps, ViewProps { }
|
||||
|
||||
export interface PressableCardProps extends CardProps, PressableProps { }
|
||||
|
||||
interface TableRowProps {
|
||||
label?: ReactNode;
|
||||
subLabel?: ReactNode;
|
||||
icon?: ReactNode;
|
||||
trailing?: ReactNode;
|
||||
arrow?: boolean | undefined;
|
||||
labelLineClamp?: TextProps["lineClamp"];
|
||||
subLabelLineClamp?: TextProps["lineClamp"];
|
||||
}
|
||||
|
||||
export interface ViewTableRowProps extends TableRowProps, ViewCardProps { }
|
||||
|
||||
export interface PressableTableRowProps extends TableRowProps, PressableCardProps { }
|
||||
|
||||
export interface FormTitleProps {
|
||||
title: string;
|
||||
icon?: ViewProps["children"];
|
||||
textStyle?: TextProps["style"];
|
||||
viewStyle?: ViewProps["style"];
|
||||
numberOfLines: TextProps["numberOfLines"];
|
||||
inset?: boolean | undefined;
|
||||
thinTitle?: boolean | undefined;
|
||||
uppercaseTitle?: boolean | undefined;
|
||||
error?: boolean | undefined;
|
||||
}
|
||||
|
||||
export type TitleStyleType = "default" | "no_border" | "no_border_or_margin";
|
||||
|
||||
export interface FormSection extends Omit<FormTitleProps, "numberOfLines" | "textStyle" | "viewStyle">, Pick<ViewProps, "accessibilityLabel" | "accessibilityRole" | "children"> {
|
||||
description?: ViewProps["children"];
|
||||
hint?: ViewProps["children"];
|
||||
titleStyleType?: TitleStyleType | undefined;
|
||||
titleTextStyle?: FormTitleProps["textStyle"];
|
||||
titleViewStyle?: FormTitleProps["viewStyle"];
|
||||
sectionBodyStyle?: ViewProps["style"];
|
||||
hasIcons?: boolean | undefined;
|
||||
}
|
||||
|
||||
export interface FormLabelProps extends Pick<TextProps, "accessible" | "color" | "numberOfLines" | "style"> {
|
||||
text?: TextProps["children"];
|
||||
}
|
||||
|
||||
export interface FormRowProps extends Pick<ViewProps, "style">,
|
||||
Pick<PressableTableRowProps, "accessibilityActions" | "accessibilityHint" | "accessibilityLabel" | "accessibilityRole" | "accessibilityState" | "accessible" | "delayLongPress" | "disabled" | "end" | "label" | "onAccessibilityAction" | "onAccessibilityTap" | "onLongPress" | "onPress" | "onPressOut" | "start" | "subLabel" | "trailing" | "variant"> {
|
||||
leading?: TableRowProps["icon"];
|
||||
leadingStyle?: ViewProps["style"];
|
||||
trailingWrapperStyle?: ViewProps["style"];
|
||||
DEPRECATED_style?: ViewProps["style"];
|
||||
numberOfLines?: TableRowProps["labelLineClamp"];
|
||||
activeOpacity?: number | undefined;
|
||||
hasError?: boolean | undefined;
|
||||
}
|
||||
|
||||
export interface FormRadioProps {
|
||||
selected?: boolean | undefined;
|
||||
}
|
||||
|
||||
export interface FormRadioRowProps extends FormRadioProps, FormRowProps {
|
||||
align?: "left" | "right";
|
||||
value?: boolean | undefined;
|
||||
}
|
||||
|
||||
export interface FormSwitchProps extends Pick<PressableProps, "accessible" | "accessibilityHint" | "accessibilityLabel" | "disabled" | "style"> {
|
||||
value?: boolean | undefined;
|
||||
onValueChange?: ((value: boolean) => void) | undefined;
|
||||
borderColor?: ColorValue | undefined;
|
||||
tintColor?: ColorValue | undefined;
|
||||
renderIosBackground?: boolean | undefined;
|
||||
}
|
||||
|
||||
export interface FormSwitchRowProps extends Pick<FormLabelProps, "numberOfLines">, Pick<FormSwitchProps, "disabled" | "onValueChange" | "value">, ViewTableRowProps {
|
||||
switchProps?: FormSwitchProps | undefined;
|
||||
}
|
||||
|
||||
export interface FormCardSectionProps extends Pick<ViewProps, "accessibilityLabel" | "accessibilityRole" | "children" | "style"> {
|
||||
title?: ReactNode;
|
||||
headerComponent?: ReactNode;
|
||||
titleStyle?: TextProps["style"];
|
||||
cardStyle?: ViewProps["style"];
|
||||
}
|
||||
|
||||
export const FormSection: ComponentType<FormSection> = Forms.FormSection;
|
||||
|
||||
export const FormRow: ComponentType<FormRowProps> = Forms.FormRow;
|
||||
|
||||
export const FormRadioRow: ComponentType<FormRadioRowProps> = Forms.FormRadioRow;
|
||||
|
||||
export const FormSwitchRow: ComponentType<FormSwitchRowProps> = Forms.FormSwitchRow;
|
||||
|
||||
export const FormCardSection: ComponentType<FormCardSectionProps> = Forms.FormCardSection;
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import type { FlashList as $FlashList } from "@shopify/flash-list";
|
||||
import { findByName, findByProps } from "@vendetta/metro";
|
||||
import type { ComponentType } from "react";
|
||||
import type { PressableProps } from "react-native";
|
||||
import type { Path as $Path, Svg as $Svg } from "react-native-svg";
|
||||
|
||||
export * from "./Builder";
|
||||
export * from "./BuilderButton";
|
||||
export * from "./Button";
|
||||
export * as Forms from "./forms";
|
||||
export * from "./Icon";
|
||||
export * from "./StaticEffect";
|
||||
export * from "./Text";
|
||||
|
||||
export const FlashList: typeof $FlashList = findByName("FlashList") ?? (() => null);
|
||||
|
||||
// Must use `as` or else `undefined` is lost due to a TS bug
|
||||
const svgModule = findByProps("Svg") as Record<string, any> | undefined;
|
||||
export const Svg: typeof $Svg = svgModule?.Svg ?? (() => null);
|
||||
export const Path: typeof $Path = svgModule?.Path ?? (() => null);
|
||||
|
||||
export interface PressableOpacityProps extends PressableProps {
|
||||
activeOpacity?: number | undefined;
|
||||
}
|
||||
|
||||
export const PressableOpacity: ComponentType<PressableOpacityProps>
|
||||
= (findByProps("PressableOpacity") as Record<string, any> | undefined)?.PressableOpacity
|
||||
?? (() => null);
|
||||
18
plugins/fake-profile-themes-and-effects/src/ui/length.ts
Normal file
18
plugins/fake-profile-themes-and-effects/src/ui/length.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { findByName, findByProps } from "@vendetta/metro";
|
||||
import type { Context } from "react";
|
||||
|
||||
export type RadiusKey = "none" | "xs" | "sm" | "md" | "lg" | "xl" | "xxl" | "round";
|
||||
|
||||
export const Radius: Record<RadiusKey, number>
|
||||
= (findByProps("Radius") as Record<string, any> | undefined)?.Radius ?? {};
|
||||
|
||||
export type SpacingKey = "PX_4" | "PX_8" | "PX_12" | "PX_16" | "PX_24" | "PX_32" | "PX_40" | "PX_48" | "PX_56" | "PX_64" | "PX_72" | "PX_80" | "PX_96";
|
||||
|
||||
export const Spacing: Record<SpacingKey, number>
|
||||
= (findByProps("Spacing") as Record<string, any> | undefined)?.Spacing ?? {};
|
||||
|
||||
export const SafeAreaContext: Context<any>
|
||||
= (findByProps("SafeAreaContext") as Record<string, any> | undefined)?.SafeAreaContext;
|
||||
|
||||
export const useWindowDimensions: () => any
|
||||
= findByName("useWindowDimensions") ?? (() => undefined);
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
import { storage } from "@vendetta/plugin";
|
||||
import { useProxy } from "@vendetta/storage";
|
||||
import React from "react";
|
||||
import { ScrollView } from "react-native";
|
||||
|
||||
import { FormRadioRow, FormRow, FormSection, FormSwitchRow } from "@fpte/ui/components/forms";
|
||||
|
||||
export function Settings() {
|
||||
useProxy(storage);
|
||||
|
||||
return (
|
||||
<ScrollView>
|
||||
<FormSection title="Settings">
|
||||
<FormRow label="Source to prioritize" />
|
||||
<FormRadioRow
|
||||
label="Nitro"
|
||||
selected={!!storage.prioritizeNitro}
|
||||
onPress={() => { storage.prioritizeNitro = true; }}
|
||||
/>
|
||||
<FormRadioRow
|
||||
label="About Me"
|
||||
selected={!storage.prioritizeNitro}
|
||||
onPress={() => { storage.prioritizeNitro = false; }}
|
||||
/>
|
||||
<FormSwitchRow
|
||||
label="Hide Builder"
|
||||
subLabel="Hide the FPTE Builder in the User Profile and Server Profiles settings pages"
|
||||
value={!!storage.hideBuilder}
|
||||
onValueChange={value => { storage.hideBuilder = value; }}
|
||||
/>
|
||||
<FormSwitchRow
|
||||
label="Force fallback effect picker"
|
||||
value={!!storage.forceFallbackEffectPicker}
|
||||
onValueChange={value => { storage.forceFallbackEffectPicker = value; }}
|
||||
/>
|
||||
</FormSection>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export * from "./Settings";
|
||||
13
plugins/fake-profile-themes-and-effects/src/ui/toasts.ts
Normal file
13
plugins/fake-profile-themes-and-effects/src/ui/toasts.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import Clipboard from "@react-native-clipboard/clipboard";
|
||||
import { showToast } from "@vendetta/ui/toasts";
|
||||
|
||||
import { TOAST_TYPE_COPY, TOAST_TYPE_FAILURE } from "@fpte/ui/assets";
|
||||
|
||||
export function copyWithToast(text: string, message: string) {
|
||||
Clipboard.setString(text);
|
||||
showToast(message, TOAST_TYPE_COPY);
|
||||
}
|
||||
|
||||
export function showErrorToast(message: string) {
|
||||
showToast(message, TOAST_TYPE_FAILURE);
|
||||
}
|
||||
79
plugins/key-inspector/src/lib/apiTree.ts
Normal file
79
plugins/key-inspector/src/lib/apiTree.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
const MAX_KEYS_PER_LEVEL = 60;
|
||||
|
||||
// This is Discord's entire live module registry (thousands of entries) - useful to search
|
||||
// individually (see the manual findByProps/findByName tools), not to dump wholesale here.
|
||||
const SKIP_PATHS = new Set(["vendetta.metro.modules"]);
|
||||
|
||||
function kindOf(value: any): string {
|
||||
if (value === null) return "null";
|
||||
if (value === undefined) return "undefined";
|
||||
if (Array.isArray(value)) return `array(${value.length})`;
|
||||
return typeof value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively walks the stable `window.vendetta` API surface exposed to every plugin and lists
|
||||
* every key path found, bounded so it stays useful instead of dumping megabytes of Discord internals.
|
||||
* This is the actual compat API plugins are built against - not Discord's full module registry.
|
||||
*/
|
||||
export function dumpVendettaApiTree(maxDepth = 3): string {
|
||||
const seen = new WeakSet<object>();
|
||||
const lines: string[] = [];
|
||||
|
||||
function walk(obj: any, path: string, depth: number) {
|
||||
if (obj == null || (typeof obj !== "object" && typeof obj !== "function")) return;
|
||||
|
||||
if (SKIP_PATHS.has(path)) {
|
||||
lines.push(`${path} -> (skipped - Discord's full module registry, use the search tools for this)`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof obj === "object") {
|
||||
if (seen.has(obj)) {
|
||||
lines.push(`${path} -> [already listed elsewhere, circular]`);
|
||||
return;
|
||||
}
|
||||
seen.add(obj);
|
||||
}
|
||||
|
||||
let keys: string[];
|
||||
try {
|
||||
keys = Object.keys(obj).sort();
|
||||
} catch (e) {
|
||||
lines.push(`${path} -> [couldn't read keys: ${e}]`);
|
||||
return;
|
||||
}
|
||||
|
||||
const limited = keys.slice(0, MAX_KEYS_PER_LEVEL);
|
||||
for (const key of limited) {
|
||||
const fullPath = `${path}.${key}`;
|
||||
let value: any;
|
||||
try {
|
||||
value = obj[key];
|
||||
} catch (e) {
|
||||
lines.push(`${fullPath} -> [threw on access: ${e}]`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const kind = kindOf(value);
|
||||
lines.push(`${fullPath} (${kind})`);
|
||||
|
||||
if (kind === "object" && depth < maxDepth) {
|
||||
walk(value, fullPath, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
if (keys.length > MAX_KEYS_PER_LEVEL) {
|
||||
lines.push(`${path} -> (+${keys.length - MAX_KEYS_PER_LEVEL} more keys not shown at this level)`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
walk((globalThis as any).vendetta, "vendetta", 0);
|
||||
} catch (e) {
|
||||
lines.push(`Error walking the vendetta object: ${e}`);
|
||||
}
|
||||
|
||||
if (!lines.length) return "vendetta object not found or empty";
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import SettingsScaffold from "@shared/ui/SettingsScaffold";
|
|||
import NoteBox from "@shared/ui/NoteBox";
|
||||
import PrimaryButton from "@shared/ui/PrimaryButton";
|
||||
import { runAllChecks, formatReport } from "../lib/checks";
|
||||
import { dumpVendettaApiTree } from "../lib/apiTree";
|
||||
|
||||
const { View, Text } = ReactNative;
|
||||
const { FormSection, FormInput } = Forms;
|
||||
|
|
@ -34,6 +35,29 @@ function FullScanSection() {
|
|||
);
|
||||
}
|
||||
|
||||
function ApiTreeSection() {
|
||||
return (
|
||||
<FormSection title="Full Vendetta API tree">
|
||||
<View style={{ paddingHorizontal: 16, paddingVertical: 8 }}>
|
||||
<PrimaryButton
|
||||
label="Copy the entire plugin API tree"
|
||||
onPress={() => {
|
||||
const tree = dumpVendettaApiTree(3);
|
||||
const lineCount = tree.split("\n").length;
|
||||
clipboard.setString(tree);
|
||||
showToast(`Copied ${lineCount} lines - every key path under window.vendetta`, undefined);
|
||||
}}
|
||||
/>
|
||||
<Text style={{ marginTop: 8, fontSize: 12.5, opacity: 0.7 }}>
|
||||
Every key path under the stable plugin API (window.vendetta), 3 levels deep - this
|
||||
is everything Revenge itself exposes to plugins, not Discord's raw internals (those
|
||||
are searchable individually below, since dumping all of them would be enormous).
|
||||
</Text>
|
||||
</View>
|
||||
</FormSection>
|
||||
);
|
||||
}
|
||||
|
||||
function ManualSearchSection() {
|
||||
const [propQuery, setPropQuery] = React.useState("");
|
||||
const [nameQuery, setNameQuery] = React.useState("");
|
||||
|
|
@ -105,6 +129,7 @@ export default function Settings() {
|
|||
return (
|
||||
<SettingsScaffold>
|
||||
<FullScanSection />
|
||||
<ApiTreeSection />
|
||||
<ManualSearchSection />
|
||||
<NoteBox>
|
||||
The full scan checks every internal lookup this repo's plugins depend on (plus a few
|
||||
|
|
|
|||
|
|
@ -113,12 +113,13 @@
|
|||
"howItWorks": "The main feature is a full scan: one button runs every internal lookup (findByProps, findByName, findByStoreName, specific semanticColors tokens) that this repo's plugins actually depend on, all in one pass, and copies a complete found/missing report plus the full current semanticColors and rawColors key lists to your clipboard. There's also a manual search section for anything not already in the scan list - type prop names or a component name and copy what findByProps/findByName returns for it. Nothing leaves your device; everything only touches your clipboard.",
|
||||
"features": [
|
||||
"Full scan: every known lookup, checked in one tap, with a copyable report",
|
||||
"Full Vendetta API tree: every key path Revenge exposes to plugins, 3 levels deep, in one copy",
|
||||
"Manual findByProps(...) / findByName(...) search for anything else",
|
||||
"Includes the complete semanticColors and rawColors key lists",
|
||||
"No network requests, no data collection - clipboard only"
|
||||
],
|
||||
"commands": [],
|
||||
"limitations": "The full-scan list only covers lookups this repo's plugins currently use - it's meant to grow as more plugins get added or fixed, not to be exhaustive of everything Discord/Revenge expose."
|
||||
"limitations": "The full-scan list only covers lookups this repo's plugins currently use - it's meant to grow as more plugins get added or fixed. The API tree covers Revenge's own plugin API (window.vendetta), not Discord's raw internal module registry, which is far too large to dump wholesale - use the search tools for that."
|
||||
},
|
||||
"better-timestamps": {
|
||||
"category": "Moderation & Appearance",
|
||||
|
|
@ -181,5 +182,20 @@
|
|||
{ "cmd": "/reverse-image-search <image-url> [engine]", "desc": "Opens the image in your browser's reverse image search" }
|
||||
],
|
||||
"limitations": "Needs a direct image URL, not a Discord attachment reference - use \"Copy Media Link\" first."
|
||||
},
|
||||
"fake-profile-themes-and-effects": {
|
||||
"category": "Identity",
|
||||
"status": "revived",
|
||||
"accent": "#EB459E",
|
||||
"tagline": "Profile theming and profile effects without Nitro.",
|
||||
"note": "Fork of shipwr3ckd's FPTE. Previously crashed every time you opened the profile editor - confirmed via on-device diagnostics that several color tokens it referenced (HEADER_SECONDARY, BACKGROUND_PRIMARY, BACKGROUND_FLOATING, TEXT_NORMAL) no longer exist on current Discord builds. Rebuilt with a fallback chain of real, currently-valid token names instead of a single hardcoded one.",
|
||||
"howItWorks": "Encodes your chosen primary color, accent color, and profile effect into invisible zero-width characters appended to your About Me - other people running a compatible plugin (this one, or the original) decode and render it as if you had real Nitro profile theming, without Discord's servers ever knowing. The builder UI for picking colors/effects lives right in the profile editor. Only the two files that were actually confirmed crashing were investigated and fixed; the rest of this plugin's original patches (Nitro upsell card, purchase checks, profile data fetching) are carried over as-is, now with crash isolation so any other latent issue only disables that one patch instead of the whole plugin.",
|
||||
"features": [
|
||||
"Pick a primary color, accent color, and profile effect from the profile editor",
|
||||
"No Nitro required - works purely by encoding data into your bio",
|
||||
"Crash isolation per patch, so one broken surface doesn't take down the rest"
|
||||
],
|
||||
"commands": [],
|
||||
"limitations": "Only the confirmed profile-editor crash was root-caused and fixed with real on-device data. The plugin's other patches (Nitro upsell card, purchase/profile data fetching) weren't individually re-audited - crash isolation means a problem there disables just that patch rather than the whole plugin, but hasn't been proven bug-free the way the color-resolution fix has."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue