[UI] Alerts API (#21)

* [UI] Initial Alerts API

* [UI > Alerts] showCustomAlert and showInputAlert

* [Constants] Add HTTP_REGEX

* [UI > Plugins] Use InputAlert for installing plugins

* [UI > Plugins/PluginCard] Pass plugin index to PluginCard to add top margin

* [UI > Alerts] Fix indentation
This commit is contained in:
Jack 2023-02-26 16:26:01 -05:00 committed by GitHub
parent c3f7d60d85
commit 9fb99ced74
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 175 additions and 35 deletions

46
src/def.d.ts vendored
View file

@ -46,6 +46,37 @@ interface Asset {
id: number;
}
declare enum ButtonColors {
BRAND = "brand",
RED = "red",
GREEN = "green",
PRIMARY = "primary",
TRANSPARENT = "transparent",
GREY = "grey",
LIGHTGREY = "lightgrey",
WHITE = "white",
LINK = "link"
}
interface ConfirmationAlertOptions {
title: string | undefined;
content: string | JSX.Element | JSX.Element[];
confirmText: string | undefined;
confirmColor: ButtonColors | undefined;
onConfirm: () => void;
cancelText: string | undefined;
}
interface InputAlertProps {
title: string | undefined;
confirmText: string | undefined;
confirmColor: ButtonColors | undefined;
onConfirm: (input: string) => void | Promise<void>;
cancelText: string | undefined;
placeholder: string | undefined;
initialValue: string | undefined;
}
interface PluginAuthor {
name: string;
id: string;
@ -230,13 +261,13 @@ type Indexable<Type> = { [index: string]: Type }
type EmitterEvent = "SET" | "GET" | "DEL";
interface EmitterListenerData {
path: string[];
value?: any;
path: string[];
value?: any;
}
type EmitterListener = (
event: EmitterEvent,
data: EmitterListenerData | any
event: EmitterEvent,
data: EmitterListenerData | any
) => any;
type EmitterListeners = Indexable<Set<EmitterListener>>;
@ -313,6 +344,7 @@ interface VendettaObject {
constants: {
DISCORD_SERVER: string;
GITHUB: string;
HTTP_REGEX: RegExp;
};
utils: {
copyText: (content: string) => void;
@ -332,6 +364,7 @@ interface VendettaObject {
Forms: PropIntellisense<"Form" | "FormSection">;
General: PropIntellisense<"Button" | "Text" | "View">;
Search: _React.ComponentType;
Alert: _React.ComponentType;
// Vendetta
Summary: (props: SummaryProps) => JSX.Element;
ErrorBoundary: (props: ErrorBoundaryProps) => JSX.Element;
@ -339,6 +372,11 @@ interface VendettaObject {
toasts: {
showToast: (content: string, asset: number) => void;
};
alerts: {
showConfirmationAlert: (options: ConfirmationAlertOptions) => void;
showCustomAlert: (component: _React.ComponentType, props: any) => void;
showInputAlert: (options: InputAlertProps) => void;
};
assets: {
all: Indexable<Asset>;
find: (filter: (a: any) => void) => Asset | null | undefined;

View file

@ -1,2 +1,3 @@
export const DISCORD_SERVER = "n9QQ4XhhJP";
export const GITHUB = "https://github.com/vendetta-mod";
export const GITHUB = "https://github.com/vendetta-mod";
export const HTTP_REGEX = /^https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_\+.~#?&\/=]*)$/;

View file

@ -11,6 +11,7 @@ import * as metro from "@metro/filters";
import * as common from "@metro/common";
import * as components from "@ui/components";
import * as toasts from "@ui/toasts";
import * as alerts from "@ui/alerts";
import * as assets from "@ui/assets";
import * as color from "@ui/color";
import * as utils from "@utils";
@ -30,6 +31,7 @@ export default async (unloads: any[]): Promise<VendettaObject> => ({
ui: {
components,
toasts,
alerts,
assets,
...color,
},

37
src/ui/alerts.ts Normal file
View file

@ -0,0 +1,37 @@
import { ConfirmationAlertOptions, InputAlertProps } from "@types";
import { findByProps } from "@metro/filters";
import InputAlert from "@ui/components/InputAlert";
const Alerts = findByProps("openLazy", "close");
interface InternalConfirmationAlertOptions extends Omit<ConfirmationAlertOptions, 'content'> {
content: string | JSX.Element | JSX.Element[] | undefined;
body: string | undefined;
children: JSX.Element | JSX.Element[];
};
export function showConfirmationAlert(options: ConfirmationAlertOptions) {
const internalOptions = options as InternalConfirmationAlertOptions;
if (typeof options.content === "string") {
internalOptions.body = options.content;
} else {
internalOptions.children = options.content;
};
delete internalOptions.content;
return Alerts.show(internalOptions);
};
export function showCustomAlert(component: React.ComponentType, props: any) {
Alerts.openLazy({
importer: async function () {
return () => React.createElement(component, props);
}
});
};
export function showInputAlert(options: InputAlertProps) {
showCustomAlert(InputAlert as React.ComponentType, options);
};

View file

@ -0,0 +1,49 @@
import { findByProps } from "@metro/filters";
import { Forms, Alert } from "@ui/components";
import { InputAlertProps } from "@types";
const { FormInput } = Forms;
const Alerts = findByProps("openLazy", "close");
export default function InputAlert({ title, confirmText, confirmColor, onConfirm, cancelText, placeholder, initialValue = "" }: InputAlertProps) {
const [value, setValue] = React.useState(initialValue);
const [error, setError] = React.useState("");
function onConfirmWrapper() {
const asyncOnConfirm = Promise.resolve(onConfirm(value))
asyncOnConfirm.then(() => {
Alerts.close();
}).catch((e: Error) => {
setError(e.message);
});
};
return (
<Alert
title={title}
confirmText={confirmText}
confirmColor={confirmColor}
isConfirmButtonDisabled={error.length !== 0}
onConfirm={onConfirmWrapper}
cancelText={cancelText}
onCancel={() => Alerts.close()}
>
<FormInput
placeholder={placeholder}
value={value}
onChangeText={(v: string) => {
setValue(v);
if (error) setError("");
}}
returnKeyType="done"
onSubmitEditing={onConfirmWrapper}
error={error}
autoFocus={true}
showBorder={true}
style={{ paddingVertical: 5, alignSelf: "stretch", paddingHorizontal: 0 }}
/>
</Alert>
);
};

View file

@ -4,6 +4,7 @@ import { findByDisplayName, findByProps } from "@metro/filters";
export const Forms = findByProps("Form", "FormSection");
export const General = findByProps("Button", "Text", "View");
export const Search = findByDisplayName("StaticSearchBarContainer");
export const Alert = findByProps("alertDarkStyles", "alertLightStyles").default;
// Vendetta
export { default as Summary } from "@ui/components/Summary";

View file

@ -0,0 +1,35 @@
import { HTTP_REGEX } from "@/lib/constants";
import { semanticColors } from "@ui/color";
import { installPlugin } from "@lib/plugins";
import { clipboard, stylesheet } from "@metro/common";
import { showInputAlert } from "@ui/alerts";
import { getAssetIDByName } from "@ui/assets";
import { General } from "@ui/components";
const { TouchableOpacity, Image } = General;
const styles = stylesheet.createThemedStyleSheet({
icon: {
marginRight: 10,
tintColor: semanticColors.HEADER_PRIMARY,
}
});
export default function InstallPluginButton() {
return (
<TouchableOpacity onPress={() =>
clipboard.getString().then((content) =>
showInputAlert({
title: "Install Plugin",
initialValue: HTTP_REGEX.test(content) ? content : "",
placeholder: "https://example.com/",
onConfirm: installPlugin,
confirmText: "Install",
confirmColor: undefined,
cancelText: "Cancel"
}))
}>
<Image style={styles.icon} source={getAssetIDByName("ic_add_24px")} />
</TouchableOpacity >
);
};

View file

@ -42,9 +42,10 @@ const styles = stylesheet.createThemedStyleSheet({
interface PluginCardProps {
plugin: Plugin;
index: number;
}
export default function PluginCard({ plugin }: PluginCardProps) {
export default function PluginCard({ plugin, index }: PluginCardProps) {
const settings = getSettings(plugin.id);
const navigation = NavigationNative.useNavigation();
const [removed, setRemoved] = React.useState(false);
@ -53,7 +54,7 @@ export default function PluginCard({ plugin }: PluginCardProps) {
if (removed) return null;
return (
<RN.View style={styles.card}>
<RN.View style={[styles.card, {marginTop: index === 0 ? 10 : 0}]}>
<FormRow
style={styles.header}
// TODO: Actually make use of user IDs

View file

@ -8,6 +8,7 @@ import General from "@ui/settings/pages/General";
import Plugins from "@ui/settings/pages/Plugins";
import Developer from "@ui/settings/pages/Developer";
import AssetBrowser from "@ui/settings/pages/AssetBrowser";
import InstallPluginButton from "@ui/settings/components/InstallPluginButton";
const screensModule = findByDisplayName("getScreens", false);
const settingsModule = findByDisplayName("UserSettingsOverviewWrapper", false);
@ -25,6 +26,7 @@ export default function initSettings() {
VendettaPlugins: {
title: "Plugins",
render: Plugins,
headerRight: InstallPluginButton,
},
VendettaDeveloper: {
title: "Developer",

View file

@ -1,44 +1,18 @@
import { ReactNative as RN } from "@metro/common";
import { Forms } from "@ui/components";
import { showToast } from "@ui/toasts";
import { getAssetIDByName } from "@ui/assets";
import { useProxy } from "@lib/storage";
import { plugins, installPlugin } from "@lib/plugins";
import { plugins } from "@lib/plugins";
import PluginCard from "@ui/settings/components/PluginCard";
import ErrorBoundary from "@ui/components/ErrorBoundary";
const { FormInput, FormRow } = Forms;
export default function Plugins() {
useProxy(plugins);
const [pluginUrl, setPluginUrl] = React.useState("");
return (
<ErrorBoundary>
<RN.View style={{ flex: 1 }}>
<FormInput
value={pluginUrl}
onChange={(v: string) => setPluginUrl(v)}
placeholder="https://example.com/"
title="PLUGIN URL"
/>
<FormRow
label="Install plugin"
// I checked, this icon exists on a fresh Discord install. Please, stop disappearing.
leading={<FormRow.Icon source={getAssetIDByName("ic_add_24px")} />}
onPress={() => {
installPlugin(pluginUrl).then(() => {
setPluginUrl("");
}).catch((e: Error) => {
showToast(e.message, getAssetIDByName("Small"));
});
}
}
/>
<RN.FlatList
style={{ marginTop: 10 }}
data={Object.values(plugins)}
renderItem={({ item }) => <PluginCard plugin={item} />}
renderItem={({ item, index }) => <PluginCard plugin={item} index={index} />}
keyExtractor={item => item.id}
/>
</RN.View>