feat(plugins): add user-badges
This commit is contained in:
parent
8d00748a6a
commit
4522303440
5 changed files with 304 additions and 0 deletions
30
src/plugins/init/user-badges/constants.ts
Normal file
30
src/plugins/init/user-badges/constants.ts
Normal file
|
|
@ -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<string, Badge>
|
||||
|
||||
export const UsersWithBadges = Data as Record<string, BadgeId[]>
|
||||
|
||||
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
|
||||
20
src/plugins/init/user-badges/data.json
Normal file
20
src/plugins/init/user-badges/data.json
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"629368283354628116": [
|
||||
"revenge_team"
|
||||
],
|
||||
"399862294143696897": [
|
||||
"revenge_team"
|
||||
],
|
||||
"853550207039832084": [
|
||||
"revenge_team"
|
||||
],
|
||||
"485376328980365312": [
|
||||
"revenge_team"
|
||||
],
|
||||
"737323631117598811": [
|
||||
"revenge_team"
|
||||
],
|
||||
"826489186327724095": [
|
||||
"revenge_team"
|
||||
]
|
||||
}
|
||||
228
src/plugins/init/user-badges/index.tsx
Normal file
228
src/plugins/init/user-badges/index.tsx
Normal file
|
|
@ -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<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 icon = badge.getAsset()
|
||||
|
||||
const el = (
|
||||
<ProfileBadge
|
||||
{...props}
|
||||
key={id}
|
||||
id={id}
|
||||
label={badge.label}
|
||||
source={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 { 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<PressableProps> =>
|
||||
node.type?.render,
|
||||
)
|
||||
|
||||
if (pressable)
|
||||
pressable.props.onPress = () => {
|
||||
openBadgeDialog(styles, badge, getAsset())
|
||||
}
|
||||
}
|
||||
|
||||
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 }: Badge,
|
||||
icon: AssetId,
|
||||
) {
|
||||
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
|
||||
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
|
||||
}
|
||||
16
src/plugins/init/user-badges/styles.ts
Normal file
16
src/plugins/init/user-badges/styles.ts
Normal file
|
|
@ -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',
|
||||
},
|
||||
})
|
||||
10
src/plugins/init/user-badges/utils.ts
Normal file
10
src/plugins/init/user-badges/utils.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { after } from '@revenge-mod/patcher'
|
||||
import type { FC, ReactElement, ReactNode } from 'react'
|
||||
|
||||
export const afterReconciled = (
|
||||
el: ReactElement<any, FC<any>>,
|
||||
hook: (fiber: ReactNode) => ReactNode,
|
||||
) =>
|
||||
after(el, 'type', fiber =>
|
||||
fiber instanceof Promise ? fiber.then(hook) : hook(fiber),
|
||||
)
|
||||
Loading…
Add table
Reference in a new issue