Add shared plugin library (@shared alias) and refactor all plugins to use it
This commit is contained in:
parent
d682bcb6a1
commit
3c4c559865
20 changed files with 389 additions and 176 deletions
11
build.mjs
11
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"],
|
||||
|
|
|
|||
35
package-lock.json
generated
35
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
24
shared/lib/color.ts
Normal 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
23
shared/lib/patcher.ts
Normal 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
88
shared/ui/ColorInput.tsx
Normal 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
27
shared/ui/ListSection.tsx
Normal 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
24
shared/ui/NoteBox.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
shared/ui/PrimaryButton.tsx
Normal file
31
shared/ui/PrimaryButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
shared/ui/SettingsScaffold.tsx
Normal file
13
shared/ui/SettingsScaffold.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue