Remove plugins and add placeholder readme
Some checks failed
Build and deploy / deploy (push) Failing after 4s
Some checks failed
Build and deploy / deploy (push) Failing after 4s
This commit is contained in:
parent
a84aabb33b
commit
077151f429
28 changed files with 1 additions and 1735 deletions
|
|
@ -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.
|
||||
|
|
@ -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,
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
export function guard<T>(fn: () => T, fallback: T): T {
|
||||
try {
|
||||
return fn()
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
export interface RadialStatusStorage {
|
||||
enabled: boolean
|
||||
colors: Record<string, string>
|
||||
ringThickness: number
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
1
plugins/readme.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
More Plugins Coming Soon
|
||||
|
|
@ -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,
|
||||
})
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
|
|
@ -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',
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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]
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue