diff --git a/build.mjs b/build.mjs
index d3adf4b..3aaebb1 100644
--- a/build.mjs
+++ b/build.mjs
@@ -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"],
diff --git a/package-lock.json b/package-lock.json
index 35783b3..079392c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index 9705096..57d44b2 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/plugins/message-snippets/src/ui/Settings.tsx b/plugins/message-snippets/src/ui/Settings.tsx
index 7e71162..9b5fa56 100644
--- a/plugins/message-snippets/src/ui/Settings.tsx
+++ b/plugins/message-snippets/src/ui/Settings.tsx
@@ -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() {
- {
saveSnippet(name.trim(), text);
setName("");
setText("");
}}
- style={{
- marginTop: 8,
- backgroundColor: "#5865F2",
- borderRadius: 8,
- padding: 10,
- alignItems: "center",
- opacity: canSave ? 1 : 0.5
- }}
- >
- Save snippet
-
+ />
);
}
@@ -45,25 +40,20 @@ export default function Settings() {
const names = Object.keys(snippets);
return (
-
+
-
- {names.length === 0 && (
-
- None yet. Add one above, then send it anywhere with /snippet name.
-
- )}
- {names.map((name) => (
- 60 ? "…" : ""} • Tap to delete`}
- onPress={() => deleteSnippet(name)}
- />
- ))}
-
-
+ ({
+ key: name,
+ label: name,
+ subLabel: `${snippets[name].slice(0, 60)}${snippets[name].length > 60 ? "…" : ""} • Tap to delete`,
+ onPress: () => deleteSnippet(name)
+ }))}
+ />
+
);
}
diff --git a/plugins/pronoun-db/src/ui/Settings.tsx b/plugins/pronoun-db/src/ui/Settings.tsx
index ba3e15b..4e01ad9 100644
--- a/plugins/pronoun-db/src/ui/Settings.tsx
+++ b/plugins/pronoun-db/src/ui/Settings.tsx
@@ -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 (
-
+
-
-
- 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.
-
-
- 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.
-
- Linking.openURL(PRONOUNDB_URL)}
- style={{
- backgroundColor: "#5865F2",
- borderRadius: 8,
- padding: 12,
- alignItems: "center"
- }}
- >
- Open pronoundb.org
-
+
+ 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.
+
+
+ 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.
+
+
+ Linking.openURL(PRONOUNDB_URL)} />
-
+
);
}
diff --git a/plugins/reminders/src/ui/Settings.tsx b/plugins/reminders/src/ui/Settings.tsx
index 7afe3a3..8a23bc6 100644
--- a/plugins/reminders/src/ui/Settings.tsx
+++ b/plugins/reminders/src/ui/Settings.tsx
@@ -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 (
-
-
- {reminders.length === 0 && (
-
- None yet. Set one from any chat with /remind, e.g. "/remind 20m Walk the dog".
-
- )}
- {reminders.map((r) => (
- 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.
-
-
-
+
+ ({
+ key: r.id,
+ label: r.text,
+ subLabel: `${formatRemaining(r.dueAt)} • Tap to cancel`,
+ 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.
+
+
);
}
diff --git a/plugins/role-color-everywhere/src/index.ts b/plugins/role-color-everywhere/src/index.ts
index 941051a..51e64e5 100644
--- a/plugins/role-color-everywhere/src/index.ts
+++ b/plugins/role-color-everywhere/src/index.ts
@@ -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
};
diff --git a/plugins/role-color-everywhere/src/lib/color.ts b/plugins/role-color-everywhere/src/lib/color.ts
index 6beb25d..3ef9fa1 100644
--- a/plugins/role-color-everywhere/src/lib/color.ts
+++ b/plugins/role-color-everywhere/src/lib/color.ts
@@ -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);
diff --git a/plugins/role-color-everywhere/src/ui/Settings.tsx b/plugins/role-color-everywhere/src/ui/Settings.tsx
index 1581a0d..92ca89c 100644
--- a/plugins/role-color-everywhere/src/ui/Settings.tsx
+++ b/plugins/role-color-everywhere/src/ui/Settings.tsx
@@ -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 (
-
+
)}
-
-
- 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.
-
-
-
+
+ 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.
+
+
);
}
diff --git a/plugins/staff-tags/src/index.ts b/plugins/staff-tags/src/index.ts
index b24748a..432b328 100644
--- a/plugins/staff-tags/src/index.ts
+++ b/plugins/staff-tags/src/index.ts
@@ -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
};
diff --git a/plugins/staff-tags/src/lib/getTag.ts b/plugins/staff-tags/src/lib/getTag.ts
index a47f1fb..a37c701 100644
--- a/plugins/staff-tags/src/lib/getTag.ts
+++ b/plugins/staff-tags/src/lib/getTag.ts
@@ -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();
}
diff --git a/plugins/staff-tags/src/ui/pages/Settings.tsx b/plugins/staff-tags/src/ui/pages/Settings.tsx
index 0a69448..99e72df 100644
--- a/plugins/staff-tags/src/ui/pages/Settings.tsx
+++ b/plugins/staff-tags/src/ui/pages/Settings.tsx
@@ -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 (
@@ -39,13 +36,11 @@ function TagSettingsSection({ id, defaultText, defaultColor }: { id: string; def
onValueChange={(v: boolean) => { settings.useCustomColor = v; }}
/>
{settings.useCustomColor && (
- { settings.color = v; }}
- helpText={invalidColor ? "Invalid hex color, e.g. #5865F2 - falling back to default for now" : undefined}
/>
)}
{ settings.useGradient = v; }}
/>
{settings.useGradient && (
- { settings.gradientColor = v; }}
- helpText={invalidGradient ? "Invalid hex color, leave blank to auto-generate" : undefined}
/>
)}
@@ -73,7 +65,7 @@ export default function Settings() {
useProxy(storage);
return (
-
+
(
))}
-
-
+
);
}
diff --git a/shared/lib/color.ts b/shared/lib/color.ts
new file mode 100644
index 0000000..b777594
--- /dev/null
+++ b/shared/lib/color.ts
@@ -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);
+}
diff --git a/shared/lib/patcher.ts b/shared/lib/patcher.ts
new file mode 100644
index 0000000..c94c54d
--- /dev/null
+++ b/shared/lib/patcher.ts
@@ -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 () => void>): () => void {
+ const unpatches = Object.entries(patches).map(([name, apply]) => safePatch(pluginName, name, apply, logger));
+ return () => unpatches.forEach((unpatch) => unpatch());
+}
diff --git a/shared/ui/ColorInput.tsx b/shared/ui/ColorInput.tsx
new file mode 100644
index 0000000..5d385e2
--- /dev/null
+++ b/shared/ui/ColorInput.tsx
@@ -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 (
+
+
+
+ {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/shared/ui/ListSection.tsx b/shared/ui/ListSection.tsx
new file mode 100644
index 0000000..13f0ceb
--- /dev/null
+++ b/shared/ui/ListSection.tsx
@@ -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 (
+
+ {items.length === 0 ? (
+ {emptyText}
+ ) : (
+ items.map((item) => (
+
+ ))
+ )}
+
+ );
+}
diff --git a/shared/ui/NoteBox.tsx b/shared/ui/NoteBox.tsx
new file mode 100644
index 0000000..20cc42e
--- /dev/null
+++ b/shared/ui/NoteBox.tsx
@@ -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 (
+
+ {children}
+
+ );
+}
diff --git a/shared/ui/PrimaryButton.tsx b/shared/ui/PrimaryButton.tsx
new file mode 100644
index 0000000..65daaef
--- /dev/null
+++ b/shared/ui/PrimaryButton.tsx
@@ -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 (
+
+ {label}
+
+ );
+}
diff --git a/shared/ui/SettingsScaffold.tsx b/shared/ui/SettingsScaffold.tsx
new file mode 100644
index 0000000..60a89c3
--- /dev/null
+++ b/shared/ui/SettingsScaffold.tsx
@@ -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 (
+
+ {children}
+
+
+ );
+}
diff --git a/tsconfig.json b/tsconfig.json
index 936e53c..73e20b3 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -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"]
}