radial-status: port from Classic (Vendetta-compat) to Revenge Next

This commit is contained in:
Ally Founder 2026-08-15 12:21:14 -04:00
parent f7e21c212e
commit f91874d676
9 changed files with 384 additions and 0 deletions

View file

@ -0,0 +1,28 @@
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

@ -0,0 +1,30 @@
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

@ -0,0 +1,5 @@
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

@ -0,0 +1,15 @@
/**
* Runs `fn` and swallows any throw, returning `fallback` instead.
*
* `getModules`'s callback runs whenever Discord itself initializes a matching module, which can
* be deep inside Discord's own boot sequence - completely outside any try/catch wrapped around
* the initial registration call. An uncaught throw there crashes app startup instead of just
* skipping the ring patch.
*/
export function guard<T>(fn: () => T, fallback: T): T {
try {
return fn()
} catch {
return fallback
}
}

View file

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

View file

@ -0,0 +1,102 @@
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'
// Forked from AngelW0lf's Radial Status (BSD-3-Clause, see LICENSE), ported from the Classic
// (Vendetta-compat) version of this plugin. Real presence-carrying wrappers are square,
// borderRadius === width/2, with a user.id + status on children[1]/[3], and exactly 5 children -
// matched against this confirmed set of sizes rather than any circular shape. This heuristic is
// Discord-UI-generic (not Classic/Next specific) and is carried over unchanged.
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 // no color configured for this status - leave the native dot alone
const baseSize = wrapper.style[circleIdx].width
// Additive growth (fixed px), not a percentage multiplier - a flat multiplier made the ring
// way too thick on large avatars (YouBar) relative to small ones (member list).
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',
})
}
/**
* `General.View` in Classic (Vendetta-compat) resolves to `findByProps("Button", "Text", "View")`
* (confirmed against vendetta-mod/Vendetta's own source, since `@revenge-mod/discord/design`'s
* `Design` object does NOT expose raw View/Text/Button - it's the higher-level design-system kit,
* a different module). `getModules` (not `lookupModule`) is used deliberately - matches this
* repo's own house convention (see staff-tags/js/patches/name.tsx): forcing this to initialize
* during `start()`, before Discord is done booting, is what crashed app startup for staff-tags
* the first time it shipped.
*/
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

@ -0,0 +1,104 @@
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

@ -0,0 +1,76 @@
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'
// Offline is opt-in (blank by default) since Discord doesn't show a colored dot for it normally.
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' },
]
/**
* `Design` is read inside the 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 (see staff-tags/js/ui/pages/Settings.tsx for the same note). By the
* time this actually renders (the user opened the plugin's settings page), Discord's design
* system is long since ready.
*/
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

@ -0,0 +1,19 @@
{
"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"
}
}