chore(plugins/user-badges): refactor

This commit is contained in:
PalmDevs 2025-07-15 22:36:45 +07:00
parent 4f46b5a635
commit 74ef7ae005
No known key found for this signature in database
GPG key ID: 6FAE8F8677ACA8BA
3 changed files with 192 additions and 188 deletions

View file

@ -1,12 +1,13 @@
import RevengeIcon from '~assets/RevengeIcon'
// TODO(PalmDevs): switch to external fetches
import Data from './data.json'
import type { AssetId } from '@revenge-mod/assets/types'
import type { ImageSourcePropType } from 'react-native'
// TODO(PalmDevs): possibly fetch from external source?
export const Badges = {
revenge_team: {
label: 'Revenge Team',
description: 'This user is a member of the Revenge team.',
description: 'This user is a Revenge team member.',
icon: RevengeIcon,
bnw: true,
showDialog: true,
@ -17,7 +18,7 @@ export const UsersWithBadges = Data as Record<string, BadgeId[]>
export type Badge = {
label: string
icon: AssetId
icon: ImageSourcePropType
description: string
/**
* Black and white icon

View file

@ -8,9 +8,9 @@ import { afterJSX, beforeJSX } from '@revenge-mod/react/jsx-runtime'
import { findInReactFiber } from '@revenge-mod/utils/react'
import { Image } from 'react-native'
import { Badges, UsersWithBadges } from './constants'
import { useStyles } from './styles'
import { styles, useBadgeStyles } from './styles'
import { afterReconciled } from './utils'
import type { FC, JSX, ReactElement } from 'react'
import type { FC, ReactElement } from 'react'
import type {
ImageProps,
ImageSourcePropType,
@ -18,191 +18,10 @@ import type {
PressableProps,
StyleProp,
} from 'react-native'
import type { Badge } from './constants'
import type { Badge, BadgeId } from './constants'
const DUMMY_SYMBOL = {} as unknown as string
registerPlugin(
{
id: 'revenge.user-badges',
name: 'User Badges',
description: 'Badges for Revenge contributors and sponsors.',
author: 'Revenge',
icon: 'ShieldUserIcon',
},
{
start({ cleanup }) {
const unsub = getModule(
byProps<{
ProfileBadgeRows: FC<ProfileBadgeRowsProps>
}>('ProfileBadgeRows'),
({ ProfileBadgeRows }) => {
unsub()
cleanup(
beforeJSX(ProfileBadgeRows, args => {
const [, props] = args
if (
UsersWithBadges[props.userId] &&
!props.badges.length
)
// Push a dummy badge to ensure we can always get the ProfileBadge in the reconciled fiber
// @ts-expect-error
props.badges.unshift({
id: DUMMY_SYMBOL,
})
return args
}),
)
cleanup(
afterJSX(ProfileBadgeRows, fiber => {
patchProfileBadgeRows(fiber as JSX.Element)
return fiber
}),
)
},
)
cleanup(unsub)
},
stop({ plugin }) {
plugin.flags |= PluginFlags.ReloadRequired
},
},
PluginFlags.Enabled,
// Essential because this is a perk
InternalPluginFlags.Internal | InternalPluginFlags.Essential,
)
function patchProfileBadgeRows(
el: ReactElement<ProfileBadgeRowsProps, FC<ProfileBadgeRowsProps>>,
) {
const styles = useStyles()
// Patch once to get the reconciled fiber
const unpatch = afterReconciled(el, fiber => {
unpatch()
const view = findInReactFiber(
fiber as ReactElement,
isViewContainingProfileBadgeElements,
)
if (view) {
// Guaranteed to exist (check isViewContainingProfileBadgeElements impl.)
const [{ type: ProfileBadge, props }] = view.props.children
const badges = UsersWithBadges[props.userId]
if (badges) {
for (const id of badges) {
const badge = Badges[id]
const el = (
<ProfileBadge
{...props}
key={id}
id={id}
label={badge.label}
source={badge.icon}
/>
)
patchProfileBadge(el, styles, badge)
view.props.children.push(el)
}
// Remove the dummy badge
if (props.id === DUMMY_SYMBOL) view.props.children.shift()
}
}
return fiber
})
}
function patchProfileBadge(
el: ReactElement<ProfileBadgeProps, FC<ProfileBadgeProps>>,
styles: ReturnType<typeof useStyles>,
badge: Badge,
) {
const { bnw, showDialog } = badge
// Only patch if we actually need to
if (bnw || showDialog) {
// Patch once to get the reconciled fiber
const unpatch = afterReconciled(el, fiber => {
unpatch()
if (showDialog) {
const pressable = findInReactFiber(
fiber as ReactElement,
(node): node is ReactElement<PressableProps> =>
node.type?.render,
)
if (pressable)
pressable.props.onPress = () => {
openBadgeDialog(styles, badge)
}
}
if (bnw) {
const image = findInReactFiber(
fiber as ReactElement,
(node): node is ReactElement<ImageProps> =>
node.type === Image,
)
if (image)
(
image.props.style as Extract<
StyleProp<ImageStyle>,
any[]
>
).push(styles.tinted)
}
return fiber
})
}
}
function openBadgeDialog(
styles: ReturnType<typeof useStyles>,
{ label, description, bnw, icon }: Badge,
) {
AlertActionCreators.openAlert(
'CUSTOM_PROFILE_BADGE',
<Design.AlertModal
title={
<Design.Stack style={styles.stack}>
<Image
source={icon}
style={[styles.display, bnw && styles.tinted]}
/>
<Design.Text
variant="heading-lg/bold"
color="header-primary"
>
{label}
</Design.Text>
</Design.Stack>
}
content={description}
actions={<Design.AlertActionButton text="Okay" />}
/>,
)
}
const isViewContainingProfileBadgeElements = (
node: any,
): node is ReactElement<{
children: Array<ReactElement<ProfileBadgeProps, FC<ProfileBadgeProps>>>
// View must have at least 1 child that is a ProfileBadge
}> => node.props?.children?.[0]?.type.name === 'ProfileBadge'
interface ProfileBadgeProps {
id: string
userId: string
@ -223,3 +42,183 @@ interface ProfileBadgeRowsProps {
themeType: string
showToastOnPress?: boolean
}
type ViewWithProfileBadges = ReactElement<{
children: Array<ReactElement<ProfileBadgeProps, FC<ProfileBadgeProps>>>
}>
registerPlugin(
{
id: 'revenge.user-badges',
name: 'User Badges',
description: 'Badges for Revenge contributors and sponsors.',
author: 'Revenge',
icon: 'ShieldUserIcon',
},
{
start({ cleanup }) {
const unsub = getModule(
byProps<{ ProfileBadgeRows: FC<ProfileBadgeRowsProps> }>(
'ProfileBadgeRows',
),
({ ProfileBadgeRows }) => {
unsub()
cleanup(
beforeJSX(ProfileBadgeRows, args => {
const [, props] = args
// Inject dummy badge for users with custom badges to ensure ProfileBadge components exist
if (
// has custom badges
UsersWithBadges[props.userId] &&
// but no actual badges
!props.badges.length
)
// @ts-expect-error: This will never be rendered
props.badges.unshift({ id: DUMMY_SYMBOL })
return args
}),
)
cleanup(
afterJSX(ProfileBadgeRows, fiber => {
patchProfileBadgeRows(fiber)
return fiber
}),
)
},
)
cleanup(unsub)
},
stop({ plugin }) {
plugin.flags |= PluginFlags.ReloadRequired
},
},
PluginFlags.Enabled,
// Essential because this is a perk
InternalPluginFlags.Internal | InternalPluginFlags.Essential,
)
function patchProfileBadgeRows(
element: ReactElement<ProfileBadgeRowsProps, FC<ProfileBadgeRowsProps>>,
): void {
const unpatch = afterReconciled(element, fiber => {
unpatch()
const container = findInReactFiber(
fiber as ReactElement,
isViewContainingProfileBadgeElements,
)
if (!container) return fiber
const [{ type: ProfileBadge, props }] = container.props.children
const userBadges = UsersWithBadges[props.userId]
if (userBadges) {
for (const badgeId of userBadges) {
const badge = Badges[badgeId]
if (!badge) continue
const badgeElement = (
<ProfileBadge
{...props}
key={badgeId}
id={badgeId}
label={badge.label}
source={badge.icon}
/>
)
patchProfileBadge(badgeElement, badgeId, badge)
container.props.children.push(badgeElement)
}
// Remove dummy badge if it exists
if (props.id === DUMMY_SYMBOL) container.props.children.shift()
}
return fiber
})
}
function patchProfileBadge(
element: ReactElement<ProfileBadgeProps, FC<ProfileBadgeProps>>,
id: BadgeId,
badge: Badge,
): void {
const { bnw, showDialog } = badge
// Return early if we don't actually need to patch this badge
if (!bnw && !showDialog) return
const unpatch = afterReconciled(element, fiber => {
unpatch()
// This will not result in a conditional hook error because badges do not change flags during runtime
const badgeStyles = useBadgeStyles()
if (showDialog) {
const pressable = findInReactFiber(
fiber as ReactElement,
(node): node is ReactElement<PressableProps> =>
node.type?.render,
)
if (pressable)
pressable.props.onPress = () =>
openBadgeDialog(id, badge, badgeStyles)
}
if (bnw) {
const image = findInReactFiber(
fiber as ReactElement,
(node): node is ReactElement<ImageProps> => node.type === Image,
)
if (image) {
const styles = image.props.style as Extract<
StyleProp<ImageStyle>,
any[]
>
styles.push(badgeStyles.tinted)
}
}
return fiber
})
}
const { AlertActionButton, AlertModal, Stack, Text } = Design
function openBadgeDialog(
id: BadgeId,
{ label, description, bnw, icon }: Badge,
badgeStyles: ReturnType<typeof useBadgeStyles>,
): void {
AlertActionCreators.openAlert(
`REVENGE_PROFILE_BADGE-${id}`,
<AlertModal
title={
<Stack style={styles.stack}>
<Image
source={icon}
style={[styles.display, bnw && badgeStyles.tinted]}
/>
<Text variant="heading-lg/bold" color="header-primary">
{label}
</Text>
</Stack>
}
content={description}
actions={<AlertActionButton text="Okay" />}
/>,
)
}
const isViewContainingProfileBadgeElements = (
node: any,
): node is ViewWithProfileBadges =>
node.props?.children?.[0]?.type.name === 'ProfileBadge'

View file

@ -1,10 +1,14 @@
import { Tokens } from '@revenge-mod/discord/common'
import { Design } from '@revenge-mod/discord/design'
import { StyleSheet } from 'react-native'
export const useStyles = Design.createStyles({
export const useBadgeStyles = Design.createStyles({
tinted: {
tintColor: Tokens.default.colors.HEADER_PRIMARY,
},
})
export const styles = StyleSheet.create({
stack: {
alignItems: 'center',
},