staff-tags: fix member list tags for real, bump to 1.0.9
1.0.8 fixed the row-pairing bug but kept patching the shared UserRow memo wrapper's .type via @revenge-mod/patcher's after() - confirmed live that this breaks member list rendering outright, unconditionally, even as a single non-stacking patch on a fresh app process. Replaced with afterJSX() intercepting each createElement(UserRow, props) call and a plain property assignment (el.type = a single stable function), never touching the shared object via after()'s Proxy machinery. Also fixed the actual tag-injection logic, live-verified end to end: UserRow renders a single TableRow with name+tag at ret.props.label (a prop, not a child reachable by the old generic tree search - that mismatch was the reason tags never appeared even before the crash). The existing tag element found there is Discord's own BotTag, which derives its label purely from a type enum and ignores any custom text/color props - so the old "mutate BotTag in place" path was a silent no-op. Now always injects the plugin's own GradientTag instead, confirmed live to actually render custom tag text and color.
This commit is contained in:
parent
d128252024
commit
abd21f3be1
2 changed files with 92 additions and 117 deletions
|
|
@ -1,7 +1,6 @@
|
|||
import { Stores } from '@revenge-mod/discord/flux'
|
||||
import { getModules } from '@revenge-mod/modules/finders'
|
||||
import { withProps } from '@revenge-mod/modules/finders/filters'
|
||||
import { after } from '@revenge-mod/patcher'
|
||||
import { afterJSX } from '@revenge-mod/react/jsx-runtime'
|
||||
import { findInReactFiber } from '@revenge-mod/utils/react'
|
||||
import { fiberFilter } from '../lib/fiber'
|
||||
import { withMemoDefaultName } from '../lib/filters'
|
||||
|
|
@ -9,7 +8,7 @@ import getTag from '../lib/getTag'
|
|||
import { guard } from '../lib/safe'
|
||||
import GradientTag from '../ui/GradientTag'
|
||||
import type { JsonStorage } from '@revenge-mod/json-storage'
|
||||
import type { FC } from 'react'
|
||||
import type { FC, ReactElement } from 'react'
|
||||
import type { StaffTagsStorage } from '../lib/types'
|
||||
|
||||
interface UserRowProps {
|
||||
|
|
@ -17,14 +16,6 @@ interface UserRowProps {
|
|||
user: any
|
||||
}
|
||||
|
||||
interface TagComponentProps {
|
||||
type: number
|
||||
text: string
|
||||
textColor: string
|
||||
backgroundColor: string
|
||||
verified: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Covers member list rows and the profile popout, where the name and tag sit on the same line.
|
||||
*
|
||||
|
|
@ -36,49 +27,59 @@ interface TagComponentProps {
|
|||
* plain `withName('UserRow')` can never match it, since the wrapper object has no `.name` of its
|
||||
* own (only the memo-hidden inner function does). `withMemoDefaultName` (`../lib/filters`) checks
|
||||
* the wrapper's inner `.type.name` instead and matches on the full module namespace, so the
|
||||
* callback below receives `{default: <memo wrapper>}`.
|
||||
* callback below receives `{default: <memo wrapper>}` - `mod.default` is the exact reference
|
||||
* Discord's own `createElement(UserRow, ...)` calls use, which is what `afterJSX` needs.
|
||||
*
|
||||
* Patches the memo wrapper's inner `.type` directly with a single persistent `after()` (same
|
||||
* pattern `chat.ts` uses on `getTagProperties`), reading `props` fresh from `args[0]` on every
|
||||
* call - NOT the old per-`createElement` `afterJSX`+`afterRendered`, self-unpatching approach.
|
||||
* That approach is unsound for memo components specifically: every row instance shares the exact
|
||||
* same inner render function (memo only produces one), so patching it per-instance still patches
|
||||
* one shared target, and every currently-stacked self-unpatching hook fires on *any* row's actual
|
||||
* render call, not just the row that registered it - each hook then unpatches itself regardless
|
||||
* of whether the `ret` it received actually belonged to its own row. In practice this meant tags
|
||||
* almost never landed on the correct row (mismatched pairing, silently swallowed by `guard()`),
|
||||
* and the never-correctly-consumed hooks piled up unbounded across scrolling/re-mounts until the
|
||||
* sheer patch-chain depth broke rendering outright (confirmed live - the member list going empty
|
||||
* after normal use, see /root/evals-for-rn). A single persistent patch on the shared function,
|
||||
* reading each call's own `args[0]`, doesn't have this pairing problem: `args[0]` is always
|
||||
* correctly paired to whichever row is actually being rendered on that call, same guarantee
|
||||
* `chat.ts` already relies on for its shared `getTagProperties` patch.
|
||||
* Does NOT patch the memo wrapper's shared inner `.type` via `@revenge-mod/patcher`'s generic
|
||||
* `after()` (tried and confirmed live to be unsound - see git history on this file). That's true
|
||||
* even for a single, non-stacking, persistent `after()` call: wrapping the shared render function
|
||||
* in `after()`'s Proxy broke member list rendering outright, every time, even on a fully fresh app
|
||||
* process with nothing else patched. Whatever `after()` does to make a target "patchable" doesn't
|
||||
* survive contact with this particular shared function.
|
||||
*
|
||||
* Uses `getModules` (reacts once Discord itself initializes each module) instead of
|
||||
* `lookupModule` (which force-initializes uninitialized modules right away) - forcing UserRow /
|
||||
* the Tag module to init during `start()`, before Discord is done booting, is what crashed app
|
||||
* startup the first time this shipped.
|
||||
* Instead, `afterJSX(wrapper, el => ...)` intercepts each `createElement(UserRow, props)` call
|
||||
* (the per-instance element, never the shared object) and does a **plain, unwrapped property
|
||||
* assignment** - `el.type = patchedRender` - to a single stable function reused for every row
|
||||
* (defined once, outside the `afterJSX` callback, not a fresh closure per row). `patchedRender`
|
||||
* manually calls the real inner render function itself (`innerRender(props)`, safe to call
|
||||
* directly - React only ever needs the dispatcher active, and it is, since `patchedRender` itself
|
||||
* is invoked *by* React as `el`'s render function), then augments the returned tree. This avoids
|
||||
* `after()`'s Proxy machinery entirely while still landing a plain, always-callable function
|
||||
* (never the non-callable memo wrapper) on `element.type`, so it doesn't hit the earlier
|
||||
* "TypeError: target is not callable" crash either. Reusing the same function reference across
|
||||
* every row (rather than a fresh closure per element) keeps `element.type` identity stable across
|
||||
* re-renders, letting React reconcile these rows normally instead of remounting each one.
|
||||
*
|
||||
* `UserRow` renders a single `TableRow` whose name+tag live at `ret.props.label` (a `View`) as a
|
||||
* **prop**, not somewhere reachable by a generic "find array children containing a string"
|
||||
* search - that mismatch (not the render-output shape itself) is why tags never appeared even
|
||||
* before the crash: `findInReactFiber` was searching for a shape that doesn't exist in this tree.
|
||||
* `label.props.children` is `[nameElement, fragment, existingTagElement]` - confirmed live via
|
||||
* `/root/evals-for-rn` - where `existingTagElement` is Discord's own `BotTag` component with a
|
||||
* static `.Types` enum and a numeric `type` prop; ordinary members render it already, with
|
||||
* `type: 0` (an inactive placeholder, not the "BOT" badge that enum value name suggests). Kept
|
||||
* `existingTag.props?.type !== 0` as a guard against stacking our own tag next to a real built-in
|
||||
* badge (bot/official/system), but dropped the old "mutate `existingTag`'s props in place" path
|
||||
* entirely - confirmed live that `BotTag` derives its displayed label purely from the `type`
|
||||
* enum (via the same module's `getBotLabel`), ignoring any custom `text`/color props entirely, so
|
||||
* that branch silently did nothing. Always injecting our own plugin-authored `GradientTag`
|
||||
* instead (confirmed live to actually render custom text/color) is the only path that works;
|
||||
* `GradientTag` already branches on its own `gradientColor` prop for solid-vs-gradient, so there's
|
||||
* no need for the old `TagModule` (Discord's `BotTag` module again, same dead end) fallback.
|
||||
*
|
||||
* Uses `getModules` (reacts once Discord itself initializes the module) instead of
|
||||
* `lookupModule` (which force-initializes uninitialized modules right away) - forcing UserRow to
|
||||
* init during `start()`, before Discord is done booting, is what crashed app startup the first
|
||||
* time this shipped.
|
||||
*
|
||||
* Every callback below is wrapped in `guard()`: it runs whenever Discord itself renders/
|
||||
* initializes the matched module, not inside `applyPatches`' try/catch, which only covers the
|
||||
* synchronous setup calls - an uncaught throw here crashes app startup instead of just skipping
|
||||
* one row, and (per the above) would otherwise poison the shared render function for every row.
|
||||
*
|
||||
* TODO(live-verify): rendered-tree shape (`c?.type?.Types`) still unconfirmed, see eval-for-revenge.
|
||||
* synchronous setup calls - an uncaught throw here would otherwise skip a row's tag at best, or
|
||||
* (per the shared-target lesson above) risk destabilizing every row's rendering at worst.
|
||||
*/
|
||||
export default function patchDetails(storage: JsonStorage<StaffTagsStorage>) {
|
||||
const patches: (() => void)[] = []
|
||||
|
||||
let TagModule: { default?: FC<TagComponentProps> } | undefined
|
||||
const unsubTagModule = getModules(
|
||||
withProps('default', 'getBotLabel'),
|
||||
mod => {
|
||||
TagModule = mod as { default?: FC<TagComponentProps> }
|
||||
},
|
||||
{ skipDefault: true },
|
||||
)
|
||||
patches.push(unsubTagModule)
|
||||
|
||||
const GuildStore = Stores.GuildStore as unknown as {
|
||||
getGuild(id: string | undefined): any
|
||||
}
|
||||
|
|
@ -88,80 +89,54 @@ export default function patchDetails(storage: JsonStorage<StaffTagsStorage>) {
|
|||
mod => {
|
||||
guard(() => {
|
||||
const wrapper = (mod as { default: { type: FC<UserRowProps> } }).default
|
||||
if (typeof wrapper.type !== 'function') return
|
||||
const innerRender = wrapper.type
|
||||
if (typeof innerRender !== 'function') return
|
||||
|
||||
// Call through unguarded - if Discord's own render function throws, that's not
|
||||
// something we caused or can meaningfully recover from here (same rationale as
|
||||
// `chat.ts`'s `getTagProperties` patch). Only our own augmentation is guarded.
|
||||
const patchedRender: FC<UserRowProps> = props => {
|
||||
const ret = innerRender(props)
|
||||
|
||||
return guard(() => {
|
||||
const { guildId, user } = props
|
||||
if (!user) return ret
|
||||
|
||||
const label = (ret as ReactElement)?.props?.label as
|
||||
| ReactElement
|
||||
| undefined
|
||||
if (!label || !Array.isArray(label.props?.children)) return ret
|
||||
|
||||
const existingTag = findInReactFiber(
|
||||
label as any,
|
||||
fiberFilter(c => c?.type?.Types),
|
||||
)
|
||||
if (existingTag && existingTag.props?.type !== 0) return ret
|
||||
|
||||
const guild = GuildStore?.getGuild(guildId)
|
||||
const tag = getTag(storage, guild, undefined, user)
|
||||
if (!tag) return ret
|
||||
|
||||
const container = label.props as { children: unknown[] }
|
||||
container.children.push(
|
||||
<GradientTag
|
||||
text={tag.text}
|
||||
textColor={tag.textColor}
|
||||
backgroundColor={tag.backgroundColor}
|
||||
gradientColor={tag.gradientColor}
|
||||
/>,
|
||||
)
|
||||
|
||||
return ret
|
||||
}, ret)
|
||||
}
|
||||
|
||||
patches.push(
|
||||
after(wrapper, 'type', (args, ret) =>
|
||||
afterJSX(wrapper as unknown as FC<UserRowProps>, el =>
|
||||
guard(() => {
|
||||
const props = args?.[0] as UserRowProps | undefined
|
||||
if (!props?.user) return ret
|
||||
|
||||
const { guildId, user } = props
|
||||
|
||||
const label = findInReactFiber(
|
||||
ret as any,
|
||||
fiberFilter(
|
||||
c =>
|
||||
Array.isArray(c?.props?.children) &&
|
||||
c.props.children.some(
|
||||
(ch: any) =>
|
||||
typeof ch === 'string' ||
|
||||
typeof ch?.props?.children === 'string',
|
||||
),
|
||||
),
|
||||
)
|
||||
if (!label) return ret
|
||||
|
||||
const existingTag = findInReactFiber(
|
||||
label as any,
|
||||
fiberFilter(c => c?.type?.Types),
|
||||
)
|
||||
if (existingTag && existingTag.props?.type !== 0) return ret
|
||||
|
||||
const guild = GuildStore?.getGuild(guildId)
|
||||
const tag = getTag(storage, guild, undefined, user)
|
||||
if (!tag) return ret
|
||||
|
||||
if (existingTag) {
|
||||
Object.assign(existingTag.props, {
|
||||
type: 0,
|
||||
text: tag.text,
|
||||
textColor: tag.textColor,
|
||||
backgroundColor: tag.backgroundColor,
|
||||
verified: tag.verified,
|
||||
})
|
||||
return ret
|
||||
}
|
||||
|
||||
const container = label as any
|
||||
if (!Array.isArray(container.props.children)) {
|
||||
container.props.children = [container.props.children]
|
||||
}
|
||||
|
||||
if (tag.gradientColor) {
|
||||
container.props.children.push(
|
||||
<GradientTag
|
||||
text={tag.text}
|
||||
textColor={tag.textColor}
|
||||
backgroundColor={tag.backgroundColor}
|
||||
gradientColor={tag.gradientColor}
|
||||
/>,
|
||||
)
|
||||
} else if (TagModule?.default) {
|
||||
const Component = TagModule.default
|
||||
container.props.children.push(
|
||||
<Component
|
||||
type={0}
|
||||
text={tag.text}
|
||||
textColor={tag.textColor}
|
||||
backgroundColor={tag.backgroundColor}
|
||||
verified={tag.verified}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
||||
return ret
|
||||
}, ret),
|
||||
;(el as { type: unknown }).type = patchedRender
|
||||
return el
|
||||
}, el),
|
||||
),
|
||||
)
|
||||
}, undefined)
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
"name": "Staff Tags",
|
||||
"description": "Adds customizable tags (OWNER, ADMIN, STAFF, MOD, and more) next to members based on their server permissions. Supports per-tag colors, gradients, custom text, and visibility toggles.",
|
||||
"author": "Rosie",
|
||||
"version": "1.0.8",
|
||||
"version": "1.0.9",
|
||||
"dependencies": {
|
||||
"revenge.api": {
|
||||
"version": ">=1 <2"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue