diff --git a/.gitignore b/.gitignore
index dd3fafc..a87dac8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,5 @@
node_modules
dist
.wrangler
+
+.superpowers/
diff --git a/docs/superpowers/plans/2026-08-10-typing-avatars.md b/docs/superpowers/plans/2026-08-10-typing-avatars.md
new file mode 100644
index 0000000..264a4fe
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-10-typing-avatars.md
@@ -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 (
+
+ {typingUserIds.map((userId, i) => {
+ const uri = getTypingAvatarURL(guildId, userId, SIZE);
+ if (!uri) return null;
+ return (
+ 0 ? st.overlap : null]}
+ />
+ );
+ })}
+
+ );
+}
+
+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 = (
+
+ );
+ } 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.
diff --git a/docs/superpowers/specs/2026-08-10-typing-avatars-design.md b/docs/superpowers/specs/2026-08-10-typing-avatars-design.md
new file mode 100644
index 0000000..0c40344
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-10-typing-avatars-design.md
@@ -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 `` 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).
diff --git a/plugins/copy-role-color/src/index.ts b/plugins/copy-role-color/src/index.ts
index ec3a6c2..cb30ceb 100644
--- a/plugins/copy-role-color/src/index.ts
+++ b/plugins/copy-role-color/src/index.ts
@@ -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()
};
diff --git a/plugins/custom-user-tags/src/patches/profile.tsx b/plugins/custom-user-tags/src/patches/profile.tsx
index 66fdfda..9b85ece 100644
--- a/plugins/custom-user-tags/src/patches/profile.tsx
+++ b/plugins/custom-user-tags/src/patches/profile.tsx
@@ -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();
+ } 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(
-
- );
- } catch {
- // Skip - a broken profile tag beats a crashed profile screen.
+ registerIntercept(UserProfilePrimaryInfo, PatchedUserProfilePrimaryInfo);
}
- });
+ );
+
+ return () => cleanups.forEach((fn) => fn());
}
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 ed1915c..71ef966 100644
--- a/plugins/staff-tags/src/patches/profile.tsx
+++ b/plugins/staff-tags/src/patches/profile.tsx
@@ -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(
+
+ );
+ } 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(
-
- );
- } catch {
- // Skip - a broken profile tag beats a crashed profile screen.
+ registerIntercept(UserProfilePrimaryInfo, PatchedUserProfilePrimaryInfo);
}
- });
+ );
+
+ return () => cleanups.forEach((fn) => fn());
}
diff --git a/plugins/typing-avatars/manifest.json b/plugins/typing-avatars/manifest.json
new file mode 100644
index 0000000..cfc24c3
--- /dev/null
+++ b/plugins/typing-avatars/manifest.json
@@ -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"
+ }
+}
diff --git a/plugins/typing-avatars/src/components/AvatarStack.tsx b/plugins/typing-avatars/src/components/AvatarStack.tsx
new file mode 100644
index 0000000..443fe82
--- /dev/null
+++ b/plugins/typing-avatars/src/components/AvatarStack.tsx
@@ -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 (
+
+ {typingUserIds.map((userId, i) => {
+ const uri = getTypingAvatarURL(guildId, userId, SIZE);
+ if (!uri) return null;
+ return (
+ 0 ? st.overlap : null]}
+ />
+ );
+ })}
+
+ );
+}
+
+const st = StyleSheet.create({
+ row: { flexDirection: "row", flexWrap: "wrap", alignItems: "center" },
+ avatar: { width: SIZE, height: SIZE, borderRadius: SIZE / 2 },
+ overlap: { marginLeft: -OVERLAP },
+});
diff --git a/plugins/typing-avatars/src/index.ts b/plugins/typing-avatars/src/index.ts
new file mode 100644
index 0000000..67ed0e0
--- /dev/null
+++ b/plugins/typing-avatars/src/index.ts
@@ -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()
+};
diff --git a/plugins/typing-avatars/src/lib/avatarUrl.ts b/plugins/typing-avatars/src/lib/avatarUrl.ts
new file mode 100644
index 0000000..a043fe7
--- /dev/null
+++ b/plugins/typing-avatars/src/lib/avatarUrl.ts
@@ -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`;
+}
diff --git a/plugins/typing-avatars/src/patches/typingIndicator.tsx b/plugins/typing-avatars/src/patches/typingIndicator.tsx
new file mode 100644
index 0000000..799eb9f
--- /dev/null
+++ b/plugins/typing-avatars/src/patches/typingIndicator.tsx
@@ -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 = (
+
+ );
+ } catch {
+ // Leave the default "X is typing..." text alone.
+ }
+
+ return ret;
+ };
+
+ registerIntercept(TypingIndicatorInner, PatchedTypingIndicatorInner);
+ }
+ );
+
+ return () => cleanups.forEach((fn) => fn());
+}
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 9f375b6..7254ab5 100644
--- a/shared/lib/createElementIntercept.ts
+++ b/shared/lib/createElementIntercept.ts
@@ -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;
@@ -12,14 +11,24 @@ interface PropsIntercept {
replacement: React.ComponentType | 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, Intercept>();
const propsIntercepts: PropsIntercept[] = [];
+const propsTransforms: PropsTransform[] = [];
let typeDetectors: TypeDetector[] = [];
+const detectorKeys = new Set();
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 | 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();
+
+ 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 | 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();
});
}