chore(plugins/user-badges): refactor
This commit is contained in:
parent
4f46b5a635
commit
74ef7ae005
3 changed files with 192 additions and 188 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue