From 74ef7ae005e362c826fa8b8616a7125fc4e30c4f Mon Sep 17 00:00:00 2001 From: PalmDevs Date: Tue, 15 Jul 2025 22:36:45 +0700 Subject: [PATCH] chore(plugins/user-badges): refactor --- src/plugins/start/user-badges/constants.ts | 7 +- src/plugins/start/user-badges/index.tsx | 367 ++++++++++----------- src/plugins/start/user-badges/styles.ts | 6 +- 3 files changed, 192 insertions(+), 188 deletions(-) diff --git a/src/plugins/start/user-badges/constants.ts b/src/plugins/start/user-badges/constants.ts index 69ad681..994b2bb 100644 --- a/src/plugins/start/user-badges/constants.ts +++ b/src/plugins/start/user-badges/constants.ts @@ -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 export type Badge = { label: string - icon: AssetId + icon: ImageSourcePropType description: string /** * Black and white icon diff --git a/src/plugins/start/user-badges/index.tsx b/src/plugins/start/user-badges/index.tsx index 379b6a6..4cc69a3 100644 --- a/src/plugins/start/user-badges/index.tsx +++ b/src/plugins/start/user-badges/index.tsx @@ -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 - }>('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>, -) { - 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 = ( - - ) - - 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>, - styles: ReturnType, - 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 => - node.type?.render, - ) - - if (pressable) - pressable.props.onPress = () => { - openBadgeDialog(styles, badge) - } - } - - if (bnw) { - const image = findInReactFiber( - fiber as ReactElement, - (node): node is ReactElement => - node.type === Image, - ) - - if (image) - ( - image.props.style as Extract< - StyleProp, - any[] - > - ).push(styles.tinted) - } - - return fiber - }) - } -} - -function openBadgeDialog( - styles: ReturnType, - { label, description, bnw, icon }: Badge, -) { - AlertActionCreators.openAlert( - 'CUSTOM_PROFILE_BADGE', - - - - {label} - - - } - content={description} - actions={} - />, - ) -} - -const isViewContainingProfileBadgeElements = ( - node: any, -): node is ReactElement<{ - children: Array>> - // 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>> +}> + +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 }>( + '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>, +): 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 = ( + + ) + + 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>, + 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 => + node.type?.render, + ) + + if (pressable) + pressable.props.onPress = () => + openBadgeDialog(id, badge, badgeStyles) + } + + if (bnw) { + const image = findInReactFiber( + fiber as ReactElement, + (node): node is ReactElement => node.type === Image, + ) + + if (image) { + const styles = image.props.style as Extract< + StyleProp, + any[] + > + styles.push(badgeStyles.tinted) + } + } + + return fiber + }) +} + +const { AlertActionButton, AlertModal, Stack, Text } = Design + +function openBadgeDialog( + id: BadgeId, + { label, description, bnw, icon }: Badge, + badgeStyles: ReturnType, +): void { + AlertActionCreators.openAlert( + `REVENGE_PROFILE_BADGE-${id}`, + + + + {label} + + + } + content={description} + actions={} + />, + ) +} + +const isViewContainingProfileBadgeElements = ( + node: any, +): node is ViewWithProfileBadges => + node.props?.children?.[0]?.type.name === 'ProfileBadge' diff --git a/src/plugins/start/user-badges/styles.ts b/src/plugins/start/user-badges/styles.ts index 5b917d2..54615a6 100644 --- a/src/plugins/start/user-badges/styles.ts +++ b/src/plugins/start/user-badges/styles.ts @@ -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', },