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 { readFile, writeFile, readdir } from "fs/promises";
|
||||||
import { extname } from "path";
|
import { extname, resolve as resolvePath } from "path";
|
||||||
import { createHash } from "crypto";
|
import { createHash } from "crypto";
|
||||||
|
|
||||||
import { rollup } from "rollup";
|
import { rollup } from "rollup";
|
||||||
|
import alias from "@rollup/plugin-alias";
|
||||||
import url from "@rollup/plugin-url";
|
import url from "@rollup/plugin-url";
|
||||||
import esbuild from "rollup-plugin-esbuild";
|
import esbuild from "rollup-plugin-esbuild";
|
||||||
import commonjs from "@rollup/plugin-commonjs";
|
import commonjs from "@rollup/plugin-commonjs";
|
||||||
|
|
@ -11,9 +12,15 @@ import swc from "@swc/core";
|
||||||
|
|
||||||
const extensions = [".js", ".jsx", ".mjs", ".ts", ".tsx", ".cts", ".mts"];
|
const extensions = [".js", ".jsx", ".mjs", ".ts", ".tsx", ".cts", ".mts"];
|
||||||
|
|
||||||
|
// Plugins are each built into an independent, self-contained bundle (users install them one at a time),
|
||||||
|
// so they can't import from each other at runtime - but they CAN share source at build time. This alias
|
||||||
|
// lets any plugin `import { x } from "@shared/..."` and have it inlined straight into its own bundle.
|
||||||
/** @type import("rollup").InputPluginOption */
|
/** @type import("rollup").InputPluginOption */
|
||||||
const plugins = [
|
const plugins = [
|
||||||
nodeResolve(),
|
alias({
|
||||||
|
entries: [{ find: "@shared", replacement: resolvePath("./shared") }],
|
||||||
|
}),
|
||||||
|
nodeResolve({ extensions: [".mjs", ".js", ".json", ".node", ".ts", ".tsx"] }),
|
||||||
commonjs(),
|
commonjs(),
|
||||||
url({
|
url({
|
||||||
include: ["**/*.svg", "**/*.png", "**/*.jpg", "**/*.gif"],
|
include: ["**/*.svg", "**/*.png", "**/*.jpg", "**/*.gif"],
|
||||||
|
|
|
||||||
35
package-lock.json
generated
35
package-lock.json
generated
|
|
@ -9,6 +9,7 @@
|
||||||
"@swc/helpers": "^0.5.17"
|
"@swc/helpers": "^0.5.17"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@rollup/plugin-alias": "^4.0.4",
|
||||||
"@rollup/plugin-commonjs": "^24.0.0",
|
"@rollup/plugin-commonjs": "^24.0.0",
|
||||||
"@rollup/plugin-node-resolve": "^15.0.1",
|
"@rollup/plugin-node-resolve": "^15.0.1",
|
||||||
"@rollup/plugin-url": "latest",
|
"@rollup/plugin-url": "latest",
|
||||||
|
|
@ -968,6 +969,27 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@rollup/plugin-alias": {
|
||||||
|
"version": "4.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/plugin-alias/-/plugin-alias-4.0.4.tgz",
|
||||||
|
"integrity": "sha512-0CaAY238SMtYAWEXXptWSR8iz8NYZnH7zNBKuJ14xFJSGwLtPgjvXYsoApAHfzYXXH1ejxpVw7WlHss3zhh9SQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"slash": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"rollup": "^1.20.0||^2.0.0||^3.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"rollup": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rollup/plugin-commonjs": {
|
"node_modules/@rollup/plugin-commonjs": {
|
||||||
"version": "24.1.0",
|
"version": "24.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-24.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-24.1.0.tgz",
|
||||||
|
|
@ -3940,6 +3962,19 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/slash": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/slash/-/slash-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/source-map": {
|
"node_modules/source-map": {
|
||||||
"version": "0.5.7",
|
"version": "0.5.7",
|
||||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
|
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
"@swc/helpers": "^0.5.17"
|
"@swc/helpers": "^0.5.17"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@rollup/plugin-alias": "^4.0.4",
|
||||||
"@rollup/plugin-commonjs": "^24.0.0",
|
"@rollup/plugin-commonjs": "^24.0.0",
|
||||||
"@rollup/plugin-node-resolve": "^15.0.1",
|
"@rollup/plugin-node-resolve": "^15.0.1",
|
||||||
"@rollup/plugin-url": "latest",
|
"@rollup/plugin-url": "latest",
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,13 @@ import { React, ReactNative } from "@vendetta/metro/common";
|
||||||
import { useProxy } from "@vendetta/storage";
|
import { useProxy } from "@vendetta/storage";
|
||||||
import { storage } from "@vendetta/plugin";
|
import { storage } from "@vendetta/plugin";
|
||||||
import { Forms } from "@vendetta/ui/components";
|
import { Forms } from "@vendetta/ui/components";
|
||||||
|
import SettingsScaffold from "@shared/ui/SettingsScaffold";
|
||||||
|
import ListSection from "@shared/ui/ListSection";
|
||||||
|
import PrimaryButton from "@shared/ui/PrimaryButton";
|
||||||
import { getSnippets, saveSnippet, deleteSnippet } from "../lib/snippets";
|
import { getSnippets, saveSnippet, deleteSnippet } from "../lib/snippets";
|
||||||
|
|
||||||
const { ScrollView, View, TouchableOpacity } = ReactNative;
|
const { View } = ReactNative;
|
||||||
const { FormSection, FormRow, FormInput, FormText } = Forms;
|
const { FormSection, FormInput } = Forms;
|
||||||
|
|
||||||
function NewSnippetForm() {
|
function NewSnippetForm() {
|
||||||
const [name, setName] = React.useState("");
|
const [name, setName] = React.useState("");
|
||||||
|
|
@ -16,24 +19,16 @@ function NewSnippetForm() {
|
||||||
<View style={{ paddingHorizontal: 16, paddingBottom: 12 }}>
|
<View style={{ paddingHorizontal: 16, paddingBottom: 12 }}>
|
||||||
<FormInput title="Name" placeholder="e.g. rules" value={name} onChange={setName} />
|
<FormInput title="Name" placeholder="e.g. rules" value={name} onChange={setName} />
|
||||||
<FormInput title="Text" placeholder="What /snippet should send" value={text} onChange={setText} multiline />
|
<FormInput title="Text" placeholder="What /snippet should send" value={text} onChange={setText} multiline />
|
||||||
<TouchableOpacity
|
<PrimaryButton
|
||||||
|
label="Save snippet"
|
||||||
disabled={!canSave}
|
disabled={!canSave}
|
||||||
|
style={{ marginTop: 8 }}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
saveSnippet(name.trim(), text);
|
saveSnippet(name.trim(), text);
|
||||||
setName("");
|
setName("");
|
||||||
setText("");
|
setText("");
|
||||||
}}
|
}}
|
||||||
style={{
|
/>
|
||||||
marginTop: 8,
|
|
||||||
backgroundColor: "#5865F2",
|
|
||||||
borderRadius: 8,
|
|
||||||
padding: 10,
|
|
||||||
alignItems: "center",
|
|
||||||
opacity: canSave ? 1 : 0.5
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FormText style={{ color: "white" }}>Save snippet</FormText>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -45,25 +40,20 @@ export default function Settings() {
|
||||||
const names = Object.keys(snippets);
|
const names = Object.keys(snippets);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView style={{ flex: 1 }}>
|
<SettingsScaffold>
|
||||||
<FormSection title="Add a snippet">
|
<FormSection title="Add a snippet">
|
||||||
<NewSnippetForm />
|
<NewSnippetForm />
|
||||||
</FormSection>
|
</FormSection>
|
||||||
<FormSection title="Saved snippets">
|
<ListSection
|
||||||
{names.length === 0 && (
|
title="Saved snippets"
|
||||||
<FormText style={{ marginHorizontal: 16, marginVertical: 8 }}>
|
emptyText="None yet. Add one above, then send it anywhere with /snippet name."
|
||||||
None yet. Add one above, then send it anywhere with /snippet name.
|
items={names.map((name) => ({
|
||||||
</FormText>
|
key: name,
|
||||||
)}
|
label: name,
|
||||||
{names.map((name) => (
|
subLabel: `${snippets[name].slice(0, 60)}${snippets[name].length > 60 ? "…" : ""} • Tap to delete`,
|
||||||
<FormRow
|
onPress: () => deleteSnippet(name)
|
||||||
key={name}
|
}))}
|
||||||
label={name}
|
|
||||||
subLabel={`${snippets[name].slice(0, 60)}${snippets[name].length > 60 ? "…" : ""} • Tap to delete`}
|
|
||||||
onPress={() => deleteSnippet(name)}
|
|
||||||
/>
|
/>
|
||||||
))}
|
</SettingsScaffold>
|
||||||
</FormSection>
|
|
||||||
</ScrollView>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,40 +1,33 @@
|
||||||
import { ReactNative } from "@vendetta/metro/common";
|
import { React, ReactNative } from "@vendetta/metro/common";
|
||||||
import { Forms } from "@vendetta/ui/components";
|
import { Forms } from "@vendetta/ui/components";
|
||||||
|
import SettingsScaffold from "@shared/ui/SettingsScaffold";
|
||||||
|
import NoteBox from "@shared/ui/NoteBox";
|
||||||
|
import PrimaryButton from "@shared/ui/PrimaryButton";
|
||||||
|
|
||||||
const { ScrollView, View, TouchableOpacity, Linking } = ReactNative;
|
const { View, Linking } = ReactNative;
|
||||||
const { FormSection, FormText } = Forms;
|
const { FormSection } = Forms;
|
||||||
|
|
||||||
const PRONOUNDB_URL = "https://pronoundb.org/";
|
const PRONOUNDB_URL = "https://pronoundb.org/";
|
||||||
|
|
||||||
export default function Settings() {
|
export default function Settings() {
|
||||||
return (
|
return (
|
||||||
<ScrollView style={{ flex: 1 }}>
|
<SettingsScaffold>
|
||||||
<FormSection title="How this works">
|
<FormSection title="How this works">
|
||||||
<View style={{ paddingHorizontal: 16, paddingVertical: 8 }}>
|
<NoteBox>
|
||||||
<FormText style={{ marginBottom: 12 }}>
|
|
||||||
This plugin doesn't have anything to configure here - there's no API key or toggle,
|
This plugin doesn't have anything to configure here - there's no API key or toggle,
|
||||||
because pronouns aren't Discord data. They come from PronounDB, a separate service
|
because pronouns aren't Discord data. They come from PronounDB, a separate service
|
||||||
where people manually set their own pronouns and link the profile to their Discord
|
where people manually set their own pronouns and link the profile to their Discord
|
||||||
account.
|
account.
|
||||||
</FormText>
|
</NoteBox>
|
||||||
<FormText style={{ marginBottom: 12 }}>
|
<NoteBox>
|
||||||
That means a user's pronouns will only show up if THEY set them at pronoundb.org - not
|
That means a user's pronouns will only show up if THEY set them at pronoundb.org - not
|
||||||
because of anything you enable here. If you want your own pronouns to show up for
|
because of anything you enable here. If you want your own pronouns to show up for
|
||||||
other people running this plugin, set them yourself at the link below.
|
other people running this plugin, set them yourself at the link below.
|
||||||
</FormText>
|
</NoteBox>
|
||||||
<TouchableOpacity
|
<View style={{ paddingHorizontal: 16, paddingTop: 4 }}>
|
||||||
onPress={() => Linking.openURL(PRONOUNDB_URL)}
|
<PrimaryButton label="Open pronoundb.org" onPress={() => Linking.openURL(PRONOUNDB_URL)} />
|
||||||
style={{
|
|
||||||
backgroundColor: "#5865F2",
|
|
||||||
borderRadius: 8,
|
|
||||||
padding: 12,
|
|
||||||
alignItems: "center"
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FormText style={{ color: "white" }}>Open pronoundb.org</FormText>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
</View>
|
||||||
</FormSection>
|
</FormSection>
|
||||||
</ScrollView>
|
</SettingsScaffold>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,11 @@
|
||||||
import { ReactNative } from "@vendetta/metro/common";
|
import { React } from "@vendetta/metro/common";
|
||||||
import { useProxy } from "@vendetta/storage";
|
import { useProxy } from "@vendetta/storage";
|
||||||
import { storage } from "@vendetta/plugin";
|
import { storage } from "@vendetta/plugin";
|
||||||
import { Forms } from "@vendetta/ui/components";
|
import SettingsScaffold from "@shared/ui/SettingsScaffold";
|
||||||
|
import ListSection from "@shared/ui/ListSection";
|
||||||
|
import NoteBox from "@shared/ui/NoteBox";
|
||||||
import { getReminders, removeReminder } from "../lib/reminders";
|
import { getReminders, removeReminder } from "../lib/reminders";
|
||||||
|
|
||||||
const { ScrollView, View } = ReactNative;
|
|
||||||
const { FormSection, FormRow, FormText } = Forms;
|
|
||||||
|
|
||||||
function formatRemaining(dueAt: number): string {
|
function formatRemaining(dueAt: number): string {
|
||||||
const ms = dueAt - Date.now();
|
const ms = dueAt - Date.now();
|
||||||
if (ms <= 0) return "due now";
|
if (ms <= 0) return "due now";
|
||||||
|
|
@ -26,29 +25,22 @@ export default function Settings() {
|
||||||
useProxy(reminders);
|
useProxy(reminders);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView style={{ flex: 1 }}>
|
<SettingsScaffold>
|
||||||
<FormSection title="Pending reminders">
|
<ListSection
|
||||||
{reminders.length === 0 && (
|
title="Pending reminders"
|
||||||
<FormText style={{ marginHorizontal: 16, marginVertical: 8 }}>
|
emptyText='None yet. Set one from any chat with /remind, e.g. "/remind 20m Walk the dog".'
|
||||||
None yet. Set one from any chat with /remind, e.g. "/remind 20m Walk the dog".
|
items={reminders.map((r) => ({
|
||||||
</FormText>
|
key: r.id,
|
||||||
)}
|
label: r.text,
|
||||||
{reminders.map((r) => (
|
subLabel: `${formatRemaining(r.dueAt)} • Tap to cancel`,
|
||||||
<FormRow
|
onPress: () => removeReminder(r.id)
|
||||||
key={r.id}
|
}))}
|
||||||
label={r.text}
|
|
||||||
subLabel={`${formatRemaining(r.dueAt)} • Tap to cancel`}
|
|
||||||
onPress={() => removeReminder(r.id)}
|
|
||||||
/>
|
/>
|
||||||
))}
|
<NoteBox>
|
||||||
</FormSection>
|
|
||||||
<View style={{ paddingHorizontal: 16, paddingVertical: 8 }}>
|
|
||||||
<FormText>
|
|
||||||
Reminders fire while Discord is open (foreground or background) - they can't wake the app
|
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
|
up from fully closed, since that needs a native OS-level notification this plugin doesn't
|
||||||
have access to.
|
have access to.
|
||||||
</FormText>
|
</NoteBox>
|
||||||
</View>
|
</SettingsScaffold>
|
||||||
</ScrollView>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,13 @@
|
||||||
import { logger } from "@vendetta";
|
import { logger } from "@vendetta";
|
||||||
import { storage } from "@vendetta/plugin";
|
import { storage } from "@vendetta/plugin";
|
||||||
|
import { applyPatches } from "@shared/lib/patcher";
|
||||||
import patchRows from "./patches/rows";
|
import patchRows from "./patches/rows";
|
||||||
import patchMemberList from "./patches/memberList";
|
import patchMemberList from "./patches/memberList";
|
||||||
import patchTypingWrapper from "./patches/typingWrapper";
|
import patchTypingWrapper from "./patches/typingWrapper";
|
||||||
import patchVoiceUserConnected from "./patches/voiceUserConnected";
|
import patchVoiceUserConnected from "./patches/voiceUserConnected";
|
||||||
import Settings from "./ui/Settings";
|
import Settings from "./ui/Settings";
|
||||||
|
|
||||||
let patches: (() => void)[] = [];
|
let unpatchAll: () => void = () => {};
|
||||||
|
|
||||||
function safePatch(name: string, apply: () => () => void): () => void {
|
|
||||||
try {
|
|
||||||
return apply();
|
|
||||||
} catch (e) {
|
|
||||||
logger.error(`[RoleColorEverywhere] Failed to apply the "${name}" patch, that surface will be skipped: ${e}`);
|
|
||||||
return () => {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
onLoad: () => {
|
onLoad: () => {
|
||||||
|
|
@ -25,13 +17,13 @@ export default {
|
||||||
storage.hideTyping ??= false;
|
storage.hideTyping ??= false;
|
||||||
storage.chatInterpolation ??= 0;
|
storage.chatInterpolation ??= 0;
|
||||||
|
|
||||||
patches = [
|
unpatchAll = applyPatches("RoleColorEverywhere", logger, {
|
||||||
safePatch("mentions & chat text", patchRows),
|
"mentions & chat text": patchRows,
|
||||||
safePatch("member list role headers", patchMemberList),
|
"member list role headers": patchMemberList,
|
||||||
safePatch("typing indicator", patchTypingWrapper),
|
"typing indicator": patchTypingWrapper,
|
||||||
safePatch("voice channel names", patchVoiceUserConnected)
|
"voice channel names": patchVoiceUserConnected
|
||||||
];
|
});
|
||||||
},
|
},
|
||||||
onUnload: () => patches.forEach((unpatch) => unpatch()),
|
onUnload: () => unpatchAll(),
|
||||||
settings: Settings
|
settings: Settings
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import { find, findByStoreName } from "@vendetta/metro";
|
import { find, findByStoreName } from "@vendetta/metro";
|
||||||
import { semanticColors } from "@vendetta/ui";
|
import { semanticColors } from "@vendetta/ui";
|
||||||
|
|
||||||
|
export { interpolateColor } from "@shared/lib/color";
|
||||||
|
|
||||||
export const ThemeStore = findByStoreName("ThemeStore");
|
export const ThemeStore = findByStoreName("ThemeStore");
|
||||||
|
|
||||||
export const resolveSemanticColor =
|
export const resolveSemanticColor =
|
||||||
|
|
@ -8,17 +10,6 @@ export const resolveSemanticColor =
|
||||||
find((m: any) => m?.meta?.resolveSemanticColor)?.meta?.resolveSemanticColor ??
|
find((m: any) => m?.meta?.resolveSemanticColor)?.meta?.resolveSemanticColor ??
|
||||||
(() => undefined);
|
(() => undefined);
|
||||||
|
|
||||||
export function interpolateColor(color1: string, color2: string, percentage: number): string {
|
|
||||||
const hexToRgb = (hex: string) => (hex.match(/\w\w/g) ?? ["00", "00", "00"]).map((x) => parseInt(x, 16));
|
|
||||||
const rgbToHex = (rgb: number[]) => "#" + rgb.map((x) => x.toString(16).padStart(2, "0")).join("");
|
|
||||||
|
|
||||||
const rgb1 = hexToRgb(color1);
|
|
||||||
const rgb2 = hexToRgb(color2);
|
|
||||||
const interpolated = rgb1.map((c1, i) => Math.round(c1 + (rgb2[i] - c1) * percentage));
|
|
||||||
|
|
||||||
return rgbToHex(interpolated);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function defaultTextColor(): string | undefined {
|
export function defaultTextColor(): string | undefined {
|
||||||
try {
|
try {
|
||||||
return resolveSemanticColor(ThemeStore?.theme, semanticColors.TEXT_NORMAL);
|
return resolveSemanticColor(ThemeStore?.theme, semanticColors.TEXT_NORMAL);
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,17 @@
|
||||||
import { ReactNative } from "@vendetta/metro/common";
|
import { React } from "@vendetta/metro/common";
|
||||||
import { storage } from "@vendetta/plugin";
|
import { storage } from "@vendetta/plugin";
|
||||||
import { useProxy } from "@vendetta/storage";
|
import { useProxy } from "@vendetta/storage";
|
||||||
import { Forms } from "@vendetta/ui/components";
|
import { Forms } from "@vendetta/ui/components";
|
||||||
|
import SettingsScaffold from "@shared/ui/SettingsScaffold";
|
||||||
|
import NoteBox from "@shared/ui/NoteBox";
|
||||||
|
|
||||||
const { ScrollView, View } = ReactNative;
|
const { FormSection, FormSwitchRow, FormInput } = Forms;
|
||||||
const { FormSection, FormSwitchRow, FormInput, FormText } = Forms;
|
|
||||||
|
|
||||||
export default function Settings() {
|
export default function Settings() {
|
||||||
useProxy(storage);
|
useProxy(storage);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView style={{ flex: 1 }}>
|
<SettingsScaffold>
|
||||||
<FormSection title="Where to show the top role color">
|
<FormSection title="Where to show the top role color">
|
||||||
<FormSwitchRow
|
<FormSwitchRow
|
||||||
label="Typing indicator"
|
label="Typing indicator"
|
||||||
|
|
@ -52,13 +53,11 @@ export default function Settings() {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</FormSection>
|
</FormSection>
|
||||||
<View style={{ paddingHorizontal: 16, paddingVertical: 8 }}>
|
<NoteBox>
|
||||||
<FormText>
|
|
||||||
Some of these surfaces (especially the member list headers) rely on component internals
|
Some of these surfaces (especially the member list headers) rely on component internals
|
||||||
Discord doesn't officially expose, so a future Discord update may silently turn one of
|
Discord doesn't officially expose, so a future Discord update may silently turn one of
|
||||||
these off again - the rest will keep working if that happens.
|
these off again - the rest will keep working if that happens.
|
||||||
</FormText>
|
</NoteBox>
|
||||||
</View>
|
</SettingsScaffold>
|
||||||
</ScrollView>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,37 +1,26 @@
|
||||||
import { logger } from "@vendetta";
|
import { logger } from "@vendetta";
|
||||||
import { storage } from "@vendetta/plugin";
|
import { storage } from "@vendetta/plugin";
|
||||||
|
import { applyPatches } from "@shared/lib/patcher";
|
||||||
import patchChat from "./patches/chat";
|
import patchChat from "./patches/chat";
|
||||||
import patchDetails from "./patches/details";
|
import patchDetails from "./patches/details";
|
||||||
import patchName from "./patches/name";
|
import patchName from "./patches/name";
|
||||||
import patchTag from "./patches/tag";
|
import patchTag from "./patches/tag";
|
||||||
import Settings from "./ui/pages/Settings";
|
import Settings from "./ui/pages/Settings";
|
||||||
|
|
||||||
let patches: (() => void)[] = [];
|
let unpatchAll: () => void = () => {};
|
||||||
|
|
||||||
// Discord occasionally renames/removes the internals a single surface (chat, member list, etc.)
|
|
||||||
// depends on. Isolating each patch means one broken lookup only disables that surface instead of
|
|
||||||
// taking the whole plugin down, which is what happened to the old build of this plugin.
|
|
||||||
function safePatch(name: string, apply: () => () => void): () => void {
|
|
||||||
try {
|
|
||||||
return apply();
|
|
||||||
} catch (e) {
|
|
||||||
logger.error(`[Staff Tags] Failed to apply the "${name}" patch, that surface will be skipped: ${e}`);
|
|
||||||
return () => {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
onLoad: () => {
|
onLoad: () => {
|
||||||
storage.useRoleColor ??= false;
|
storage.useRoleColor ??= false;
|
||||||
storage.tags ??= {};
|
storage.tags ??= {};
|
||||||
|
|
||||||
patches = [
|
unpatchAll = applyPatches("Staff Tags", logger, {
|
||||||
safePatch("tag", patchTag),
|
tag: patchTag,
|
||||||
safePatch("chat", patchChat),
|
chat: patchChat,
|
||||||
safePatch("name", patchName),
|
name: patchName,
|
||||||
safePatch("details", patchDetails)
|
details: patchDetails
|
||||||
];
|
});
|
||||||
},
|
},
|
||||||
onUnload: () => patches.forEach((unpatch) => unpatch()),
|
onUnload: () => unpatchAll(),
|
||||||
settings: Settings
|
settings: Settings
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { findByProps, findByStoreName } from "@vendetta/metro";
|
||||||
import { chroma, constants } from "@vendetta/metro/common";
|
import { chroma, constants } from "@vendetta/metro/common";
|
||||||
import { storage } from "@vendetta/plugin";
|
import { storage } from "@vendetta/plugin";
|
||||||
import { rawColors } from "@vendetta/ui";
|
import { rawColors } from "@vendetta/ui";
|
||||||
|
import { isValidHex } from "@shared/lib/color";
|
||||||
|
|
||||||
const { Permissions } = constants;
|
const { Permissions } = constants;
|
||||||
const permissionsModule = findByProps("computePermissions", "canEveryoneRole");
|
const permissionsModule = findByProps("computePermissions", "canEveryoneRole");
|
||||||
|
|
@ -43,8 +44,6 @@ export interface ResolvedTag {
|
||||||
verified: boolean;
|
verified: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const HEX_REGEX = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i;
|
|
||||||
|
|
||||||
/** Lazily initializes and returns the per-tag settings object, backed directly by plugin storage. */
|
/** Lazily initializes and returns the per-tag settings object, backed directly by plugin storage. */
|
||||||
export function tagSettings(id: string): TagOverride {
|
export function tagSettings(id: string): TagOverride {
|
||||||
storage.tags ??= {};
|
storage.tags ??= {};
|
||||||
|
|
@ -53,7 +52,7 @@ export function tagSettings(id: string): TagOverride {
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveBackgroundColor(def: TagDefinition, settings: TagOverride, guild: any, user: any): string {
|
function resolveBackgroundColor(def: TagDefinition, settings: TagOverride, guild: any, user: any): string {
|
||||||
if (settings.useCustomColor && settings.color && HEX_REGEX.test(settings.color)) {
|
if (settings.useCustomColor && isValidHex(settings.color)) {
|
||||||
return settings.color;
|
return settings.color;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -100,7 +99,7 @@ export default function getTag(guild: any, channel: any, user: any): ResolvedTag
|
||||||
|
|
||||||
let gradientColor: string | undefined;
|
let gradientColor: string | undefined;
|
||||||
if (settings.useGradient) {
|
if (settings.useGradient) {
|
||||||
gradientColor = settings.gradientColor && HEX_REGEX.test(settings.gradientColor)
|
gradientColor = isValidHex(settings.gradientColor)
|
||||||
? settings.gradientColor
|
? settings.gradientColor
|
||||||
: chroma(backgroundColor).brighten(1.4).hex();
|
: chroma(backgroundColor).brighten(1.4).hex();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,18 @@
|
||||||
import { React, ReactNative } from "@vendetta/metro/common";
|
import { React } from "@vendetta/metro/common";
|
||||||
import { storage } from "@vendetta/plugin";
|
import { storage } from "@vendetta/plugin";
|
||||||
import { useProxy } from "@vendetta/storage";
|
import { useProxy } from "@vendetta/storage";
|
||||||
import { Forms } from "@vendetta/ui/components";
|
import { Forms } from "@vendetta/ui/components";
|
||||||
|
import SettingsScaffold from "@shared/ui/SettingsScaffold";
|
||||||
|
import ColorInput from "@shared/ui/ColorInput";
|
||||||
import { TAG_DEFINITIONS, tagSettings } from "../../lib/getTag";
|
import { TAG_DEFINITIONS, tagSettings } from "../../lib/getTag";
|
||||||
|
|
||||||
const { ScrollView, View } = ReactNative;
|
|
||||||
const { FormSection, FormSwitchRow, FormInput } = Forms;
|
const { FormSection, FormSwitchRow, FormInput } = Forms;
|
||||||
|
|
||||||
const HEX_REGEX = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i;
|
|
||||||
|
|
||||||
function TagSettingsSection({ id, defaultText, defaultColor }: { id: string; defaultText: string; defaultColor: string }) {
|
function TagSettingsSection({ id, defaultText, defaultColor }: { id: string; defaultText: string; defaultColor: string }) {
|
||||||
const settings = tagSettings(id);
|
const settings = tagSettings(id);
|
||||||
useProxy(settings);
|
useProxy(settings);
|
||||||
|
|
||||||
const enabled = settings.enabled !== false;
|
const enabled = settings.enabled !== false;
|
||||||
const invalidColor = !!settings.color && !HEX_REGEX.test(settings.color);
|
|
||||||
const invalidGradient = !!settings.gradientColor && !HEX_REGEX.test(settings.gradientColor);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormSection title={defaultText}>
|
<FormSection title={defaultText}>
|
||||||
|
|
@ -39,13 +36,11 @@ function TagSettingsSection({ id, defaultText, defaultColor }: { id: string; def
|
||||||
onValueChange={(v: boolean) => { settings.useCustomColor = v; }}
|
onValueChange={(v: boolean) => { settings.useCustomColor = v; }}
|
||||||
/>
|
/>
|
||||||
{settings.useCustomColor && (
|
{settings.useCustomColor && (
|
||||||
<FormInput
|
<ColorInput
|
||||||
title="Color (hex)"
|
title="Color"
|
||||||
|
value={settings.color}
|
||||||
placeholder={defaultColor}
|
placeholder={defaultColor}
|
||||||
value={settings.color ?? ""}
|
|
||||||
editable={enabled}
|
|
||||||
onChange={(v: string) => { settings.color = v; }}
|
onChange={(v: string) => { settings.color = v; }}
|
||||||
helpText={invalidColor ? "Invalid hex color, e.g. #5865F2 - falling back to default for now" : undefined}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<FormSwitchRow
|
<FormSwitchRow
|
||||||
|
|
@ -56,13 +51,10 @@ function TagSettingsSection({ id, defaultText, defaultColor }: { id: string; def
|
||||||
onValueChange={(v: boolean) => { settings.useGradient = v; }}
|
onValueChange={(v: boolean) => { settings.useGradient = v; }}
|
||||||
/>
|
/>
|
||||||
{settings.useGradient && (
|
{settings.useGradient && (
|
||||||
<FormInput
|
<ColorInput
|
||||||
title="Gradient color (hex)"
|
title="Gradient color"
|
||||||
placeholder="Leave blank to auto-generate"
|
value={settings.gradientColor}
|
||||||
value={settings.gradientColor ?? ""}
|
|
||||||
editable={enabled}
|
|
||||||
onChange={(v: string) => { settings.gradientColor = v; }}
|
onChange={(v: string) => { settings.gradientColor = v; }}
|
||||||
helpText={invalidGradient ? "Invalid hex color, leave blank to auto-generate" : undefined}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</FormSection>
|
</FormSection>
|
||||||
|
|
@ -73,7 +65,7 @@ export default function Settings() {
|
||||||
useProxy(storage);
|
useProxy(storage);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView style={{ flex: 1 }}>
|
<SettingsScaffold>
|
||||||
<FormSection title="General">
|
<FormSection title="General">
|
||||||
<FormSwitchRow
|
<FormSwitchRow
|
||||||
label="Use top role color"
|
label="Use top role color"
|
||||||
|
|
@ -85,7 +77,6 @@ export default function Settings() {
|
||||||
{TAG_DEFINITIONS.map((def) => (
|
{TAG_DEFINITIONS.map((def) => (
|
||||||
<TagSettingsSection key={def.id} id={def.id} defaultText={def.defaultText} defaultColor={def.defaultColor} />
|
<TagSettingsSection key={def.id} id={def.id} defaultText={def.defaultText} defaultColor={def.defaultColor} />
|
||||||
))}
|
))}
|
||||||
<View style={{ height: 24 }} />
|
</SettingsScaffold>
|
||||||
</ScrollView>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
24
shared/lib/color.ts
Normal file
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",
|
"moduleResolution": "node",
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"jsx": "react-native",
|
"jsx": "react-native",
|
||||||
"allowJs": true
|
"allowJs": true,
|
||||||
},
|
"baseUrl": ".",
|
||||||
"include": ["plugins", "node_modules/vendetta-types"]
|
"paths": {
|
||||||
|
"@shared/*": ["shared/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["plugins", "shared", "node_modules/vendetta-types"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue