From 8c72244370a7bc85cec50a6b523a641bfe051db5 Mon Sep 17 00:00:00 2001 From: Ally Founder Date: Mon, 10 Aug 2026 20:29:17 -0400 Subject: [PATCH] Fix PronounDB profile injection + TypingAvatars review fixes --- .../plans/2026-08-10-typing-avatars.md | 34 +++++------ plugins/pronoun-db/src/index.tsx | 59 +++++++++++++++---- plugins/typing-avatars/src/lib/avatarUrl.ts | 50 +++++++++------- 3 files changed, 94 insertions(+), 49 deletions(-) diff --git a/docs/superpowers/plans/2026-08-10-typing-avatars.md b/docs/superpowers/plans/2026-08-10-typing-avatars.md index 264a4fe..a984ed0 100644 --- a/docs/superpowers/plans/2026-08-10-typing-avatars.md +++ b/docs/superpowers/plans/2026-08-10-typing-avatars.md @@ -28,7 +28,7 @@ **Interfaces:** - Produces: a default-exported plugin object (`{ onLoad, onUnload }`) that later tasks wire their patch functions into. -- [ ] **Step 1: Create the manifest** +- [x] **Step 1: Create the manifest** ```json { @@ -46,7 +46,7 @@ `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** +- [x] **Step 2: Create the plugin entry point with no patches wired yet** ```ts import { logger } from "@vendetta"; @@ -62,12 +62,12 @@ export default { }; ``` -- [ ] **Step 3: Build and verify** +- [x] **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** +- [x] **Step 4: Commit** ```bash git add plugins/typing-avatars/manifest.json plugins/typing-avatars/src/index.ts @@ -85,7 +85,7 @@ git commit -m "typing-avatars: scaffold new plugin" - 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** +- [x] **Step 1: Write the resolution helper** ```ts import { findByStoreName } from "@vendetta/metro"; @@ -124,12 +124,12 @@ export function getTypingAvatarURL(guildId: string | undefined, userId: string, } ``` -- [ ] **Step 2: Build and verify** +- [x] **Step 2: Build and verify** Run: `cd /root/revenge-plugins && node build.mjs` Expected: `Successfully built TypingAvatars!`, no type/syntax errors. -- [ ] **Step 3: Commit** +- [x] **Step 3: Commit** ```bash git add plugins/typing-avatars/src/lib/avatarUrl.ts @@ -149,7 +149,7 @@ git commit -m "typing-avatars: add avatar URL resolution helper" - 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** +- [x] **Step 1: Write the AvatarStack component** ```tsx import React from "react"; @@ -189,7 +189,7 @@ const st = StyleSheet.create({ }); ``` -- [ ] **Step 2: Write the typing-indicator patch** +- [x] **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. @@ -242,7 +242,7 @@ export default function patchTypingIndicator(): () => void { } ``` -- [ ] **Step 3: Wire the patch into the plugin entry point** +- [x] **Step 3: Wire the patch into the plugin entry point** Replace the contents of `plugins/typing-avatars/src/index.ts` with: @@ -263,12 +263,12 @@ export default { }; ``` -- [ ] **Step 4: Build and verify** +- [x] **Step 4: Build and verify** Run: `cd /root/revenge-plugins && node build.mjs` Expected: `Successfully built TypingAvatars!`, no type/syntax errors. -- [ ] **Step 5: Commit** +- [x] **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 @@ -283,7 +283,7 @@ git commit -m "typing-avatars: add AvatarStack component and typing-indicator pa **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** +- [x] **Step 1: Push to deploy** ```bash cd /root/revenge-plugins && git push @@ -291,19 +291,19 @@ 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** +- [x] **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** +- [x] **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** +- [x] **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** +- [x] **Step 5: Fix and re-deploy anything found wrong in Steps 2-4, then commit** ```bash git add -A diff --git a/plugins/pronoun-db/src/index.tsx b/plugins/pronoun-db/src/index.tsx index bd121f6..99afe22 100644 --- a/plugins/pronoun-db/src/index.tsx +++ b/plugins/pronoun-db/src/index.tsx @@ -1,19 +1,27 @@ import { logger } from "@vendetta"; -import { find } from "@vendetta/metro"; import { after } from "@vendetta/patcher"; import { findInReactTree } from "@vendetta/utils"; +import { rawFindByTypeName } from "@shared/lib/rawFind"; import PronounSection from "./ui/PronounSection"; import Settings from "./ui/Settings"; +// @vendetta/metro's find() caches a negative result forever, including one that only missed +// because this plugin's onLoad ran before UserProfileContent's module had registered yet - once +// cached, no amount of retrying with find() would ever see the real module. rawFindByTypeName +// scans window.modules directly instead, so retrying it actually works. Confirmed live: this +// exact search + the findInReactTree logic below both work correctly once given a real reference +// - the only thing broken was find()'s one-shot, permanently-cached lookup. +// // Discord renamed the top-level profile component from "UserProfile" to "UserProfileContent" at // some point (confirmed against decompiled current-build source) - checking both names covers // whichever one a given Discord build actually uses. -const UserProfile = - find((m: any) => m?.type?.name === "UserProfileContent") ?? - find((m: any) => m?.type?.name === "UserProfile"); +function getUserProfile(): any { + return rawFindByTypeName("UserProfileContent") ?? rawFindByTypeName("UserProfile"); +} -function patchProfile(): () => void { - if (!UserProfile) return () => {}; +function patchProfile(): (() => void) | false { + const UserProfile = getUserProfile(); + if (!UserProfile) return false; return after("type", UserProfile, (_: any, res: any) => { try { @@ -43,15 +51,44 @@ function patchProfile(): () => void { } let unpatch: () => void = () => {}; +let patched = false; +let retryHandle: ReturnType | undefined; + +function stopRetrying() { + if (retryHandle) { + clearInterval(retryHandle); + retryHandle = undefined; + } +} + +function attempt() { + try { + const result = patchProfile(); + if (result) { + unpatch = result; + patched = true; + stopRetrying(); + } + } catch (e) { + logger.error(`[PronounDB] Failed to apply the profile patch: ${e}`); + stopRetrying(); + } +} export default { onLoad: () => { - try { - unpatch = patchProfile(); - } catch (e) { - logger.error(`[PronounDB] Failed to apply the profile patch: ${e}`); + attempt(); + if (!patched) { + let ticks = 0; + retryHandle = setInterval(() => { + attempt(); + if (++ticks >= 30) stopRetrying(); // ~9s at 300ms, then give up + }, 300); } }, - onUnload: () => unpatch(), + onUnload: () => { + stopRetrying(); + unpatch(); + }, settings: Settings }; diff --git a/plugins/typing-avatars/src/lib/avatarUrl.ts b/plugins/typing-avatars/src/lib/avatarUrl.ts index a043fe7..c930705 100644 --- a/plugins/typing-avatars/src/lib/avatarUrl.ts +++ b/plugins/typing-avatars/src/lib/avatarUrl.ts @@ -7,28 +7,36 @@ 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. +// member.avatar / user.avatar confirmed live via devtools eval: UserStore.getCurrentUser().avatar +// returned a real hash, and GuildMemberStore.getMember(guildId, userId).avatar exists as a field +// and is correctly null for a user with no server-specific avatar set (the fallback-to-global +// path is real and was exercised, not just theorized). +// +// The whole body is wrapped so a single bad input (e.g. a malformed userId reaching BigInt()) +// degrades to "this one avatar doesn't render" instead of throwing during the message list's +// render pass - that render happens outside typingIndicator.tsx's own try/catch, which only +// guards element creation, not AvatarStack's actual render. export function getTypingAvatarURL(guildId: string | undefined, userId: string, size = 32): string | null { - if (!userId) return null; + try { + 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 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`; + } catch { + return null; } - - 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`; }