This commit is contained in:
Rosie Val 2026-08-10 17:58:10 -04:00
commit 1263276938
25 changed files with 1195 additions and 537 deletions

2
.gitignore vendored
View file

@ -1,3 +1,5 @@
node_modules
dist
.wrangler
.superpowers/

View file

@ -0,0 +1,314 @@
# Typing Avatars Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build a new standalone Vendetta plugin, `TypingAvatars`, that replaces Discord's "X is typing..." indicator with a row of the avatars of whoever's currently typing.
**Architecture:** Reuses the proven-safe `TypingIndicatorInner` interception already verified in `role-color-everywhere/src/patches/typingWrapper.ts` (this repo, same session): catch the component via `registerTypeDetector`, wrap it so the original still renders exactly once (preserving its hooks), find the label node that used to hold the typing text, and replace its children with a new `AvatarStack` component built from `props.typingUserIds`.
**Tech Stack:** TypeScript, React (via `@vendetta/metro/common`), React Native (`View`/`Image`), this repo's shared `@shared/lib/createElementIntercept` and `@shared/lib/patcher` helpers, Rollup/SWC build (`node build.mjs`).
## Global Constraints
- No test runner exists in this repo (`package.json` has only a `build` script) — every plugin here is verified by `node build.mjs` (bundling/syntax correctness) plus live on-device verification via `revenge-devtools`, not unit tests. This plan follows that same convention.
- Every patch point must be wrapped in try/catch that leaves the original behavior intact on failure, matching every other plugin in this repo (see `role-color-everywhere`, `copy-role-color`, `server-drawer`).
- `channel.guild_id` (snake_case) is the confirmed field for a channel's guild id — verified in `typingWrapper.ts:46` and `rows.ts:39`. Do not use `channel.guildId`.
- `GuildMemberStore.getMember(guildId, userId)` and `UserStore` (via `findByStoreName`) are confirmed-working store lookups elsewhere in this repo — but the exact avatar-hash property name on the returned member/user record (`member.avatar` / `user.avatar`) has **not** been live-verified. Flag this clearly in code comments; confirm in Task 4.
- CDN avatar URLs are hand-built directly (`https://cdn.discordapp.com/...`), matching this repo's own established pattern in `server-drawer/src/components/GuildIcon.tsx:26` — not a guessed Discord internal "IconUtils" module lookup.
- `revenge-devtools` was disconnected while this plan was written. Task 4 (live verification) cannot run until it reconnects — do not claim the plugin "works" before that task is actually completed against a live device.
---
### Task 1: Scaffold the plugin
**Files:**
- Create: `plugins/typing-avatars/manifest.json`
- Create: `plugins/typing-avatars/src/index.ts`
**Interfaces:**
- Produces: a default-exported plugin object (`{ onLoad, onUnload }`) that later tasks wire their patch functions into.
- [ ] **Step 1: Create the manifest**
```json
{
"name": "TypingAvatars",
"description": "Replaces the \"X is typing...\" indicator with the avatars of whoever's typing.",
"authors": [
{ "name": "Rosie" }
],
"main": "src/index.ts",
"vendetta": {
"icon": "ic_group"
}
}
```
`ic_group` is a confirmed-existing bundled icon name — already used by another plugin in this repo (`grep -h '"icon"' plugins/*/manifest.json`).
- [ ] **Step 2: Create the plugin entry point with no patches wired yet**
```ts
import { logger } from "@vendetta";
import { applyPatches } from "@shared/lib/patcher";
let unpatchAll: () => void = () => {};
export default {
onLoad: () => {
unpatchAll = applyPatches("TypingAvatars", logger, {});
},
onUnload: () => unpatchAll()
};
```
- [ ] **Step 3: Build and verify**
Run: `cd /root/revenge-plugins && node build.mjs`
Expected: output includes `Successfully built TypingAvatars!` with no errors.
- [ ] **Step 4: Commit**
```bash
git add plugins/typing-avatars/manifest.json plugins/typing-avatars/src/index.ts
git commit -m "typing-avatars: scaffold new plugin"
```
---
### Task 2: Avatar URL resolution
**Files:**
- Create: `plugins/typing-avatars/src/lib/avatarUrl.ts`
**Interfaces:**
- Consumes: nothing from other tasks.
- Produces: `getTypingAvatarURL(guildId: string | undefined, userId: string, size?: number): string | null`, used by Task 3's `AvatarStack` component.
- [ ] **Step 1: Write the resolution helper**
```ts
import { findByStoreName } from "@vendetta/metro";
const UserStore = findByStoreName("UserStore");
const GuildMemberStore = findByStoreName("GuildMemberStore");
function avatarExtension(hash: string): string {
return hash.startsWith("a_") ? "gif" : "png";
}
// member.avatar / user.avatar are the property names Discord's client uses for a per-server /
// global avatar hash respectively - GuildMemberStore.getMember and UserStore.getUser are both
// confirmed-working lookups elsewhere in this repo (typingWrapper.ts, getTag.ts), but the avatar
// hash field itself hasn't been live-verified yet. If avatars come back broken, check these two
// property reads first via devtools eval against a real member/user record.
export function getTypingAvatarURL(guildId: string | undefined, userId: string, size = 32): string | null {
if (!userId) return null;
const member = guildId ? GuildMemberStore?.getMember?.(guildId, userId) : null;
const guildAvatarHash = member?.avatar;
if (guildId && guildAvatarHash) {
return `https://cdn.discordapp.com/guilds/${guildId}/users/${userId}/avatars/${guildAvatarHash}.${avatarExtension(guildAvatarHash)}?size=${size * 2}`;
}
const user = UserStore?.getUser?.(userId);
const globalAvatarHash = user?.avatar;
if (globalAvatarHash) {
return `https://cdn.discordapp.com/avatars/${userId}/${globalAvatarHash}.${avatarExtension(globalAvatarHash)}?size=${size * 2}`;
}
// Discord's default avatar for users on the modern username system (no discriminator):
// index = (snowflake >> 22) % 6. BigInt is required - the shift loses precision as a Number.
const defaultIndex = Number((BigInt(userId) >> 22n) % 6n);
return `https://cdn.discordapp.com/embed/avatars/${defaultIndex}.png`;
}
```
- [ ] **Step 2: Build and verify**
Run: `cd /root/revenge-plugins && node build.mjs`
Expected: `Successfully built TypingAvatars!`, no type/syntax errors.
- [ ] **Step 3: Commit**
```bash
git add plugins/typing-avatars/src/lib/avatarUrl.ts
git commit -m "typing-avatars: add avatar URL resolution helper"
```
---
### Task 3: AvatarStack component and the typing-indicator patch
**Files:**
- Create: `plugins/typing-avatars/src/components/AvatarStack.tsx`
- Create: `plugins/typing-avatars/src/patches/typingIndicator.tsx`
- Modify: `plugins/typing-avatars/src/index.ts`
**Interfaces:**
- Consumes: `getTypingAvatarURL` from Task 2 (`../lib/avatarUrl`).
- Produces: `AvatarStack` (default export, props `{ typingUserIds: string[]; guildId: string | undefined }`) and `patchTypingIndicator` (default export, `() => () => void`, same shape as every other patch function in this repo).
- [ ] **Step 1: Write the AvatarStack component**
```tsx
import React from "react";
import { View, Image, StyleSheet } from "react-native";
import { getTypingAvatarURL } from "../lib/avatarUrl";
const SIZE = 20;
const OVERLAP = 8;
// No text label and no tap handler by design - purely decorative avatars replacing the
// "X is typing..." text entirely. If a lot of people are typing, this wraps to a second
// overlapping row rather than clipping or capping with a "+N" badge.
export default function AvatarStack({ typingUserIds, guildId }: { typingUserIds: string[]; guildId: string | undefined }) {
if (!typingUserIds?.length) return null;
return (
<View style={st.row}>
{typingUserIds.map((userId, i) => {
const uri = getTypingAvatarURL(guildId, userId, SIZE);
if (!uri) return null;
return (
<Image
key={userId}
source={{ uri }}
style={[st.avatar, i > 0 ? st.overlap : null]}
/>
);
})}
</View>
);
}
const st = StyleSheet.create({
row: { flexDirection: "row", flexWrap: "wrap", alignItems: "center" },
avatar: { width: SIZE, height: SIZE, borderRadius: SIZE / 2 },
overlap: { marginLeft: -OVERLAP },
});
```
- [ ] **Step 2: Write the typing-indicator patch**
This mirrors `role-color-everywhere/src/patches/typingWrapper.ts` exactly for the detection/wrapping part (already proven live in this repo), but replaces the label's content with `AvatarStack` instead of recoloring the existing per-user text nodes. Unlike the color-matching version, this doesn't need to zip `typingUserIds` against per-user text elements at all - it only needs `props.typingUserIds` directly, so it isn't affected by the "several people are typing..." collapsed-string case that made the color patch bail out.
```tsx
import { React } from "@vendetta/metro/common";
import { findInReactTree } from "@vendetta/utils";
import { registerTypeDetector, registerIntercept, patchCreateElement } from "@shared/lib/createElementIntercept";
import AvatarStack from "../components/AvatarStack";
export default function patchTypingIndicator(): () => void {
const cleanups: (() => void)[] = [];
patchCreateElement(cleanups);
registerTypeDetector(
"typing-avatars-indicator",
(type) => typeof type === "function" ? type.name === "TypingIndicatorInner" : type?.type?.name === "TypingIndicatorInner",
(TypingIndicatorInner: any) => {
const inner = typeof TypingIndicatorInner === "function" ? TypingIndicatorInner : TypingIndicatorInner.type;
const PatchedTypingIndicatorInner = (props: any) => {
const ret = inner(props);
try {
const label = findInReactTree(
ret,
(n: any) =>
Array.isArray(n?.props?.children) &&
n.props.children.some((c: any) => typeof c === "string" && c.includes("typing..."))
);
if (!label?.props) return ret;
const typingUserIds: string[] = props?.typingUserIds ?? [];
if (!typingUserIds.length) return ret;
label.props.children = (
<AvatarStack typingUserIds={typingUserIds} guildId={props?.channel?.guild_id} />
);
} catch {
// Leave the default "X is typing..." text alone.
}
return ret;
};
registerIntercept(TypingIndicatorInner, PatchedTypingIndicatorInner);
}
);
return () => cleanups.forEach((fn) => fn());
}
```
- [ ] **Step 3: Wire the patch into the plugin entry point**
Replace the contents of `plugins/typing-avatars/src/index.ts` with:
```ts
import { logger } from "@vendetta";
import { applyPatches } from "@shared/lib/patcher";
import patchTypingIndicator from "./patches/typingIndicator";
let unpatchAll: () => void = () => {};
export default {
onLoad: () => {
unpatchAll = applyPatches("TypingAvatars", logger, {
"typing indicator avatars": patchTypingIndicator
});
},
onUnload: () => unpatchAll()
};
```
- [ ] **Step 4: Build and verify**
Run: `cd /root/revenge-plugins && node build.mjs`
Expected: `Successfully built TypingAvatars!`, no type/syntax errors.
- [ ] **Step 5: Commit**
```bash
git add plugins/typing-avatars/src/components/AvatarStack.tsx plugins/typing-avatars/src/patches/typingIndicator.tsx plugins/typing-avatars/src/index.ts
git commit -m "typing-avatars: add AvatarStack component and typing-indicator patch"
```
---
### Task 4: Deploy and live-verify
**Files:** none (verification only, using `revenge-devtools`)
**Interfaces:** none — this task confirms Tasks 1-3's assumptions against a real device and fixes anything wrong in place.
- [ ] **Step 1: Push to deploy**
```bash
cd /root/revenge-plugins && git push
```
Confirms the auto-deploy pipeline (GitHub Actions → `rp.jarviscli.dev`) picks up the new plugin. Wait for the user to enable it on their device.
- [ ] **Step 2: Confirm `TypingIndicatorInner` interception fires**
Via `revenge-devtools` `eval`, install a one-off probe patching `TypingIndicatorInner`'s call count (same technique used earlier this session for `useIsMobileQuestDockRendered`), then have the user start typing in a channel with someone else. Confirm the patch's `console.log`/counter actually fires - if it doesn't, the type-name check (`type.name === "TypingIndicatorInner"`) needs re-verification against the current Discord build, the same way this session discovered `QuestDockContentExpanded` had silently drifted.
- [ ] **Step 3: Confirm the avatar hash field names**
Via `eval`, call `GuildMemberStore.getMember(guildId, userId)` and `UserStore.getUser(userId)` for a real user who is currently typing, and inspect the returned object's keys directly (`Object.keys(member)`) rather than assuming `.avatar` is correct. Fix `avatarUrl.ts` if the real field name differs, per the Global Constraints note above.
- [ ] **Step 4: Confirm the rendered layout on-device**
Have the user get 1, 2, and 3+ people typing simultaneously in a test channel (or DM group) and screenshot each state. Confirm: avatars render (not broken images), the overlap direction looks right, and 3+ typers wrap to a second row rather than clipping off-screen.
- [ ] **Step 5: Fix and re-deploy anything found wrong in Steps 2-4, then commit**
```bash
git add -A
git commit -m "typing-avatars: fix issues found in live verification"
git push
```
Only after this task's steps have actually been run against a live device should the plugin be described as working - not before.

View file

@ -0,0 +1,71 @@
# Typing Avatars — Design
## Origin
Inspired by the (now-defunct) community plugin at https://fierdetta.github.io/typing-avatars/,
which replaced Discord's typing indicator with the avatars of whoever's typing. Reviving the
concept as a new plugin in this repo, built on top of the `TypingIndicatorInner` interception
work already done and verified in `role-color-everywhere/src/patches/typingWrapper.ts`.
## Scope
New standalone plugin, `typing-avatars` — not folded into RoleColorEverywhere, so it can be
enabled independently.
## Mechanism
Reuses the proven-safe pattern from `typingWrapper.ts`:
1. `registerTypeDetector` (from `@shared/lib/createElementIntercept`) catches
`TypingIndicatorInner` by name.
2. The captured inner render function is wrapped so it's still called exactly once per render,
from within the wrapper - this is what preserves its internal hooks and avoids the
"Rendered more/fewer hooks than during the previous render" crash class encountered earlier
this session when a component was called out-of-band as a side effect.
3. `findInReactTree` locates the label node that previously held the "X is typing..." text and
per-user colored `<Text>` children (typingWrapper.ts already established this lookup works).
4. Instead of recoloring that text (RoleColorEverywhere's approach), this plugin replaces the
label node's rendered content entirely with a new `AvatarStack` component, built from
`props.typingUserIds`.
## AvatarStack component
- **Input:** `typingUserIds: string[]`, `guildId: string`.
- **Avatar source:** per-server avatar first (via `GuildMemberStore`, matching what's shown
elsewhere in that server's member list/messages), falling back to the user's global Discord
avatar (via `UserStore`) if they have no server-specific avatar set.
- **URL construction:** via Discord's own avatar-URL utility module (handles animated/gif
avatars and the default-avatar fallback correctly) rather than hand-rolled CDN URL strings.
The exact module/function name is **not yet verified live** - `revenge-devtools` was
disconnected during this design session. This must be confirmed via live devtools before
shipping, not guessed.
- **Layout:** overlapping circular avatars (each avatar slightly overlapping the previous, like
Discord's own "who's here" clusters elsewhere in the app).
- **No text label** - avatars only, matching the original plugin's description most literally.
- **No tap interaction** - purely decorative for v1. A per-avatar "tap to view profile" handler
is a plausible future addition, out of scope here.
- **Overflow:** no cap on typer count - if the row would overflow the available width, it wraps
to a second overlapping row rather than clipping or truncating with a "+N" badge.
## Error handling
Matches this repo's established convention:
- Every patch point wrapped in try/catch; a thrown error leaves the indicator without the
avatar-row patch rather than crashing the surrounding render.
- If avatar resolution fails for a specific user (missing member/user store data), that one
avatar falls back to Discord's default avatar image rather than breaking the whole row.
- If `TypingIndicatorInner` can't be found at all (Discord build changed, module not yet
registered), the plugin logs a warning via the shared `applyPatches` logger and no-ops,
consistent with every other plugin in this repo.
## Testing plan
- Local build/bundle check via `node build.mjs` (repo's existing build script) before every
deploy, same as done for the CopyRoleColor gradient fix.
- Live verification via `revenge-devtools` (eval + react tree tools) once reconnected:
confirm the avatar-URL module API, confirm `TypingIndicatorInner`'s label-replacement still
renders correctly with real typing users, confirm per-server avatar fallback behaves as
expected for a user with no server-specific avatar.
- No claim of "working" until live-verified on a real device, per this session's established
standard (no best-effort claims).

View file

@ -1,11 +1,51 @@
import { logger } from "@vendetta";
import { findByName } from "@vendetta/metro";
import { findByName, findByStoreName } from "@vendetta/metro";
import { after } from "@vendetta/patcher";
import { findInReactTree } from "@vendetta/utils";
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";
// A gradient role's real colors live at role.colorStrings ({primaryColor, secondaryColor,
// tertiaryColor}), not the flattened colorString - copying that exact JSON back into Discord's own
// role color picker recreates the gradient. A solid-color role only has primaryColor set, so this
// falls back to the plain hex for those, keeping the existing single-color behavior unchanged.
function formatReadable(colorStrings: any): string {
const lines = [`Primary: ${colorStrings.primaryColor}`, `Secondary: ${colorStrings.secondaryColor}`];
if (colorStrings.tertiaryColor) lines.push(`Tertiary: ${colorStrings.tertiaryColor}`);
return lines.join("\n");
}
// Clipboard only holds one value, so a gradient can't be copied both ways at once - instead, a
// second long-press on the same role within this window swaps the JSON for the readable text.
const GRADIENT_TOGGLE_WINDOW_MS = 3000;
let lastGradientCopy: { key: string; time: number } | null = null;
function copyRoleColor(colorStrings: any, fallbackColor?: string | null) {
const isGradient = !!(colorStrings?.primaryColor && colorStrings?.secondaryColor);
if (!isGradient) {
const value = colorStrings?.primaryColor ?? fallbackColor;
if (!value) return;
clipboard.setString(value);
showToast("Copied role color to clipboard", getAssetIDByName("ic_message_copy"));
lastGradientCopy = null;
return;
}
const key = JSON.stringify(colorStrings);
const now = Date.now();
if (lastGradientCopy?.key === key && now - lastGradientCopy.time < GRADIENT_TOGGLE_WINDOW_MS) {
clipboard.setString(formatReadable(colorStrings));
showToast("Copied readable colors to clipboard", getAssetIDByName("ic_message_copy"));
lastGradientCopy = null;
} else {
clipboard.setString(key);
showToast("Copied gradient JSON - press again for readable text", getAssetIDByName("ic_message_copy"));
lastGradientCopy = { key, time: now };
}
}
function patchRolePill(): () => void {
// Confirmed against decompiled current-build Discord source: the component is now just
@ -14,6 +54,8 @@ function patchRolePill(): () => void {
const RolePillComponent = findByName("RolePill", false) ?? findByName("ThemedRolePill", false);
if (!RolePillComponent) return () => {};
const GuildRoleStore = findByStoreName("GuildRoleStore");
return after("default", RolePillComponent, (_args: any[], res: any) => {
try {
if (!res?.props?.onPress) return;
@ -26,21 +68,66 @@ function patchRolePill(): () => void {
const color = roleIcon?.props?.style?.[1]?.backgroundColor ?? verifiedIcon?.props?.roleColor;
if (!color) return;
res.props.onLongPress = () => {
clipboard.setString(color);
showToast("Copied role color to clipboard", getAssetIDByName("ic_message_copy"));
};
// The pill only gives us a flattened color from its rendered style - fetching the real
// role record (if guildId/roleId are present on it) is what actually exposes the gradient.
const guildId = res.props?.guildId;
const roleId = res.props?.roleId ?? res.props?.role?.id;
const role = guildId && roleId ? GuildRoleStore?.getRole?.(guildId, roleId) : null;
res.props.onLongPress = () => copyRoleColor(role?.colorStrings, color);
} catch {
// Leave the role pill without a long-press handler.
}
});
}
// 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
// both colorStrings and colorString on it already, 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 colorStrings = props?.role?.colorStrings;
const fallback = props?.role?.colorString;
if ((colorStrings?.primaryColor || fallback) && ret && typeof ret === "object") {
ret.props = {
...ret.props,
onLongPress: () => copyRoleColor(colorStrings, fallback)
};
}
} 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

@ -1,74 +1,48 @@
import { find, findByProps } from "@vendetta/metro";
import { after } from "@vendetta/patcher";
import { findInReactTree } from "@vendetta/utils";
import { registerTypeDetector, registerIntercept, patchCreateElement } from "@shared/lib/createElementIntercept";
import resolveTag from "../lib/resolveTag";
import CustomTag from "../ui/Tag";
// Same top-level profile component PronounDB already confirmed works, and the same one Staff Tags
// now patches for its own profile-screen tag - see that plugin's patches/profile.tsx for the full
// writeup on why this is a separate component from UserRow (which details.tsx already handles).
const UserProfile =
find((m: any) => m?.type?.name === "UserProfileContent") ??
find((m: any) => m?.type?.name === "UserProfile");
const TagModule = findByProps("getBotLabel");
/**
* Not independently confirmed against a live render yet - the exact prop shape passed to this
* component isn't verified on-device, so this pulls from whichever of several plausible field names
* is actually present. Every step is guarded, so a wrong assumption just means no tag appears here.
*/
// 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 {
if (!UserProfile) return () => {};
const cleanups: (() => void)[] = [];
patchCreateElement(cleanups);
return after("type", UserProfile, (args: any[], res: any) => {
try {
const props = args?.[0] ?? {};
const userId: string | undefined = props.userId ?? props.user?.id ?? props.displayProfile?.userId;
if (!userId) return;
registerTypeDetector(
"custom-user-tags-profile-primary-info",
(type) => typeof type === "function" && type.name === "UserProfilePrimaryInfo",
(UserProfilePrimaryInfo: any) => {
const PatchedUserProfilePrimaryInfo = (props: any) => {
const ret = UserProfilePrimaryInfo(props);
const tag = resolveTag(userId);
if (!tag) return;
try {
const userId: string | undefined = props?.user?.id;
if (!userId) return ret;
const primaryInfo = findInReactTree(res, (c) => c?.type?.name === "PrimaryInfo" || c?.type?.displayName === "PrimaryInfo");
if (!primaryInfo) return;
const tag = resolveTag(userId);
if (!tag) return ret;
const existingTag = findInReactTree(primaryInfo, (c) => c?.type?.Types);
if (existingTag && existingTag.props?.type !== 0) return;
const row = findInReactTree(
ret,
(c: any) =>
Array.isArray(c?.props?.children) &&
c.props.children.some((ch: any) => ch?.type?.name === "UserTagAndPronouns")
);
if (!Array.isArray(row?.props?.children)) return ret;
if (existingTag) {
Object.assign(existingTag.props, {
type: 0,
text: tag.text,
textColor: tag.textColor,
backgroundColor: tag.backgroundColor,
icon: tag.icon,
iconColor: tag.iconColor,
verified: false
});
return;
}
row.props.children.push(<CustomTag tag={tag} />);
} catch {
// Skip - a broken profile tag beats a crashed profile screen.
}
const nameRow = findInReactTree(
primaryInfo,
(c) =>
Array.isArray(c?.props?.children) &&
c.props.children.some((ch: any) => typeof ch === "string" || typeof ch?.props?.children === "string")
);
if (!Array.isArray(nameRow?.props?.children) || !TagModule) return;
return ret;
};
nameRow.props.children.push(
<TagModule.default
type={0}
text={tag.text}
textColor={tag.textColor}
backgroundColor={tag.backgroundColor}
icon={tag.icon}
iconColor={tag.iconColor}
verified={false}
/>
);
} catch {
// Skip - a broken profile tag beats a crashed profile screen.
registerIntercept(UserProfilePrimaryInfo, PatchedUserProfilePrimaryInfo);
}
});
);
return () => cleanups.forEach((fn) => fn());
}

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

@ -1,86 +1,65 @@
import { find, findByProps, findByStoreName } from "@vendetta/metro";
import { after } from "@vendetta/patcher";
import { findByStoreName } from "@vendetta/metro";
import { findInReactTree } from "@vendetta/utils";
import { registerTypeDetector, registerIntercept, patchCreateElement } from "@shared/lib/createElementIntercept";
import getTag from "../lib/getTag";
import GradientTag from "../ui/GradientTag";
// Same top-level profile component PronounDB already confirmed works on current Discord (the
// findByName-based lookup this repo used to use for it stopped matching after a rename, but this
// predicate-based find still succeeds). The full profile screen doesn't render through UserRow the
// way member list rows and the old "profile popout" did - this is a separate component entirely,
// which is why tags weren't showing up here even though details.tsx's own patch is working fine.
const UserProfile =
find((m: any) => m?.type?.name === "UserProfileContent") ??
find((m: any) => m?.type?.name === "UserProfile");
const TagModule = findByProps("getBotLabel");
const GuildStore = findByStoreName("GuildStore");
const UserStore = findByStoreName("UserStore");
/**
* Not independently confirmed against a live render the way most of this repo's fixes are - the
* exact prop shape passed to this component's render (userId vs. a full user object, whether
* guildId is even present) isn't verified on-device yet, so this pulls from whichever of several
* plausible field names is actually present rather than assuming one. Every step is guarded, so a
* wrong assumption just means no tag appears here, not a crash.
*/
// 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 {
if (!UserProfile) return () => {};
const cleanups: (() => void)[] = [];
patchCreateElement(cleanups);
return after("type", UserProfile, (args: any[], res: any) => {
try {
const props = args?.[0] ?? {};
const userId: string | undefined = props.userId ?? props.user?.id ?? props.displayProfile?.userId;
if (!userId) return;
registerTypeDetector(
"staff-tags-profile-primary-info",
(type) => typeof type === "function" && type.name === "UserProfilePrimaryInfo",
(UserProfilePrimaryInfo: any) => {
const PatchedUserProfilePrimaryInfo = (props: any) => {
const ret = UserProfilePrimaryInfo(props);
const user = props.user ?? UserStore?.getUser?.(userId);
if (!user) return;
try {
const { user, guildId } = props ?? {};
if (!user) return ret;
const guildId: string | undefined = props.guildId;
const guild = guildId ? GuildStore?.getGuild(guildId) : undefined;
const guild = guildId ? GuildStore?.getGuild(guildId) : undefined;
const tag = getTag(guild, undefined, user);
if (!tag) return ret;
const tag = getTag(guild, undefined, user);
if (!tag) return;
const row = findInReactTree(
ret,
(c: any) =>
Array.isArray(c?.props?.children) &&
c.props.children.some((ch: any) => ch?.type?.name === "UserTagAndPronouns")
);
if (!Array.isArray(row?.props?.children)) return ret;
const primaryInfo = findInReactTree(res, (c) => c?.type?.name === "PrimaryInfo" || c?.type?.displayName === "PrimaryInfo");
if (!primaryInfo) return;
row.props.children.push(
<GradientTag
text={tag.text}
textColor={tag.textColor}
backgroundColor={tag.backgroundColor}
gradientColor={tag.gradientColor}
icon={tag.icon}
iconColor={tag.iconColor}
/>
);
} catch {
// Skip - a broken profile tag beats a crashed profile screen.
}
const existingTag = findInReactTree(primaryInfo, (c) => c?.type?.Types);
if (existingTag && existingTag.props?.type !== 0) return;
return ret;
};
if (existingTag) {
Object.assign(existingTag.props, {
type: 0,
text: tag.text,
textColor: tag.textColor,
backgroundColor: tag.backgroundColor,
icon: tag.icon,
iconColor: tag.iconColor,
verified: false
});
return;
}
const nameRow = findInReactTree(
primaryInfo,
(c) =>
Array.isArray(c?.props?.children) &&
c.props.children.some((ch: any) => typeof ch === "string" || typeof ch?.props?.children === "string")
);
if (!Array.isArray(nameRow?.props?.children) || !TagModule) return;
nameRow.props.children.push(
<TagModule.default
type={0}
text={tag.text}
textColor={tag.textColor}
backgroundColor={tag.backgroundColor}
icon={tag.icon}
iconColor={tag.iconColor}
verified={false}
/>
);
} catch {
// Skip - a broken profile tag beats a crashed profile screen.
registerIntercept(UserProfilePrimaryInfo, PatchedUserProfilePrimaryInfo);
}
});
);
return () => cleanups.forEach((fn) => fn());
}

View file

@ -0,0 +1,11 @@
{
"name": "TypingAvatars",
"description": "Replaces the \"X is typing...\" indicator with the avatars of whoever's typing.",
"authors": [
{ "name": "Rosie" }
],
"main": "src/index.ts",
"vendetta": {
"icon": "ic_group"
}
}

View file

@ -0,0 +1,35 @@
import React from "react";
import { View, Image, StyleSheet } from "react-native";
import { getTypingAvatarURL } from "../lib/avatarUrl";
const SIZE = 20;
const OVERLAP = 8;
// No text label and no tap handler by design - purely decorative avatars replacing the
// "X is typing..." text entirely. If a lot of people are typing, this wraps to a second
// overlapping row rather than clipping or capping with a "+N" badge.
export default function AvatarStack({ typingUserIds, guildId }: { typingUserIds: string[]; guildId: string | undefined }) {
if (!typingUserIds?.length) return null;
return (
<View style={st.row}>
{typingUserIds.map((userId, i) => {
const uri = getTypingAvatarURL(guildId, userId, SIZE);
if (!uri) return null;
return (
<Image
key={userId}
source={{ uri }}
style={[st.avatar, i > 0 ? st.overlap : null]}
/>
);
})}
</View>
);
}
const st = StyleSheet.create({
row: { flexDirection: "row", flexWrap: "wrap", alignItems: "center" },
avatar: { width: SIZE, height: SIZE, borderRadius: SIZE / 2 },
overlap: { marginLeft: -OVERLAP },
});

View file

@ -0,0 +1,14 @@
import { logger } from "@vendetta";
import { applyPatches } from "@shared/lib/patcher";
import patchTypingIndicator from "./patches/typingIndicator";
let unpatchAll: () => void = () => {};
export default {
onLoad: () => {
unpatchAll = applyPatches("TypingAvatars", logger, {
"typing indicator avatars": patchTypingIndicator
});
},
onUnload: () => unpatchAll()
};

View file

@ -0,0 +1,34 @@
import { findByStoreName } from "@vendetta/metro";
const UserStore = findByStoreName("UserStore");
const GuildMemberStore = findByStoreName("GuildMemberStore");
function avatarExtension(hash: string): string {
return hash.startsWith("a_") ? "gif" : "png";
}
// member.avatar / user.avatar are the property names Discord's client uses for a per-server /
// global avatar hash respectively - GuildMemberStore.getMember and UserStore.getUser are both
// confirmed-working lookups elsewhere in this repo (typingWrapper.ts, getTag.ts), but the avatar
// hash field itself hasn't been live-verified yet. If avatars come back broken, check these two
// property reads first via devtools eval against a real member/user record.
export function getTypingAvatarURL(guildId: string | undefined, userId: string, size = 32): string | null {
if (!userId) return null;
const member = guildId ? GuildMemberStore?.getMember?.(guildId, userId) : null;
const guildAvatarHash = member?.avatar;
if (guildId && guildAvatarHash) {
return `https://cdn.discordapp.com/guilds/${guildId}/users/${userId}/avatars/${guildAvatarHash}.${avatarExtension(guildAvatarHash)}?size=${size * 2}`;
}
const user = UserStore?.getUser?.(userId);
const globalAvatarHash = user?.avatar;
if (globalAvatarHash) {
return `https://cdn.discordapp.com/avatars/${userId}/${globalAvatarHash}.${avatarExtension(globalAvatarHash)}?size=${size * 2}`;
}
// Discord's default avatar for users on the modern username system (no discriminator):
// index = (snowflake >> 22) % 6. BigInt is required - the shift loses precision as a Number.
const defaultIndex = Number((BigInt(userId) >> 22n) % 6n);
return `https://cdn.discordapp.com/embed/avatars/${defaultIndex}.png`;
}

View file

@ -0,0 +1,51 @@
import { React } from "@vendetta/metro/common";
import { findInReactTree } from "@vendetta/utils";
import { registerTypeDetector, registerIntercept, patchCreateElement } from "@shared/lib/createElementIntercept";
import AvatarStack from "../components/AvatarStack";
// Mirrors role-color-everywhere/src/patches/typingWrapper.ts for the detection/wrapping part
// (already proven live in this repo), but replaces the label's content with AvatarStack instead
// of recoloring the existing per-user text nodes. This only needs props.typingUserIds directly,
// so unlike the color-matching version it isn't affected by the "several people are typing..."
// collapsed-string case.
export default function patchTypingIndicator(): () => void {
const cleanups: (() => void)[] = [];
patchCreateElement(cleanups);
registerTypeDetector(
"typing-avatars-indicator",
(type) => typeof type === "function" ? type.name === "TypingIndicatorInner" : type?.type?.name === "TypingIndicatorInner",
(TypingIndicatorInner: any) => {
const inner = typeof TypingIndicatorInner === "function" ? TypingIndicatorInner : TypingIndicatorInner.type;
const PatchedTypingIndicatorInner = (props: any) => {
const ret = inner(props);
try {
const label = findInReactTree(
ret,
(n: any) =>
Array.isArray(n?.props?.children) &&
n.props.children.some((c: any) => typeof c === "string" && c.includes("typing..."))
);
if (!label?.props) return ret;
const typingUserIds: string[] = props?.typingUserIds ?? [];
if (!typingUserIds.length) return ret;
label.props.children = (
<AvatarStack typingUserIds={typingUserIds} guildId={props?.channel?.guild_id} />
);
} catch {
// Leave the default "X is typing..." text alone.
}
return ret;
};
registerIntercept(TypingIndicatorInner, PatchedTypingIndicatorInner);
}
);
return () => cleanups.forEach((fn) => fn());
}

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

@ -1,6 +1,5 @@
import { React } from "@vendetta/metro/common";
import { rawFind } from "@shared/lib/rawFind";
import { waitFor } from "@shared/lib/waitFor";
import { after } from "@vendetta/patcher";
interface Intercept {
replacement: React.ComponentType<any>;
@ -12,14 +11,24 @@ interface PropsIntercept {
replacement: React.ComponentType<any> | null;
}
interface PropsTransform {
predicate: (props: any) => boolean;
transform: (props: any) => any;
}
interface TypeDetector {
key: string;
predicate: (type: any) => boolean;
onDetected: (type: any) => void;
persistent: boolean;
justFired?: boolean;
}
const intercepts = new Map<React.ComponentType<any>, Intercept>();
const propsIntercepts: PropsIntercept[] = [];
const propsTransforms: PropsTransform[] = [];
let typeDetectors: TypeDetector[] = [];
const detectorKeys = new Set<string>();
let isPatched = false;
export function registerIntercept(
@ -30,38 +39,49 @@ 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 });
}
/**
* Purely observational, one-shot: the first time an element is created whose `type` matches
* `predicate`, calls `onDetected(type)` with the real, live type reference and then stops
* watching - it never alters what actually renders.
*
* 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.
*/
export function registerTypeDetector(predicate: (type: any) => boolean, onDetected: (type: any) => void) {
typeDetectors.push({ predicate, onDetected });
// 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 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,
onDetected: (type: any) => void,
options?: { persistent?: boolean },
) {
if (detectorKeys.has(key)) return;
detectorKeys.add(key);
typeDetectors.push({ key, predicate, onDetected, persistent: options?.persistent ?? false });
}
export function hasTypeDetector(key: string): boolean {
return detectorKeys.has(key);
}
function runTypeDetectors(type: any) {
if (typeDetectors.length === 0) return;
const remaining: TypeDetector[] = [];
let consumed = false;
for (const detector of typeDetectors) {
let matched = false;
try {
@ -69,28 +89,49 @@ function runTypeDetectors(type: any) {
} catch {
// A bad predicate shouldn't block every other detector or every element from rendering.
}
if (matched) {
try {
detector.onDetected(type);
} catch {
// Same - a detector's own handler throwing shouldn't take anything else down.
}
} else {
remaining.push(detector);
if (!matched) continue;
try {
detector.onDetected(type);
} catch {
// Same - a detector's own handler throwing shouldn't take anything else down.
}
if (!detector.persistent) {
detector.justFired = true;
consumed = true;
}
}
typeDetectors = remaining;
// Only rebuild the list when a one-shot detector actually fired - rebuilding on every single
// element creation app-wide would be an allocation per element on the hottest path in the app
// whenever any persistent detector is registered (the list is then never empty).
if (consumed) {
typeDetectors = typeDetectors.filter((d) => d.persistent || !d.justFired);
}
}
/** Returns a replacement type if this element should be intercepted, `null` to render nothing, or `undefined` to pass through unchanged. */
function resolveReplacement(type: any, props: any): { type: any; props: any } | null | undefined {
function resolveReplacement(type: any, props: any, rest: any[]): { type: any; props: any } | null | undefined {
runTypeDetectors(type);
if (props) {
let effectiveProps = props;
if (effectiveProps) {
for (const { predicate, transform } of propsTransforms) {
try {
if (predicate(effectiveProps, type, rest)) {
effectiveProps = transform(effectiveProps);
}
} catch {
// A bad transform shouldn't block every other element from rendering.
}
}
}
if (effectiveProps) {
for (const { predicate, replacement } of propsIntercepts) {
try {
if (predicate(props)) {
return replacement ? { type: replacement, props } : null;
if (predicate(effectiveProps, type, rest)) {
return replacement ? { type: replacement, props: effectiveProps } : null;
}
} catch {
// A bad predicate shouldn't block every other element from rendering.
@ -100,75 +141,123 @@ function resolveReplacement(type: any, props: any): { type: any; props: any } |
const entry = intercepts.get(type);
if (entry) {
return { type: entry.replacement, props: entry.extraProps ? { ...props, ...entry.extraProps } : props };
return { type: entry.replacement, props: entry.extraProps ? { ...effectiveProps, ...entry.extraProps } : effectiveProps };
}
if (effectiveProps !== props) {
return { type, props: effectiveProps };
}
return undefined;
}
// 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);
if (resolved === null) {
res.type = () => null;
return res;
}
if (resolved) {
res.type = resolved.type;
res.props = resolved.props ?? null;
}
return res;
}
export function patchCreateElement(cleanups: (() => void)[]) {
if (isPatched) return;
const origCreateElement = React.createElement;
if (!origCreateElement) {
console.warn("[createElementIntercept] React.createElement is null, skipping patch");
return;
}
const patchedCreateElement = function (type: any, props: any, ...rest: any[]) {
const resolved = resolveReplacement(type, props);
if (resolved === null) return null;
if (resolved) return origCreateElement.call(React, resolved.type, resolved.props, ...rest);
return origCreateElement.call(React, type, props, ...rest);
};
Object.assign(patchedCreateElement, origCreateElement);
React.createElement = patchedCreateElement as typeof React.createElement;
isPatched = true;
// Modern React/RN builds (Discord's included) compile JSX through the automatic runtime
// (jsx/jsxs from react/jsx-runtime) instead of React.createElement - patching only
// createElement misses every one of Discord's own render calls entirely. Found by shape
// (jsx/jsxs/Fragment together), not by name, for the same reason everything else in this file
// avoids name-based lookups.
//
// This used to be a single one-shot rawFind() call - if the jsx-runtime module hadn't been
// required yet at the exact moment this ran (very early in boot, when a plugin's onLoad first
// calls this), jsx/jsxs would never get patched for the rest of the session at all, silently
// (only a console.warn, invisible in normal use). Since Discord renders almost everything
// through jsx/jsxs and not createElement, that made every registerTypeDetector/
// registerPropsIntercept consumer's patch a near-total no-op whenever that race was lost -
// plausibly the real reason a component-creation-interception approach could still miss a
// component that only ever renders once, early in boot. Retrying via waitFor (with the same
// passive rawFind, so it still never force-requires anything) closes that gap.
const restoreJsx: (() => void)[] = [];
const jsxHandle = waitFor(
() => rawFind((m: any) => typeof m?.jsx === "function" && typeof m?.jsxs === "function" && "Fragment" in m),
(jsxRuntime: any) => {
for (const key of ["jsx", "jsxs"] as const) {
const orig = jsxRuntime[key];
if (typeof orig !== "function") continue;
if (React.createElement) {
cleanups.push(
after("createElement", React, (args: any[], res: any) => applyResolved(res, args[0], args[1], args.slice(2)))
);
} else {
console.warn("[createElementIntercept] React.createElement is null, skipping patch");
}
jsxRuntime[key] = function (type: any, props: any, ...rest: any[]) {
const resolved = resolveReplacement(type, props);
if (resolved === null) return null;
if (resolved) return orig.call(this, resolved.type, resolved.props, ...rest);
return orig.call(this, type, props, ...rest);
};
restoreJsx.push(() => { jsxRuntime[key] = orig; });
}
// 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 {
return typeof m?.jsx === "function" || typeof m?.jsxs === "function" || typeof m?.jsxDEV === "function";
}
function patchJsxObject(runtime: any) {
if (patchedJsxRuntimes.has(runtime)) return;
patchedJsxRuntimes.add(runtime);
for (const key of ["jsx", "jsxs", "jsxDEV"] as const) {
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 patchJsxModule(def: any) {
if (!def?.publicModule?.exports) return;
const exports = def.publicModule.exports;
try {
if (isJsxRuntime(exports)) patchJsxObject(exports);
const dflt = exports.default;
if (dflt != null && isJsxRuntime(dflt)) patchJsxObject(dflt);
} catch {
// Ignore one module's bad shape and continue scanning.
}
}
function scanAndPatchJsxRuntimes() {
const modules = (window as any)?.modules;
if (!modules) return;
for (const id in modules) {
const def = modules[id];
if (!def?.isInitialized) continue;
patchJsxModule(def);
}
}
// Patch any jsx runtimes already loaded before Discord's first render pass starts, synchronously
// - an async scan would complete too late for the very first render of an early-mounting target.
scanAndPatchJsxRuntimes();
// Fast while the app is still booting and chunks are being pulled in, then slow, then stop - a
// full window.modules walk is not free and there's nothing left to catch once the UI has settled.
let ticks = 0;
let timer: ReturnType<typeof setInterval> | undefined = setInterval(() => {
scanAndPatchJsxRuntimes();
if (++ticks === 50 && timer) { // ~5s at 100ms
clearInterval(timer);
timer = setInterval(() => {
scanAndPatchJsxRuntimes();
if (++ticks >= 75 && timer) { // + ~25s at 1s
clearInterval(timer);
timer = undefined;
}
}, 1000);
}
}, 100);
cleanups.push(() => {
jsxHandle.cancel();
if (isPatched) {
React.createElement = origCreateElement;
isPatched = false;
}
restoreJsx.forEach((fn) => fn());
if (timer) clearInterval(timer);
timer = undefined;
isPatched = false;
intercepts.clear();
propsIntercepts.length = 0;
propsTransforms.length = 0;
typeDetectors = [];
detectorKeys.clear();
});
}