Fix the 6 broken plugins, plus profile tags, YouBar+, ServerDrawer

This commit is contained in:
Ally Founder 2026-08-09 14:36:37 -04:00
parent 9e3bb4992b
commit 3e72269c10
17 changed files with 386 additions and 435 deletions

View file

@ -6,6 +6,7 @@ import { clipboard } from "@vendetta/metro/common";
import { getAssetIDByName } from "@vendetta/ui/assets";
import { showToast } from "@vendetta/ui/toasts";
import { applyPatches } from "@shared/lib/patcher";
import { registerTypeDetector, registerIntercept, patchCreateElement } from "@shared/lib/createElementIntercept";
function patchRolePill(): () => void {
// Confirmed against decompiled current-build Discord source: the component is now just
@ -36,11 +37,55 @@ function patchRolePill(): () => void {
});
}
// Covers the role pills in a full profile's Roles section - a different render path from
// patchRolePill above (that one's for role mentions in message text). This section renders
// UserProfileRolesCard -> RolesList -> RoleItem, none of which are top-level exports, so we need
// createElementIntercept to catch RoleItem's reference. It hands us the role object directly with
// role.colorString already a hex string, so no need to guess at it from rendered styles.
function patchProfileRoleItem(): () => void {
const cleanups: (() => void)[] = [];
patchCreateElement(cleanups);
registerTypeDetector(
"copy-role-color-role-item",
(type) => typeof type === "function" && type.name === "RoleItem",
(RoleItem: any) => {
const PatchedRoleItem = (props: any) => {
const ret = RoleItem(props);
try {
const color = props?.role?.colorString;
if (color && ret && typeof ret === "object") {
ret.props = {
...ret.props,
onLongPress: () => {
clipboard.setString(color);
showToast("Copied role color to clipboard", getAssetIDByName("ic_message_copy"));
}
};
}
} catch {
// Leave the role item without a long-press handler.
}
return ret;
};
registerIntercept(RoleItem, PatchedRoleItem);
}
);
return () => cleanups.forEach((fn) => fn());
}
let unpatchAll: () => void = () => {};
export default {
onLoad: () => {
unpatchAll = applyPatches("CopyRoleColor", logger, { "role pill long-press": patchRolePill });
unpatchAll = applyPatches("CopyRoleColor", logger, {
"role pill long-press": patchRolePill,
"profile role item long-press": patchProfileRoleItem
});
},
onUnload: () => unpatchAll()
};

View file

@ -3,19 +3,9 @@ import { registerTypeDetector, registerIntercept, patchCreateElement } from "@sh
import resolveTag from "../lib/resolveTag";
import CustomTag from "../ui/Tag";
/**
* 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.
*/
// Same approach as Staff Tags' patches/profile.tsx: UserProfilePrimaryInfo builds the name row but
// isn't a top-level export, so we grab it via createElementIntercept. Unlike Staff Tags, resolveTag
// only needs a userId, no guild context, so we don't bother reading guildId here.
export default function patchProfile(): () => void {
const cleanups: (() => void)[] = [];
patchCreateElement(cleanups);

View file

@ -67,7 +67,7 @@ export function isElement(arg: RN.Node): arg is RN.Element {
}
export function isElementWithChildren<T extends RN.Element>(arg: T | RN.Element<RN.PropsWithChildren>): arg is RN.Element<RN.PropsWithChildren> {
return "children" in arg.props;
return isNonNullObject(arg.props) && "children" in arg.props;
}
export function isProviderType(arg: Exclude<RN.ElementType, symbol>): arg is RN.ProviderType {

View file

@ -6,9 +6,6 @@ import { getSnippets, saveSnippet, deleteSnippet } from "./lib/snippets";
// Discord slash-command option type for a plain string argument.
const STRING = 3;
// Returning a CommandResult object from `execute` and relying on the framework to auto-send it
// turned out to be unreliable in practice - real-world plugins (e.g. InfoCommands) route around it
// by sending the message themselves, so this does the same instead of relying on the return value.
const MessageActions = findByProps("sendMessage", "sendBotMessage");
export default function loadCommands(): (() => void)[] {
@ -30,7 +27,14 @@ export default function loadCommands(): (() => void)[] {
return;
}
MessageActions.sendMessage(ctx.channel.id, { content: text });
// sendMessage silently rejects (TypeError reading nonce) without a 3rd/4th arg, even
// though its own signature only shows two params - both are required to actually send.
MessageActions.sendMessage(
ctx.channel.id,
{ content: text, tts: false, invalidEmojis: [], validNonShortcutEmojis: [] },
true,
{}
);
}
});

View file

@ -27,8 +27,12 @@ function patchProfile(): () => void {
const children = bioSection?.props?.children;
if (!Array.isArray(children)) return;
const withProfile = children.find((c: any) => typeof c?.props?.displayProfile?.userId === "string");
const userId = withProfile?.props?.displayProfile?.userId;
// userId is a direct prop on these cards now, not nested under displayProfile.userId
// - keeping the old path as a fallback in case another build still shapes it that way.
const withProfile = children.find(
(c: any) => typeof c?.props?.userId === "string" || typeof c?.props?.displayProfile?.userId === "string"
);
const userId = withProfile?.props?.userId ?? withProfile?.props?.displayProfile?.userId;
if (!userId) return;
children.unshift(<PronounSection userId={userId} />);

View file

@ -1,23 +1,21 @@
import { React, NavigationNative } from "@vendetta/metro/common";
import { getAssetIDByName } from "@vendetta/ui/assets";
import { findByProps, findByName } from "@vendetta/metro";
import { after } from "@vendetta/patcher";
import { Forms } from "@vendetta/ui/components";
import { findInReactTree } from "@vendetta/utils";
const { FormSection, FormRow } = Forms;
const bunny = (window as any).bunny;
declare global {
interface Window {
bunny?: any;
}
}
const tabsNavigationRef = bunny?.metro?.findByPropsLazy?.("getRootNavigationRef");
const settingConstants = bunny?.metro?.findByPropsLazy?.("SETTING_RENDERER_CONFIG");
const createListModule = bunny?.metro?.findByPropsLazy?.("createList");
const SettingsOverviewScreen = bunny?.metro?.findByNameLazy?.("SettingsOverviewScreen", false);
const TableRowIconModule = bunny?.metro?.findByPropsLazy?.("TableRowIcon");
// Pins a settings entry into Discord's own App Settings screen instead of leaving it buried under
// Revenge's Plugins list - ported from Commands (kmmiio99o/vd-plugins), which calls through
// window.bunny. This client only has vendetta, not bunny, so those lookups always came back
// undefined and it silently fell back to copying the theme URL. Swapped to Vendetta's
// findByProps/findByName, same as every other plugin in this repo.
const tabsNavigationRef = findByProps("getRootNavigationRef");
const settingConstants = findByProps("SETTING_RENDERER_CONFIG");
const createListModule = findByProps("createList");
const SettingsOverviewScreen = findByName("SettingsOverviewScreen", false);
const TableRowIconModule = findByProps("TableRowIcon");
function Section({ tabs }: { tabs: any }) {
const navigation = NavigationNative.useNavigation();
@ -40,41 +38,45 @@ function Section({ tabs }: { tabs: any }) {
}
function patchPanelUI(tabs: any, patches: any[]) {
const target = bunny?.metro?.findByPropsLazy?.(["renderTitle", "sections"], false)
?? bunny?.metro?.findByProps?.("renderTitle", "sections");
const target = findByProps("renderTitle", "sections");
if (!target) return;
patches.push(
after("default", target, (_: any, ret: any) => {
const UserSettingsOverview = findInReactTree(
ret.props.children,
(n: any) => n.type?.name === "UserSettingsOverview"
);
if (UserSettingsOverview) {
patches.push(
after("render", UserSettingsOverview.type.prototype, (_args: any, res: any) => {
const sections = findInReactTree(
res.props.children,
(n: any) => n?.children?.[1]?.type === FormSection
)?.children;
if (sections) {
const index = sections.findIndex((c: any) =>
["BILLING_SETTINGS", "PREMIUM_SETTINGS"].includes(c?.props?.label)
);
sections.splice(
-~index || 4,
0,
React.createElement(Section, { key: tabs.key, tabs })
);
}
})
try {
patches.push(
after("default", target, (_: any, ret: any) => {
const UserSettingsOverview = findInReactTree(
ret.props.children,
(n: any) => n.type?.name === "UserSettingsOverview"
);
}
}, true)
);
if (UserSettingsOverview) {
patches.push(
after("render", UserSettingsOverview.type.prototype, (_args: any, res: any) => {
const sections = findInReactTree(
res.props.children,
(n: any) => n?.children?.[1]?.type === FormSection
)?.children;
if (sections) {
const index = sections.findIndex((c: any) =>
["BILLING_SETTINGS", "PREMIUM_SETTINGS"].includes(c?.props?.label)
);
sections.splice(
-~index || 4,
0,
React.createElement(Section, { key: tabs.key, tabs })
);
}
})
);
}
}, true)
);
} catch {
// This surface (the tablet/desktop-style settings panel) may not exist on this build at
// all - patchTabsUI below covers the mobile settings list, which is what actually matters
// here. A miss on this one shouldn't block that one.
}
}
function patchTabsUI(tabs: any, patches: any[]) {

View file

@ -3,6 +3,7 @@ import { storage } from "@vendetta/plugin";
import { useProxy } from "@vendetta/storage";
import { clipboard } from "@vendetta/metro/common";
import { showToast } from "@vendetta/ui/toasts";
import { themes } from "@vendetta";
import { TableRow, TableRowGroup, TableRowArrow } from "@shared/ui/table";
import SettingsScaffold from "@shared/ui/SettingsScaffold";
import NoteBox from "@shared/ui/NoteBox";
@ -10,23 +11,25 @@ import { THEMES } from "../lib/themes";
const { Text } = ReactNative;
// Used to check window.bunny.themes - this client only has vendetta, so every tap silently fell
// back to the clipboard-copy path. vendetta.themes has the same shape, ported directly.
export default function Settings() {
useProxy(storage);
const applyTheme = async (theme: typeof THEMES[number]) => {
const bunny = (window as any).bunny;
if (!bunny?.themes?.installTheme) {
if (!themes?.installTheme) {
clipboard.setString(theme.url);
showToast("Theme URL copied — paste it into Settings > Revenge > Themes");
return;
}
try {
await bunny.themes.installTheme(theme.url);
const installed = bunny.themes.themes?.[theme.url];
if (installed && bunny.themes.selectTheme) {
bunny.themes.selectTheme(installed);
await themes.installTheme(theme.url);
// vendetta-types says selectTheme takes an id string, but that resolves without
// applying anything - it actually wants the installed theme object.
const installed = themes.themes?.[theme.url];
if (installed && themes.selectTheme) {
await themes.selectTheme(installed as any);
}
storage.current = theme.id;
showToast(`Switched to ${theme.name}`);
@ -36,14 +39,12 @@ export default function Settings() {
};
const reset = () => {
const bunny = (window as any).bunny;
if (!bunny?.themes?.selectTheme) {
if (!themes?.selectTheme) {
showToast("Revenge theme engine not available");
return;
}
bunny.themes.selectTheme(null);
themes.selectTheme(null);
storage.current = "";
showToast("Default theme restored");
};

View file

@ -1,46 +1,44 @@
import { React, ReactNative } from "@vendetta/metro/common";
import { findByStoreName } from "@vendetta/metro";
import { ReactNative } from "@vendetta/metro/common";
import { after } from "@vendetta/patcher";
import { findInReactTree } from "@vendetta/utils";
import { storage } from "@vendetta/plugin";
import { registerPropsTransform, patchCreateElement } from "@shared/lib/createElementIntercept";
const { View } = ReactNative;
const GuildStore = findByStoreName("GuildStore");
const { Text } = ReactNative;
const SelectedGuildStore = findByStoreName("SelectedGuildStore");
const GuildRoleStore = findByStoreName("GuildRoleStore");
// Colors the role section headers in the member list. Patches the generic View render, since these headers
// aren't exposed as a named component - `roleId` on the found node is what narrows it down to the right one.
// Colors the role section headers in the member list ("MODERATORS", "ADMINS", etc). The old
// version patched View.prototype.render looking for a roleId prop - View isn't a class component
// anymore and roleId doesn't exist by the time this reaches React. All we get is a pre-formatted
// title string like "🚨 Moderator — 1" on a memo'd component called UserSectionInner, so we parse
// the role name back out of that and match it against the guild's roles by name.
export default function patchMemberList(): () => void {
if (!View) return () => {};
const cleanups: (() => void)[] = [];
patchCreateElement(cleanups);
return after("render", View, (_: any, res: any) => {
try {
if (storage.noRole) return;
registerPropsTransform(
(props: any, type: any) => {
if (storage.noRole) return false;
if (typeof props?.title !== "string") return false;
const isSectionHeader = typeof type === "function" ? type.name === "UserSectionInner" : type?.type?.name === "UserSectionInner";
return isSectionHeader;
},
(props: any) => {
try {
const stripped = props.title.replace(/^[^\p{L}\p{N}]+/u, "").replace(/\s*—\s*\d+$/, "").trim();
if (!stripped) return props;
const roleHeader = findInReactTree(res, (r) => r?.props?.roleId !== undefined);
if (!roleHeader) return;
if (Number.isNaN(Number(roleHeader.props.roleId))) return;
if (roleHeader.props.excludedApplications) return;
if ("displayRoleIcon" in roleHeader.props) return;
if ("searchable" in roleHeader.props) return;
const guildId = SelectedGuildStore?.getGuildId?.();
const roles = guildId ? GuildRoleStore?.getSortedRoles?.(guildId) : null;
const role = roles?.find((r: any) => r.name === stripped);
if (!role?.colorString) return props;
const outer = { type: { ...roleHeader.type } };
after("type", outer.type, (_: any, inner: any) => {
const labelWrapper = inner?.props?.children?.[1];
if (!labelWrapper?.type) return;
const label = { type: { ...labelWrapper.type } };
after("render", label.type, (_: any, labelRes: any) => {
const role = GuildStore?.getGuild(roleHeader.props.guildId)?.roles?.[roleHeader.props.roleId];
if (!role?.colorString || !labelRes?.props?.style?.push) return;
labelRes.props.style.push({ color: role.colorString });
});
labelWrapper.type = label.type;
});
roleHeader.type = outer.type;
} catch {
// Skip this render pass - a header staying uncolored beats a crashed member list.
return { ...props, title: React.createElement(Text, { style: { color: role.colorString } }, props.title) };
} catch {
return props;
}
}
});
);
return () => cleanups.forEach((fn) => fn());
}

View file

@ -1,60 +1,67 @@
import { findByProps, findByStoreName } from "@vendetta/metro";
import { React, constants } from "@vendetta/metro/common";
import { after } from "@vendetta/patcher";
import { General } from "@vendetta/ui/components";
import { findInReactTree } from "@vendetta/utils";
import { storage } from "@vendetta/plugin";
import { registerTypeDetector, registerIntercept, patchCreateElement } from "@shared/lib/createElementIntercept";
import { defaultTextColor } from "../lib/color";
const { Text } = General;
const UserStore = findByStoreName("UserStore");
const RelationshipStore = findByStoreName("RelationshipStore");
const GuildMemberStore = findByStoreName("GuildMemberStore");
const TypingWrapper = findByProps("TYPING_WRAPPER_HEIGHT");
// Colors usernames in the "X is typing..." indicator. TYPING_WRAPPER_HEIGHT (what this used to
// look up) doesn't exist anymore - the actual component is TypingIndicatorInner, which isn't a
// top-level export, and it hands us channel + typingUserIds directly as props. Each typing user
// gets their own inner <Text> element in the same order as typingUserIds, so we just zip them up.
export default function patchTypingWrapper(): () => void {
if (!TypingWrapper?.default) return () => {};
const cleanups: (() => void)[] = [];
patchCreateElement(cleanups);
return after("default", TypingWrapper, ([{ channel }]: any[], res: any) => {
try {
if (!res || storage.hideTyping) return;
registerTypeDetector(
"role-color-everywhere-typing-indicator",
(type) => typeof type === "function" ? type.name === "TypingIndicatorInner" : type?.type?.name === "TypingIndicatorInner",
(TypingIndicatorInner: any) => {
const inner = typeof TypingIndicatorInner === "function" ? TypingIndicatorInner : TypingIndicatorInner.type;
const Typing = res.props?.children;
if (!Typing) return;
const PatchedTypingIndicatorInner = (props: any) => {
const ret = inner(props);
const unpatchTyping = after("type", Typing, (_: any, typingRes: any) => {
try {
React.useEffect(() => () => unpatchTyping(), []);
if (storage.hideTyping) return ret;
const typingThing = typingRes?.props?.children?.[0]?.props?.children?.[1]?.props;
if (!typingThing?.children || typingThing.children === "Several people are typing...") return;
const label = findInReactTree(
ret,
(n: any) =>
Array.isArray(n?.props?.children) &&
n.props.children.some((c: any) => typeof c === "string" && c.includes("typing..."))
);
if (!Array.isArray(label?.props?.children)) return ret;
const users = (TypingWrapper.useTypingUserIds?.(channel.id) ?? []).map((userId: string) => {
const member = GuildMemberStore?.getMember(channel.guild_id, userId);
const user = UserStore?.getUser(userId);
const name = member?.nick || RelationshipStore?.getNickname?.(userId) || user?.globalName || user?.username || "Someone";
// "Several people are typing..." collapses to one plain string with no
// per-user elements to color - nothing to do here, leave it as-is.
const userElements = label.props.children.filter((c: any) => c && typeof c === "object");
const typingUserIds: string[] = props?.typingUserIds ?? [];
if (!userElements.length || userElements.length !== typingUserIds.length) return ret;
const channel = props?.channel;
userElements.forEach((el: any, i: number) => {
const userId = typingUserIds[i];
const member = userId && channel ? GuildMemberStore?.getMember(channel.guild_id, userId) : null;
const color = member?.colorString || defaultTextColor();
return { name, color };
if (!color || !el?.props) return;
el.props.style = Array.isArray(el.props.style)
? [...el.props.style, { color }]
: el.props.style
? [el.props.style, { color }]
: { color };
});
if (!users.length) return;
const userText = (u: { name: string; color: any }) =>
React.createElement(Text, { style: { color: u.color, fontFamily: constants.Fonts.DISPLAY_SEMIBOLD } }, u.name);
typingThing.children =
users.length === 1
? [userText(users[0]), " is typing..."]
: [
...users.slice(0, -1).flatMap((u: any, i: number) => [userText(u), i < users.length - 2 ? ", " : " and "]),
userText(users[users.length - 1]),
" are typing..."
];
} catch {
// Leave the default "X is typing..." text alone.
}
});
} catch {
// Skip typing indicator coloring entirely for this render.
return ret;
};
registerIntercept(TypingIndicatorInner, PatchedTypingIndicatorInner);
}
});
);
return () => cleanups.forEach((fn) => fn());
}

View file

@ -1,4 +1,5 @@
import { findByName } from "@vendetta/metro";
import { findInReactTree } from "@vendetta/utils";
import { after } from "@vendetta/patcher";
import { storage } from "@vendetta/plugin";
import { defaultTextColor } from "../lib/color";
@ -11,16 +12,12 @@ export default function patchVoiceUserConnected(): () => void {
try {
if (storage.noVoice || !res?.type) return;
const usesRender = !!res.type.prototype?.render;
const hook = usesRender ? "render" : "type";
const target = usesRender ? res.type.prototype : res.type;
const unpatch = after(hook, target, (_: any, innerRes: any) => {
const unpatch = after("type", res.type, (_: any, innerRes: any) => {
try {
unpatch();
const textProps = innerRes?.props?.children?.[1]?.props;
if (!textProps?.style) return;
textProps.style.color = args?.member?.colorString || defaultTextColor();
const nameItem = findInReactTree(innerRes, (n: any) => n?.type?.name === "VoiceUserNameItem");
if (!nameItem?.props) return;
nameItem.props.color = args?.member?.colorString || defaultTextColor();
} catch {
// Leave the default voice row color.
}

View file

@ -1,4 +1,5 @@
import { React } from "@vendetta/metro/common";
import { after } from "@vendetta/patcher";
interface Intercept {
replacement: React.ComponentType<any>;
@ -73,61 +74,33 @@ export function registerIntercept(
intercepts.set(original, { replacement, extraProps, collapseAncestors: options?.collapseAncestors });
}
/**
* Same idea as registerIntercept, but matches by a predicate over the element's props instead of
* an exact type reference - useful when the component itself has no reliable name to find it by
* (no named export, and a stripped/mangled runtime function name), but it always passes some
* distinguishing literal prop (e.g. a nativeID) that survives into the compiled bundle unchanged.
* Pass `replacement: null` to render nothing at all instead of swapping in a component.
*/
// Same idea as registerIntercept, but matches on a predicate over the element's props instead of
// an exact type reference - useful when the component has no reliable name to find it by, but
// always passes some distinguishing prop. Pass replacement: null to render nothing instead.
export function registerPropsIntercept(predicate: (props: any) => boolean, replacement: React.ComponentType<any> | null) {
propsIntercepts.push({ predicate, replacement });
}
/**
* 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. Confirmed live (Key
* Inspector fiber capture) that Discord's Quest Dock hooks are 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 the `value` prop of a Context.Provider element here reaches every consumer
* exactly as if the underlying hooks had actually returned that value.
*/
// Rewrites an element's props in place, keeping the same type - unlike registerPropsIntercept,
// this doesn't swap in a different component, it just lets the real one render with different
// input. Exists because Quest Dock's hooks are called through a same-chunk closure reference, not
// the exported property - patching the export does nothing, but every element creation goes
// through this one shared path regardless of chunk, so there's no equivalent staleness here.
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.
*
* `key` deduplicates registrations. index.ts retries any patch that hasn't landed yet every 200ms
* for ~10s, and each of those retries used to append another copy of the same detector - up to ~50
* duplicates per patch, every one of them re-tested against the `type` of every element created
* anywhere in the app, during the exact window (cold boot) where the drawer is trying to appear
* quickly. Registering under a key makes a retry a no-op instead.
*
* `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 GuildsBar, whose reference
* changes on some navigation paths (e.g. switching servers via this plugin's own drawer), which a
* one-shot detector plus registerIntercept's identity-keyed map can't follow: the intercept stays
* registered for the old, now-abandoned reference while the new one renders unmodified.
*/
// Purely observational: the first time an element is created whose type matches predicate, calls
// onDetected(type) with the live reference. Doesn't touch rendering - pair it with
// registerIntercept/registerPropsIntercept in the callback for that. Exists for components that
// aren't findable by searching Metro's registry after the fact (races against first mount).
//
// key dedupes registrations - index.ts retries every 200ms for ~10s, and duplicate detectors used
// to pile up, each re-tested against every element created anywhere in the app.
//
// persistent keeps the detector alive for every future match instead of firing once. GuildsBar's
// own type reference changes on some navigation paths (switching servers via this plugin's own
// drawer), so a one-shot detector would only ever catch the first, now-abandoned reference.
export function registerTypeDetector(
key: string,
predicate: (type: any) => boolean,
@ -161,7 +134,10 @@ function runTypeDetectors(type: any) {
} catch {
// Same - a detector's own handler throwing shouldn't take anything else down.
}
if (!detector.persistent) consumed = true;
if (!detector.persistent) {
detector.justFired = true;
consumed = true;
}
}
// Only rebuild the list when a one-shot detector actually fired. The previous version rebuilt
@ -264,54 +240,45 @@ function resolveReplacement(type: any, props: any, rest: any[]): { type: any; pr
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) {
const el = orig.call(thisArg ?? this, resolved.type, resolved.props ?? null, ...rest);
if (resolved.collapse && resolved.collapse > 0 && el && typeof el === "object") {
collapseMarks.set(el, resolved.collapse);
}
return el;
// Mutates the already-created element in place rather than substituting args before creation -
// after() only sees the return value, not the call itself. A raw property reassignment on the
// jsx-runtime object (what this used to do) doesn't reliably reach real render calls here; after()
// does. Confirmed live: GuildsBar creations were observed firing through this exact patch point,
// but res.type never actually changed - the intercept logic was correct, the patch mechanism wasn't.
function applyResolved(res: any, type: any, props: any, rest: any[]) {
if (!res || typeof res !== "object") return res;
const resolved = resolveReplacement(type, props, rest);
if (resolved === null) {
res.type = () => null;
return res;
}
if (resolved) {
res.type = resolved.type;
res.props = resolved.props ?? null;
if (resolved.collapse && resolved.collapse > 0) {
collapseMarks.set(res, resolved.collapse);
}
return orig.call(thisArg ?? this, type, props ?? null, ...rest);
};
}
return res;
}
export function patchCreateElement(cleanups: (() => void)[]) {
if (isPatched) return;
const origCreateElement = React.createElement;
if (!origCreateElement) {
console.warn("[ServerDrawer] React.createElement is null, skipping patch");
return;
}
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/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/Fragment together), not by name, for the same reason everything else in this file
// avoids name-based lookups.
//
// Two changes over the previous version, both of which were causing the drawer to show up only
// sometimes:
//
// 1. ALL matching runtime modules get patched, not just the first one rawFind returned. A
// bundle can hold more than one copy of the runtime, and any chunk importing a copy we
// didn't patch renders completely uninterceptable elements.
// 2. The scan keeps running instead of stopping at the first success. Metro registers modules
// lazily, so a runtime copy belonging to a chunk that hasn't been required yet at boot
// simply does not exist to be found at that moment - the old one-shot waitFor resolved off
// the first copy and never looked again, permanently missing every copy registered later.
const restoreJsx: (() => void)[] = [];
if (React.createElement) {
cleanups.push(
after("createElement", React, (args: any[], res: any) => applyResolved(res, args[0], args[1], args.slice(2)))
);
} else {
console.warn("[ServerDrawer] React.createElement is null, skipping patch");
}
// Discord's bundle compiles JSX through the automatic runtime (jsx/jsxs/jsxDEV) instead of
// React.createElement, so patching createElement alone misses nearly everything. We scan for
// the runtime by shape rather than name since names get mangled, patch every copy we find (a
// bundle can have more than one), and keep scanning on an interval since Metro registers
// modules lazily - a copy that hasn't been required yet at boot just isn't there to find.
const patchedJsxRuntimes = new WeakSet<any>();
function isJsxRuntime(m: any): boolean {
@ -322,49 +289,22 @@ export function patchCreateElement(cleanups: (() => void)[]) {
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; });
if (typeof runtime[key] !== "function") continue;
cleanups.push(
after(key, runtime, (args: any[], res: any) => applyResolved(res, args[0], args[1], args.slice(2)))
);
}
}
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);
}
if (isJsxRuntime(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; });
}
if (dflt != null && isJsxRuntime(dflt)) patchJsxObject(dflt);
} catch {
// Ignore one module's bad shape and continue scanning.
}
@ -380,25 +320,20 @@ export function patchCreateElement(cleanups: (() => void)[]) {
}
}
// Patch any jsx runtimes already loaded before Discord's first render pass starts.
// This is synchronous because the race we're fixing is specifically the first QuestDock render:
// an async scan completes too late and the element is created through an unpatched runtime.
// 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();
const scan = () => { scanAndPatchJsxRuntimes(); };
scan();
// 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(() => {
scan();
scanAndPatchJsxRuntimes();
if (++ticks === 50 && timer) { // ~5s at 100ms
clearInterval(timer);
timer = setInterval(() => {
scan();
scanAndPatchJsxRuntimes();
if (++ticks >= 75 && timer) { // + ~25s at 1s
clearInterval(timer);
timer = undefined;
@ -410,12 +345,7 @@ export function patchCreateElement(cleanups: (() => void)[]) {
cleanups.push(() => {
if (timer) clearInterval(timer);
timer = undefined;
if (isPatched) {
React.createElement = origCreateElement;
isPatched = false;
}
restoreJsx.forEach((fn) => fn());
restoreJsx.length = 0;
isPatched = false;
intercepts.clear();
propsIntercepts.length = 0;
propsTransforms.length = 0;

View file

@ -77,11 +77,32 @@ function hasChildWithTestID(children: any, rest: any[], testID: string): boolean
export function patchHideGuildsBar(cleanups: (() => void)[]): boolean {
// Hard-enforce the GuildsBar parent's visibility by detecting the hidden Nothing child and
// applying display: none to the rail wrapper itself.
// zeroing the rail wrapper itself. Confirmed live that the immediate parent carries an
// explicit width: 72 in its own style, so display: none alone left the space reserved -
// zeroing width/flex directly, the same way collapseAncestors does further up, actually
// reclaims it.
registerPropsTransform(
(props: any, _type: any, rest: any[]) =>
hasChildWithTestID(props?.children, rest, SD_NOTHING_TEST_ID),
(props: any) => ({ ...props, style: [props?.style, { display: "none" }] }),
(props: any) => ({
...props,
style: [
props?.style,
{
display: "none",
width: 0,
minWidth: 0,
maxWidth: 0,
flexGrow: 0,
flexShrink: 0,
flexBasis: 0,
margin: 0,
padding: 0,
borderWidth: 0,
overflow: "hidden",
},
],
}),
);
// Use a stable string key so duplicate registrations from retry loops are deduped correctly.

View file

@ -6,39 +6,13 @@ import GradientTag from "../ui/GradientTag";
const GuildStore = findByStoreName("GuildStore");
/**
* 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.
*/
// The full profile doesn't render through UserRow at all - it's PrimaryInfo -> UserProfilePrimaryInfo,
// and PrimaryInfo only shows up unrendered inside UserProfileContent's own output (no children to
// search). UserProfilePrimaryInfo is the one that actually builds the name row, and it's not a
// top-level export, so we grab its reference via createElementIntercept the first time it renders.
// The row it builds is [DisplayName, [UserTagAndPronouns, GuildTag, ProfileBadgeRows]] - none of
// those read custom text/color (UserTagAndPronouns is just the @handle, GuildTag is Discord's own
// server-tag feature), so we push our GradientTag in as a sibling instead of trying to reuse one.
export default function patchProfile(): () => void {
const cleanups: (() => void)[] = [];
patchCreateElement(cleanups);

View file

@ -5,8 +5,6 @@ import { fetchDefinition } from "./lib/urbanDictionary";
const STRING = 3;
// Same lesson as Message Snippets: returning a CommandResult and relying on the framework to
// auto-send it is unreliable in practice, so this sends the message directly instead.
const MessageActions = findByProps("sendMessage", "sendBotMessage");
export default function loadCommands(): (() => void)[] {
@ -40,7 +38,14 @@ export default function loadCommands(): (() => void)[] {
`**${definition.word}** (👍 ${definition.thumbsUp} 👎 ${definition.thumbsDown})\n${definition.definition}` +
(definition.example ? `\n\n*${definition.example}*` : "");
MessageActions.sendMessage(ctx.channel.id, { content });
// sendMessage silently rejects (TypeError reading nonce) without a 3rd/4th arg, even
// though its own signature only shows two params - both are required to actually send.
MessageActions.sendMessage(
ctx.channel.id,
{ content, tts: false, invalidEmojis: [], validNonShortcutEmojis: [] },
true,
{}
);
}
});

View file

@ -1,20 +1,53 @@
import { logger } from "@vendetta";
import { storage } from "@vendetta/plugin";
import { applyPatches } from "@shared/lib/patcher";
import patchYouBarButtons from "./patches/youBarButtons";
import Settings from "./ui/Settings";
let unpatchAll: () => void = () => {};
let unpatchButtons: () => void = () => {};
let patched = false;
let retryHandle: ReturnType<typeof setInterval> | undefined;
function stopRetrying() {
if (retryHandle) {
clearInterval(retryHandle);
retryHandle = undefined;
}
}
// YouBarNotificationsButton isn't registered in Metro until the YouBar itself has rendered at
// least once, which hasn't necessarily happened yet by the time a plugin's onLoad runs - a single
// attempt just misses it some of the time. Retries on an interval until it lands, then stops.
function attempt() {
try {
const unpatch = patchYouBarButtons();
if (unpatch) {
unpatchButtons = unpatch;
patched = true;
stopRetrying();
}
} catch (e) {
logger.error(`[YouBar+] Failed to apply the "YouBar buttons" patch: ${e}`);
stopRetrying();
}
}
export default {
onLoad: () => {
storage.showDMButton ??= false;
storage.showSettingsButton ??= true;
unpatchAll = applyPatches("YouBar+", logger, {
"YouBar buttons": patchYouBarButtons
});
attempt();
if (!patched) {
let ticks = 0;
retryHandle = setInterval(() => {
attempt();
if (++ticks >= 30) stopRetrying(); // ~9s at 300ms, then give up
}, 300);
}
},
onUnload: () => {
stopRetrying();
unpatchButtons();
},
onUnload: () => unpatchAll(),
settings: Settings
};

View file

@ -4,18 +4,13 @@ import { instead } from "@vendetta/patcher";
import { storage } from "@vendetta/plugin";
import { getAssetIDByName } from "@vendetta/ui/assets";
// Reset to Purple-EyeZ's original upstream logic, verbatim aside from the import path
// (@revenge-mod/metro -> @vendetta/metro, since this repo only has the vendetta-compat alias
// configured) - no retry loop, no raw* passive finder, no forced re-render. Every one of those
// layers added this session was meant to fix "sometimes needs a reload," but on the actual device
// buttons stopped appearing at all, even after ten restarts - worse than upstream's own documented
// "requires a restart" baseline. Resetting to the exact known-quantity upstream version first, to
// find out whether the *added* logic was the actual problem or something else in this port
// (a renamed lookup, a build difference) was always broken and just wasn't visible under the retry
// logic's own try/catch swallowing it.
// Keep the actual patch logic here dead simple - a previous attempt to fix the load-order race
// by adding retry/re-render logic directly into this function made buttons stop appearing at all.
// The retry now lives one level up in index.ts instead, calling this repeatedly until it succeeds.
export let updateYouBar = () => {};
export default function patchYouBarButtons(): () => void {
/** Returns null (not undefined) when YouBarNotificationsButton isn't registered in Metro yet, so a caller can tell "not ready" apart from "patched, here's the unpatch fn". */
export default function patchYouBarButtons(): (() => void) | null {
const YouBarNotificationsButton = findByTypeName("YouBarNotificationsButton");
const userSettingsAction = findByProps("openUserSettings");
const transitionModule = findByProps("transitionToGuild");
@ -23,7 +18,7 @@ export default function patchYouBarButtons(): () => void {
const SettingsIcon = getAssetIDByName("SettingsIcon");
const ChatIcon = getAssetIDByName("ChatIcon");
if (!YouBarNotificationsButton) return () => {};
if (!YouBarNotificationsButton) return null;
return instead("type", YouBarNotificationsButton, (args: any[], OriginalRender: (...a: any[]) => any) => {
const [, forceUpdate] = React.useReducer((x: number) => ~x, 0);

View file

@ -39,60 +39,30 @@ export function registerIntercept(
intercepts.set(original, { replacement, extraProps });
}
/**
* Same idea as registerIntercept, but matches by a predicate over the element's props instead of
* an exact type reference - useful when the component itself has no reliable name to find it by
* (no named export, and a stripped/mangled runtime function name), but it always passes some
* distinguishing literal prop (e.g. a nativeID) that survives into the compiled bundle unchanged.
* Pass `replacement: null` to render nothing at all instead of swapping in a component.
*/
// Same idea as registerIntercept, but matches on a predicate over the element's props instead of
// an exact type reference - useful when the component has no reliable name to find it by, but
// always passes some distinguishing prop. Pass replacement: null to render nothing instead.
export function registerPropsIntercept(predicate: (props: any) => boolean, replacement: React.ComponentType<any> | null) {
propsIntercepts.push({ predicate, replacement });
}
/**
* 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.
*/
// Rewrites an element's props in place, keeping the same type - unlike registerPropsIntercept,
// this doesn't swap in a different component, it just lets the real one render with different
// input. Useful where patching a hook's exported property wouldn't work (same-chunk closures
// often bypass the export entirely); every element creation goes through this one shared path
// regardless of which chunk it came from, so there's no equivalent staleness problem here.
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, 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.
*/
// Purely observational: the first time an element is created whose type matches predicate, calls
// onDetected(type) with the live reference. Doesn't touch rendering itself - pair it with
// registerIntercept/registerPropsIntercept in the callback for that. This is how you find a
// component that isn't a top-level module export and can't be searched for after the fact.
//
// key dedupes registrations (matters if the call site can run more than once, e.g. a retry loop).
// persistent keeps the detector alive for every future match instead of firing once and dropping
// - needed for components whose reference isn't stable for the whole session.
export function registerTypeDetector(
key: string,
predicate: (type: any) => boolean,
@ -181,32 +151,12 @@ function resolveReplacement(type: any, props: any, rest: any[]): { type: any; pr
return undefined;
}
/**
* Applies a resolved replacement to an already-created element in place, mutating `res` rather
* than substituting arguments before creation.
*
* This patches with `@vendetta/patcher`'s `after()` - the same mechanism proven reliable
* throughout this repo for every other patch - rather than raw property reassignment
* (`runtime.jsx = wrapper`). Confirmed live (see /root/evals-for-rn) that raw reassignment on the
* jsx-runtime module's `jsx`/`jsxs` properties does *not* reliably reach real render calls on this
* client: after mutating the property directly, a diagnostic hook wrapped the *same* object with
* `vendetta.patcher.after` and correctly observed real calls, while a diagnostic that only
* reassigned the property directly (mirroring what this file used to do, and what
* `server-drawer`'s otherwise more battle-tested copy still does) never fired at all for the same
* real calls. `after()` clearly does something a plain reassignment doesn't to actually reach
* consumers - since `React.createElement`/`jsx`/`jsxs` are always looked up fresh through the one
* shared runtime function (not destructured into a same-chunk closure the way some of Discord's
* own hooks are, per this repo's other same-chunk-staleness notes), the difference isn't about
* reaching a stale binding - it's specifically that `after()`'s patching mechanism is what
* actually gets observed, and a bare property set on the exports object is not, for reasons this
* investigation didn't need to fully explain to fix: use the mechanism proven to work.
*
* Because `after()` fires once the real function has already run and produced `res`, replacing
* the *type* (registerIntercept/registerPropsIntercept) is expressed by mutating `res.type`/
* `res.props` after the fact rather than by substituting arguments beforehand - React reads
* `.type`/`.props` off whatever object it's handed for reconciliation regardless of how those
* fields were set, so this has the same practical effect (confirmed live).
*/
// Mutates the already-created element in place rather than substituting args before creation.
// This has to go through @vendetta/patcher's after() instead of a raw property reassignment on
// the jsx-runtime object - a plain reassignment (what this file and server-drawer's copy used to
// do) just doesn't reach real render calls here, after() does. Since after() only sees the return
// value, not the original call, we mutate res.type/res.props after the fact instead of swapping
// the type beforehand - same result, React just reads whatever's on the object it gets handed.
function applyResolved(res: any, type: any, props: any, rest: any[]) {
if (!res || typeof res !== "object") return res;
const resolved = resolveReplacement(type, props, rest);
@ -233,16 +183,11 @@ export function patchCreateElement(cleanups: (() => void)[]) {
console.warn("[createElementIntercept] React.createElement is null, skipping patch");
}
// Modern React/RN builds (Discord's included) compile JSX through the automatic runtime
// (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.
//
// 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.
// Discord's bundle compiles JSX through the automatic runtime (jsx/jsxs/jsxDEV) instead of
// React.createElement, so patching createElement alone misses nearly everything. We scan for
// the runtime by shape rather than name since names get mangled, patch every copy we find (a
// bundle can have more than one), and keep scanning on an interval since Metro registers
// modules lazily - a copy that hasn't been required yet at boot just isn't there to find.
const patchedJsxRuntimes = new WeakSet<any>();
function isJsxRuntime(m: any): boolean {