Add shared plugin library (@shared alias) and refactor all plugins to use it

This commit is contained in:
everestmcarthur 2026-08-01 10:34:47 -04:00
parent d682bcb6a1
commit 3c4c559865
20 changed files with 389 additions and 176 deletions

View file

@ -1,8 +1,9 @@
import { readFile, writeFile, readdir } from "fs/promises"; import { readFile, writeFile, readdir } from "fs/promises";
import { extname } from "path"; import { extname, resolve as resolvePath } from "path";
import { createHash } from "crypto"; import { createHash } from "crypto";
import { rollup } from "rollup"; import { rollup } from "rollup";
import alias from "@rollup/plugin-alias";
import url from "@rollup/plugin-url"; import url from "@rollup/plugin-url";
import esbuild from "rollup-plugin-esbuild"; import esbuild from "rollup-plugin-esbuild";
import commonjs from "@rollup/plugin-commonjs"; import commonjs from "@rollup/plugin-commonjs";
@ -11,9 +12,15 @@ import swc from "@swc/core";
const extensions = [".js", ".jsx", ".mjs", ".ts", ".tsx", ".cts", ".mts"]; const extensions = [".js", ".jsx", ".mjs", ".ts", ".tsx", ".cts", ".mts"];
// Plugins are each built into an independent, self-contained bundle (users install them one at a time),
// so they can't import from each other at runtime - but they CAN share source at build time. This alias
// lets any plugin `import { x } from "@shared/..."` and have it inlined straight into its own bundle.
/** @type import("rollup").InputPluginOption */ /** @type import("rollup").InputPluginOption */
const plugins = [ const plugins = [
nodeResolve(), alias({
entries: [{ find: "@shared", replacement: resolvePath("./shared") }],
}),
nodeResolve({ extensions: [".mjs", ".js", ".json", ".node", ".ts", ".tsx"] }),
commonjs(), commonjs(),
url({ url({
include: ["**/*.svg", "**/*.png", "**/*.jpg", "**/*.gif"], include: ["**/*.svg", "**/*.png", "**/*.jpg", "**/*.gif"],

35
package-lock.json generated
View file

@ -9,6 +9,7 @@
"@swc/helpers": "^0.5.17" "@swc/helpers": "^0.5.17"
}, },
"devDependencies": { "devDependencies": {
"@rollup/plugin-alias": "^4.0.4",
"@rollup/plugin-commonjs": "^24.0.0", "@rollup/plugin-commonjs": "^24.0.0",
"@rollup/plugin-node-resolve": "^15.0.1", "@rollup/plugin-node-resolve": "^15.0.1",
"@rollup/plugin-url": "latest", "@rollup/plugin-url": "latest",
@ -968,6 +969,27 @@
} }
} }
}, },
"node_modules/@rollup/plugin-alias": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/@rollup/plugin-alias/-/plugin-alias-4.0.4.tgz",
"integrity": "sha512-0CaAY238SMtYAWEXXptWSR8iz8NYZnH7zNBKuJ14xFJSGwLtPgjvXYsoApAHfzYXXH1ejxpVw7WlHss3zhh9SQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"slash": "^4.0.0"
},
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"rollup": "^1.20.0||^2.0.0||^3.0.0"
},
"peerDependenciesMeta": {
"rollup": {
"optional": true
}
}
},
"node_modules/@rollup/plugin-commonjs": { "node_modules/@rollup/plugin-commonjs": {
"version": "24.1.0", "version": "24.1.0",
"resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-24.1.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-24.1.0.tgz",
@ -3940,6 +3962,19 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/slash": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-4.0.0.tgz",
"integrity": "sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/source-map": { "node_modules/source-map": {
"version": "0.5.7", "version": "0.5.7",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",

View file

@ -8,6 +8,7 @@
"@swc/helpers": "^0.5.17" "@swc/helpers": "^0.5.17"
}, },
"devDependencies": { "devDependencies": {
"@rollup/plugin-alias": "^4.0.4",
"@rollup/plugin-commonjs": "^24.0.0", "@rollup/plugin-commonjs": "^24.0.0",
"@rollup/plugin-node-resolve": "^15.0.1", "@rollup/plugin-node-resolve": "^15.0.1",
"@rollup/plugin-url": "latest", "@rollup/plugin-url": "latest",

View file

@ -2,10 +2,13 @@ import { React, ReactNative } from "@vendetta/metro/common";
import { useProxy } from "@vendetta/storage"; import { useProxy } from "@vendetta/storage";
import { storage } from "@vendetta/plugin"; import { storage } from "@vendetta/plugin";
import { Forms } from "@vendetta/ui/components"; import { Forms } from "@vendetta/ui/components";
import SettingsScaffold from "@shared/ui/SettingsScaffold";
import ListSection from "@shared/ui/ListSection";
import PrimaryButton from "@shared/ui/PrimaryButton";
import { getSnippets, saveSnippet, deleteSnippet } from "../lib/snippets"; import { getSnippets, saveSnippet, deleteSnippet } from "../lib/snippets";
const { ScrollView, View, TouchableOpacity } = ReactNative; const { View } = ReactNative;
const { FormSection, FormRow, FormInput, FormText } = Forms; const { FormSection, FormInput } = Forms;
function NewSnippetForm() { function NewSnippetForm() {
const [name, setName] = React.useState(""); const [name, setName] = React.useState("");
@ -16,24 +19,16 @@ function NewSnippetForm() {
<View style={{ paddingHorizontal: 16, paddingBottom: 12 }}> <View style={{ paddingHorizontal: 16, paddingBottom: 12 }}>
<FormInput title="Name" placeholder="e.g. rules" value={name} onChange={setName} /> <FormInput title="Name" placeholder="e.g. rules" value={name} onChange={setName} />
<FormInput title="Text" placeholder="What /snippet should send" value={text} onChange={setText} multiline /> <FormInput title="Text" placeholder="What /snippet should send" value={text} onChange={setText} multiline />
<TouchableOpacity <PrimaryButton
label="Save snippet"
disabled={!canSave} disabled={!canSave}
style={{ marginTop: 8 }}
onPress={() => { onPress={() => {
saveSnippet(name.trim(), text); saveSnippet(name.trim(), text);
setName(""); setName("");
setText(""); setText("");
}} }}
style={{ />
marginTop: 8,
backgroundColor: "#5865F2",
borderRadius: 8,
padding: 10,
alignItems: "center",
opacity: canSave ? 1 : 0.5
}}
>
<FormText style={{ color: "white" }}>Save snippet</FormText>
</TouchableOpacity>
</View> </View>
); );
} }
@ -45,25 +40,20 @@ export default function Settings() {
const names = Object.keys(snippets); const names = Object.keys(snippets);
return ( return (
<ScrollView style={{ flex: 1 }}> <SettingsScaffold>
<FormSection title="Add a snippet"> <FormSection title="Add a snippet">
<NewSnippetForm /> <NewSnippetForm />
</FormSection> </FormSection>
<FormSection title="Saved snippets"> <ListSection
{names.length === 0 && ( title="Saved snippets"
<FormText style={{ marginHorizontal: 16, marginVertical: 8 }}> emptyText="None yet. Add one above, then send it anywhere with /snippet name."
None yet. Add one above, then send it anywhere with /snippet name. items={names.map((name) => ({
</FormText> key: name,
)} label: name,
{names.map((name) => ( subLabel: `${snippets[name].slice(0, 60)}${snippets[name].length > 60 ? "…" : ""} • Tap to delete`,
<FormRow onPress: () => deleteSnippet(name)
key={name} }))}
label={name} />
subLabel={`${snippets[name].slice(0, 60)}${snippets[name].length > 60 ? "…" : ""} • Tap to delete`} </SettingsScaffold>
onPress={() => deleteSnippet(name)}
/>
))}
</FormSection>
</ScrollView>
); );
} }

View file

@ -1,40 +1,33 @@
import { ReactNative } from "@vendetta/metro/common"; import { React, ReactNative } from "@vendetta/metro/common";
import { Forms } from "@vendetta/ui/components"; import { Forms } from "@vendetta/ui/components";
import SettingsScaffold from "@shared/ui/SettingsScaffold";
import NoteBox from "@shared/ui/NoteBox";
import PrimaryButton from "@shared/ui/PrimaryButton";
const { ScrollView, View, TouchableOpacity, Linking } = ReactNative; const { View, Linking } = ReactNative;
const { FormSection, FormText } = Forms; const { FormSection } = Forms;
const PRONOUNDB_URL = "https://pronoundb.org/"; const PRONOUNDB_URL = "https://pronoundb.org/";
export default function Settings() { export default function Settings() {
return ( return (
<ScrollView style={{ flex: 1 }}> <SettingsScaffold>
<FormSection title="How this works"> <FormSection title="How this works">
<View style={{ paddingHorizontal: 16, paddingVertical: 8 }}> <NoteBox>
<FormText style={{ marginBottom: 12 }}> This plugin doesn't have anything to configure here - there's no API key or toggle,
This plugin doesn't have anything to configure here - there's no API key or toggle, because pronouns aren't Discord data. They come from PronounDB, a separate service
because pronouns aren't Discord data. They come from PronounDB, a separate service where people manually set their own pronouns and link the profile to their Discord
where people manually set their own pronouns and link the profile to their Discord account.
account. </NoteBox>
</FormText> <NoteBox>
<FormText style={{ marginBottom: 12 }}> That means a user's pronouns will only show up if THEY set them at pronoundb.org - not
That means a user's pronouns will only show up if THEY set them at pronoundb.org - not because of anything you enable here. If you want your own pronouns to show up for
because of anything you enable here. If you want your own pronouns to show up for other people running this plugin, set them yourself at the link below.
other people running this plugin, set them yourself at the link below. </NoteBox>
</FormText> <View style={{ paddingHorizontal: 16, paddingTop: 4 }}>
<TouchableOpacity <PrimaryButton label="Open pronoundb.org" onPress={() => Linking.openURL(PRONOUNDB_URL)} />
onPress={() => Linking.openURL(PRONOUNDB_URL)}
style={{
backgroundColor: "#5865F2",
borderRadius: 8,
padding: 12,
alignItems: "center"
}}
>
<FormText style={{ color: "white" }}>Open pronoundb.org</FormText>
</TouchableOpacity>
</View> </View>
</FormSection> </FormSection>
</ScrollView> </SettingsScaffold>
); );
} }

View file

@ -1,12 +1,11 @@
import { ReactNative } from "@vendetta/metro/common"; import { React } from "@vendetta/metro/common";
import { useProxy } from "@vendetta/storage"; import { useProxy } from "@vendetta/storage";
import { storage } from "@vendetta/plugin"; import { storage } from "@vendetta/plugin";
import { Forms } from "@vendetta/ui/components"; import SettingsScaffold from "@shared/ui/SettingsScaffold";
import ListSection from "@shared/ui/ListSection";
import NoteBox from "@shared/ui/NoteBox";
import { getReminders, removeReminder } from "../lib/reminders"; import { getReminders, removeReminder } from "../lib/reminders";
const { ScrollView, View } = ReactNative;
const { FormSection, FormRow, FormText } = Forms;
function formatRemaining(dueAt: number): string { function formatRemaining(dueAt: number): string {
const ms = dueAt - Date.now(); const ms = dueAt - Date.now();
if (ms <= 0) return "due now"; if (ms <= 0) return "due now";
@ -26,29 +25,22 @@ export default function Settings() {
useProxy(reminders); useProxy(reminders);
return ( return (
<ScrollView style={{ flex: 1 }}> <SettingsScaffold>
<FormSection title="Pending reminders"> <ListSection
{reminders.length === 0 && ( title="Pending reminders"
<FormText style={{ marginHorizontal: 16, marginVertical: 8 }}> emptyText='None yet. Set one from any chat with /remind, e.g. "/remind 20m Walk the dog".'
None yet. Set one from any chat with /remind, e.g. "/remind 20m Walk the dog". items={reminders.map((r) => ({
</FormText> key: r.id,
)} label: r.text,
{reminders.map((r) => ( subLabel: `${formatRemaining(r.dueAt)} • Tap to cancel`,
<FormRow onPress: () => removeReminder(r.id)
key={r.id} }))}
label={r.text} />
subLabel={`${formatRemaining(r.dueAt)} • Tap to cancel`} <NoteBox>
onPress={() => removeReminder(r.id)} Reminders fire while Discord is open (foreground or background) - they can't wake the app
/> up from fully closed, since that needs a native OS-level notification this plugin doesn't
))} have access to.
</FormSection> </NoteBox>
<View style={{ paddingHorizontal: 16, paddingVertical: 8 }}> </SettingsScaffold>
<FormText>
Reminders fire while Discord is open (foreground or background) - they can't wake the app
up from fully closed, since that needs a native OS-level notification this plugin doesn't
have access to.
</FormText>
</View>
</ScrollView>
); );
} }

View file

@ -1,21 +1,13 @@
import { logger } from "@vendetta"; import { logger } from "@vendetta";
import { storage } from "@vendetta/plugin"; import { storage } from "@vendetta/plugin";
import { applyPatches } from "@shared/lib/patcher";
import patchRows from "./patches/rows"; import patchRows from "./patches/rows";
import patchMemberList from "./patches/memberList"; import patchMemberList from "./patches/memberList";
import patchTypingWrapper from "./patches/typingWrapper"; import patchTypingWrapper from "./patches/typingWrapper";
import patchVoiceUserConnected from "./patches/voiceUserConnected"; import patchVoiceUserConnected from "./patches/voiceUserConnected";
import Settings from "./ui/Settings"; import Settings from "./ui/Settings";
let patches: (() => void)[] = []; let unpatchAll: () => void = () => {};
function safePatch(name: string, apply: () => () => void): () => void {
try {
return apply();
} catch (e) {
logger.error(`[RoleColorEverywhere] Failed to apply the "${name}" patch, that surface will be skipped: ${e}`);
return () => {};
}
}
export default { export default {
onLoad: () => { onLoad: () => {
@ -25,13 +17,13 @@ export default {
storage.hideTyping ??= false; storage.hideTyping ??= false;
storage.chatInterpolation ??= 0; storage.chatInterpolation ??= 0;
patches = [ unpatchAll = applyPatches("RoleColorEverywhere", logger, {
safePatch("mentions & chat text", patchRows), "mentions & chat text": patchRows,
safePatch("member list role headers", patchMemberList), "member list role headers": patchMemberList,
safePatch("typing indicator", patchTypingWrapper), "typing indicator": patchTypingWrapper,
safePatch("voice channel names", patchVoiceUserConnected) "voice channel names": patchVoiceUserConnected
]; });
}, },
onUnload: () => patches.forEach((unpatch) => unpatch()), onUnload: () => unpatchAll(),
settings: Settings settings: Settings
}; };

View file

@ -1,6 +1,8 @@
import { find, findByStoreName } from "@vendetta/metro"; import { find, findByStoreName } from "@vendetta/metro";
import { semanticColors } from "@vendetta/ui"; import { semanticColors } from "@vendetta/ui";
export { interpolateColor } from "@shared/lib/color";
export const ThemeStore = findByStoreName("ThemeStore"); export const ThemeStore = findByStoreName("ThemeStore");
export const resolveSemanticColor = export const resolveSemanticColor =
@ -8,17 +10,6 @@ export const resolveSemanticColor =
find((m: any) => m?.meta?.resolveSemanticColor)?.meta?.resolveSemanticColor ?? find((m: any) => m?.meta?.resolveSemanticColor)?.meta?.resolveSemanticColor ??
(() => undefined); (() => undefined);
export function interpolateColor(color1: string, color2: string, percentage: number): string {
const hexToRgb = (hex: string) => (hex.match(/\w\w/g) ?? ["00", "00", "00"]).map((x) => parseInt(x, 16));
const rgbToHex = (rgb: number[]) => "#" + rgb.map((x) => x.toString(16).padStart(2, "0")).join("");
const rgb1 = hexToRgb(color1);
const rgb2 = hexToRgb(color2);
const interpolated = rgb1.map((c1, i) => Math.round(c1 + (rgb2[i] - c1) * percentage));
return rgbToHex(interpolated);
}
export function defaultTextColor(): string | undefined { export function defaultTextColor(): string | undefined {
try { try {
return resolveSemanticColor(ThemeStore?.theme, semanticColors.TEXT_NORMAL); return resolveSemanticColor(ThemeStore?.theme, semanticColors.TEXT_NORMAL);

View file

@ -1,16 +1,17 @@
import { ReactNative } from "@vendetta/metro/common"; import { React } from "@vendetta/metro/common";
import { storage } from "@vendetta/plugin"; import { storage } from "@vendetta/plugin";
import { useProxy } from "@vendetta/storage"; import { useProxy } from "@vendetta/storage";
import { Forms } from "@vendetta/ui/components"; import { Forms } from "@vendetta/ui/components";
import SettingsScaffold from "@shared/ui/SettingsScaffold";
import NoteBox from "@shared/ui/NoteBox";
const { ScrollView, View } = ReactNative; const { FormSection, FormSwitchRow, FormInput } = Forms;
const { FormSection, FormSwitchRow, FormInput, FormText } = Forms;
export default function Settings() { export default function Settings() {
useProxy(storage); useProxy(storage);
return ( return (
<ScrollView style={{ flex: 1 }}> <SettingsScaffold>
<FormSection title="Where to show the top role color"> <FormSection title="Where to show the top role color">
<FormSwitchRow <FormSwitchRow
label="Typing indicator" label="Typing indicator"
@ -52,13 +53,11 @@ export default function Settings() {
/> />
)} )}
</FormSection> </FormSection>
<View style={{ paddingHorizontal: 16, paddingVertical: 8 }}> <NoteBox>
<FormText> Some of these surfaces (especially the member list headers) rely on component internals
Some of these surfaces (especially the member list headers) rely on component internals Discord doesn't officially expose, so a future Discord update may silently turn one of
Discord doesn't officially expose, so a future Discord update may silently turn one of these off again - the rest will keep working if that happens.
these off again - the rest will keep working if that happens. </NoteBox>
</FormText> </SettingsScaffold>
</View>
</ScrollView>
); );
} }

View file

@ -1,37 +1,26 @@
import { logger } from "@vendetta"; import { logger } from "@vendetta";
import { storage } from "@vendetta/plugin"; import { storage } from "@vendetta/plugin";
import { applyPatches } from "@shared/lib/patcher";
import patchChat from "./patches/chat"; import patchChat from "./patches/chat";
import patchDetails from "./patches/details"; import patchDetails from "./patches/details";
import patchName from "./patches/name"; import patchName from "./patches/name";
import patchTag from "./patches/tag"; import patchTag from "./patches/tag";
import Settings from "./ui/pages/Settings"; import Settings from "./ui/pages/Settings";
let patches: (() => void)[] = []; let unpatchAll: () => void = () => {};
// Discord occasionally renames/removes the internals a single surface (chat, member list, etc.)
// depends on. Isolating each patch means one broken lookup only disables that surface instead of
// taking the whole plugin down, which is what happened to the old build of this plugin.
function safePatch(name: string, apply: () => () => void): () => void {
try {
return apply();
} catch (e) {
logger.error(`[Staff Tags] Failed to apply the "${name}" patch, that surface will be skipped: ${e}`);
return () => {};
}
}
export default { export default {
onLoad: () => { onLoad: () => {
storage.useRoleColor ??= false; storage.useRoleColor ??= false;
storage.tags ??= {}; storage.tags ??= {};
patches = [ unpatchAll = applyPatches("Staff Tags", logger, {
safePatch("tag", patchTag), tag: patchTag,
safePatch("chat", patchChat), chat: patchChat,
safePatch("name", patchName), name: patchName,
safePatch("details", patchDetails) details: patchDetails
]; });
}, },
onUnload: () => patches.forEach((unpatch) => unpatch()), onUnload: () => unpatchAll(),
settings: Settings settings: Settings
}; };

View file

@ -2,6 +2,7 @@ import { findByProps, findByStoreName } from "@vendetta/metro";
import { chroma, constants } from "@vendetta/metro/common"; import { chroma, constants } from "@vendetta/metro/common";
import { storage } from "@vendetta/plugin"; import { storage } from "@vendetta/plugin";
import { rawColors } from "@vendetta/ui"; import { rawColors } from "@vendetta/ui";
import { isValidHex } from "@shared/lib/color";
const { Permissions } = constants; const { Permissions } = constants;
const permissionsModule = findByProps("computePermissions", "canEveryoneRole"); const permissionsModule = findByProps("computePermissions", "canEveryoneRole");
@ -43,8 +44,6 @@ export interface ResolvedTag {
verified: boolean; verified: boolean;
} }
const HEX_REGEX = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i;
/** Lazily initializes and returns the per-tag settings object, backed directly by plugin storage. */ /** Lazily initializes and returns the per-tag settings object, backed directly by plugin storage. */
export function tagSettings(id: string): TagOverride { export function tagSettings(id: string): TagOverride {
storage.tags ??= {}; storage.tags ??= {};
@ -53,7 +52,7 @@ export function tagSettings(id: string): TagOverride {
} }
function resolveBackgroundColor(def: TagDefinition, settings: TagOverride, guild: any, user: any): string { function resolveBackgroundColor(def: TagDefinition, settings: TagOverride, guild: any, user: any): string {
if (settings.useCustomColor && settings.color && HEX_REGEX.test(settings.color)) { if (settings.useCustomColor && isValidHex(settings.color)) {
return settings.color; return settings.color;
} }
@ -100,7 +99,7 @@ export default function getTag(guild: any, channel: any, user: any): ResolvedTag
let gradientColor: string | undefined; let gradientColor: string | undefined;
if (settings.useGradient) { if (settings.useGradient) {
gradientColor = settings.gradientColor && HEX_REGEX.test(settings.gradientColor) gradientColor = isValidHex(settings.gradientColor)
? settings.gradientColor ? settings.gradientColor
: chroma(backgroundColor).brighten(1.4).hex(); : chroma(backgroundColor).brighten(1.4).hex();
} }

View file

@ -1,21 +1,18 @@
import { React, ReactNative } from "@vendetta/metro/common"; import { React } from "@vendetta/metro/common";
import { storage } from "@vendetta/plugin"; import { storage } from "@vendetta/plugin";
import { useProxy } from "@vendetta/storage"; import { useProxy } from "@vendetta/storage";
import { Forms } from "@vendetta/ui/components"; import { Forms } from "@vendetta/ui/components";
import SettingsScaffold from "@shared/ui/SettingsScaffold";
import ColorInput from "@shared/ui/ColorInput";
import { TAG_DEFINITIONS, tagSettings } from "../../lib/getTag"; import { TAG_DEFINITIONS, tagSettings } from "../../lib/getTag";
const { ScrollView, View } = ReactNative;
const { FormSection, FormSwitchRow, FormInput } = Forms; const { FormSection, FormSwitchRow, FormInput } = Forms;
const HEX_REGEX = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i;
function TagSettingsSection({ id, defaultText, defaultColor }: { id: string; defaultText: string; defaultColor: string }) { function TagSettingsSection({ id, defaultText, defaultColor }: { id: string; defaultText: string; defaultColor: string }) {
const settings = tagSettings(id); const settings = tagSettings(id);
useProxy(settings); useProxy(settings);
const enabled = settings.enabled !== false; const enabled = settings.enabled !== false;
const invalidColor = !!settings.color && !HEX_REGEX.test(settings.color);
const invalidGradient = !!settings.gradientColor && !HEX_REGEX.test(settings.gradientColor);
return ( return (
<FormSection title={defaultText}> <FormSection title={defaultText}>
@ -39,13 +36,11 @@ function TagSettingsSection({ id, defaultText, defaultColor }: { id: string; def
onValueChange={(v: boolean) => { settings.useCustomColor = v; }} onValueChange={(v: boolean) => { settings.useCustomColor = v; }}
/> />
{settings.useCustomColor && ( {settings.useCustomColor && (
<FormInput <ColorInput
title="Color (hex)" title="Color"
value={settings.color}
placeholder={defaultColor} placeholder={defaultColor}
value={settings.color ?? ""}
editable={enabled}
onChange={(v: string) => { settings.color = v; }} onChange={(v: string) => { settings.color = v; }}
helpText={invalidColor ? "Invalid hex color, e.g. #5865F2 - falling back to default for now" : undefined}
/> />
)} )}
<FormSwitchRow <FormSwitchRow
@ -56,13 +51,10 @@ function TagSettingsSection({ id, defaultText, defaultColor }: { id: string; def
onValueChange={(v: boolean) => { settings.useGradient = v; }} onValueChange={(v: boolean) => { settings.useGradient = v; }}
/> />
{settings.useGradient && ( {settings.useGradient && (
<FormInput <ColorInput
title="Gradient color (hex)" title="Gradient color"
placeholder="Leave blank to auto-generate" value={settings.gradientColor}
value={settings.gradientColor ?? ""}
editable={enabled}
onChange={(v: string) => { settings.gradientColor = v; }} onChange={(v: string) => { settings.gradientColor = v; }}
helpText={invalidGradient ? "Invalid hex color, leave blank to auto-generate" : undefined}
/> />
)} )}
</FormSection> </FormSection>
@ -73,7 +65,7 @@ export default function Settings() {
useProxy(storage); useProxy(storage);
return ( return (
<ScrollView style={{ flex: 1 }}> <SettingsScaffold>
<FormSection title="General"> <FormSection title="General">
<FormSwitchRow <FormSwitchRow
label="Use top role color" label="Use top role color"
@ -85,7 +77,6 @@ export default function Settings() {
{TAG_DEFINITIONS.map((def) => ( {TAG_DEFINITIONS.map((def) => (
<TagSettingsSection key={def.id} id={def.id} defaultText={def.defaultText} defaultColor={def.defaultColor} /> <TagSettingsSection key={def.id} id={def.id} defaultText={def.defaultText} defaultColor={def.defaultColor} />
))} ))}
<View style={{ height: 24 }} /> </SettingsScaffold>
</ScrollView>
); );
} }

24
shared/lib/color.ts Normal file
View file

@ -0,0 +1,24 @@
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;
}
export function interpolateColor(color1: string, color2: string, percentage: number): string {
const hexToRgb = (hex: string) => (normalizeHex(hex).match(/\w\w/g) ?? ["00", "00", "00"]).map((x) => parseInt(x, 16));
const rgbToHex = (rgb: number[]) => "#" + rgb.map((x) => x.toString(16).padStart(2, "0")).join("");
const rgb1 = hexToRgb(color1);
const rgb2 = hexToRgb(color2);
const interpolated = rgb1.map((c1, i) => Math.round(c1 + (rgb2[i] - c1) * percentage));
return rgbToHex(interpolated);
}

23
shared/lib/patcher.ts Normal file
View file

@ -0,0 +1,23 @@
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 onLoad and taking every other patch down with it -
* this is the root-cause fix for why Staff Tags and RoleColorEverywhere used to break completely.
*/
export function safePatch(pluginName: string, patchName: string, apply: () => () => void, logger: PluginLogger): () => void {
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, () => () => void>): () => void {
const unpatches = Object.entries(patches).map(([name, apply]) => safePatch(pluginName, name, apply, logger));
return () => unpatches.forEach((unpatch) => unpatch());
}

88
shared/ui/ColorInput.tsx Normal file
View file

@ -0,0 +1,88 @@
import { React, ReactNative } from "@vendetta/metro/common";
import { isValidHex } from "../lib/color";
const { View, Text, TextInput, TouchableOpacity } = ReactNative;
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 plugins' 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>
);
}

27
shared/ui/ListSection.tsx Normal file
View file

@ -0,0 +1,27 @@
import { React } from "@vendetta/metro/common";
import { Forms } from "@vendetta/ui/components";
import NoteBox from "./NoteBox";
const { FormSection, FormRow } = Forms;
export interface ListItem {
key: string;
label: string;
subLabel?: string;
onPress?: () => void;
}
/** A titled section listing tap-able rows, with a friendly note shown in place of an empty list. */
export default function ListSection({ title, items, emptyText }: { title: string; items: ListItem[]; emptyText: string }) {
return (
<FormSection title={title}>
{items.length === 0 ? (
<NoteBox>{emptyText}</NoteBox>
) : (
items.map((item) => (
<FormRow key={item.key} label={item.label} subLabel={item.subLabel} onPress={item.onPress} />
))
)}
</FormSection>
);
}

24
shared/ui/NoteBox.tsx Normal file
View file

@ -0,0 +1,24 @@
import { React, ReactNative } from "@vendetta/metro/common";
const { View, Text } = ReactNative;
/** Small bordered callout for explanatory text in a settings screen - theme-agnostic by design. */
export default function NoteBox({ children, style }: { children: any; style?: any }) {
return (
<View
style={[
{
marginHorizontal: 16,
marginVertical: 8,
padding: 10,
borderRadius: 8,
borderWidth: 1,
borderColor: "rgba(128,128,128,0.25)"
},
style
]}
>
<Text style={{ fontSize: 12.5, lineHeight: 18, opacity: 0.75 }}>{children}</Text>
</View>
);
}

View file

@ -0,0 +1,31 @@
import { React, ReactNative } from "@vendetta/metro/common";
const { TouchableOpacity, Text } = ReactNative;
interface PrimaryButtonProps {
label: string;
onPress: () => void;
disabled?: boolean;
style?: any;
}
export default function PrimaryButton({ label, onPress, disabled, style }: PrimaryButtonProps) {
return (
<TouchableOpacity
disabled={disabled}
onPress={onPress}
style={[
{
backgroundColor: "#5865F2",
borderRadius: 8,
padding: 10,
alignItems: "center",
opacity: disabled ? 0.5 : 1
},
style
]}
>
<Text style={{ color: "white", fontWeight: "600" }}>{label}</Text>
</TouchableOpacity>
);
}

View file

@ -0,0 +1,13 @@
import { React, ReactNative } from "@vendetta/metro/common";
const { ScrollView, View } = ReactNative;
/** Consistent wrapper for every plugin's settings screen: scroll view + bottom breathing room. */
export default function SettingsScaffold({ children }: { children: any }) {
return (
<ScrollView style={{ flex: 1 }}>
{children}
<View style={{ height: 24 }} />
</ScrollView>
);
}

View file

@ -6,7 +6,11 @@
"moduleResolution": "node", "moduleResolution": "node",
"esModuleInterop": true, "esModuleInterop": true,
"jsx": "react-native", "jsx": "react-native",
"allowJs": true "allowJs": true,
"baseUrl": ".",
"paths": {
"@shared/*": ["shared/*"]
}
}, },
"include": ["plugins", "node_modules/vendetta-types"] "include": ["plugins", "shared", "node_modules/vendetta-types"]
} }