staff-tags, custom-user-tags: fix full profile screen tags
This commit is contained in:
parent
843e42adc9
commit
244c6e49fd
3 changed files with 326 additions and 190 deletions
|
|
@ -1,74 +1,58 @@
|
|||
import { find, findByProps } from "@vendetta/metro";
|
||||
import { after } from "@vendetta/patcher";
|
||||
import { findInReactTree } from "@vendetta/utils";
|
||||
import { registerTypeDetector, registerIntercept, patchCreateElement } from "@shared/lib/createElementIntercept";
|
||||
import resolveTag from "../lib/resolveTag";
|
||||
|
||||
// Same top-level profile component PronounDB already confirmed works, and the same one Staff Tags
|
||||
// now patches for its own profile-screen tag - see that plugin's patches/profile.tsx for the full
|
||||
// writeup on why this is a separate component from UserRow (which details.tsx already handles).
|
||||
const UserProfile =
|
||||
find((m: any) => m?.type?.name === "UserProfileContent") ??
|
||||
find((m: any) => m?.type?.name === "UserProfile");
|
||||
|
||||
const TagModule = findByProps("getBotLabel");
|
||||
import CustomTag from "../ui/Tag";
|
||||
|
||||
/**
|
||||
* Not independently confirmed against a live render yet - the exact prop shape passed to this
|
||||
* component isn't verified on-device, so this pulls from whichever of several plausible field names
|
||||
* is actually present. Every step is guarded, so a wrong assumption just means no tag appears here.
|
||||
* Full profile screen tag - see Staff Tags' own `patches/profile.tsx` (same repo pattern, worked
|
||||
* out together in the same live session, see /root/evals-for-rn) for the full writeup of why this
|
||||
* needs `createElement`/`jsx` interception rather than patching `UserProfileContent` and searching
|
||||
* its output: `UserProfilePrimaryInfo` (the component that actually renders the name+tag row) is
|
||||
* only ever created as an implementation detail several layers deep, unreachable by a Metro module
|
||||
* search, and receives `user` directly as its own prop - so `registerTypeDetector` catches the
|
||||
* first render to recover the live reference, and `registerIntercept` swaps in a wrapper for every
|
||||
* later use, no separate context-passing patch needed.
|
||||
*
|
||||
* Unlike Staff Tags, `resolveTag` only needs a `userId` - no guild/permission context - so this
|
||||
* doesn't need `guildId` from props at all.
|
||||
*/
|
||||
export default function patchProfile(): () => void {
|
||||
if (!UserProfile) return () => {};
|
||||
const cleanups: (() => void)[] = [];
|
||||
patchCreateElement(cleanups);
|
||||
|
||||
return after("type", UserProfile, (args: any[], res: any) => {
|
||||
try {
|
||||
const props = args?.[0] ?? {};
|
||||
const userId: string | undefined = props.userId ?? props.user?.id ?? props.displayProfile?.userId;
|
||||
if (!userId) return;
|
||||
registerTypeDetector(
|
||||
"custom-user-tags-profile-primary-info",
|
||||
(type) => typeof type === "function" && type.name === "UserProfilePrimaryInfo",
|
||||
(UserProfilePrimaryInfo: any) => {
|
||||
const PatchedUserProfilePrimaryInfo = (props: any) => {
|
||||
const ret = UserProfilePrimaryInfo(props);
|
||||
|
||||
const tag = resolveTag(userId);
|
||||
if (!tag) return;
|
||||
try {
|
||||
const userId: string | undefined = props?.user?.id;
|
||||
if (!userId) return ret;
|
||||
|
||||
const primaryInfo = findInReactTree(res, (c) => c?.type?.name === "PrimaryInfo" || c?.type?.displayName === "PrimaryInfo");
|
||||
if (!primaryInfo) return;
|
||||
const tag = resolveTag(userId);
|
||||
if (!tag) return ret;
|
||||
|
||||
const existingTag = findInReactTree(primaryInfo, (c) => c?.type?.Types);
|
||||
if (existingTag && existingTag.props?.type !== 0) return;
|
||||
const row = findInReactTree(
|
||||
ret,
|
||||
(c: any) =>
|
||||
Array.isArray(c?.props?.children) &&
|
||||
c.props.children.some((ch: any) => ch?.type?.name === "UserTagAndPronouns")
|
||||
);
|
||||
if (!Array.isArray(row?.props?.children)) return ret;
|
||||
|
||||
if (existingTag) {
|
||||
Object.assign(existingTag.props, {
|
||||
type: 0,
|
||||
text: tag.text,
|
||||
textColor: tag.textColor,
|
||||
backgroundColor: tag.backgroundColor,
|
||||
icon: tag.icon,
|
||||
iconColor: tag.iconColor,
|
||||
verified: false
|
||||
});
|
||||
return;
|
||||
}
|
||||
row.props.children.push(<CustomTag tag={tag} />);
|
||||
} catch {
|
||||
// Skip - a broken profile tag beats a crashed profile screen.
|
||||
}
|
||||
|
||||
const nameRow = findInReactTree(
|
||||
primaryInfo,
|
||||
(c) =>
|
||||
Array.isArray(c?.props?.children) &&
|
||||
c.props.children.some((ch: any) => typeof ch === "string" || typeof ch?.props?.children === "string")
|
||||
);
|
||||
if (!Array.isArray(nameRow?.props?.children) || !TagModule) return;
|
||||
return ret;
|
||||
};
|
||||
|
||||
nameRow.props.children.push(
|
||||
<TagModule.default
|
||||
type={0}
|
||||
text={tag.text}
|
||||
textColor={tag.textColor}
|
||||
backgroundColor={tag.backgroundColor}
|
||||
icon={tag.icon}
|
||||
iconColor={tag.iconColor}
|
||||
verified={false}
|
||||
/>
|
||||
);
|
||||
} catch {
|
||||
// Skip - a broken profile tag beats a crashed profile screen.
|
||||
registerIntercept(UserProfilePrimaryInfo, PatchedUserProfilePrimaryInfo);
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
return () => cleanups.forEach((fn) => fn());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,86 +1,91 @@
|
|||
import { find, findByProps, findByStoreName } from "@vendetta/metro";
|
||||
import { after } from "@vendetta/patcher";
|
||||
import { findByStoreName } from "@vendetta/metro";
|
||||
import { findInReactTree } from "@vendetta/utils";
|
||||
import { registerTypeDetector, registerIntercept, patchCreateElement } from "@shared/lib/createElementIntercept";
|
||||
import getTag from "../lib/getTag";
|
||||
import GradientTag from "../ui/GradientTag";
|
||||
|
||||
// Same top-level profile component PronounDB already confirmed works on current Discord (the
|
||||
// findByName-based lookup this repo used to use for it stopped matching after a rename, but this
|
||||
// predicate-based find still succeeds). The full profile screen doesn't render through UserRow the
|
||||
// way member list rows and the old "profile popout" did - this is a separate component entirely,
|
||||
// which is why tags weren't showing up here even though details.tsx's own patch is working fine.
|
||||
const UserProfile =
|
||||
find((m: any) => m?.type?.name === "UserProfileContent") ??
|
||||
find((m: any) => m?.type?.name === "UserProfile");
|
||||
|
||||
const TagModule = findByProps("getBotLabel");
|
||||
const GuildStore = findByStoreName("GuildStore");
|
||||
const UserStore = findByStoreName("UserStore");
|
||||
|
||||
/**
|
||||
* Not independently confirmed against a live render the way most of this repo's fixes are - the
|
||||
* exact prop shape passed to this component's render (userId vs. a full user object, whether
|
||||
* guildId is even present) isn't verified on-device yet, so this pulls from whichever of several
|
||||
* plausible field names is actually present rather than assuming one. Every step is guarded, so a
|
||||
* wrong assumption just means no tag appears here, not a crash.
|
||||
* Full profile screen tag, live-verified end to end (see /root/evals-for-rn for the session this
|
||||
* was worked out in, and the Next repo's `staff-tags` `patches/details.tsx` for the exact same
|
||||
* class of bug this replaces one of).
|
||||
*
|
||||
* The full profile doesn't render through `UserRow` (member list/profile popout) at all - it's a
|
||||
* separate tree, `UserProfileContent` -> `PrimaryInfo` -> `UserProfilePrimaryInfo`, and neither
|
||||
* `PrimaryInfo` nor `UserProfileContent` carry a rendered name+tag row worth patching:
|
||||
* `PrimaryInfo` only ever appears as an *unrendered* `<PrimaryInfo user=... guildId=... />`
|
||||
* element inside `UserProfileContent`'s own output (confirmed live it has no `children` at that
|
||||
* point, so searching inside it for anything finds nothing, silently, no matter how good the
|
||||
* search predicate is). `UserProfilePrimaryInfo` is the component that actually renders the name
|
||||
* row and, conveniently, receives `user`/`guildId` directly as its own props - no need to also
|
||||
* patch `UserProfileContent` just to recover context.
|
||||
*
|
||||
* `UserProfilePrimaryInfo` can't be found by a Metro module search the way `UserProfileContent`
|
||||
* is above (confirmed live - it isn't a top-level export findByProps/findByTypeName can reach,
|
||||
* only ever created as an implementation detail inside `PrimaryInfo`'s own render). Patching
|
||||
* `React.createElement`/the `jsx` runtime (`@shared/lib/createElementIntercept`) sidesteps that:
|
||||
* `registerTypeDetector` observes the *first* time anything, anywhere, creates an element with
|
||||
* this name, handing back the live function reference the moment it exists - then
|
||||
* `registerIntercept` swaps in our own wrapper for every future use of that exact reference,
|
||||
* including the one that triggered the detector (both run synchronously inside the same
|
||||
* `createElement`/`jsx` call, so even the very first render is already patched).
|
||||
*
|
||||
* Confirmed live, `UserProfilePrimaryInfo` renders:
|
||||
* `<View>[<DisplayName/>, <View>[<UserTagAndPronouns/>, <GuildTag/>, <ProfileBadgeRows/>]]</View>`
|
||||
* - `UserTagAndPronouns` is Discord's `@username` handle + pronouns display, not a badge slot
|
||||
* (misleading name); `GuildTag` is Discord's own unrelated "server tag" feature; `ProfileBadgeRows`
|
||||
* is Nitro/HypeSquad-style badges. None of these read custom text/color, so - same lesson as the
|
||||
* member list's `BotTag` - the fix pushes the plugin's own `GradientTag` as a new sibling in that
|
||||
* inner row rather than trying to repurpose any of them.
|
||||
*/
|
||||
export default function patchProfile(): () => void {
|
||||
if (!UserProfile) return () => {};
|
||||
const cleanups: (() => void)[] = [];
|
||||
patchCreateElement(cleanups);
|
||||
|
||||
return after("type", UserProfile, (args: any[], res: any) => {
|
||||
try {
|
||||
const props = args?.[0] ?? {};
|
||||
const userId: string | undefined = props.userId ?? props.user?.id ?? props.displayProfile?.userId;
|
||||
if (!userId) return;
|
||||
registerTypeDetector(
|
||||
"staff-tags-profile-primary-info",
|
||||
(type) => typeof type === "function" && type.name === "UserProfilePrimaryInfo",
|
||||
(UserProfilePrimaryInfo: any) => {
|
||||
const PatchedUserProfilePrimaryInfo = (props: any) => {
|
||||
const ret = UserProfilePrimaryInfo(props);
|
||||
|
||||
const user = props.user ?? UserStore?.getUser?.(userId);
|
||||
if (!user) return;
|
||||
try {
|
||||
const { user, guildId } = props ?? {};
|
||||
if (!user) return ret;
|
||||
|
||||
const guildId: string | undefined = props.guildId;
|
||||
const guild = guildId ? GuildStore?.getGuild(guildId) : undefined;
|
||||
const guild = guildId ? GuildStore?.getGuild(guildId) : undefined;
|
||||
const tag = getTag(guild, undefined, user);
|
||||
if (!tag) return ret;
|
||||
|
||||
const tag = getTag(guild, undefined, user);
|
||||
if (!tag) return;
|
||||
const row = findInReactTree(
|
||||
ret,
|
||||
(c: any) =>
|
||||
Array.isArray(c?.props?.children) &&
|
||||
c.props.children.some((ch: any) => ch?.type?.name === "UserTagAndPronouns")
|
||||
);
|
||||
if (!Array.isArray(row?.props?.children)) return ret;
|
||||
|
||||
const primaryInfo = findInReactTree(res, (c) => c?.type?.name === "PrimaryInfo" || c?.type?.displayName === "PrimaryInfo");
|
||||
if (!primaryInfo) return;
|
||||
row.props.children.push(
|
||||
<GradientTag
|
||||
text={tag.text}
|
||||
textColor={tag.textColor}
|
||||
backgroundColor={tag.backgroundColor}
|
||||
gradientColor={tag.gradientColor}
|
||||
icon={tag.icon}
|
||||
iconColor={tag.iconColor}
|
||||
/>
|
||||
);
|
||||
} catch {
|
||||
// Skip - a broken profile tag beats a crashed profile screen.
|
||||
}
|
||||
|
||||
const existingTag = findInReactTree(primaryInfo, (c) => c?.type?.Types);
|
||||
if (existingTag && existingTag.props?.type !== 0) return;
|
||||
return ret;
|
||||
};
|
||||
|
||||
if (existingTag) {
|
||||
Object.assign(existingTag.props, {
|
||||
type: 0,
|
||||
text: tag.text,
|
||||
textColor: tag.textColor,
|
||||
backgroundColor: tag.backgroundColor,
|
||||
icon: tag.icon,
|
||||
iconColor: tag.iconColor,
|
||||
verified: false
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const nameRow = findInReactTree(
|
||||
primaryInfo,
|
||||
(c) =>
|
||||
Array.isArray(c?.props?.children) &&
|
||||
c.props.children.some((ch: any) => typeof ch === "string" || typeof ch?.props?.children === "string")
|
||||
);
|
||||
if (!Array.isArray(nameRow?.props?.children) || !TagModule) return;
|
||||
|
||||
nameRow.props.children.push(
|
||||
<TagModule.default
|
||||
type={0}
|
||||
text={tag.text}
|
||||
textColor={tag.textColor}
|
||||
backgroundColor={tag.backgroundColor}
|
||||
icon={tag.icon}
|
||||
iconColor={tag.iconColor}
|
||||
verified={false}
|
||||
/>
|
||||
);
|
||||
} catch {
|
||||
// Skip - a broken profile tag beats a crashed profile screen.
|
||||
registerIntercept(UserProfilePrimaryInfo, PatchedUserProfilePrimaryInfo);
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
return () => cleanups.forEach((fn) => fn());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
import { React } from "@vendetta/metro/common";
|
||||
import { rawFind } from "@shared/lib/rawFind";
|
||||
import { waitFor } from "@shared/lib/waitFor";
|
||||
|
||||
interface Intercept {
|
||||
replacement: React.ComponentType<any>;
|
||||
|
|
@ -12,14 +10,24 @@ interface PropsIntercept {
|
|||
replacement: React.ComponentType<any> | null;
|
||||
}
|
||||
|
||||
interface PropsTransform {
|
||||
predicate: (props: any) => boolean;
|
||||
transform: (props: any) => any;
|
||||
}
|
||||
|
||||
interface TypeDetector {
|
||||
key: string;
|
||||
predicate: (type: any) => boolean;
|
||||
onDetected: (type: any) => void;
|
||||
persistent: boolean;
|
||||
justFired?: boolean;
|
||||
}
|
||||
|
||||
const intercepts = new Map<React.ComponentType<any>, Intercept>();
|
||||
const propsIntercepts: PropsIntercept[] = [];
|
||||
const propsTransforms: PropsTransform[] = [];
|
||||
let typeDetectors: TypeDetector[] = [];
|
||||
const detectorKeys = new Set<string>();
|
||||
let isPatched = false;
|
||||
|
||||
export function registerIntercept(
|
||||
|
|
@ -42,26 +50,67 @@ export function registerPropsIntercept(predicate: (props: any) => boolean, repla
|
|||
}
|
||||
|
||||
/**
|
||||
* Purely observational, one-shot: the first time an element is created whose `type` matches
|
||||
* `predicate`, calls `onDetected(type)` with the real, live type reference and then stops
|
||||
* watching - it never alters what actually renders.
|
||||
* Rewrites an element's props in place while keeping the same type - unlike registerPropsIntercept,
|
||||
* this doesn't swap in a different component, it lets the real one render with different input.
|
||||
*
|
||||
* Exists because monkey-patching a hook's exported property (`someModule.useSomeHook = wrapper`)
|
||||
* only affects callers that go through that exact exports-object reference. Discord's own hooks are
|
||||
* often called via an internal, same-chunk closure reference instead - the patched export sits there
|
||||
* unused, and the real, unpatched hook result flows through untouched. A React.createElement/jsx call
|
||||
* has no such ambiguity: it's always the one shared runtime function, called the same way regardless
|
||||
* of which chunk the JSX originated in - so rewriting an element's props here reaches every consumer
|
||||
* exactly as if the underlying data had actually been different.
|
||||
*/
|
||||
export function registerPropsTransform(predicate: (props: any) => boolean, transform: (props: any) => any) {
|
||||
propsTransforms.push({ predicate, transform });
|
||||
}
|
||||
|
||||
/**
|
||||
* Purely observational: the first time an element is created whose `type` matches `predicate`,
|
||||
* calls `onDetected(type)` with the real, live type reference - it never alters what actually
|
||||
* renders (pair with registerIntercept/registerPropsIntercept in the callback for that).
|
||||
*
|
||||
* This exists for components that can't be found reliably by searching Metro's module registry
|
||||
* after the fact (findByTypeName/rawFindByTypeName), because that races against whenever the
|
||||
* component first mounts - if it mounts before the search finds it, and it never mounts again for
|
||||
* the rest of the session (true for some React.memo'd chrome components), the search losing that
|
||||
* race means the patch never gets a chance to apply at all, permanently. Intercepting element
|
||||
* creation instead sidesteps the race entirely: by the time ANYTHING calls createElement/jsx with
|
||||
* this component as `type`, that reference already exists and is already the real one about to be
|
||||
* used - there's no way to observe the call any earlier than this.
|
||||
* the rest of the session (true for some React.memo'd chrome components, and for inner components
|
||||
* only reachable by rendering an outer one first), the search losing that race means the patch never
|
||||
* gets a chance to apply at all, permanently. Intercepting element creation instead sidesteps the
|
||||
* race entirely: by the time ANYTHING calls createElement/jsx with this component as `type`, that
|
||||
* reference already exists and is already the real one about to be used - there's no way to observe
|
||||
* the call any earlier than this.
|
||||
*
|
||||
* `key` deduplicates registrations - important if the call site that registers a detector can run
|
||||
* more than once (a retry loop, a patch that reapplies on reload), since duplicate detectors mean
|
||||
* duplicate `onDetected` firings and a permanently growing list re-tested against every element
|
||||
* created anywhere in the app.
|
||||
*
|
||||
* `persistent` (default false, one-shot): keeps watching and firing `onDetected` again for every
|
||||
* later match too, instead of stopping after the first. Needed when the component's own type
|
||||
* reference isn't a stable session-long singleton (confirmed live for some chrome components, whose
|
||||
* reference changes on certain navigation paths) - a one-shot detector plus registerIntercept's
|
||||
* identity-keyed map can't follow that: the intercept stays registered for the old, now-abandoned
|
||||
* reference while the new one renders unmodified.
|
||||
*/
|
||||
export function registerTypeDetector(predicate: (type: any) => boolean, onDetected: (type: any) => void) {
|
||||
typeDetectors.push({ predicate, onDetected });
|
||||
export function registerTypeDetector(
|
||||
key: string,
|
||||
predicate: (type: any) => boolean,
|
||||
onDetected: (type: any) => void,
|
||||
options?: { persistent?: boolean },
|
||||
) {
|
||||
if (detectorKeys.has(key)) return;
|
||||
detectorKeys.add(key);
|
||||
typeDetectors.push({ key, predicate, onDetected, persistent: options?.persistent ?? false });
|
||||
}
|
||||
|
||||
export function hasTypeDetector(key: string): boolean {
|
||||
return detectorKeys.has(key);
|
||||
}
|
||||
|
||||
function runTypeDetectors(type: any) {
|
||||
if (typeDetectors.length === 0) return;
|
||||
const remaining: TypeDetector[] = [];
|
||||
|
||||
let consumed = false;
|
||||
for (const detector of typeDetectors) {
|
||||
let matched = false;
|
||||
try {
|
||||
|
|
@ -69,28 +118,49 @@ function runTypeDetectors(type: any) {
|
|||
} catch {
|
||||
// A bad predicate shouldn't block every other detector or every element from rendering.
|
||||
}
|
||||
if (matched) {
|
||||
try {
|
||||
detector.onDetected(type);
|
||||
} catch {
|
||||
// Same - a detector's own handler throwing shouldn't take anything else down.
|
||||
}
|
||||
} else {
|
||||
remaining.push(detector);
|
||||
if (!matched) continue;
|
||||
|
||||
try {
|
||||
detector.onDetected(type);
|
||||
} catch {
|
||||
// Same - a detector's own handler throwing shouldn't take anything else down.
|
||||
}
|
||||
if (!detector.persistent) {
|
||||
detector.justFired = true;
|
||||
consumed = true;
|
||||
}
|
||||
}
|
||||
typeDetectors = remaining;
|
||||
|
||||
// Only rebuild the list when a one-shot detector actually fired - rebuilding on every single
|
||||
// element creation app-wide would be an allocation per element on the hottest path in the app
|
||||
// whenever any persistent detector is registered (the list is then never empty).
|
||||
if (consumed) {
|
||||
typeDetectors = typeDetectors.filter((d) => d.persistent || !d.justFired);
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns a replacement type if this element should be intercepted, `null` to render nothing, or `undefined` to pass through unchanged. */
|
||||
function resolveReplacement(type: any, props: any): { type: any; props: any } | null | undefined {
|
||||
function resolveReplacement(type: any, props: any, rest: any[]): { type: any; props: any } | null | undefined {
|
||||
runTypeDetectors(type);
|
||||
|
||||
if (props) {
|
||||
let effectiveProps = props;
|
||||
if (effectiveProps) {
|
||||
for (const { predicate, transform } of propsTransforms) {
|
||||
try {
|
||||
if (predicate(effectiveProps, type, rest)) {
|
||||
effectiveProps = transform(effectiveProps);
|
||||
}
|
||||
} catch {
|
||||
// A bad transform shouldn't block every other element from rendering.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (effectiveProps) {
|
||||
for (const { predicate, replacement } of propsIntercepts) {
|
||||
try {
|
||||
if (predicate(props)) {
|
||||
return replacement ? { type: replacement, props } : null;
|
||||
if (predicate(effectiveProps, type, rest)) {
|
||||
return replacement ? { type: replacement, props: effectiveProps } : null;
|
||||
}
|
||||
} catch {
|
||||
// A bad predicate shouldn't block every other element from rendering.
|
||||
|
|
@ -100,12 +170,25 @@ function resolveReplacement(type: any, props: any): { type: any; props: any } |
|
|||
|
||||
const entry = intercepts.get(type);
|
||||
if (entry) {
|
||||
return { type: entry.replacement, props: entry.extraProps ? { ...props, ...entry.extraProps } : props };
|
||||
return { type: entry.replacement, props: entry.extraProps ? { ...effectiveProps, ...entry.extraProps } : effectiveProps };
|
||||
}
|
||||
|
||||
if (effectiveProps !== props) {
|
||||
return { type, props: effectiveProps };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function makeWrapper(orig: Function, thisArg: any) {
|
||||
return function (this: any, type: any, props: any, ...rest: any[]) {
|
||||
const resolved = resolveReplacement(type, props, rest);
|
||||
if (resolved === null) return null;
|
||||
if (resolved) return orig.call(thisArg ?? this, resolved.type, resolved.props ?? null, ...rest);
|
||||
return orig.call(thisArg ?? this, type, props ?? null, ...rest);
|
||||
};
|
||||
}
|
||||
|
||||
export function patchCreateElement(cleanups: (() => void)[]) {
|
||||
if (isPatched) return;
|
||||
|
||||
|
|
@ -115,60 +198,124 @@ export function patchCreateElement(cleanups: (() => void)[]) {
|
|||
return;
|
||||
}
|
||||
|
||||
const patchedCreateElement = function (type: any, props: any, ...rest: any[]) {
|
||||
const resolved = resolveReplacement(type, props);
|
||||
if (resolved === null) return null;
|
||||
if (resolved) return origCreateElement.call(React, resolved.type, resolved.props, ...rest);
|
||||
return origCreateElement.call(React, type, props, ...rest);
|
||||
};
|
||||
|
||||
const patchedCreateElement = makeWrapper(origCreateElement, React);
|
||||
Object.assign(patchedCreateElement, origCreateElement);
|
||||
React.createElement = patchedCreateElement as typeof React.createElement;
|
||||
isPatched = true;
|
||||
|
||||
// Modern React/RN builds (Discord's included) compile JSX through the automatic runtime
|
||||
// (jsx/jsxs from react/jsx-runtime) instead of React.createElement - patching only
|
||||
// createElement misses every one of Discord's own render calls entirely. Found by shape
|
||||
// (jsx/jsxs/Fragment together), not by name, for the same reason everything else in this file
|
||||
// avoids name-based lookups.
|
||||
// (jsx/jsxs/jsxDEV from react/jsx-runtime) instead of React.createElement - patching only
|
||||
// createElement misses effectively every one of Discord's own render calls. Found by shape
|
||||
// (jsx/jsxs/jsxDEV together), not by name, since runtime function names get mangled/stripped.
|
||||
//
|
||||
// This used to be a single one-shot rawFind() call - if the jsx-runtime module hadn't been
|
||||
// required yet at the exact moment this ran (very early in boot, when a plugin's onLoad first
|
||||
// calls this), jsx/jsxs would never get patched for the rest of the session at all, silently
|
||||
// (only a console.warn, invisible in normal use). Since Discord renders almost everything
|
||||
// through jsx/jsxs and not createElement, that made every registerTypeDetector/
|
||||
// registerPropsIntercept consumer's patch a near-total no-op whenever that race was lost -
|
||||
// plausibly the real reason a component-creation-interception approach could still miss a
|
||||
// component that only ever renders once, early in boot. Retrying via waitFor (with the same
|
||||
// passive rawFind, so it still never force-requires anything) closes that gap.
|
||||
// ALL matching runtime modules get patched, not just the first one found, and the scan keeps
|
||||
// running instead of stopping at the first success: a bundle can hold more than one copy of the
|
||||
// runtime (different chunks importing different copies), and Metro registers modules lazily, so
|
||||
// a copy belonging to a chunk that hasn't been required yet at boot simply does not exist to be
|
||||
// found at that moment - a one-shot search permanently misses every copy registered later.
|
||||
const restoreJsx: (() => void)[] = [];
|
||||
const jsxHandle = waitFor(
|
||||
() => rawFind((m: any) => typeof m?.jsx === "function" && typeof m?.jsxs === "function" && "Fragment" in m),
|
||||
(jsxRuntime: any) => {
|
||||
for (const key of ["jsx", "jsxs"] as const) {
|
||||
const orig = jsxRuntime[key];
|
||||
if (typeof orig !== "function") continue;
|
||||
const patchedJsxRuntimes = new WeakSet<any>();
|
||||
|
||||
jsxRuntime[key] = function (type: any, props: any, ...rest: any[]) {
|
||||
const resolved = resolveReplacement(type, props);
|
||||
if (resolved === null) return null;
|
||||
if (resolved) return orig.call(this, resolved.type, resolved.props, ...rest);
|
||||
return orig.call(this, type, props, ...rest);
|
||||
};
|
||||
restoreJsx.push(() => { jsxRuntime[key] = orig; });
|
||||
function isJsxRuntime(m: any): boolean {
|
||||
return typeof m?.jsx === "function" || typeof m?.jsxs === "function" || typeof m?.jsxDEV === "function";
|
||||
}
|
||||
|
||||
function patchJsxObject(runtime: any) {
|
||||
if (patchedJsxRuntimes.has(runtime)) return;
|
||||
patchedJsxRuntimes.add(runtime);
|
||||
for (const key of ["jsx", "jsxs", "jsxDEV"] as const) {
|
||||
const orig = runtime[key];
|
||||
if (typeof orig !== "function") continue;
|
||||
runtime[key] = makeWrapper(orig, runtime);
|
||||
restoreJsx.push(() => { runtime[key] = orig; });
|
||||
}
|
||||
}
|
||||
|
||||
function wrapJsxFunction(runtime: any): any {
|
||||
const wrappedJsx = makeWrapper(runtime, runtime);
|
||||
for (const key of ["jsxs", "jsxDEV"] as const) {
|
||||
const orig = runtime[key];
|
||||
if (typeof orig === "function") {
|
||||
wrappedJsx[key] = makeWrapper(orig, runtime);
|
||||
}
|
||||
}
|
||||
);
|
||||
if ("Fragment" in runtime) {
|
||||
wrappedJsx.Fragment = runtime.Fragment;
|
||||
}
|
||||
return wrappedJsx;
|
||||
}
|
||||
|
||||
function patchJsxModule(def: any) {
|
||||
if (!def?.publicModule?.exports) return;
|
||||
const exports = def.publicModule.exports;
|
||||
|
||||
try {
|
||||
if (typeof exports === "function" && isJsxRuntime(exports) && !patchedJsxRuntimes.has(exports)) {
|
||||
patchedJsxRuntimes.add(exports);
|
||||
const wrapped = wrapJsxFunction(exports);
|
||||
def.publicModule.exports = wrapped;
|
||||
restoreJsx.push(() => { def.publicModule.exports = exports; });
|
||||
} else if (isJsxRuntime(exports) && !patchedJsxRuntimes.has(exports)) {
|
||||
patchedJsxRuntimes.add(exports);
|
||||
patchJsxObject(exports);
|
||||
}
|
||||
|
||||
const dflt = exports.default;
|
||||
if (dflt != null && typeof dflt === "function" && isJsxRuntime(dflt) && !patchedJsxRuntimes.has(dflt)) {
|
||||
patchedJsxRuntimes.add(dflt);
|
||||
const wrapped = wrapJsxFunction(dflt);
|
||||
exports.default = wrapped;
|
||||
restoreJsx.push(() => { exports.default = dflt; });
|
||||
}
|
||||
} catch {
|
||||
// Ignore one module's bad shape and continue scanning.
|
||||
}
|
||||
}
|
||||
|
||||
function scanAndPatchJsxRuntimes() {
|
||||
const modules = (window as any)?.modules;
|
||||
if (!modules) return;
|
||||
for (const id in modules) {
|
||||
const def = modules[id];
|
||||
if (!def?.isInitialized) continue;
|
||||
patchJsxModule(def);
|
||||
}
|
||||
}
|
||||
|
||||
// Patch any jsx runtimes already loaded before Discord's first render pass starts, synchronously
|
||||
// - an async scan would complete too late for the very first render of an early-mounting target.
|
||||
scanAndPatchJsxRuntimes();
|
||||
|
||||
// Fast while the app is still booting and chunks are being pulled in, then slow, then stop - a
|
||||
// full window.modules walk is not free and there's nothing left to catch once the UI has settled.
|
||||
let ticks = 0;
|
||||
let timer: ReturnType<typeof setInterval> | undefined = setInterval(() => {
|
||||
scanAndPatchJsxRuntimes();
|
||||
if (++ticks === 50 && timer) { // ~5s at 100ms
|
||||
clearInterval(timer);
|
||||
timer = setInterval(() => {
|
||||
scanAndPatchJsxRuntimes();
|
||||
if (++ticks >= 75 && timer) { // + ~25s at 1s
|
||||
clearInterval(timer);
|
||||
timer = undefined;
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
}, 100);
|
||||
|
||||
cleanups.push(() => {
|
||||
jsxHandle.cancel();
|
||||
if (timer) clearInterval(timer);
|
||||
timer = undefined;
|
||||
if (isPatched) {
|
||||
React.createElement = origCreateElement;
|
||||
isPatched = false;
|
||||
}
|
||||
restoreJsx.forEach((fn) => fn());
|
||||
restoreJsx.length = 0;
|
||||
intercepts.clear();
|
||||
propsIntercepts.length = 0;
|
||||
propsTransforms.length = 0;
|
||||
typeDetectors = [];
|
||||
detectorKeys.clear();
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue