From 452230344008bd0cb14d4417e6dea3ddcc7d41f9 Mon Sep 17 00:00:00 2001 From: PalmDevs Date: Sun, 13 Jul 2025 16:30:20 +0700 Subject: [PATCH] feat(plugins): add `user-badges` --- src/plugins/init/user-badges/constants.ts | 30 +++ src/plugins/init/user-badges/data.json | 20 ++ src/plugins/init/user-badges/index.tsx | 228 ++++++++++++++++++++++ src/plugins/init/user-badges/styles.ts | 16 ++ src/plugins/init/user-badges/utils.ts | 10 + 5 files changed, 304 insertions(+) create mode 100644 src/plugins/init/user-badges/constants.ts create mode 100644 src/plugins/init/user-badges/data.json create mode 100644 src/plugins/init/user-badges/index.tsx create mode 100644 src/plugins/init/user-badges/styles.ts create mode 100644 src/plugins/init/user-badges/utils.ts diff --git a/src/plugins/init/user-badges/constants.ts b/src/plugins/init/user-badges/constants.ts new file mode 100644 index 0000000..ef9b848 --- /dev/null +++ b/src/plugins/init/user-badges/constants.ts @@ -0,0 +1,30 @@ +import RevengeIcon from '~assets/RevengeIcon' +// TODO(PalmDevs): switch to external fetches +import Data from './data.json' +import type { AssetId } from '@revenge-mod/assets/types' + +export const Badges = { + revenge_team: { + label: 'Revenge Team', + description: 'This user is a member of the Revenge team.', + getAsset: () => RevengeIcon, + bnw: true, + showDialog: true, + }, +} satisfies Record + +export const UsersWithBadges = Data as Record + +export type Badge = { + label: string + getAsset: () => AssetId + description: string + /** + * Black and white icon + * @default false + */ + bnw?: boolean + showDialog?: boolean +} + +export type BadgeId = keyof typeof Badges diff --git a/src/plugins/init/user-badges/data.json b/src/plugins/init/user-badges/data.json new file mode 100644 index 0000000..6f23d86 --- /dev/null +++ b/src/plugins/init/user-badges/data.json @@ -0,0 +1,20 @@ +{ + "629368283354628116": [ + "revenge_team" + ], + "399862294143696897": [ + "revenge_team" + ], + "853550207039832084": [ + "revenge_team" + ], + "485376328980365312": [ + "revenge_team" + ], + "737323631117598811": [ + "revenge_team" + ], + "826489186327724095": [ + "revenge_team" + ] +} \ No newline at end of file diff --git a/src/plugins/init/user-badges/index.tsx b/src/plugins/init/user-badges/index.tsx new file mode 100644 index 0000000..5c6dc38 --- /dev/null +++ b/src/plugins/init/user-badges/index.tsx @@ -0,0 +1,228 @@ +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 { useStyles } from './styles' +import { afterReconciled } from './utils' +import type { AssetId } from '@revenge-mod/assets/types' +import type { FC, JSX, ReactElement } from 'react' +import type { + ImageProps, + ImageSourcePropType, + ImageStyle, + PressableProps, + StyleProp, +} from 'react-native' +import type { Badge } 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 icon = badge.getAsset() + + 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 { getAsset, 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, getAsset()) + } + } + + 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 }: Badge, + icon: AssetId, +) { + 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 + 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 +} diff --git a/src/plugins/init/user-badges/styles.ts b/src/plugins/init/user-badges/styles.ts new file mode 100644 index 0000000..5b917d2 --- /dev/null +++ b/src/plugins/init/user-badges/styles.ts @@ -0,0 +1,16 @@ +import { Tokens } from '@revenge-mod/discord/common' +import { Design } from '@revenge-mod/discord/design' + +export const useStyles = Design.createStyles({ + tinted: { + tintColor: Tokens.default.colors.HEADER_PRIMARY, + }, + stack: { + alignItems: 'center', + }, + display: { + width: 96, + height: 96, + objectFit: 'contain', + }, +}) diff --git a/src/plugins/init/user-badges/utils.ts b/src/plugins/init/user-badges/utils.ts new file mode 100644 index 0000000..f34cec4 --- /dev/null +++ b/src/plugins/init/user-badges/utils.ts @@ -0,0 +1,10 @@ +import { after } from '@revenge-mod/patcher' +import type { FC, ReactElement, ReactNode } from 'react' + +export const afterReconciled = ( + el: ReactElement>, + hook: (fiber: ReactNode) => ReactNode, +) => + after(el, 'type', fiber => + fiber instanceof Promise ? fiber.then(hook) : hook(fiber), + )