feat(ui): "generalize" plugin components

This commit is contained in:
amsyarasyiq 2024-07-30 18:31:31 +08:00
parent 605685e86e
commit e5c87994db
6 changed files with 217 additions and 176 deletions

View file

@ -1,27 +1,25 @@
import { CardWrapper } from "@core/ui/components/AddonCard";
import { VdPluginManager, VendettaPlugin } from "@core/vendetta/plugins";
import { findAssetId } from "@lib/api/assets";
import { useProxy } from "@lib/api/storage";
import { lazyDestructure } from "@lib/utils/lazy";
import { findByProps } from "@metro";
import { NavigationNative } from "@metro/common";
import { Card, IconButton, Stack, TableSwitch, Text } from "@metro/common/components";
import { showSheet } from "@ui/sheets";
import { createContext, memo, useContext } from "react";
import { Image, View } from "react-native";
import { PluginManifest } from ".";
import { usePluginCardStyles } from "./usePluginCardStyles";
const { useToken } = lazyDestructure(() => findByProps("useToken"));
const PluginContext = createContext<VendettaPlugin>(null!);
const PluginContext = createContext<PluginManifest>(null!);
const usePlugin = () => useContext(PluginContext);
function Authors() {
const plugin = usePlugin();
if (!plugin.authors) return null;
const children = ["by "];
for (const author of plugin.manifest.authors) {
children.push(author.name);
for (const author of plugin.authors) {
children.push(typeof author === "string" ? author : author.name);
children.push(", ");
}
@ -36,15 +34,14 @@ function Title() {
const styles = usePluginCardStyles();
const plugin = usePlugin();
const iconName = plugin.manifest.vendetta?.icon;
const icon = iconName && findAssetId(iconName);
const icon = plugin.icon && findAssetId(plugin.icon);
const textElement = (
<Text
numberOfLines={1}
variant="heading-lg/semibold"
>
{plugin.manifest.name}
{plugin.name}
</Text>
);
@ -94,10 +91,10 @@ const Actions = memo(() => {
size="sm"
variant="secondary"
icon={findAssetId("WrenchIcon")}
disabled={!VdPluginManager.getSettings(plugin.id)}
disabled={!plugin.getPluginSettingsComponent()}
onPress={() => navigation.push("VendettaCustomPage", {
title: plugin.manifest.name,
render: VdPluginManager.getSettings(plugin.id),
title: plugin.name,
render: plugin.getPluginSettingsComponent(),
})}
/>
<IconButton
@ -106,15 +103,15 @@ const Actions = memo(() => {
icon={findAssetId("CircleInformationIcon-primary")}
onPress={() => void showSheet(
"PluginInfoActionSheet",
import("./sheets/PluginInfoActionSheet"),
plugin.resolveSheetComponent(),
{ plugin, navigation }
)}
/>
</View>;
});
export default function PluginCard({ item: plugin }: CardWrapper<VendettaPlugin>) {
useProxy(plugin);
export default function PluginCard({ item: plugin }: CardWrapper<PluginManifest>) {
plugin.usePluginState();
return (
<PluginContext.Provider value={plugin}>
@ -130,17 +127,16 @@ export default function PluginCard({ item: plugin }: CardWrapper<VendettaPlugin>
<Stack spacing={12} direction="horizontal">
<Actions />
<TableSwitch
value={plugin.enabled}
value={plugin.isEnabled()}
onValueChange={(v: boolean) => {
if (v) VdPluginManager.startPlugin(plugin.id);
else VdPluginManager.stopPlugin(plugin.id);
plugin.toggle(v);
}}
/>
</Stack>
</View>
</View>
<Text variant="text-md/medium">
{plugin.manifest.description}
{plugin.description}
</Text>
</Stack>
</Card>

View file

@ -6,19 +6,61 @@ import { settings } from "@lib/api/settings";
import { useProxy } from "@lib/api/storage";
import { Author } from "@lib/utils/types";
export interface PluginManifest {
id: string;
name: string;
description?: string;
authors?: Array<Author | string>;
icon?: string;
isEnabled(): boolean;
usePluginState(): void;
toggle(start: boolean): void;
resolveSheetComponent(): Promise<{ default: React.ComponentType<any>; }>;
getPluginSettingsComponent(): React.ComponentType<any> | null;
}
function resolveFromVdPlugin(vdPlugin: VendettaPlugin): PluginManifest {
return {
id: vdPlugin.id,
name: vdPlugin.manifest.name,
description: vdPlugin.manifest.description,
authors: vdPlugin.manifest.authors,
icon: vdPlugin.manifest.vendetta?.icon,
isEnabled() {
return vdPlugin.enabled;
},
usePluginState() {
useProxy(VdPluginManager.plugins[vdPlugin.id]);
},
toggle(start: boolean) {
start
? VdPluginManager.startPlugin(vdPlugin.id)
: VdPluginManager.stopPlugin(vdPlugin.id);
},
resolveSheetComponent() {
return import("./sheets/VdPluginInfoActionSheet");
},
getPluginSettingsComponent() {
return VdPluginManager.getSettings(vdPlugin.id);
},
};
}
export default function Plugins() {
useProxy(settings);
return (
<AddonPage<VendettaPlugin>
<AddonPage<PluginManifest>
title={Strings.PLUGINS}
searchKeywords={[
"manifest.name",
"manifest.description",
p => p.manifest.authors?.map((a: Author) => a.name).join()
"name",
"description",
p => p.authors?.map((a: Author | string) => typeof a === "string" ? a : a.name).join()
]}
items={VdPluginManager.plugins}
fetchFunction={VdPluginManager.installPlugin.bind(VdPluginManager)}
resolveItem={resolveFromVdPlugin}
fetchFunction={async () => { /** TODO */ }}
safeModeMessage={Strings.SAFE_MODE_NOTICE_PLUGINS}
card={PluginCard}
/>

View file

@ -1,141 +1,3 @@
import { formatString, Strings } from "@core/i18n";
import { VdPluginManager, VendettaPlugin } from "@core/vendetta/plugins";
import { findAssetId } from "@lib/api/assets";
import { purgeStorage, useProxy } from "@lib/api/storage";
import { ButtonColors } from "@lib/utils/types";
import { clipboard } from "@metro/common";
import { ActionSheet, ActionSheetRow, Button, TableRow, Text } from "@metro/common/components";
import { showConfirmationAlert } from "@ui/alerts";
import { hideSheet } from "@ui/sheets";
import { showToast } from "@ui/toasts";
import { ScrollView, View } from "react-native";
interface InfoProps {
plugin: VendettaPlugin;
navigation: any;
}
export default function PluginInfoActionSheet({ plugin, navigation }: InfoProps) {
useProxy(plugin);
const Settings = VdPluginManager.getSettings(plugin.id);
return <ActionSheet>
<ScrollView>
<View style={{ flexDirection: "row", alignItems: "center", paddingVertical: 24 }}>
<Text variant="heading-xl/semibold">
{plugin.manifest.name}
</Text>
<View style={{ marginLeft: "auto" }}>
{Settings && <Button
size="md"
text="Configure"
variant="secondary"
icon={findAssetId("WrenchIcon")}
onPress={() => {
hideSheet("PluginInfoActionSheet");
navigation.push("VendettaCustomPage", {
title: plugin.manifest.name,
render: Settings,
});
}}
/>}
</View>
</View>
<ActionSheetRow.Group>
<ActionSheetRow
label={Strings.REFETCH}
icon={<TableRow.Icon source={findAssetId("RetryIcon")} />}
onPress={async () => {
if (plugin.enabled) VdPluginManager.stopPlugin(plugin.id, false);
try {
await VdPluginManager.fetchPlugin(plugin.id);
showToast(Strings.PLUGIN_REFETCH_SUCCESSFUL, findAssetId("toast_image_saved"));
} catch {
showToast(Strings.PLUGIN_REFETCH_FAILED, findAssetId("Small"));
}
if (plugin.enabled) await VdPluginManager.startPlugin(plugin.id);
hideSheet("PluginInfoActionSheet");
}}
/>
<ActionSheetRow
label={Strings.COPY_URL}
icon={<TableRow.Icon source={findAssetId("copy")} />}
onPress={() => {
clipboard.setString(plugin.id);
showToast.showCopyToClipboard();
}}
/>
<ActionSheetRow
label={plugin.update ? Strings.DISABLE_UPDATES : Strings.ENABLE_UPDATES}
icon={<TableRow.Icon source={findAssetId("ic_download_24px")} />}
onPress={() => {
plugin.update = !plugin.update;
showToast(formatString("TOASTS_PLUGIN_UPDATE", {
update: plugin.update,
name: plugin.manifest.name
}), findAssetId("toast_image_saved"));
}}
/>
<ActionSheetRow
label={Strings.CLEAR_DATA}
icon={<TableRow.Icon source={findAssetId("ic_duplicate")} />}
onPress={() => showConfirmationAlert({
title: Strings.HOLD_UP,
content: formatString("ARE_YOU_SURE_TO_CLEAR_DATA", { name: plugin.manifest.name }),
confirmText: Strings.CLEAR,
cancelText: Strings.CANCEL,
confirmColor: ButtonColors.RED,
onConfirm: async () => {
if (plugin.enabled) VdPluginManager.stopPlugin(plugin.id, false);
try {
await VdPluginManager.fetchPlugin(plugin.id);
showToast(Strings.PLUGIN_REFETCH_SUCCESSFUL, findAssetId("toast_image_saved"));
} catch {
showToast(Strings.PLUGIN_REFETCH_FAILED, findAssetId("Small"));
}
let message: any[];
try {
purgeStorage(plugin.id);
message = ["CLEAR_DATA_SUCCESSFUL", "trash"];
} catch {
message = ["CLEAR_DATA_FAILED", "Small"];
}
showToast(
formatString(message[0], { name: plugin.manifest.name }),
findAssetId(message[1])
);
if (plugin.enabled) await VdPluginManager.startPlugin(plugin.id);
hideSheet("PluginInfoActionSheet");
}
})}
/>
<ActionSheetRow
label={Strings.DELETE}
icon={<TableRow.Icon source={findAssetId("ic_message_delete")} />}
onPress={() => showConfirmationAlert({
title: Strings.HOLD_UP,
content: formatString("ARE_YOU_SURE_TO_DELETE_PLUGIN", { name: plugin.manifest.name }),
confirmText: Strings.DELETE,
cancelText: Strings.CANCEL,
confirmColor: ButtonColors.RED,
onConfirm: () => {
try {
VdPluginManager.removePlugin(plugin.id);
} catch (e) {
showToast(String(e), findAssetId("Small"));
}
hideSheet("PluginInfoActionSheet");
}
})}
/>
</ActionSheetRow.Group>
</ScrollView>
</ActionSheet>;
export default function PluginInfoActionSheet() {
return <></>;
}

View file

@ -0,0 +1,144 @@
import { formatString, Strings } from "@core/i18n";
import { VdPluginManager } from "@core/vendetta/plugins";
import { findAssetId } from "@lib/api/assets";
import { purgeStorage } from "@lib/api/storage";
import { ButtonColors } from "@lib/utils/types";
import { clipboard } from "@metro/common";
import { ActionSheet, ActionSheetRow, Button, TableRow, Text } from "@metro/common/components";
import { showConfirmationAlert } from "@ui/alerts";
import { hideSheet } from "@ui/sheets";
import { showToast } from "@ui/toasts";
import { ScrollView, View } from "react-native";
import { PluginManifest } from "..";
interface InfoProps {
plugin: PluginManifest;
navigation: any;
}
export default function PluginInfoActionSheet({ plugin, navigation }: InfoProps) {
plugin.usePluginState();
const vdPlugin = VdPluginManager.plugins[plugin.id];
const SettingsComponent = plugin.getPluginSettingsComponent();
return <ActionSheet>
<ScrollView>
<View style={{ flexDirection: "row", alignItems: "center", paddingVertical: 24 }}>
<Text variant="heading-xl/semibold">
{plugin.name}
</Text>
<View style={{ marginLeft: "auto" }}>
{SettingsComponent && <Button
size="md"
text="Configure"
variant="secondary"
icon={findAssetId("WrenchIcon")}
onPress={() => {
hideSheet("PluginInfoActionSheet");
navigation.push("VendettaCustomPage", {
title: plugin.name,
render: SettingsComponent,
});
}}
/>}
</View>
</View>
<ActionSheetRow.Group>
<ActionSheetRow
label={Strings.REFETCH}
icon={<TableRow.Icon source={findAssetId("RetryIcon")} />}
onPress={async () => {
if (vdPlugin.enabled) VdPluginManager.stopPlugin(plugin.id, false);
try {
await VdPluginManager.fetchPlugin(plugin.id);
showToast(Strings.PLUGIN_REFETCH_SUCCESSFUL, findAssetId("toast_image_saved"));
} catch {
showToast(Strings.PLUGIN_REFETCH_FAILED, findAssetId("Small"));
}
if (vdPlugin.enabled) await VdPluginManager.startPlugin(plugin.id);
hideSheet("PluginInfoActionSheet");
}}
/>
<ActionSheetRow
label={Strings.COPY_URL}
icon={<TableRow.Icon source={findAssetId("copy")} />}
onPress={() => {
clipboard.setString(plugin.id);
showToast.showCopyToClipboard();
}}
/>
<ActionSheetRow
label={vdPlugin.update ? Strings.DISABLE_UPDATES : Strings.ENABLE_UPDATES}
icon={<TableRow.Icon source={findAssetId("ic_download_24px")} />}
onPress={() => {
vdPlugin.update = !vdPlugin.update;
showToast(formatString("TOASTS_PLUGIN_UPDATE", {
update: vdPlugin.update,
name: plugin.name
}), findAssetId("toast_image_saved"));
}}
/>
<ActionSheetRow
label={Strings.CLEAR_DATA}
icon={<TableRow.Icon source={findAssetId("ic_duplicate")} />}
onPress={() => showConfirmationAlert({
title: Strings.HOLD_UP,
content: formatString("ARE_YOU_SURE_TO_CLEAR_DATA", { name: plugin.name }),
confirmText: Strings.CLEAR,
cancelText: Strings.CANCEL,
confirmColor: ButtonColors.RED,
onConfirm: async () => {
if (vdPlugin.enabled) VdPluginManager.stopPlugin(plugin.id, false);
try {
await VdPluginManager.fetchPlugin(plugin.id);
showToast(Strings.PLUGIN_REFETCH_SUCCESSFUL, findAssetId("toast_image_saved"));
} catch {
showToast(Strings.PLUGIN_REFETCH_FAILED, findAssetId("Small"));
}
let message: any[];
try {
purgeStorage(plugin.id);
message = ["CLEAR_DATA_SUCCESSFUL", "trash"];
} catch {
message = ["CLEAR_DATA_FAILED", "Small"];
}
showToast(
formatString(message[0], { name: plugin.name }),
findAssetId(message[1])
);
if (vdPlugin.enabled) await VdPluginManager.startPlugin(plugin.id);
hideSheet("PluginInfoActionSheet");
}
})}
/>
<ActionSheetRow
label={Strings.DELETE}
icon={<TableRow.Icon source={findAssetId("ic_message_delete")} />}
onPress={() => showConfirmationAlert({
title: Strings.HOLD_UP,
content: formatString("ARE_YOU_SURE_TO_DELETE_PLUGIN", { name: plugin.name }),
confirmText: Strings.DELETE,
cancelText: Strings.CANCEL,
confirmColor: ButtonColors.RED,
onConfirm: () => {
try {
VdPluginManager.removePlugin(plugin.id);
} catch (e) {
showToast(String(e), findAssetId("Small"));
}
hideSheet("PluginInfoActionSheet");
}
})}
/>
</ActionSheetRow.Group>
</ScrollView>
</ActionSheet>;
}

View file

@ -8,7 +8,7 @@ import { Author } from "@lib/utils/types";
type EvaledPlugin = {
onLoad?(): void;
onUnload(): void;
settings: JSX.Element;
settings: React.ComponentType<unknown>;
};
// See https://github.com/vendetta-mod/polymanifest

View file

@ -1,7 +1,6 @@
// New iteration of storage API, mostly yoinked from unreleased pyoncord (and sunrise?)
import { fileExists, readFile, writeFile } from "@lib/api/native/fs";
import { Emitter } from "@lib/utils/Emitter";
import invariant from "@lib/utils/invariant";
interface StorageBackend<T = unknown> {
get: () => Promise<T>;
@ -21,7 +20,7 @@ function createFileBackend<T>(filePath: string): StorageBackend<T> {
try {
return JSON.parse(await readFile(filePath));
} catch (e) {
throw new Error(`Failed to parse storage '${filePath}: ${e}'`);
throw new Error(`Failed to parse storage from '${filePath}'`, { cause: e });
}
},
set: async data => {
@ -158,15 +157,16 @@ export async function createStorageAsync<T>(path: string, dflt = {} as T): Promi
export const createStorage = <T>(path: string, dflt = {} as T): T & { [key: symbol]: any; } => {
const promise = new Promise(r => resolvePromise = r);
let awaited: any, error: any, resolvePromise: (val?: unknown) => void;
let awaited: any, resolved: boolean, error: any, resolvePromise: (val?: unknown) => void;
createStorageAndCallback(path, dflt, proxy => {
awaited = proxy;
resolved = true;
resolvePromise();
});
const check = () => {
if (awaited) return true;
if (resolved) return true;
throw new Error("Attempted to access storage without initializing");
};
@ -190,11 +190,8 @@ export async function preloadStorageIfExists(path: string) {
if (_loadedPath[path]) return;
if (!await fileExists(path)) return;
try {
const data = await createFileBackend<any>(path).get();
invariant(data !== undefined);
_loadedPath[path] = data;
} catch {}
const data = await createFileBackend<any>(path).get();
_loadedPath[path] = data;
}
export function awaitStorage(...proxies: any[]) {