diff --git a/plugins/radial-status/LICENSE b/plugins/radial-status/LICENSE new file mode 100644 index 0000000..3744c08 --- /dev/null +++ b/plugins/radial-status/LICENSE @@ -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. diff --git a/plugins/radial-status/js/index.ts b/plugins/radial-status/js/index.ts new file mode 100644 index 0000000..d0217ac --- /dev/null +++ b/plugins/radial-status/js/index.ts @@ -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, +}) diff --git a/plugins/radial-status/js/lib/color.ts b/plugins/radial-status/js/lib/color.ts new file mode 100644 index 0000000..9bdd798 --- /dev/null +++ b/plugins/radial-status/js/lib/color.ts @@ -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) +} diff --git a/plugins/radial-status/js/lib/safe.ts b/plugins/radial-status/js/lib/safe.ts new file mode 100644 index 0000000..b1fd7c3 --- /dev/null +++ b/plugins/radial-status/js/lib/safe.ts @@ -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(fn: () => T, fallback: T): T { + try { + return fn() + } catch { + return fallback + } +} diff --git a/plugins/radial-status/js/lib/types.ts b/plugins/radial-status/js/lib/types.ts new file mode 100644 index 0000000..b72c88e --- /dev/null +++ b/plugins/radial-status/js/lib/types.ts @@ -0,0 +1,5 @@ +export interface RadialStatusStorage { + enabled: boolean + colors: Record + ringThickness: number +} diff --git a/plugins/radial-status/js/patches/ring.tsx b/plugins/radial-status/js/patches/ring.tsx new file mode 100644 index 0000000..4ca58cf --- /dev/null +++ b/plugins/radial-status/js/patches/ring.tsx @@ -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, +) { + 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) { + 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()) +} diff --git a/plugins/radial-status/js/ui/ColorInput.tsx b/plugins/radial-status/js/ui/ColorInput.tsx new file mode 100644 index 0000000..bd03c16 --- /dev/null +++ b/plugins/radial-status/js/ui/ColorInput.tsx @@ -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 ( + + + + {title} + + + + {PRESETS.map(preset => { + const active = value?.toLowerCase() === preset.toLowerCase() + return ( + 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)', + }} + /> + ) + })} + + {!valid && ( + + Invalid hex color, e.g. #5865F2 + + )} + + ) +} diff --git a/plugins/radial-status/js/ui/pages/Settings.tsx b/plugins/radial-status/js/ui/pages/Settings.tsx new file mode 100644 index 0000000..8bf9e3e --- /dev/null +++ b/plugins/radial-status/js/ui/pages/Settings.tsx @@ -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 ( + + + + 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. + + + + api.jsonStorage.set({ enabled: v })} + /> + + + {STATUSES.map(({ key, label, defaultColor }) => ( + + api.jsonStorage.set({ colors: { [key]: v } }) + } + /> + ))} + + + { + const n = Number.parseFloat(v) + if (!Number.isNaN(n) && n > 0) + api.jsonStorage.set({ ringThickness: n }) + }} + /> + + + + ) +} diff --git a/plugins/radial-status/manifest.json b/plugins/radial-status/manifest.json new file mode 100644 index 0000000..9526df0 --- /dev/null +++ b/plugins/radial-status/manifest.json @@ -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" + } +}