Remove plugins and add placeholder readme
Some checks failed
Build and deploy / deploy (push) Failing after 4s

This commit is contained in:
Rosie Val 2026-08-20 18:06:00 -04:00
parent a84aabb33b
commit 077151f429
28 changed files with 1 additions and 1735 deletions

View file

@ -1,28 +0,0 @@
BSD 3-Clause License
Copyright (c) 2024, Angel
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

View file

@ -1,30 +0,0 @@
import patchRing from './patches/ring'
import Settings from './ui/pages/Settings'
import type { RadialStatusStorage } from './lib/types'
export default plugin<{ jsonStorage: RadialStatusStorage }>({
jsonStorage: {
load: true,
default: {
enabled: true,
colors: {
online: '#23A55A',
idle: '#F0B232',
dnd: '#F23F42',
},
ringThickness: 2,
},
},
start(api) {
let unpatch = () => {}
try {
unpatch = patchRing(api.jsonStorage)
} catch (e) {
api.logger.error(`[Radial Status] Failed to apply the ring patch: ${e}`)
}
api.cleanup(unpatch)
},
SettingsComponent: Settings,
})

View file

@ -1,5 +0,0 @@
export const HEX_REGEX = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i
export function isValidHex(value: string | undefined | null): value is string {
return !!value && HEX_REGEX.test(value)
}

View file

@ -1,7 +0,0 @@
export function guard<T>(fn: () => T, fallback: T): T {
try {
return fn()
} catch {
return fallback
}
}

View file

@ -1,5 +0,0 @@
export interface RadialStatusStorage {
enabled: boolean
colors: Record<string, string>
ringThickness: number
}

View file

@ -1,86 +0,0 @@
import { getModules } from '@revenge-mod/modules/finders'
import { withProps } from '@revenge-mod/modules/finders/filters'
import { beforeJSX } from '@revenge-mod/react/jsx-runtime'
import { guard } from '../lib/safe'
import type { JsonStorage } from '@revenge-mod/json-storage'
import type { ElementType } from 'react'
import type { RadialStatusStorage } from '../lib/types'
const CONFIRMED_SIZES = new Set([24, 32, 40, 50, 60, 80])
function tryApplyRing(
props: unknown,
storage: JsonStorage<RadialStatusStorage>,
) {
if (!storage.cache?.enabled) return
if (!props || !Array.isArray((props as any).style)) return
const wrapper = props as { style: any[]; children?: any[] }
if (!Array.isArray(wrapper.children) || wrapper.children.length !== 5) return
const circleIdx = wrapper.style.findIndex(
s =>
s &&
typeof s.width === 'number' &&
s.width === s.height &&
s.borderRadius === s.width / 2 &&
CONFIRMED_SIZES.has(s.width),
)
if (circleIdx === -1) return
const userProps = wrapper.children?.[1]?.props
const presenceProps = wrapper.children?.[3]?.props
if (!userProps || typeof userProps.user?.id !== 'string') return
if (!presenceProps || typeof presenceProps.status !== 'string') return
const colors = storage.cache?.colors ?? {}
const color = colors[presenceProps.status as string]
if (!color) return
const baseSize = wrapper.style[circleIdx].width
const thickness = storage.cache?.ringThickness ?? 2
const newSize = baseSize + thickness * 2
presenceProps.size = 0
presenceProps.isMobileOnline = false
if (presenceProps.style) presenceProps.style.display = 'none'
if (userProps.cutout?.nativeCutouts?.[0])
userProps.cutout.nativeCutouts[0].size = 0
wrapper.style[circleIdx] = {
width: newSize,
height: newSize,
borderRadius: newSize / 2,
overflow: 'hidden',
}
wrapper.style.push({
borderWidth: thickness,
borderColor: color,
borderStyle: 'solid',
})
}
export default function patchRing(storage: JsonStorage<RadialStatusStorage>) {
const cleanups: (() => void)[] = []
const unsubGeneral = getModules(
withProps('Button', 'Text', 'View'),
mod => {
guard(() => {
const View = (mod as { View?: ElementType }).View
if (!View) return
cleanups.push(
beforeJSX(View, args => {
guard(() => tryApplyRing(args[1], storage), undefined)
return args
}),
)
}, undefined)
},
{ skipDefault: true },
)
cleanups.push(unsubGeneral)
return () => cleanups.forEach(fn => fn())
}

View file

@ -1,99 +0,0 @@
import { Text, TextInput, TouchableOpacity, View } from 'react-native'
import { isValidHex } from '../lib/color'
const PRESETS = [
'#F23F42',
'#F0B232',
'#FEE75C',
'#23A55A',
'#1ABC9C',
'#5865F2',
'#9B59B6',
'#EB459E',
'#99AAB5',
'#FFFFFF',
]
interface ColorInputProps {
title: string
value?: string
placeholder?: string
onChange: (value: string) => void
}
export default function ColorInput({
title,
value,
placeholder,
onChange,
}: ColorInputProps) {
const valid = !value || isValidHex(value)
const swatchColor = isValidHex(value)
? value
: isValidHex(placeholder)
? placeholder
: '#5865F2'
return (
<View style={{ paddingHorizontal: 16, paddingVertical: 8 }}>
<View
style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 8 }}
>
<View
style={{
width: 20,
height: 20,
borderRadius: 6,
backgroundColor: swatchColor,
marginRight: 10,
borderWidth: 1,
borderColor: 'rgba(128,128,128,0.35)',
}}
/>
<Text style={{ fontSize: 15, fontWeight: '600' }}>{title}</Text>
</View>
<TextInput
value={value ?? ''}
placeholder={placeholder ?? '#5865F2'}
onChangeText={onChange}
autoCapitalize="none"
autoCorrect={false}
style={{
borderWidth: 1,
borderColor: valid ? 'rgba(128,128,128,0.35)' : '#F23F42',
borderRadius: 8,
paddingHorizontal: 10,
paddingVertical: 8,
fontSize: 14,
marginBottom: 8,
}}
/>
<View style={{ flexDirection: 'row', flexWrap: 'wrap' }}>
{PRESETS.map(preset => {
const active = value?.toLowerCase() === preset.toLowerCase()
return (
<TouchableOpacity
key={preset}
onPress={() => onChange(preset)}
style={{
width: 24,
height: 24,
borderRadius: 6,
backgroundColor: preset,
marginRight: 8,
marginBottom: 8,
borderWidth: active ? 2 : 1,
borderColor: active ? '#ffffff' : 'rgba(128,128,128,0.35)',
}}
/>
)
})}
</View>
{!valid && (
<Text style={{ color: '#F23F42', marginTop: 2, fontSize: 12 }}>
Invalid hex color, e.g. #5865F2
</Text>
)}
</View>
)
}

View file

@ -1,67 +0,0 @@
import { Design } from '@revenge-mod/discord/design'
import { ScrollView, View } from 'react-native'
import ColorInput from '../ColorInput'
import type { PluginApi } from '@revenge-mod/plugins/types'
import type { RadialStatusStorage } from '../../lib/types'
const STATUSES: { key: string; label: string; defaultColor?: string }[] = [
{ key: 'online', label: 'Online', defaultColor: '#23A55A' },
{ key: 'idle', label: 'Idle', defaultColor: '#F0B232' },
{ key: 'dnd', label: 'Do Not Disturb', defaultColor: '#F23F42' },
{ key: 'offline', label: 'Offline', defaultColor: '#80848E' },
]
export default function Settings({
api,
}: {
api: PluginApi<{ jsonStorage: RadialStatusStorage }>
}) {
const { TableRowGroup, TableSwitchRow, TextInput, Text } = Design
const storage = api.jsonStorage.use()
const colors = storage?.colors ?? {}
return (
<ScrollView style={{ flex: 1 }}>
<View style={{ paddingHorizontal: 16, paddingVertical: 12 }}>
<Text variant="text-sm/normal" color="text-muted">
Replaces the small presence dot on an avatar with a colored ring
instead, per status below. Leave a status blank to keep showing the
normal dot for it.
</Text>
</View>
<TableRowGroup title="Enable">
<TableSwitchRow
label="Draw ring around avatars"
value={!!storage?.enabled}
onValueChange={(v: boolean) => api.jsonStorage.set({ enabled: v })}
/>
</TableRowGroup>
<TableRowGroup title="Ring colors">
{STATUSES.map(({ key, label, defaultColor }) => (
<ColorInput
key={key}
title={label}
value={colors[key]}
placeholder={defaultColor}
onChange={(v: string) =>
api.jsonStorage.set({ colors: { [key]: v } })
}
/>
))}
</TableRowGroup>
<TableRowGroup title="Ring size">
<TextInput
label="Ring thickness"
placeholder="2"
value={String(storage?.ringThickness ?? 2)}
onChange={(v: string) => {
const n = Number.parseFloat(v)
if (!Number.isNaN(n) && n > 0)
api.jsonStorage.set({ ringThickness: n })
}}
/>
</TableRowGroup>
<View style={{ height: 24 }} />
</ScrollView>
)
}

View file

@ -1,19 +0,0 @@
{
"format": 1,
"id": "dev.everestmcarthur.radial-status",
"name": "Radial Status",
"description": "Replaces the small presence dot on avatars with a colored ring instead - one color per status (online/idle/dnd/offline), fully configurable, plus thickness control.",
"author": "Rosie",
"version": "1.0.0",
"dependencies": {
"revenge.api": {
"version": ">=1 <2"
},
"discord": {
"version": "*"
}
},
"dist": {
"script": "index.js"
}
}

1
plugins/readme.md Normal file
View file

@ -0,0 +1 @@
More Plugins Coming Soon

View file

@ -1,27 +0,0 @@
import { applyPatches } from './lib/applyPatches'
import patchChat from './patches/chat'
import patchDetails from './patches/details'
import patchName from './patches/name'
import patchTag from './patches/tag'
import Settings from './ui/pages/Settings'
import type { StaffTagsStorage } from './lib/types'
export default plugin<{ jsonStorage: StaffTagsStorage }>({
jsonStorage: {
load: true,
default: { useRoleColor: false, tags: {} },
},
start(api) {
const unpatchAll = applyPatches('Staff Tags', api.logger, {
tag: patchTag,
chat: () => patchChat(api.jsonStorage),
name: () => patchName(api.jsonStorage),
details: () => patchDetails(api.jsonStorage),
})
api.cleanup(unpatchAll)
},
SettingsComponent: Settings,
})

View file

@ -1,50 +0,0 @@
import { after } from '@revenge-mod/patcher'
import type { FC, ReactElement, ReactNode } from 'react'
/**
* Hooks the render output of a *specific element instance* by patching its own `type` property.
*
* This is safe (unlike patching a component's module export) because `element.type` is a fresh
* reference on an object we just created ourselves - nothing else has captured it yet, so there's
* no same-chunk-destructuring staleness to worry about. See @revenge-mod/patcher's `after` docs
* and the pattern used by Revenge's own `user-badges` plugin (`afterRendered` there).
*
* For a `React.memo()`-wrapped type, `element.type` is a non-callable `{$$typeof, type, compare}`
* object, not a function. Patching `element.type` directly (confirmed live - crashed the member
* list on `UserRow`) replaces it with a proxy whose own stub target *is* callable, which fools
* React's reconciler into taking the plain-function-component fast path (it checks
* `typeof Component === 'function'` before checking `$$typeof`) instead of the memo path - React
* then calls the proxy directly, whose `apply` trap forwards to `Reflect.apply(realMemoWrapper,
* ...)` and throws `TypeError: target is not callable`, since the real memo wrapper was never
* callable. Patching the wrapper's *inner* `.type` instead - the actual render function memo
* hides - avoids this: it's genuinely callable, it's what React actually invokes for a memo
* component (`updateSimpleMemoComponent` calls the inner function, not the wrapper), and leaving
* the wrapper's own shape (`$$typeof`/`compare`) untouched keeps React's own type detection
* correct.
*
* CAUTION: only call this once per distinct memo-wrapped type, ever. The memo wrapper's inner
* `.type` is shared by every instance of that component (memo only produces one render function
* total), not fresh per instance the way a plain element's `.type` is - so calling this from
* something that re-registers on every render (e.g. an `afterJSX` callback firing once per
* `createElement`) stacks a new self-unpatching hook onto that same shared function each time,
* and each stacked hook fires on *any* instance's render, not just the one that registered it.
* That mismatch broke tag placement in the member list for a long time (see git history on
* `patches/details.tsx`) - `patches/details.tsx` now patches the memo case directly with a single
* persistent `@revenge-mod/patcher` `after()` instead of going through this helper.
*/
export const afterRendered = (
element: ReactElement<any, FC<any>>,
hook: (el: ReactNode) => ReactNode,
) => {
const type = element.type as unknown as {
type?: unknown
$$typeof?: unknown
}
const isMemoWrapped =
typeof element.type !== 'function' && typeof type?.type === 'function'
const target = (isMemoWrapped ? type : element) as Record<'type', FC<any>>
return after(target, 'type', el =>
el instanceof Promise ? el.then(hook) : hook(el),
)
}

View file

@ -1,35 +0,0 @@
export interface PluginLogger {
error(message: string): void
}
/**
* Wraps a patch-application function so a single broken lookup only disables that one surface
* instead of crashing the whole plugin's `start()` and taking every other patch down with it.
*/
function safePatch(
pluginName: string,
patchName: string,
apply: () => () => unknown,
logger: PluginLogger,
): () => unknown {
try {
return apply()
} catch (e) {
logger.error(
`[${pluginName}] Failed to apply the "${patchName}" patch, that surface will be skipped: ${e}`,
)
return () => {}
}
}
/** Applies a named set of patches with safePatch and returns one combined unpatch function. */
export function applyPatches(
pluginName: string,
logger: PluginLogger,
patches: Record<string, () => () => unknown>,
): () => void {
const unpatches = Object.entries(patches).map(([name, apply]) =>
safePatch(pluginName, name, apply, logger),
)
return () => unpatches.forEach(unpatch => unpatch())
}

View file

@ -1,64 +0,0 @@
export const HEX_REGEX = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i
export function isValidHex(value: string | undefined | null): value is string {
return !!value && HEX_REGEX.test(value)
}
/** Expands short form #abc to #aabbcc; leaves 6-digit hex untouched. */
export function normalizeHex(hex: string): string {
if (hex.length === 4) {
return `#${hex
.slice(1)
.split('')
.map(c => c + c)
.join('')}`
}
return hex
}
function hexToRgb(hex: string): number[] {
return (normalizeHex(hex).match(/\w\w/g) ?? ['00', '00', '00']).map(x =>
Number.parseInt(x, 16),
)
}
function rgbToHex(rgb: number[]): string {
return `#${rgb.map(x => Math.max(0, Math.min(255, x)).toString(16).padStart(2, '0')).join('')}`
}
export function interpolateColor(
color1: string,
color2: string,
percentage: number,
): string {
const rgb1 = hexToRgb(color1)
const rgb2 = hexToRgb(color2)
const interpolated = rgb1.map((c1, i) =>
Math.round(c1 + (rgb2[i] - c1) * percentage),
)
return rgbToHex(interpolated)
}
/** Mixes a color toward white by `amount` (0-1). Stands in for chroma's `.brighten()` without a chroma-js module lookup. */
export function brighten(hex: string, amount: number): string {
return interpolateColor(hex, '#FFFFFF', Math.max(0, Math.min(1, amount)))
}
/**
* WCAG relative luminance (0 = black, 1 = white). Used to pick readable text color against a
* background - stands in for chroma's `.get('lab.l')` threshold without a chroma-js module lookup.
*/
export function relativeLuminance(hex: string): number {
const [r, g, b] = hexToRgb(hex).map(c => {
const s = c / 255
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4
})
return 0.2126 * r + 0.7152 * g + 0.0722 * b
}
/** Hardcoded Discord raw palette values (not exposed anywhere in @revenge-mod/types yet). */
export const rawColors = {
WHITE_500: '#FFFFFF',
BLACK_500: '#1E1F22',
}

View file

@ -1,10 +0,0 @@
/**
* `findInReactFiber` only narrows its result when given a type-predicate function
* (`(node) => node is X`). These walks are over untyped, Discord-internal fiber/element shapes,
* so there's no real `X` to narrow to - this just satisfies that signature with `any`.
*/
export function fiberFilter(
predicate: (node: any) => boolean,
): (node: any) => node is any {
return predicate as (node: any) => node is any
}

View file

@ -1,28 +0,0 @@
import {
createFilterGenerator,
FilterFlag,
FilterScopes,
} from '@revenge-mod/modules/finders/filters'
/**
* `withName` checks `exports.name`/`exports.default.name` - a plain function/class's own
* runtime name. A `React.memo()`-wrapped component is a `{$$typeof, type, compare}` object with
* no `.name` of its own (the real name lives on the hidden inner `.type`), so `withName` can
* structurally never match one - confirmed live for `UserRow` via a memo-patching sweep
* (`next-eval-checks`' `memoSweep`), and confirmed from `withName`'s own source
* (`@revenge-mod/modules/finders/filters`): it only ever checks `exports.name` and
* `exports.default.name`, never `exports.default.type.name`.
*
* This filter checks the memo wrapper's inner name instead, and matches on the full module
* namespace (so the callback receives `{default: <memo wrapper>, ...}`, not the unwrapped
* default) - the wrapper is what `afterJSX` needs, since that's the exact reference Discord's own
* `createElement(UserRow, ...)` calls use as `type`, not the inner render function memo hides.
*/
export const withMemoDefaultName = createFilterGenerator<[name: string]>(
([name], _id, exports) =>
(exports as { default?: { type?: { name?: string } } })?.default?.type
?.name === name,
([name]) => `revenge-next-plugs.staff-tags.memoDefaultName(${name})`,
FilterFlag.RequiresExports,
FilterScopes.Initialized,
)

View file

@ -1,245 +0,0 @@
import { Stores } from '@revenge-mod/discord/flux'
import { getModules } from '@revenge-mod/modules/finders'
import { withProps, withSingleProp } from '@revenge-mod/modules/finders/filters'
import { brighten, isValidHex, rawColors, relativeLuminance } from './color'
import type { DiscordModules } from '@revenge-mod/discord/types'
import type { JsonStorage } from '@revenge-mod/json-storage'
import type { StaffTagsStorage, TagOverride } from './types'
interface PermissionsModule {
computePermissions(args: {
user: unknown
context: unknown
overwrites?: unknown
}): bigint | number | undefined
canEveryoneRole?: unknown
}
interface GuildMember {
colorString?: string
}
type GuildMemberStoreType = DiscordModules.Flux.Store<{
getMember(
guildId: string | undefined,
userId: string | undefined,
): GuildMember | null
}>
/**
* Reads `Stores.GuildMemberStore` fresh on each call rather than caching it at module scope.
*
* External plugin bundles run via a bare `new Function('revenge', 'plugin', script)` call with
* no Metro-style dependency ordering, at preInit, regardless of which lifecycle the plugin
* actually declares - so touching *any* `revenge.*` API at module top level runs before
* Revenge's own API-providing plugins are guaranteed to have set anything up. This function is
* only ever called from inside a render patch, well after boot, where that's no longer a risk.
*/
function getGuildMemberStore(): GuildMemberStoreType {
return Stores.GuildMemberStore as GuildMemberStoreType
}
/**
* Subscribed once, lazily, from `start()` rather than at plugin-module import time. Uses
* `getModules` (reacts once Discord itself initializes the module) instead of `lookupModule`
* (which force-initializes uninitialized modules right away) - `getTag()` only runs from inside
* a render patch, well after boot, so this is lower-risk than the boot-time lookups elsewhere in
* this plugin, but there's no reason to force it either. If the module isn't warmed up yet on the
* very first call, permission-based tags just don't resolve for that call and pick up on the next
* render once it's available.
*
* TODO(live-verify): computePermissions/Permissions module shapes, see eval-for-revenge.
*/
let permissionsModule: PermissionsModule | undefined
let PermissionsMap: { Permissions: Record<string, bigint | number> } | undefined
let permissionsSubscribed = false
function resolvePermissionsModules(): void {
if (permissionsSubscribed) return
permissionsSubscribed = true
getModules(
withProps<PermissionsModule>('computePermissions', 'canEveryoneRole'),
mod => {
permissionsModule = mod
},
)
getModules(
withSingleProp<{ Permissions: Record<string, bigint | number> }>(
'Permissions',
),
mod => {
PermissionsMap = mod
},
)
}
export interface TagDefinition {
id: string
defaultText: string
defaultColor: string
condition?: (guild: any, channel: any, user: any) => boolean
permissions?: string[]
}
export const TAG_DEFINITIONS: TagDefinition[] = [
{
id: 'webhook',
defaultText: 'WEBHOOK',
defaultColor: '#99AAB5',
condition: (_g, _c, user) => !!user?.isNonUserBot?.(),
},
{
id: 'owner',
defaultText: 'OWNER',
defaultColor: '#F0B232',
condition: (guild, _c, user) => !!guild && guild.ownerId === user?.id,
},
{
id: 'admin',
defaultText: 'ADMIN',
defaultColor: '#F23F42',
permissions: ['ADMINISTRATOR'],
},
{
id: 'staff',
defaultText: 'STAFF',
defaultColor: '#23A55A',
permissions: [
'MANAGE_GUILD',
'MANAGE_CHANNELS',
'MANAGE_ROLES',
'MANAGE_WEBHOOKS',
],
},
{
id: 'mod',
defaultText: 'MOD',
defaultColor: '#5865F2',
permissions: ['MANAGE_MESSAGES', 'KICK_MEMBERS', 'BAN_MEMBERS'],
},
{
id: 'vc_mod',
defaultText: 'VC Mod',
defaultColor: '#1ABC9C',
permissions: ['MOVE_MEMBERS', 'MUTE_MEMBERS', 'DEAFEN_MEMBERS'],
},
{
id: 'chat_mod',
defaultText: 'Chat Mod',
defaultColor: '#9B59B6',
permissions: ['MODERATE_MEMBERS'],
},
]
export interface ResolvedTag {
id: string
text: string
textColor: string
backgroundColor: string
gradientColor?: string
verified: boolean
}
/** Reads the per-tag settings override from the (already-loaded) storage cache. Never mutates it - use `storage.set()` for writes. */
export function tagSettings(
storage: JsonStorage<StaffTagsStorage>,
id: string,
): TagOverride {
return storage.cache?.tags?.[id] ?? {}
}
function resolveBackgroundColor(
def: TagDefinition,
settings: TagOverride,
storage: JsonStorage<StaffTagsStorage>,
guild: any,
user: any,
): string {
if (settings.useCustomColor && isValidHex(settings.color)) {
return settings.color
}
if (storage.cache?.useRoleColor) {
try {
const roleColor = getGuildMemberStore()?.getMember?.(
guild?.id,
user?.id,
)?.colorString
if (roleColor) return roleColor
} catch {
// fall through to default
}
}
return def.defaultColor
}
export default function getTag(
storage: JsonStorage<StaffTagsStorage>,
guild: any,
channel: any,
user: any,
): ResolvedTag | undefined {
if (!user) return undefined
resolvePermissionsModules()
let permissions: string[] = []
if (guild) {
try {
const permissionsInt = permissionsModule?.computePermissions?.({
user,
context: guild,
overwrites: channel?.permissionOverwrites,
})
if (permissionsInt != null && PermissionsMap?.Permissions) {
permissions = Object.entries(PermissionsMap.Permissions)
.filter(([, bit]) => (permissionsInt as bigint) & (bit as bigint))
.map(([name]) => name)
}
} catch {
// no guild permission context available, treat as none
}
}
for (const def of TAG_DEFINITIONS) {
const settings = tagSettings(storage, def.id)
if (settings.enabled === false) continue
const matchesCondition = !!def.condition?.(guild, channel, user)
const matchesPermission =
!user.bot && !!def.permissions?.some(p => permissions.includes(p))
if (!matchesCondition && !matchesPermission) continue
const backgroundColor = resolveBackgroundColor(
def,
settings,
storage,
guild,
user,
)
const textColor =
relativeLuminance(backgroundColor) > 0.35
? rawColors.BLACK_500
: rawColors.WHITE_500
let gradientColor: string | undefined
if (settings.useGradient) {
gradientColor = isValidHex(settings.gradientColor)
? settings.gradientColor
: brighten(backgroundColor, 0.4)
}
return {
id: def.id,
text: settings.text?.trim() || def.defaultText,
textColor,
backgroundColor,
gradientColor,
verified: false,
}
}
return undefined
}

View file

@ -1,53 +0,0 @@
import { lookupModule } from '@revenge-mod/modules/finders'
import { withProps } from '@revenge-mod/modules/finders/filters'
import type { FC, ReactNode } from 'react'
import type { StyleProp, ViewStyle } from 'react-native'
interface GradientProps {
colors: string[]
start?: { x: number; y: number }
end?: { x: number; y: number }
style?: StyleProp<ViewStyle>
children?: ReactNode
}
let cached: FC<GradientProps> | null | undefined
let attempted = false
/**
* Discord ships a native gradient view (used for boost/nitro badges) but it isn't part of any
* stable public API, so this is a best-effort lookup that quietly returns null if it can't be
* found - gradient tags then fall back to a solid color instead of crashing anything.
*
* `withProps` checks the default export automatically and unwraps to it, mirroring the old
* `findByProps(...).default` lookup from the Classic build.
*
* Deliberately still uses `lookupModule` (which force-initializes uninitialized modules) rather
* than `getModules` here, unlike the other lookups in this plugin: this only ever runs from
* inside `GradientTag`'s render, which only happens post-boot when a gradient tag is actually
* about to be drawn on screen, and the call site needs a component reference synchronously to
* decide what JSX to return - `getModules`' callback-based "maybe later" shape doesn't fit here.
*
* TODO(live-verify): unconfirmed against the current Next/Discord build, see eval-for-revenge.
*/
export function getGradientComponent(): FC<GradientProps> | null {
if (attempted) return cached ?? null
attempted = true
try {
const [startEndMatch] = lookupModule(
withProps<Record<string, unknown>>('colors', 'start', 'end'),
)
const [locationsMatch] = lookupModule(
withProps<Record<string, unknown>>('colors', 'locations'),
)
cached =
(startEndMatch as FC<GradientProps> | undefined) ??
(locationsMatch as FC<GradientProps> | undefined) ??
null
} catch {
cached = null
}
return cached
}

View file

@ -1,31 +0,0 @@
/**
* Runs `fn` and swallows any throw, returning `fallback` instead.
*
* `applyPatches`/`safePatch` only guard the synchronous call that *registers* a patch (e.g.
* `getModules(filter, cb)` itself). The `cb` runs later - whenever Discord happens to initialize
* a matching module, which can be deep inside Discord's own boot sequence - completely outside
* that try/catch. An uncaught throw there crashes app startup instead of just skipping a tag.
* Every render-triggered callback (getModules/afterJSX/beforeJSX/afterRendered) needs its own
* guard for this reason.
*/
export function guard<T>(fn: () => T, fallback: T): T {
try {
return fn()
} catch {
return fallback
}
}
/**
* Adds a style object to an existing `style` prop without assuming its shape. RN style props are
* commonly a single object (not an array) - spreading a non-array/non-iterable value with `[...x]`
* throws, which is what crashed app boot the first time this shipped.
*/
export function mergeStyle(
style: unknown,
patch: Record<string, unknown>,
): unknown[] {
if (Array.isArray(style)) return [...style, patch]
if (style) return [style, patch]
return [patch]
}

View file

@ -1,13 +0,0 @@
export interface TagOverride {
enabled?: boolean
text?: string
useCustomColor?: boolean
color?: string
useGradient?: boolean
gradientColor?: string
}
export interface StaffTagsStorage {
useRoleColor: boolean
tags: Record<string, TagOverride>
}

View file

@ -1,103 +0,0 @@
import { Stores } from '@revenge-mod/discord/flux'
import { getModules } from '@revenge-mod/modules/finders'
import { withName } from '@revenge-mod/modules/finders/filters'
import { instead } from '@revenge-mod/patcher'
import { processColor } from 'react-native'
import getTag from '../lib/getTag'
import { guard } from '../lib/safe'
import type { JsonStorage } from '@revenge-mod/json-storage'
import type { StaffTagsStorage } from '../lib/types'
interface GetTagPropertiesArgs {
message: { author?: any }
channel?: { guild_id?: string }
}
interface TagProperties {
tagType?: unknown
tagText?: string
tagTextColor?: unknown
tagBackgroundColor?: unknown
tagVerified?: boolean
}
/**
* Chat message tags are rendered from plain data (not a patchable element tree), so gradients
* aren't possible here - only in the member list and profile, where we control the JSX directly.
*
* `getTagProperties` is a plain data function, not a rendered component, so `afterJSX` doesn't
* apply - this stays a module-property patch via `patcher.instead`, same mechanism the Classic
* build used successfully for this exact function.
*
* Uses `getModules` (reacts once Discord itself initializes the module) instead of
* `lookupModule` (which force-initializes uninitialized modules right away) - forcing this
* module to init during `start()`, before Discord is done booting, is what crashed app startup
* the first time this shipped.
*
* `{ returnNamespace: true }` asks for the whole module object (not just the unwrapped default
* export) so `instead` has a `parent`/`key` pair to patch in place.
*
* The `instead` hook is guarded: unlike the JSX patches, it *replaces* the original function for
* every future call, so an uncaught throw here wouldn't just skip one tag - it would break
* message rendering entirely from that point on.
*
* Confirmed live (see /root/evals-for-rn): args are `{message, isSystemDM, channel, colors}` -
* `channel` is handed to us directly, so no `ChannelStore.getChannel()` lookup is needed.
*/
export default function patchChat(storage: JsonStorage<StaffTagsStorage>) {
let unpatch = () => {}
const GuildStore = Stores.GuildStore as unknown as {
getGuild(id: string | undefined): any
}
const unsub = getModules(
withName<(args: GetTagPropertiesArgs) => TagProperties>('getTagProperties'),
TagPropertiesModule => {
guard(() => {
const mod = TagPropertiesModule as unknown as {
default?: (args: GetTagPropertiesArgs) => TagProperties
}
if (!mod.default) return
unpatch = instead(
mod as { default: (args: GetTagPropertiesArgs) => TagProperties },
'default',
([args], original) => {
// Call through unguarded - if Discord's own function throws, that's not
// something we caused or can meaningfully recover from here.
const ret = original(args)
// Only our own augmentation is guarded: fall back to the real, unmodified
// result rather than risk breaking every message render from here on.
return guard(() => {
if (ret?.tagType) return ret
const { message, channel } = args
const guild = GuildStore?.getGuild(channel?.guild_id)
const tag = getTag(storage, guild, channel, message?.author)
if (!tag) return ret
return {
...ret,
tagText: tag.text,
tagTextColor: tag.textColor
? processColor(tag.textColor)
: undefined,
tagBackgroundColor: processColor(tag.backgroundColor),
tagVerified: tag.verified,
tagType: undefined,
}
}, ret)
},
)
}, undefined)
},
{ returnNamespace: true },
)
return () => {
unsub()
unpatch()
}
}

View file

@ -1,155 +0,0 @@
import { Stores } from '@revenge-mod/discord/flux'
import { getModules } from '@revenge-mod/modules/finders'
import { afterJSX } from '@revenge-mod/react/jsx-runtime'
import { withMemoDefaultName } from '../lib/filters'
import getTag from '../lib/getTag'
import { guard } from '../lib/safe'
import GradientTag from '../ui/GradientTag'
import type { JsonStorage } from '@revenge-mod/json-storage'
import type { FC, ReactElement } from 'react'
import type { StaffTagsStorage } from '../lib/types'
interface UserRowProps {
guildId?: string
user: any
}
/**
* Covers member list rows and the profile popout, where the name and tag sit on the same line.
*
* Multiple distinct `UserRow` implementations can exist in the same build (member list vs.
* profile popout, etc.), so this subscribes rather than doing a single lookup - `max` is a guess
* and may need adjusting once verified live, see eval-for-revenge.
*
* `UserRow` is real but `React.memo()`-wrapped (confirmed live via a memo-patching sweep) - a
* plain `withName('UserRow')` can never match it, since the wrapper object has no `.name` of its
* own (only the memo-hidden inner function does). `withMemoDefaultName` (`../lib/filters`) checks
* the wrapper's inner `.type.name` instead and matches on the full module namespace, so the
* callback below receives `{default: <memo wrapper>}` - `mod.default` is the exact reference
* Discord's own `createElement(UserRow, ...)` calls use, which is what `afterJSX` needs.
*
* Does NOT patch the memo wrapper's shared inner `.type` via `@revenge-mod/patcher`'s generic
* `after()` (tried and confirmed live to be unsound - see git history on this file). That's true
* even for a single, non-stacking, persistent `after()` call: wrapping the shared render function
* in `after()`'s Proxy broke member list rendering outright, every time, even on a fully fresh app
* process with nothing else patched. Whatever `after()` does to make a target "patchable" doesn't
* survive contact with this particular shared function.
*
* Instead, `afterJSX(wrapper, el => ...)` intercepts each `createElement(UserRow, props)` call
* (the per-instance element, never the shared object) and does a **plain, unwrapped property
* assignment** - `el.type = patchedRender` - to a single stable function reused for every row
* (defined once, outside the `afterJSX` callback, not a fresh closure per row). `patchedRender`
* manually calls the real inner render function itself (`innerRender(props)`, safe to call
* directly - React only ever needs the dispatcher active, and it is, since `patchedRender` itself
* is invoked *by* React as `el`'s render function), then augments the returned tree. This avoids
* `after()`'s Proxy machinery entirely while still landing a plain, always-callable function
* (never the non-callable memo wrapper) on `element.type`, so it doesn't hit the earlier
* "TypeError: target is not callable" crash either. Reusing the same function reference across
* every row (rather than a fresh closure per element) keeps `element.type` identity stable across
* re-renders, letting React reconcile these rows normally instead of remounting each one.
*
* `UserRow` renders a single `TableRow` whose name+tag live at `ret.props.label` (a `View`) as a
* **prop**, not somewhere reachable by a generic "find array children containing a string"
* search - that mismatch (not the render-output shape itself) is why tags never appeared even
* before the crash: `findInReactFiber` was searching for a shape that doesn't exist in this tree.
* `label.props.children` is `[nameElement, fragment, existingTagElement]` - confirmed live via
* `/root/evals-for-rn` - where `existingTagElement` is Discord's own `BotTag` component with a
* static `.Types` enum and a numeric `type` prop; ordinary members render it already, with
* `type: 0` (an inactive placeholder, not the "BOT" badge that enum value name suggests). Kept
* `existingTag.props?.type !== 0` as a guard against stacking our own tag next to a real built-in
* badge (bot/official/system), but dropped the old "mutate `existingTag`'s props in place" path
* entirely - confirmed live that `BotTag` derives its displayed label purely from the `type`
* enum (via the same module's `getBotLabel`), ignoring any custom `text`/color props entirely, so
* that branch silently did nothing. Always injecting our own plugin-authored `GradientTag`
* instead (confirmed live to actually render custom text/color) is the only path that works;
* `GradientTag` already branches on its own `gradientColor` prop for solid-vs-gradient, so there's
* no need for the old `TagModule` (Discord's `BotTag` module again, same dead end) fallback.
*
* Uses `getModules` (reacts once Discord itself initializes the module) instead of
* `lookupModule` (which force-initializes uninitialized modules right away) - forcing UserRow to
* init during `start()`, before Discord is done booting, is what crashed app startup the first
* time this shipped.
*
* Every callback below is wrapped in `guard()`: it runs whenever Discord itself renders/
* initializes the matched module, not inside `applyPatches`' try/catch, which only covers the
* synchronous setup calls - an uncaught throw here would otherwise skip a row's tag at best, or
* (per the shared-target lesson above) risk destabilizing every row's rendering at worst.
*/
export default function patchDetails(storage: JsonStorage<StaffTagsStorage>) {
const patches: (() => void)[] = []
const GuildStore = Stores.GuildStore as unknown as {
getGuild(id: string | undefined): any
}
const unsubUserRow = getModules(
withMemoDefaultName('UserRow'),
mod => {
guard(() => {
const wrapper = (mod as { default: { type: FC<UserRowProps> } }).default
const innerRender = wrapper.type
if (typeof innerRender !== 'function') return
// Call through unguarded - if Discord's own render function throws, that's not
// something we caused or can meaningfully recover from here (same rationale as
// `chat.ts`'s `getTagProperties` patch). Only our own augmentation is guarded.
const patchedRender: FC<UserRowProps> = props => {
const ret = innerRender(props)
return guard(() => {
const { guildId, user } = props
if (!user) return ret
const label = (ret as ReactElement)?.props?.label as
| ReactElement
| undefined
if (!label || !Array.isArray(label.props?.children)) return ret
// A plain array `.find()`, not `findInReactFiber` - confirmed live that
// `findInReactFiber` doesn't match here. It's built for real React fiber
// nodes (`.child`/`.sibling`/`.return`), not the plain element tree `ret`
// actually is at this point (`innerRender`'s return value, pre-reconciliation
// - createElement() output, not yet a fiber). `label.props.children` is a
// flat, already-known array, so a direct search needs no fiber-walking helper.
const existingTag = (
label.props.children as {
type?: { Types?: unknown }
props?: any
}[]
).find(c => c?.type?.Types)
if (existingTag && existingTag.props?.type !== 0) return ret
const guild = GuildStore?.getGuild(guildId)
const tag = getTag(storage, guild, undefined, user)
if (!tag) return ret
const container = label.props as { children: unknown[] }
container.children.push(
<GradientTag
text={tag.text}
textColor={tag.textColor}
backgroundColor={tag.backgroundColor}
gradientColor={tag.gradientColor}
/>,
)
return ret
}, ret)
}
patches.push(
afterJSX(wrapper as unknown as FC<UserRowProps>, el =>
guard(() => {
;(el as { type: unknown }).type = patchedRender
return el
}, el),
),
)
}, undefined)
},
{ max: 4 },
)
patches.push(unsubUserRow)
return () => patches.forEach(unpatch => unpatch())
}

View file

@ -1,188 +0,0 @@
import { Stores } from '@revenge-mod/discord/flux'
import { getModules } from '@revenge-mod/modules/finders'
import { withName, withProps } from '@revenge-mod/modules/finders/filters'
import { afterJSX } from '@revenge-mod/react/jsx-runtime'
import { findInReactFiber } from '@revenge-mod/utils/react'
import { afterRendered } from '../lib/afterRendered'
import { fiberFilter } from '../lib/fiber'
import getTag from '../lib/getTag'
import { guard } from '../lib/safe'
import GradientTag from '../ui/GradientTag'
import type { JsonStorage } from '@revenge-mod/json-storage'
import type { FC } from 'react'
import type { StaffTagsStorage } from '../lib/types'
interface DisplayNameProps {
guildId?: string
channelId?: string
user: any
}
interface HeaderNameProps {
channelId?: string
}
interface TagComponentProps {
type: number
text: string
textColor: string
backgroundColor: string
verified: boolean
style?: any
}
/**
* Covers the name row shown in chat messages (next to the display name + timestamp) and channel
* headers.
*
* `HeaderName` doesn't exist under that name in the current build (confirmed live - Discord
* renamed/refactored it at some point). `ChannelHeader` is the confirmed replacement: found via a
* broad name-sweep as a plain, non-memo-wrapped function, high confidence given its name and
* shape - a plain `withName` lookup can find it directly, unlike `UserRow` in `details.tsx` which
* needed a memo-aware filter.
*
* `DisplayName` (the message-author-name row) is still unresolved - not found under any name even
* via a memo/forwardRef sweep and a text-content correlation sweep across two separate live
* sessions. Most likely inlined into a bigger message-row component now rather than factored out
* the way Classic had it. Left as a best-effort lookup below (guarded, silently no-ops) rather
* than guessed at - fixing this needs a fresh live investigation, not a guess.
*
* Uses `getModules` (reacts once Discord itself initializes each module) instead of
* `lookupModule` (which force-initializes uninitialized modules right away) - forcing
* DisplayName/ChannelHeader/the Tag module to init during `start()`, before Discord is done
* booting, is what crashed app startup the first time this shipped.
*
* Every callback below is wrapped in `guard()`: it runs whenever Discord itself renders/
* initializes the matched module, not inside `applyPatches`' try/catch, which only covers the
* synchronous setup calls - an uncaught throw here crashes app startup instead of just skipping
* a name row.
*
* TODO(live-verify): ChannelHeader's rendered-tree shape (`c?.type?.Types`, the
* flexDirection: "row" container) still unconfirmed, see eval-for-revenge. DisplayName remains
* fully unresolved.
*/
export default function patchName(storage: JsonStorage<StaffTagsStorage>) {
const cleanups: (() => void)[] = []
let TagModule: { default?: FC<TagComponentProps> } | undefined
const unsubTagModule = getModules(
withProps('default', 'getBotLabel'),
mod => {
TagModule = mod as { default?: FC<TagComponentProps> }
},
{ skipDefault: true },
)
cleanups.push(unsubTagModule)
const GuildStore = Stores.GuildStore as unknown as {
getGuild(id: string | undefined): any
}
const ChannelStore = Stores.ChannelStore as unknown as {
getChannel(id: string | undefined): any
}
const unsubHeaderName = getModules(
withName<FC<HeaderNameProps>>('ChannelHeader'),
ChannelHeader => {
guard(() => {
cleanups.push(
afterJSX(ChannelHeader, el =>
guard(() => {
const { channelId } = el.props
const unpatch = afterRendered(el, ret =>
guard(() => {
unpatch()
const node = ret as any
if (node?.props) node.props.channelId = channelId
return ret
}, ret),
)
return el
}, el),
),
)
}, undefined)
},
)
cleanups.push(unsubHeaderName)
const unsubDisplayName = getModules(
withName<FC<DisplayNameProps>>('DisplayName'),
DisplayName => {
guard(() => {
cleanups.push(
afterJSX(DisplayName, el =>
guard(() => {
const { guildId, channelId, user } = el.props
const unpatch = afterRendered(el, ret =>
guard(() => {
unpatch()
const tagComponent = findInReactFiber(
ret as any,
fiberFilter(c => c?.type?.Types),
)
// A real built-in tag (bot/system/etc.) is already present - don't touch it.
if (tagComponent && tagComponent.props?.type !== 0) return ret
const guild = GuildStore?.getGuild(guildId)
const channel = ChannelStore?.getChannel(channelId)
const tag = getTag(storage, guild, channel, user)
if (!tag) return ret
if (tagComponent) {
tagComponent.props = {
type: 0,
text: tag.text,
textColor: tag.textColor,
backgroundColor: tag.backgroundColor,
verified: tag.verified,
}
return ret
}
const row = findInReactFiber(
ret as any,
fiberFilter(c => c?.props?.style?.flexDirection === 'row'),
)
if (!Array.isArray(row?.props?.children)) return ret
if (tag.gradientColor) {
row.props.children.push(
<GradientTag
style={{ marginLeft: 0 }}
text={tag.text}
textColor={tag.textColor}
backgroundColor={tag.backgroundColor}
gradientColor={tag.gradientColor}
/>,
)
} else if (TagModule?.default) {
const Component = TagModule.default
row.props.children.push(
<Component
style={{ marginLeft: 0 }}
type={0}
text={tag.text}
textColor={tag.textColor}
backgroundColor={tag.backgroundColor}
verified={tag.verified}
/>,
)
}
return ret
}, ret),
)
return el
}, el),
),
)
}, undefined)
},
)
cleanups.push(unsubDisplayName)
return () => cleanups.forEach(fn => fn())
}

View file

@ -1,94 +0,0 @@
import { getModules } from '@revenge-mod/modules/finders'
import { withProps } from '@revenge-mod/modules/finders/filters'
import { afterJSX } from '@revenge-mod/react/jsx-runtime'
import { findInReactFiber } from '@revenge-mod/utils/react'
import { afterRendered } from '../lib/afterRendered'
import { fiberFilter } from '../lib/fiber'
import { guard, mergeStyle } from '../lib/safe'
import type { FC } from 'react'
interface TagProps {
text?: string
textColor?: string
backgroundColor?: string
}
/**
* Discord's own Tag component ignores custom text/color props unless we reach into its
* already-rendered output and push them in ourselves - this is what actually makes the
* text/textColor/backgroundColor props (that `name.tsx`/`details.tsx` inject) visible.
*
* `afterJSX` intercepts every `createElement(Tag, ...)` call globally, regardless of who calls
* it (Discord's own code or ours) and regardless of which chunk captured the reference -
* patching the component's module export directly wouldn't reliably reach Discord's own
* same-chunk render calls.
*
* Uses `getModules` (reacts once Discord itself initializes the module) instead of
* `lookupModule` (which force-initializes uninitialized modules right away to check their
* exports) - forcing this module to init during `start()`, before Discord is done booting, is
* what crashed app startup the first time this shipped.
*
* Every callback below is wrapped in `guard()`: it runs whenever Discord itself renders/
* initializes the matched module, not inside `applyPatches`' try/catch, which only covers the
* synchronous setup call above - an uncaught throw here crashes app startup instead of just
* skipping this one tag.
*
* TODO(live-verify): Tag module shape (`default` + `getBotLabel`), see eval-for-revenge.
*/
export default function patchTag() {
let unpatchTag = () => {}
const unsub = getModules(
withProps<{ default: FC<TagProps>; getBotLabel: unknown }>(
'default',
'getBotLabel',
),
TagModule => {
guard(() => {
const Tag = TagModule.default
if (!Tag) return
unpatchTag = afterJSX(Tag, el =>
guard(() => {
const { text, textColor, backgroundColor } = el.props
if (!text && !textColor && !backgroundColor) return el
const unpatch = afterRendered(el, ret =>
guard(() => {
unpatch()
const label = findInReactFiber(
ret as any,
fiberFilter(c => typeof c?.props?.children === 'string'),
)
if (!label) return ret
if (text) label.props.children = text
if (textColor)
label.props.style = mergeStyle(label.props.style, {
color: textColor,
})
if (backgroundColor && (ret as any)?.props) {
;(ret as any).props.style = mergeStyle(
(ret as any).props.style,
{ backgroundColor },
)
}
return ret
}, ret),
)
return el
}, el),
)
}, undefined)
},
{ skipDefault: true },
)
return () => {
unsub()
unpatchTag()
}
}

View file

@ -1,104 +0,0 @@
import { Text, TextInput, TouchableOpacity, View } from 'react-native'
import { isValidHex } from '../lib/color'
const PRESETS = [
'#F23F42',
'#F0B232',
'#FEE75C',
'#23A55A',
'#1ABC9C',
'#5865F2',
'#9B59B6',
'#EB459E',
'#99AAB5',
'#FFFFFF',
]
interface ColorInputProps {
title: string
value?: string
placeholder?: string
onChange: (value: string) => void
}
/**
* Hex color field with a live swatch preview and tap-to-pick presets. Built entirely from RN
* primitives (no Discord-internal component reuse), so it doesn't carry the same "might silently
* stop rendering after a Discord update" risk as the plugin's UI patches do.
*/
export default function ColorInput({
title,
value,
placeholder,
onChange,
}: ColorInputProps) {
const valid = !value || isValidHex(value)
const swatchColor = isValidHex(value)
? value
: isValidHex(placeholder)
? placeholder
: '#5865F2'
return (
<View style={{ paddingHorizontal: 16, paddingVertical: 8 }}>
<View
style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 8 }}
>
<View
style={{
width: 20,
height: 20,
borderRadius: 6,
backgroundColor: swatchColor,
marginRight: 10,
borderWidth: 1,
borderColor: 'rgba(128,128,128,0.35)',
}}
/>
<Text style={{ fontSize: 15, fontWeight: '600' }}>{title}</Text>
</View>
<TextInput
value={value ?? ''}
placeholder={placeholder ?? '#5865F2'}
onChangeText={onChange}
autoCapitalize="none"
autoCorrect={false}
style={{
borderWidth: 1,
borderColor: valid ? 'rgba(128,128,128,0.35)' : '#F23F42',
borderRadius: 8,
paddingHorizontal: 10,
paddingVertical: 8,
fontSize: 14,
marginBottom: 8,
}}
/>
<View style={{ flexDirection: 'row', flexWrap: 'wrap' }}>
{PRESETS.map(preset => {
const active = value?.toLowerCase() === preset.toLowerCase()
return (
<TouchableOpacity
key={preset}
onPress={() => onChange(preset)}
style={{
width: 24,
height: 24,
borderRadius: 6,
backgroundColor: preset,
marginRight: 8,
marginBottom: 8,
borderWidth: active ? 2 : 1,
borderColor: active ? '#ffffff' : 'rgba(128,128,128,0.35)',
}}
/>
)
})}
</View>
{!valid && (
<Text style={{ color: '#F23F42', marginTop: 2, fontSize: 12 }}>
Invalid hex color, e.g. #5865F2
</Text>
)}
</View>
)
}

View file

@ -1,54 +0,0 @@
import { Text, View } from 'react-native'
import { getGradientComponent } from '../lib/gradient'
import type { StyleProp, ViewStyle } from 'react-native'
const baseStyle: ViewStyle = {
flexDirection: 'row',
alignItems: 'center',
borderRadius: 4,
paddingHorizontal: 4,
marginLeft: 4,
overflow: 'hidden',
}
interface GradientTagProps {
text: string
textColor: string
backgroundColor: string
gradientColor?: string
style?: StyleProp<ViewStyle>
}
export default function GradientTag({
text,
textColor,
backgroundColor,
gradientColor,
style,
}: GradientTagProps) {
const textStyle = {
color: textColor,
fontSize: 11,
fontWeight: '700' as const,
}
const Gradient = gradientColor ? getGradientComponent() : null
if (Gradient) {
return (
<Gradient
colors={[backgroundColor, gradientColor as string]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={[baseStyle, style]}
>
<Text style={textStyle}>{text}</Text>
</Gradient>
)
}
return (
<View style={[baseStyle, { backgroundColor }, style]}>
<Text style={textStyle}>{text}</Text>
</View>
)
}

View file

@ -1,116 +0,0 @@
import { Design } from '@revenge-mod/discord/design'
import { ScrollView, View } from 'react-native'
import { TAG_DEFINITIONS } from '../../lib/getTag'
import ColorInput from '../ColorInput'
import type { PluginApi } from '@revenge-mod/plugins/types'
import type { StaffTagsStorage, TagOverride } from '../../lib/types'
interface TagSettingsSectionProps {
api: PluginApi<{ jsonStorage: StaffTagsStorage }>
id: string
defaultText: string
defaultColor: string
}
/**
* `Design` is read inside each component (not destructured at module top level): external
* plugin bundles run via a bare `new Function('revenge', 'plugin', script)` call at preInit with
* no Metro-style dependency ordering, so `revenge.discord.design.Design`'s fields aren't
* guaranteed populated yet at that point. By the time these components actually render (the user
* opened the plugin's settings page), Discord's design system is long since ready.
*/
function TagSettingsSection({
api,
id,
defaultText,
defaultColor,
}: TagSettingsSectionProps) {
const { TableRowGroup, TableSwitchRow, TextInput } = Design
const storage = api.jsonStorage.use()
const settings: TagOverride = storage?.tags?.[id] ?? {}
const enabled = settings.enabled !== false
const update = (patch: TagOverride) => {
api.jsonStorage.set({ tags: { [id]: patch } })
}
return (
<TableRowGroup title={defaultText}>
<TableSwitchRow
label="Show this tag"
value={enabled}
onValueChange={(v: boolean) => update({ enabled: v })}
/>
<TextInput
label="Tag text"
placeholder={defaultText}
value={settings.text ?? ''}
editable={enabled}
onChange={(v: string) => update({ text: v })}
/>
<TableSwitchRow
label="Custom color"
subLabel={`Default: ${defaultColor}`}
value={!!settings.useCustomColor}
disabled={!enabled}
onValueChange={(v: boolean) => update({ useCustomColor: v })}
/>
{settings.useCustomColor && (
<ColorInput
title="Color"
value={settings.color}
placeholder={defaultColor}
onChange={(v: string) => update({ color: v })}
/>
)}
<TableSwitchRow
label="Gradient"
subLabel="Member list & profile only, chat tags stay solid"
value={!!settings.useGradient}
disabled={!enabled}
onValueChange={(v: boolean) => update({ useGradient: v })}
/>
{settings.useGradient && (
<ColorInput
title="Gradient color"
value={settings.gradientColor}
onChange={(v: string) => update({ gradientColor: v })}
/>
)}
</TableRowGroup>
)
}
export default function Settings({
api,
}: {
api: PluginApi<{ jsonStorage: StaffTagsStorage }>
}) {
const { TableRowGroup, TableSwitchRow } = Design
const storage = api.jsonStorage.use()
return (
<ScrollView style={{ flex: 1 }}>
<TableRowGroup title="General">
<TableSwitchRow
label="Use top role color"
subLabel="Used when a tag has no custom color set"
value={!!storage?.useRoleColor}
onValueChange={(v: boolean) =>
api.jsonStorage.set({ useRoleColor: v })
}
/>
</TableRowGroup>
{TAG_DEFINITIONS.map(def => (
<TagSettingsSection
key={def.id}
api={api}
id={def.id}
defaultText={def.defaultText}
defaultColor={def.defaultColor}
/>
))}
<View style={{ height: 24 }} />
</ScrollView>
)
}

View file

@ -1,19 +0,0 @@
{
"format": 1,
"id": "dev.everestmcarthur.staff-tags",
"name": "Staff Tags",
"description": "Adds customizable tags (OWNER, ADMIN, STAFF, MOD, and more) next to members based on their server permissions. Supports per-tag colors, gradients, custom text, and visibility toggles.",
"author": "Rosie",
"version": "1.0.10",
"dependencies": {
"revenge.api": {
"version": ">=1 <2"
},
"discord": {
"version": "*"
}
},
"dist": {
"script": "index.js"
}
}