revenge-bundle-next/src/plugins/start/user-badges/index.tsx
2025-07-15 22:36:45 +07:00

224 lines
6.8 KiB
TypeScript

import { AlertActionCreators } from '@revenge-mod/discord/actions'
import { Design } from '@revenge-mod/discord/design'
import { byProps } from '@revenge-mod/modules/finders/filters'
import { getModule } from '@revenge-mod/modules/finders/get'
import { InternalPluginFlags, registerPlugin } from '@revenge-mod/plugins/_'
import { PluginFlags } from '@revenge-mod/plugins/constants'
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 { styles, useBadgeStyles } from './styles'
import { afterReconciled } from './utils'
import type { FC, ReactElement } from 'react'
import type {
ImageProps,
ImageSourcePropType,
ImageStyle,
PressableProps,
StyleProp,
} from 'react-native'
import type { Badge, BadgeId } from './constants'
const DUMMY_SYMBOL = {} as unknown as string
interface ProfileBadgeProps {
id: string
userId: string
label: string
source: ImageSourcePropType
themeType: string
badgeSize: number
}
interface ProfileBadgeRowsProps {
userId: string
badges: Array<{
description: string
icon: string
id: string
link?: string
}>
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'