From 3c4c55986575ff562a0808d0f2739722a1d24c32 Mon Sep 17 00:00:00 2001 From: everestmcarthur <280397636+everestmcarthur@users.noreply.github.com> Date: Sat, 1 Aug 2026 10:34:47 -0400 Subject: [PATCH] Add shared plugin library (@shared alias) and refactor all plugins to use it --- build.mjs | 11 ++- package-lock.json | 35 ++++++++ package.json | 1 + plugins/message-snippets/src/ui/Settings.tsx | 52 +++++------ plugins/pronoun-db/src/ui/Settings.tsx | 49 +++++------ plugins/reminders/src/ui/Settings.tsx | 50 +++++------ plugins/role-color-everywhere/src/index.ts | 26 ++---- .../role-color-everywhere/src/lib/color.ts | 13 +-- .../role-color-everywhere/src/ui/Settings.tsx | 23 +++-- plugins/staff-tags/src/index.ts | 29 ++---- plugins/staff-tags/src/lib/getTag.ts | 7 +- plugins/staff-tags/src/ui/pages/Settings.tsx | 31 +++---- shared/lib/color.ts | 24 +++++ shared/lib/patcher.ts | 23 +++++ shared/ui/ColorInput.tsx | 88 +++++++++++++++++++ shared/ui/ListSection.tsx | 27 ++++++ shared/ui/NoteBox.tsx | 24 +++++ shared/ui/PrimaryButton.tsx | 31 +++++++ shared/ui/SettingsScaffold.tsx | 13 +++ tsconfig.json | 8 +- 20 files changed, 389 insertions(+), 176 deletions(-) create mode 100644 shared/lib/color.ts create mode 100644 shared/lib/patcher.ts create mode 100644 shared/ui/ColorInput.tsx create mode 100644 shared/ui/ListSection.tsx create mode 100644 shared/ui/NoteBox.tsx create mode 100644 shared/ui/PrimaryButton.tsx create mode 100644 shared/ui/SettingsScaffold.tsx 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"] }