Fix PronounDB profile injection + TypingAvatars review fixes

This commit is contained in:
Ally Founder 2026-08-10 20:29:17 -04:00
parent 76e38e3bc4
commit 8c72244370
3 changed files with 94 additions and 49 deletions

View file

@ -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

View file

@ -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<typeof setInterval> | 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
};

View file

@ -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`;
}