refactor+feat: wip plugin browser
This commit is contained in:
parent
405fce876b
commit
c3fb133d62
19 changed files with 159 additions and 98 deletions
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 }) },
|
||||
],
|
||||
})}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 />,
|
||||
})}
|
||||
|
|
|
|||
5
src/core/ui/settings/pages/PluginBrowser/index.tsx
Normal file
5
src/core/ui/settings/pages/PluginBrowser/index.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
|
||||
|
||||
export default function PluginBrowser() {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
31
src/core/ui/settings/pages/Plugins/models/bunny.ts
Normal file
31
src/core/ui/settings/pages/Plugins/models/bunny.ts
Normal 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));
|
||||
},
|
||||
};
|
||||
}
|
||||
30
src/core/ui/settings/pages/Plugins/models/vendetta.ts
Normal file
30
src/core/ui/settings/pages/Plugins/models/vendetta.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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(),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { PluginManifest } from "..";
|
||||
import { UnifiedPluginModel } from "..";
|
||||
|
||||
export interface PluginInfoActionSheetProps {
|
||||
plugin: PluginManifest;
|
||||
plugin: UnifiedPluginModel;
|
||||
navigation: any;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
}
|
||||
})));
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ export function wrapOnPress(
|
|||
}
|
||||
|
||||
navigation ??= tabsNavigationRef.getRootNavigationRef();
|
||||
navigation.navigate("VendettaCustomPage", {
|
||||
navigation.navigate("BUNNY_CUSTOM_PAGE", {
|
||||
...screenOptions,
|
||||
render: () => <Component {...props} />
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue