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(); }); }