refactor+feat: wip plugin browser

This commit is contained in:
amsyarasyiq 2024-07-31 17:06:17 +08:00
parent 405fce876b
commit c3fb133d62
19 changed files with 159 additions and 98 deletions

View file

@ -10,7 +10,7 @@ import { FloatingActionButton, HelpMessage } from "@metro/common/components";
import { showInputAlert } from "@ui/alerts";
import { ErrorBoundary, Search } from "@ui/components";
import fuzzysort from "fuzzysort";
import { useMemo } from "react";
import { ComponentType, ReactNode, useMemo } from "react";
import { View } from "react-native";
type SearchKeywords = Array<string | ((obj: any & {}) => string)>;
@ -18,13 +18,14 @@ type SearchKeywords = Array<string | ((obj: any & {}) => string)>;
interface AddonPageProps<T extends object> {
title: string;
fetchFunction?: (url: string) => Promise<void>;
items: Record<string, any>;
items: any[];
resolveItem?: (value: any) => T | undefined;
safeModeMessage: string;
safeModeExtras?: JSX.Element | JSX.Element[];
card: React.ComponentType<CardWrapper<T>>;
safeModeExtras?: ReactNode;
card: ComponentType<CardWrapper<T>>;
searchKeywords: SearchKeywords;
onFABPress?: () => void;
onFabPress?: () => void;
ListFooterComponent?: ReactNode | ComponentType;
}
// TODO: Move to somewhere else
@ -36,7 +37,7 @@ export default function AddonPage<T extends object>({ card: CardComponent, ...pr
const [search, setSearch] = React.useState("");
const items = useMemo(() => {
let values = Object.values(props.items);
let values = props.items;
if (props.resolveItem) values = values.map(props.resolveItem);
return values.filter(i => i && typeof i === "object");
}, [props.items]);
@ -54,8 +55,7 @@ export default function AddonPage<T extends object>({ card: CardComponent, ...pr
</View>}
<Search
style={{ padding: 8 }}
onChangeText={(v: string) => setSearch(v.toLowerCase())}
placeholder={Strings.SEARCH}
onChangeText={v => setSearch(v)}
/>
</View>
), []);
@ -67,15 +67,16 @@ export default function AddonPage<T extends object>({ card: CardComponent, ...pr
estimatedItemSize={136}
ListHeaderComponent={headerElement}
contentContainerStyle={{ paddingBottom: 90, paddingHorizontal: 5 }}
ListFooterComponent={props.ListFooterComponent}
renderItem={({ item }: any) => (
<View style={{ paddingVertical: 6, paddingHorizontal: 8 }}>
<CardComponent item={item} />
</View>
)}
/>
{(props.fetchFunction ?? props.onFABPress) && <FloatingActionButton
{(props.fetchFunction ?? props.onFabPress) && <FloatingActionButton
icon={findAssetId("PlusLargeIcon")}
onPress={props.onFABPress ?? (() => {
onPress={props.onFabPress ?? (() => {
// from ./InstallButton.tsx
clipboard.getString().then((content: string) =>
showInputAlert({

View file

@ -101,7 +101,7 @@ export default function Developer() {
label={Strings.ASSET_BROWSER}
icon={<TableRow.Icon source={findAssetId("ic_image")} />}
trailing={TableRow.Arrow}
onPress={() => navigation.push("VendettaCustomPage", {
onPress={() => navigation.push("BUNNY_CUSTOM_PAGE", {
title: Strings.ASSET_BROWSER,
render: AssetBrowser,
})}
@ -120,8 +120,8 @@ export default function Developer() {
options: [
// @ts-expect-error
// Of course, to trigger an error, we need to do something incorrectly. The below will do!
{ label: Strings.BUNNY, onPress: () => navigation.push("VendettaCustomPage", { render: () => <undefined /> }) },
{ label: "Discord", isDestructive: true, onPress: () => navigation.push("VendettaCustomPage", { noErrorBoundary: true }) },
{ label: Strings.BUNNY, onPress: () => navigation.push("BUNNY_CUSTOM_PAGE", { render: () => <undefined /> }) },
{ label: "Discord", isDestructive: true, onPress: () => navigation.push("BUNNY_CUSTOM_PAGE", { noErrorBoundary: true }) },
],
})}
/>

View file

@ -83,7 +83,7 @@ export default function FontCard({ item: font }: CardWrapper<FontDefinition>) {
<Stack spacing={12} direction="horizontal">
<IconButton
onPress={() => {
navigation.push("VendettaCustomPage", {
navigation.push("BUNNY_CUSTOM_PAGE", {
title: "Edit Font",
render: () => <FontEditor name={font.name} />
});

View file

@ -19,11 +19,11 @@ export default function Fonts() {
title={Strings.FONTS}
searchKeywords={["name", "description"]}
fetchFunction={installFont}
items={fonts as Record<string, FontDefinition>}
items={Object.values(fonts)}
safeModeMessage={Strings.SAFE_MODE_NOTICE_FONTS}
card={FontCard}
onFABPress={() => {
navigation.push("VendettaCustomPage", {
onFabPress={() => {
navigation.push("BUNNY_CUSTOM_PAGE", {
title: "Import Font",
render: () => <FontEditor />
});

View file

@ -35,7 +35,7 @@ export default function General() {
label={Strings.ABOUT}
icon={<TableRow.Icon source={findAssetId("CircleInformationIcon-primary")} />}
trailing={TableRow.Arrow}
onPress={() => navigation.push("VendettaCustomPage", {
onPress={() => navigation.push("BUNNY_CUSTOM_PAGE", {
title: Strings.ABOUT,
render: () => <About />,
})}

View file

@ -0,0 +1,5 @@
export default function PluginBrowser() {
return null;
}

View file

@ -6,10 +6,10 @@ import { showSheet } from "@ui/sheets";
import { createContext, memo, useContext } from "react";
import { Image, View } from "react-native";
import { PluginManifest } from ".";
import { UnifiedPluginModel } from ".";
import { usePluginCardStyles } from "./usePluginCardStyles";
const PluginContext = createContext<PluginManifest>(null!);
const PluginContext = createContext<UnifiedPluginModel>(null!);
const usePlugin = () => useContext(PluginContext);
function Authors() {
@ -92,7 +92,7 @@ const Actions = memo(() => {
variant="secondary"
icon={findAssetId("WrenchIcon")}
disabled={!plugin.getPluginSettingsComponent()}
onPress={() => navigation.push("VendettaCustomPage", {
onPress={() => navigation.push("BUNNY_CUSTOM_PAGE", {
title: plugin.name,
render: plugin.getPluginSettingsComponent(),
})}
@ -110,7 +110,7 @@ const Actions = memo(() => {
</View>;
});
export default function PluginCard({ item: plugin }: CardWrapper<PluginManifest>) {
export default function PluginCard({ item: plugin }: CardWrapper<UnifiedPluginModel>) {
plugin.usePluginState();
return (

View file

@ -1,17 +1,21 @@
import { Strings } from "@core/i18n";
import AddonPage from "@core/ui/components/AddonPage";
import PluginCard from "@core/ui/settings/pages/Plugins/PluginCard";
import { VdPluginManager, VendettaPlugin } from "@core/vendetta/plugins";
import { VdPluginManager } from "@core/vendetta/plugins";
import { findAssetId } from "@lib/api/assets";
import { settings } from "@lib/api/settings";
import { useProxy } from "@lib/api/storage";
import { useProxy as useProxyNew } from "@lib/api/storage/new";
import { getId, getPluginSettingsComponent, isPluginEnabled, pluginSettings, registeredPlugins, startPlugin, stopPlugin } from "@lib/plugins";
import { BunnyPluginManifest } from "@lib/plugins/types";
import { registeredPlugins } from "@lib/plugins";
import { Author } from "@lib/utils/types";
import { SegmentedControl, SegmentedControlPages, useSegmentedControlState } from "@metro/common/components";
import { NavigationNative } from "@metro/common";
import { Button, SegmentedControl, SegmentedControlPages, Text, useSegmentedControlState } from "@metro/common/components";
import { ComponentProps } from "react";
import { useWindowDimensions, View } from "react-native";
export interface PluginManifest {
import unifyBunnyPlugin from "./models/bunny";
import unifyVdPlugin from "./models/vendetta";
export interface UnifiedPluginModel {
id: string;
name: string;
description?: string;
@ -25,62 +29,15 @@ export interface PluginManifest {
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);
},
};
function navigateToPluginBrowser(navigation: any) {
navigation.push("BUNNY_CUSTOM_PAGE", {
title: "Plugin Browser",
render: React.lazy(() => import("../PluginBrowser")),
});
}
function resolveFromBunnyPlugin(manifest: BunnyPluginManifest): PluginManifest {
return {
id: manifest.id,
name: manifest.name,
description: manifest.description,
authors: manifest.authors,
isEnabled() {
return isPluginEnabled(getId(manifest));
},
usePluginState() {
useProxyNew(pluginSettings);
},
toggle(start: boolean) {
start
? startPlugin(getId(manifest))
: stopPlugin(getId(manifest));
},
resolveSheetComponent() {
return import("./sheets/PluginInfoActionSheet");
},
getPluginSettingsComponent() {
return getPluginSettingsComponent(getId(manifest));
},
};
}
function Page({ items, resolveItem, fetchFunction }: Record<string, any>) {
return <AddonPage<PluginManifest>
function Page(props: Partial<ComponentProps<typeof AddonPage<UnifiedPluginModel>>>) {
return <AddonPage<UnifiedPluginModel>
card={PluginCard}
title={Strings.PLUGINS}
searchKeywords={[
@ -88,16 +45,16 @@ function Page({ items, resolveItem, fetchFunction }: Record<string, any>) {
"description",
p => p.authors?.map((a: Author | string) => typeof a === "string" ? a : a.name).join()
]}
items={items}
resolveItem={resolveItem}
fetchFunction={fetchFunction}
safeModeMessage={Strings.SAFE_MODE_NOTICE_PLUGINS}
items={props.items!}
{...props}
/>;
}
export default function Plugins() {
useProxy(settings);
const navigation = NavigationNative.useNavigation();
const { width: pageWidth } = useWindowDimensions();
const state = useSegmentedControlState({
@ -108,8 +65,8 @@ export default function Plugins() {
id: "vendetta-plugins",
page: (
<Page
items={VdPluginManager.plugins}
resolveItem={resolveFromVdPlugin}
items={Object.values(VdPluginManager.plugins)}
resolveItem={unifyVdPlugin}
fetchFunction={(url: string) => VdPluginManager.installPlugin(url)}
/>
)
@ -119,15 +76,34 @@ export default function Plugins() {
id: "bunny-plugins",
page: (
<Page
items={Object.fromEntries(registeredPlugins.entries())}
resolveItem={resolveFromBunnyPlugin}
items={[...registeredPlugins.values()]}
resolveItem={unifyBunnyPlugin}
ListFooterComponent={() => (
<View style={{ alignItems: "center", justifyContent: "center", paddingTop: 16, gap: 12 }}>
<Text variant="heading-lg/bold">{"Looking for more?"}</Text>
<Button
size="lg"
text="Browse plugins"
icon={findAssetId("discover")}
onPress={() => navigateToPluginBrowser(navigation)}
/>
</View>
)}
/>
)
},
]
});
return <View style={{ margin: 8, gap: 8, alignItems: "center", justifyContent: "center", height: "100%" }}>
return <View
style={{
margin: 8,
gap: 8,
alignItems: "center",
justifyContent: "center",
height: "100%"
}}
>
<SegmentedControl state={state} />
<SegmentedControlPages state={state} />
</View>;

View file

@ -0,0 +1,31 @@
import { useProxy } from "@lib/api/storage/new";
import { getId, getPluginSettingsComponent,isPluginEnabled, pluginSettings, startPlugin, stopPlugin } from "@lib/plugins";
import { BunnyPluginManifest } from "@lib/plugins/types";
import { UnifiedPluginModel } from "..";
export default function unifyBunnyPlugin(manifest: BunnyPluginManifest): UnifiedPluginModel {
return {
id: manifest.id,
name: manifest.name,
description: manifest.description,
authors: manifest.authors,
isEnabled() {
return isPluginEnabled(getId(manifest));
},
usePluginState() {
useProxy(pluginSettings);
},
toggle(start: boolean) {
start
? startPlugin(getId(manifest))
: stopPlugin(getId(manifest));
},
resolveSheetComponent() {
return import("../sheets/PluginInfoActionSheet");
},
getPluginSettingsComponent() {
return getPluginSettingsComponent(getId(manifest));
},
};
}

View file

@ -0,0 +1,30 @@
import { VdPluginManager, VendettaPlugin } from "@core/vendetta/plugins";
import { useProxy } from "@lib/api/storage";
import { UnifiedPluginModel } from "..";
export default function unifyVdPlugin(vdPlugin: VendettaPlugin): UnifiedPluginModel {
return {
id: vdPlugin.id,
name: vdPlugin.manifest.name,
description: vdPlugin.manifest.description,
authors: vdPlugin.manifest.authors,
icon: vdPlugin.manifest.vendetta?.icon,
isEnabled: () => 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);
},
};
}

View file

@ -22,7 +22,7 @@ export default function PluginInfoActionSheet({ plugin, navigation }: PluginInfo
icon={findAssetId("WrenchIcon")}
onPress={() => {
hideSheet("PluginInfoActionSheet");
navigation.push("VendettaCustomPage", {
navigation.push("BUNNY_CUSTOM_PAGE", {
title: plugin.name,
render: plugin.getPluginSettingsComponent(),
});

View file

@ -32,7 +32,7 @@ export default function PluginInfoActionSheet({ plugin, navigation }: PluginInfo
icon={findAssetId("WrenchIcon")}
onPress={() => {
hideSheet("PluginInfoActionSheet");
navigation.push("VendettaCustomPage", {
navigation.push("BUNNY_CUSTOM_PAGE", {
title: plugin.name,
render: SettingsComponent,
});

View file

@ -1,6 +1,6 @@
import { PluginManifest } from "..";
import { UnifiedPluginModel } from "..";
export interface PluginInfoActionSheetProps {
plugin: PluginManifest;
plugin: UnifiedPluginModel;
navigation: any;
}

View file

@ -20,7 +20,7 @@ export default function Themes() {
p => p.manifest.authors?.map((a: Author) => a.name).join()
]}
fetchFunction={installTheme}
items={themes}
items={Object.values(themes)}
safeModeMessage={formatString("SAFE_MODE_NOTICE_THEMES", { enabled: Boolean(settings.safeMode?.currentThemeId) })}
safeModeExtras={settings.safeMode?.currentThemeId ? <Button
text={Strings.DISABLE_THEME}

View file

@ -31,7 +31,11 @@ export function patchPanelUI(unpatches: (() => void | boolean)[]) {
unpatches.push(after("default", findByNameLazy("getScreens", false), (_a, screens) => ({
...screens,
VendettaCustomPage: {
title: "Bnuuy",
title: "Bunny",
render: () => <CustomPageRenderer />
},
BUNNY_CUSTOM_PAGE: {
title: "Bunny",
render: () => <CustomPageRenderer />
}
})));

View file

@ -33,7 +33,7 @@ export function wrapOnPress(
}
navigation ??= tabsNavigationRef.getRootNavigationRef();
navigation.navigate("VendettaCustomPage", {
navigation.navigate("BUNNY_CUSTOM_PAGE", {
...screenOptions,
render: () => <Component {...props} />
});

View file

@ -46,6 +46,14 @@ export function patchTabsUI(unpatches: (() => void | boolean)[]) {
getComponent: () => CustomPageRenderer
}
},
BUNNY_CUSTOM_PAGE: {
type: "route",
title: () => "Bunny",
screen: {
route: "BUNNY_CUSTOM_PAGE",
getComponent: () => CustomPageRenderer
}
},
...getRows()
}),
set: v => rendererConfigValue = v,

View file

@ -60,7 +60,7 @@ export const TextInput = findSingular("TextInput") as t.TextInput;
// SegmentedControl
export const SegmentedControl = findProp("SegmentedControl") as t.SegmentedControl;
export const SegmentedControlPages = findProp("SegmentedControlPages") as t.SegmentedControlPages;
export const useSegmentedControlState = findSingular("useSegmentedControlState") as (...args: unknown[]) => t.SegmentedControlState;
export const useSegmentedControlState = findSingular("useSegmentedControlState") as (arg: t.SegmentedControlStateArgs) => t.SegmentedControlState;
export const CompatSegmentedControl = findProp("CompatSegmentedControl") as t.CompatSegmentedControl;
export const FloatingActionButton = findProp("FloatingActionButton") as t.FloatingActionButton;

View file

@ -36,6 +36,12 @@ interface SegmentedControlItem {
page?: JSX.Element | null;
}
export interface SegmentedControlStateArgs {
items: SegmentedControlItem[];
pageWidth: number;
defaultIndex?: number;
}
export interface SegmentedControlState {
activeIndex: SharedValue<number>;
pagerRef: RefObject<unknown>;