GoonCord/src/core/ui/components/AddonPage.tsx
2024-10-21 23:05:59 +08:00

221 lines
8.8 KiB
TypeScript

import { CardWrapper } from "@core/ui/components/AddonCard";
import { findAssetId } from "@lib/api/assets";
import { settings } from "@lib/api/settings";
import { dismissAlert, openAlert } from "@lib/ui/alerts";
import { showSheet } from "@lib/ui/sheets";
import isValidHttpUrl from "@lib/utils/isValidHttpUrl";
import { lazyDestructure } from "@lib/utils/lazy";
import { findByProps } from "@metro";
import { clipboard, NavigationNative } from "@metro/common";
import { AlertActionButton, AlertModal, Button, FlashList, FloatingActionButton, HelpMessage, IconButton, Stack, Text, TextInput } from "@metro/common/components";
import { ErrorBoundary, Search } from "@ui/components";
import { isNotNil } from "es-toolkit";
import fuzzysort from "fuzzysort";
import { ComponentType, ReactNode, useCallback, useEffect, useMemo } from "react";
import { Image, ScrollView, View } from "react-native";
const { showSimpleActionSheet, hideActionSheet } = lazyDestructure(() => findByProps("showSimpleActionSheet"));
type SearchKeywords<T> = Array<string | ((obj: T & {}) => string)>;
interface AddonPageProps<T extends object, I = any> {
title: string;
items: I[];
searchKeywords: SearchKeywords<T>;
sortOptions?: Record<string, (a: T, b: T) => number>;
resolveItem?: (value: I) => T | undefined;
safeModeHint?: {
message?: string;
footer?: ReactNode;
};
installAction?: {
label?: string;
// Ignored when onPress is defined!
fetchFn?: (url: string) => Promise<void>;
onPress?: () => void;
};
OptionsActionSheetComponent?: ComponentType<any>;
CardComponent: ComponentType<CardWrapper<T>>;
ListHeaderComponent?: ComponentType<any>;
ListFooterComponent?: ComponentType<any>;
}
function InputAlert(props: { label: string, fetchFn: (url: string) => Promise<void>; }) {
const [value, setValue] = React.useState("");
const [error, setError] = React.useState("");
const [isFetching, setIsFetching] = React.useState(false);
function onConfirmWrapper() {
setIsFetching(true);
props.fetchFn(value)
.then(() => dismissAlert("AddonInputAlert"))
.catch((e: unknown) => e instanceof Error ? setError(e.message) : String(e))
.finally(() => setIsFetching(false));
}
return <AlertModal
title={props.label}
content="Type in the source URL you want to install from:"
extraContent={
<Stack style={{ marginTop: -12 }}>
<TextInput
autoFocus={true}
isClearable={true}
value={value}
onChange={(v: string) => {
setValue(v);
if (error) setError("");
}}
returnKeyType="done"
onSubmitEditing={onConfirmWrapper}
state={error ? "error" : undefined}
errorMessage={error || undefined}
/>
<ScrollView
horizontal={true}
showsHorizontalScrollIndicator={false}
style={{ gap: 8 }}
>
<Button
size="sm"
variant="tertiary"
text="Import from clipboard"
icon={findAssetId("ClipboardListIcon")}
onPress={() => clipboard.getString().then((str: string) => setValue(str))}
/>
</ScrollView>
</Stack>
}
actions={
<Stack>
{/* Manual button as we don't want alert to immediately dismiss when we tap on it */}
<Button
loading={isFetching}
text="Install"
variant="primary"
disabled={!value || !isValidHttpUrl(value)}
onPress={onConfirmWrapper}
/>
<AlertActionButton
disabled={isFetching}
text="Cancel"
variant="secondary"
/>
</Stack>
}
/>;
}
export default function AddonPage<T extends object>({ CardComponent, ...props }: AddonPageProps<T>) {
const [search, setSearch] = React.useState("");
const [sortFn, setSortFn] = React.useState<((a: T, b: T) => number) | null>(() => null);
const navigation = NavigationNative.useNavigation();
useEffect(() => {
if (props.OptionsActionSheetComponent) {
navigation.setOptions({
headerRight: () => <IconButton
size="sm"
variant="secondary"
icon={findAssetId("MoreHorizontalIcon")}
onPress={() => showSheet("AddonMoreSheet", props.OptionsActionSheetComponent!)}
/>
});
}
}, [navigation]);
const results = useMemo(() => {
let values = props.items;
if (props.resolveItem) values = values.map(props.resolveItem).filter(isNotNil);
const items = values.filter(i => isNotNil(i) && typeof i === "object");
if (!search && sortFn) items.sort(sortFn);
return fuzzysort.go(search, items, { keys: props.searchKeywords, all: true });
}, [props.items, sortFn, search]);
const onInstallPress = useCallback(() => {
if (!props.installAction) return () => { };
const { label, onPress, fetchFn } = props.installAction;
if (fetchFn) {
openAlert("AddonInputAlert", <InputAlert label={label ?? "Install"} fetchFn={fetchFn} />);
} else {
onPress?.();
}
}, []);
if (results.length === 0 && !search) {
return <View style={{ gap: 32, flexGrow: 1, justifyContent: "center", alignItems: "center" }}>
<View style={{ gap: 8, alignItems: "center" }}>
<Image source={findAssetId("empty_quick_switcher")!} />
<Text variant="text-lg/semibold" color="text-normal">
Oops! Nothing to see here… yet!
</Text>
</View>
<Button
size="lg"
icon={findAssetId("DownloadIcon")}
text={props.installAction?.label ?? "Install"}
onPress={onInstallPress}
/>
</View>;
}
const headerElement = (
<View style={{ paddingBottom: 8 }}>
{settings.safeMode?.enabled && <View style={{ marginBottom: 10 }}>
<HelpMessage messageType={0}>
{props.safeModeHint?.message}
</HelpMessage>
{props.safeModeHint?.footer}
</View>}
<View style={{ flexDirection: "row", gap: 8 }}>
<Search style={{ flexGrow: 1 }} isRound={!!props.sortOptions} onChangeText={v => setSearch(v)} />
{props.sortOptions && <IconButton
icon={findAssetId("ic_forum_channel_sort_order_24px")}
variant="tertiary"
disabled={!!search}
onPress={() => showSimpleActionSheet({
key: "AddonListSortOptions",
header: {
title: "Sort Options",
onClose: () => hideActionSheet("AddonListSortOptions"),
},
options: Object.entries(props.sortOptions!).map(([name, fn]) => ({
label: name,
onPress: () => setSortFn(() => fn)
}))
})}
/>}
</View>
{props.ListHeaderComponent && <props.ListHeaderComponent />}
</View>
);
return (
<ErrorBoundary>
<FlashList
data={results}
extraData={search}
estimatedItemSize={136}
ListHeaderComponent={headerElement}
ListEmptyComponent={() => <View style={{ gap: 12, padding: 12, alignItems: "center" }}>
<Image source={findAssetId("devices_not_found")!} />
<Text variant="text-lg/semibold" color="text-normal">
Hmmm... could not find that!
</Text>
</View>}
contentContainerStyle={{ padding: 8, paddingHorizontal: 12, paddingBottom: 90 }}
ItemSeparatorComponent={() => <View style={{ height: 8 }} />}
ListFooterComponent={props.ListFooterComponent}
renderItem={({ item }: any) => <CardComponent item={item.obj} result={item} />}
/>
{props.installAction && <FloatingActionButton
icon={findAssetId("PlusLargeIcon")}
onPress={onInstallPress}
/>}
</ErrorBoundary>
);
}