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 { extname } from "path";
import { extname, resolve as resolvePath } from "path";
import { createHash } from "crypto";
import { rollup } from "rollup";
import alias from "@rollup/plugin-alias";
import url from "@rollup/plugin-url";
import esbuild from "rollup-plugin-esbuild";
import commonjs from "@rollup/plugin-commonjs";
@ -11,9 +12,15 @@ import swc from "@swc/core";
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 */
const plugins = [
nodeResolve(),
alias({
entries: [{ find: "@shared", replacement: resolvePath("./shared") }],
}),
nodeResolve({ extensions: [".mjs", ".js", ".json", ".node", ".ts", ".tsx"] }),
commonjs(),
url({
include: ["**/*.svg", "**/*.png", "**/*.jpg", "**/*.gif"],

35
package-lock.json generated
View file

@ -9,6 +9,7 @@
"@swc/helpers": "^0.5.17"
},
"devDependencies": {
"@rollup/plugin-alias": "^4.0.4",
"@rollup/plugin-commonjs": "^24.0.0",
"@rollup/plugin-node-resolve": "^15.0.1",
"@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": {
"version": "24.1.0",
"resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-24.1.0.tgz",
@ -3940,6 +3962,19 @@
"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": {
"version": "0.5.7",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",

View file

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

View file

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

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 SettingsScaffold from "@shared/ui/SettingsScaffold";
import NoteBox from "@shared/ui/NoteBox";
import PrimaryButton from "@shared/ui/PrimaryButton";
const { ScrollView, View, TouchableOpacity, Linking } = ReactNative;
const { FormSection, FormText } = Forms;
const { View, Linking } = ReactNative;
const { FormSection } = Forms;
const PRONOUNDB_URL = "https://pronoundb.org/";
export default function Settings() {
return (
<ScrollView style={{ flex: 1 }}>
<SettingsScaffold>
<FormSection title="How this works">
<View style={{ paddingHorizontal: 16, paddingVertical: 8 }}>
<FormText style={{ marginBottom: 12 }}>
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
where people manually set their own pronouns and link the profile to their Discord
account.
</FormText>
<FormText style={{ marginBottom: 12 }}>
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
other people running this plugin, set them yourself at the link below.
</FormText>
<TouchableOpacity
onPress={() => Linking.openURL(PRONOUNDB_URL)}
style={{
backgroundColor: "#5865F2",
borderRadius: 8,
padding: 12,
alignItems: "center"
}}
>
<FormText style={{ color: "white" }}>Open pronoundb.org</FormText>
</TouchableOpacity>
<NoteBox>
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
where people manually set their own pronouns and link the profile to their Discord
account.
</NoteBox>
<NoteBox>
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
other people running this plugin, set them yourself at the link below.
</NoteBox>
<View style={{ paddingHorizontal: 16, paddingTop: 4 }}>
<PrimaryButton label="Open pronoundb.org" onPress={() => Linking.openURL(PRONOUNDB_URL)} />
</View>
</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 { 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";
const { ScrollView, View } = ReactNative;
const { FormSection, FormRow, FormText } = Forms;
function formatRemaining(dueAt: number): string {
const ms = dueAt - Date.now();
if (ms <= 0) return "due now";
@ -26,29 +25,22 @@ export default function Settings() {
useProxy(reminders);
return (
<ScrollView style={{ flex: 1 }}>
<FormSection title="Pending reminders">
{reminders.length === 0 && (
<FormText style={{ marginHorizontal: 16, marginVertical: 8 }}>
None yet. Set one from any chat with /remind, e.g. "/remind 20m Walk the dog".
</FormText>
)}
{reminders.map((r) => (
<FormRow
key={r.id}
label={r.text}
subLabel={`${formatRemaining(r.dueAt)} • Tap to cancel`}
onPress={() => removeReminder(r.id)}
/>
))}
</FormSection>
<View style={{ paddingHorizontal: 16, paddingVertical: 8 }}>
<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>
<SettingsScaffold>
<ListSection
title="Pending reminders"
emptyText='None yet. Set one from any chat with /remind, e.g. "/remind 20m Walk the dog".'
items={reminders.map((r) => ({
key: r.id,
label: r.text,
subLabel: `${formatRemaining(r.dueAt)} • Tap to cancel`,
onPress: () => removeReminder(r.id)
}))}
/>
<NoteBox>
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.
</NoteBox>
</SettingsScaffold>
);
}

View file

@ -1,21 +1,13 @@
import { logger } from "@vendetta";
import { storage } from "@vendetta/plugin";
import { applyPatches } from "@shared/lib/patcher";
import patchRows from "./patches/rows";
import patchMemberList from "./patches/memberList";
import patchTypingWrapper from "./patches/typingWrapper";
import patchVoiceUserConnected from "./patches/voiceUserConnected";
import Settings from "./ui/Settings";
let patches: (() => 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 () => {};
}
}
let unpatchAll: () => void = () => {};
export default {
onLoad: () => {
@ -25,13 +17,13 @@ export default {
storage.hideTyping ??= false;
storage.chatInterpolation ??= 0;
patches = [
safePatch("mentions & chat text", patchRows),
safePatch("member list role headers", patchMemberList),
safePatch("typing indicator", patchTypingWrapper),
safePatch("voice channel names", patchVoiceUserConnected)
];
unpatchAll = applyPatches("RoleColorEverywhere", logger, {
"mentions & chat text": patchRows,
"member list role headers": patchMemberList,
"typing indicator": patchTypingWrapper,
"voice channel names": patchVoiceUserConnected
});
},
onUnload: () => patches.forEach((unpatch) => unpatch()),
onUnload: () => unpatchAll(),
settings: Settings
};

View file

@ -1,6 +1,8 @@
import { find, findByStoreName } from "@vendetta/metro";
import { semanticColors } from "@vendetta/ui";
export { interpolateColor } from "@shared/lib/color";
export const ThemeStore = findByStoreName("ThemeStore");
export const resolveSemanticColor =
@ -8,17 +10,6 @@ export const resolveSemanticColor =
find((m: any) => m?.meta?.resolveSemanticColor)?.meta?.resolveSemanticColor ??
(() => 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 {
try {
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 { useProxy } from "@vendetta/storage";
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, FormText } = Forms;
const { FormSection, FormSwitchRow, FormInput } = Forms;
export default function Settings() {
useProxy(storage);
return (
<ScrollView style={{ flex: 1 }}>
<SettingsScaffold>
<FormSection title="Where to show the top role color">
<FormSwitchRow
label="Typing indicator"
@ -52,13 +53,11 @@ export default function Settings() {
/>
)}
</FormSection>
<View style={{ paddingHorizontal: 16, paddingVertical: 8 }}>
<FormText>
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
these off again - the rest will keep working if that happens.
</FormText>
</View>
</ScrollView>
<NoteBox>
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
these off again - the rest will keep working if that happens.
</NoteBox>
</SettingsScaffold>
);
}

View file

@ -1,37 +1,26 @@
import { logger } from "@vendetta";
import { storage } from "@vendetta/plugin";
import { applyPatches } from "@shared/lib/patcher";
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";
let patches: (() => 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 () => {};
}
}
let unpatchAll: () => void = () => {};
export default {
onLoad: () => {
storage.useRoleColor ??= false;
storage.tags ??= {};
patches = [
safePatch("tag", patchTag),
safePatch("chat", patchChat),
safePatch("name", patchName),
safePatch("details", patchDetails)
];
unpatchAll = applyPatches("Staff Tags", logger, {
tag: patchTag,
chat: patchChat,
name: patchName,
details: patchDetails
});
},
onUnload: () => patches.forEach((unpatch) => unpatch()),
onUnload: () => unpatchAll(),
settings: Settings
};

View file

@ -2,6 +2,7 @@ import { findByProps, findByStoreName } from "@vendetta/metro";
import { chroma, constants } from "@vendetta/metro/common";
import { storage } from "@vendetta/plugin";
import { rawColors } from "@vendetta/ui";
import { isValidHex } from "@shared/lib/color";
const { Permissions } = constants;
const permissionsModule = findByProps("computePermissions", "canEveryoneRole");
@ -43,8 +44,6 @@ export interface ResolvedTag {
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. */
export function tagSettings(id: string): TagOverride {
storage.tags ??= {};
@ -53,7 +52,7 @@ export function tagSettings(id: string): TagOverride {
}
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;
}
@ -100,7 +99,7 @@ export default function getTag(guild: any, channel: any, user: any): ResolvedTag
let gradientColor: string | undefined;
if (settings.useGradient) {
gradientColor = settings.gradientColor && HEX_REGEX.test(settings.gradientColor)
gradientColor = isValidHex(settings.gradientColor)
? settings.gradientColor
: 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 { useProxy } from "@vendetta/storage";
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";
const { ScrollView, View } = ReactNative;
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 }) {
const settings = tagSettings(id);
useProxy(settings);
const enabled = settings.enabled !== false;
const invalidColor = !!settings.color && !HEX_REGEX.test(settings.color);
const invalidGradient = !!settings.gradientColor && !HEX_REGEX.test(settings.gradientColor);
return (
<FormSection title={defaultText}>
@ -39,13 +36,11 @@ function TagSettingsSection({ id, defaultText, defaultColor }: { id: string; def
onValueChange={(v: boolean) => { settings.useCustomColor = v; }}
/>
{settings.useCustomColor && (
<FormInput
title="Color (hex)"
<ColorInput
title="Color"
value={settings.color}
placeholder={defaultColor}
value={settings.color ?? ""}
editable={enabled}
onChange={(v: string) => { settings.color = v; }}
helpText={invalidColor ? "Invalid hex color, e.g. #5865F2 - falling back to default for now" : undefined}
/>
)}
<FormSwitchRow
@ -56,13 +51,10 @@ function TagSettingsSection({ id, defaultText, defaultColor }: { id: string; def
onValueChange={(v: boolean) => { settings.useGradient = v; }}
/>
{settings.useGradient && (
<FormInput
title="Gradient color (hex)"
placeholder="Leave blank to auto-generate"
value={settings.gradientColor ?? ""}
editable={enabled}
<ColorInput
title="Gradient color"
value={settings.gradientColor}
onChange={(v: string) => { settings.gradientColor = v; }}
helpText={invalidGradient ? "Invalid hex color, leave blank to auto-generate" : undefined}
/>
)}
</FormSection>
@ -73,7 +65,7 @@ export default function Settings() {
useProxy(storage);
return (
<ScrollView style={{ flex: 1 }}>
<SettingsScaffold>
<FormSection title="General">
<FormSwitchRow
label="Use top role color"
@ -85,7 +77,6 @@ export default function Settings() {
{TAG_DEFINITIONS.map((def) => (
<TagSettingsSection key={def.id} id={def.id} defaultText={def.defaultText} defaultColor={def.defaultColor} />
))}
<View style={{ height: 24 }} />
</ScrollView>
</SettingsScaffold>
);
}

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",
"esModuleInterop": true,
"jsx": "react-native",
"allowJs": true
"allowJs": true,
"baseUrl": ".",
"paths": {
"@shared/*": ["shared/*"]
}
},
"include": ["plugins", "node_modules/vendetta-types"]
"include": ["plugins", "shared", "node_modules/vendetta-types"]
}