radial-status: port from Classic (Vendetta-compat) to Revenge Next
This commit is contained in:
parent
f7e21c212e
commit
f91874d676
9 changed files with 384 additions and 0 deletions
28
plugins/radial-status/LICENSE
Normal file
28
plugins/radial-status/LICENSE
Normal 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.
|
||||
30
plugins/radial-status/js/index.ts
Normal file
30
plugins/radial-status/js/index.ts
Normal 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,
|
||||
})
|
||||
5
plugins/radial-status/js/lib/color.ts
Normal file
5
plugins/radial-status/js/lib/color.ts
Normal 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)
|
||||
}
|
||||
15
plugins/radial-status/js/lib/safe.ts
Normal file
15
plugins/radial-status/js/lib/safe.ts
Normal 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
|
||||
}
|
||||
}
|
||||
5
plugins/radial-status/js/lib/types.ts
Normal file
5
plugins/radial-status/js/lib/types.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export interface RadialStatusStorage {
|
||||
enabled: boolean
|
||||
colors: Record<string, string>
|
||||
ringThickness: number
|
||||
}
|
||||
102
plugins/radial-status/js/patches/ring.tsx
Normal file
102
plugins/radial-status/js/patches/ring.tsx
Normal 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())
|
||||
}
|
||||
104
plugins/radial-status/js/ui/ColorInput.tsx
Normal file
104
plugins/radial-status/js/ui/ColorInput.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
76
plugins/radial-status/js/ui/pages/Settings.tsx
Normal file
76
plugins/radial-status/js/ui/pages/Settings.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
19
plugins/radial-status/manifest.json
Normal file
19
plugins/radial-status/manifest.json
Normal 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"
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue