diff --git a/plugins/copy-role-color/src/index.ts b/plugins/copy-role-color/src/index.ts index ec3a6c2..a4bc06b 100644 --- a/plugins/copy-role-color/src/index.ts +++ b/plugins/copy-role-color/src/index.ts @@ -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() }; diff --git a/plugins/custom-user-tags/src/patches/profile.tsx b/plugins/custom-user-tags/src/patches/profile.tsx index caa821e..9b85ece 100644 --- a/plugins/custom-user-tags/src/patches/profile.tsx +++ b/plugins/custom-user-tags/src/patches/profile.tsx @@ -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); diff --git a/plugins/fake-profile-themes-and-effects/src/lib/reactNativeRenderTree.ts b/plugins/fake-profile-themes-and-effects/src/lib/reactNativeRenderTree.ts index 1bbeb1a..3ae9d76 100644 --- a/plugins/fake-profile-themes-and-effects/src/lib/reactNativeRenderTree.ts +++ b/plugins/fake-profile-themes-and-effects/src/lib/reactNativeRenderTree.ts @@ -67,7 +67,7 @@ export function isElement(arg: RN.Node): arg is RN.Element { } export function isElementWithChildren(arg: T | RN.Element): arg is RN.Element { - return "children" in arg.props; + return isNonNullObject(arg.props) && "children" in arg.props; } export function isProviderType(arg: Exclude): arg is RN.ProviderType { diff --git a/plugins/message-snippets/src/commands.ts b/plugins/message-snippets/src/commands.ts index 13f795d..ab29ed0 100644 --- a/plugins/message-snippets/src/commands.ts +++ b/plugins/message-snippets/src/commands.ts @@ -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, + {} + ); } }); diff --git a/plugins/pronoun-db/src/index.tsx b/plugins/pronoun-db/src/index.tsx index cf0ba32..bd121f6 100644 --- a/plugins/pronoun-db/src/index.tsx +++ b/plugins/pronoun-db/src/index.tsx @@ -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(); diff --git a/plugins/raidens-themes/src/patches/settings.ts b/plugins/raidens-themes/src/patches/settings.ts index 1e3766e..4b5ad6e 100644 --- a/plugins/raidens-themes/src/patches/settings.ts +++ b/plugins/raidens-themes/src/patches/settings.ts @@ -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[]) { diff --git a/plugins/raidens-themes/src/ui/Settings.tsx b/plugins/raidens-themes/src/ui/Settings.tsx index ba54a90..6a20808 100644 --- a/plugins/raidens-themes/src/ui/Settings.tsx +++ b/plugins/raidens-themes/src/ui/Settings.tsx @@ -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"); }; diff --git a/plugins/role-color-everywhere/src/patches/memberList.ts b/plugins/role-color-everywhere/src/patches/memberList.ts index 9f498d8..264c1e8 100644 --- a/plugins/role-color-everywhere/src/patches/memberList.ts +++ b/plugins/role-color-everywhere/src/patches/memberList.ts @@ -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()); } diff --git a/plugins/role-color-everywhere/src/patches/typingWrapper.ts b/plugins/role-color-everywhere/src/patches/typingWrapper.ts index e503af5..730a46d 100644 --- a/plugins/role-color-everywhere/src/patches/typingWrapper.ts +++ b/plugins/role-color-everywhere/src/patches/typingWrapper.ts @@ -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 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()); } diff --git a/plugins/role-color-everywhere/src/patches/voiceUserConnected.ts b/plugins/role-color-everywhere/src/patches/voiceUserConnected.ts index a089089..7fd0f6c 100644 --- a/plugins/role-color-everywhere/src/patches/voiceUserConnected.ts +++ b/plugins/role-color-everywhere/src/patches/voiceUserConnected.ts @@ -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. } diff --git a/plugins/server-drawer/src/lib/createElementIntercept.ts b/plugins/server-drawer/src/lib/createElementIntercept.ts index 148216d..c17b749 100644 --- a/plugins/server-drawer/src/lib/createElementIntercept.ts +++ b/plugins/server-drawer/src/lib/createElementIntercept.ts @@ -1,4 +1,5 @@ import { React } from "@vendetta/metro/common"; +import { after } from "@vendetta/patcher"; interface Intercept { replacement: React.ComponentType; @@ -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 | 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(); 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 | 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; diff --git a/plugins/server-drawer/src/patches/hideGuildsBar.tsx b/plugins/server-drawer/src/patches/hideGuildsBar.tsx index 0166257..d5f984f 100644 --- a/plugins/server-drawer/src/patches/hideGuildsBar.tsx +++ b/plugins/server-drawer/src/patches/hideGuildsBar.tsx @@ -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. diff --git a/plugins/staff-tags/src/patches/profile.tsx b/plugins/staff-tags/src/patches/profile.tsx index 0d78362..71ef966 100644 --- a/plugins/staff-tags/src/patches/profile.tsx +++ b/plugins/staff-tags/src/patches/profile.tsx @@ -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* `` - * 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: - * `[, [, , ]]` - * - `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); diff --git a/plugins/urban-dictionary/src/commands.ts b/plugins/urban-dictionary/src/commands.ts index bedd376..1a9d6d2 100644 --- a/plugins/urban-dictionary/src/commands.ts +++ b/plugins/urban-dictionary/src/commands.ts @@ -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, + {} + ); } }); diff --git a/plugins/you-bar-plus/src/index.ts b/plugins/you-bar-plus/src/index.ts index 8e15841..b57b7a3 100644 --- a/plugins/you-bar-plus/src/index.ts +++ b/plugins/you-bar-plus/src/index.ts @@ -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 | 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 }; diff --git a/plugins/you-bar-plus/src/patches/youBarButtons.tsx b/plugins/you-bar-plus/src/patches/youBarButtons.tsx index e0e1e7f..76103fc 100644 --- a/plugins/you-bar-plus/src/patches/youBarButtons.tsx +++ b/plugins/you-bar-plus/src/patches/youBarButtons.tsx @@ -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); diff --git a/shared/lib/createElementIntercept.ts b/shared/lib/createElementIntercept.ts index 3a32dcf..7254ab5 100644 --- a/shared/lib/createElementIntercept.ts +++ b/shared/lib/createElementIntercept.ts @@ -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 | 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(); function isJsxRuntime(m: any): boolean {