feat: fonts overhaul

This commit is contained in:
amsyarasyiq 2024-05-20 00:04:25 +08:00
parent 6243316c35
commit 2e3dc3c487
7 changed files with 295 additions and 104 deletions

View file

@ -25,6 +25,7 @@ interface AddonPageProps<T> {
card: React.ComponentType<CardWrapper<T>>;
isRemoveMode?: boolean;
headerComponent?: JSX.Element;
onFABPress?: () => void;
}
function getItemsByQuery<T extends AddonPageProps<unknown>["items"][string]>(items: T[], query: string): T[] {
@ -45,7 +46,7 @@ function getItemsByQuery<T extends AddonPageProps<unknown>["items"][string]>(ite
const reanimated = findByProps("useSharedValue");
const { FloatingActionButton } = findByProps("FloatingActionButton");
export default function AddonPage<T>({ floatingButtonText, fetchFunction, items, safeModeMessage, safeModeExtras, card: CardComponent, isRemoveMode, headerComponent }: AddonPageProps<T>) {
export default function AddonPage<T>({ floatingButtonText, fetchFunction, items, safeModeMessage, safeModeExtras, card: CardComponent, isRemoveMode, headerComponent, onFABPress }: AddonPageProps<T>) {
useProxy(items);
useProxy(settings);
@ -83,9 +84,9 @@ export default function AddonPage<T>({ floatingButtonText, fetchFunction, items,
/>
<FloatingActionButton
text={floatingButtonText}
icon={getAssetIDByName("DownloadIcon")}
icon={getAssetIDByName("PlusLargeIcon")}
state={{ collapseText }}
onPress={() => {
onPress={onFABPress ?? (() => {
// from ./InstallButton.tsx
clipboard.getString().then(content =>
showInputAlert({
@ -97,7 +98,7 @@ export default function AddonPage<T>({ floatingButtonText, fetchFunction, items,
cancelText: Strings.CANCEL,
})
);
}}
})}
/>
</ErrorBoundary>
);

View file

@ -125,7 +125,7 @@ export default function Card(props: CardProps) {
})}
size="sm"
variant="secondary"
icon={getAssetIDByName("CircleInformationIcon")}
icon={getAssetIDByName("CircleInformationIcon-primary")}
/>}
{props.actions?.map(({ icon, onPress, disabled }) => (
<IconButton

View file

@ -20,7 +20,6 @@ export default function FontCard({ item: font, index }: CardWrapper<FontDefiniti
<TableRowGroup>
<TableRow
label={font.name}
subLabel={font.previewText ?? "Lorem ipsum is placeholder text commonly used in the graphic, print, and publishing industries for previewing layouts and visual mockups."}
trailing={
<Pressable onPress={() => {
selectFont(selected ? null : font.name).then(() => {

View file

@ -0,0 +1,274 @@
import { formatString, Strings } from "@core/i18n";
import { getAssetIDByName } from "@lib/api/assets";
import { createProxy, useProxy } from "@lib/api/storage";
import { FontDefinition, saveFont, validateFont } from "@lib/managers/fonts";
import { getCurrentTheme } from "@lib/managers/themes";
import { findByProps } from "@lib/metro";
import { ErrorBoundary } from "@lib/ui/components";
import { ActionSheet, BottomSheetTitleHeader, Button, IconButton, Stack, TableRow, TableRowGroup, Text, TextInput, useNavigation } from "@lib/ui/components/discord/Redesign";
import { safeFetch } from "@lib/utils";
import { useMemo, useRef, useState } from "react";
import { ScrollView, View } from "react-native";
const actionSheet = findByProps("hideActionSheet");
function guessFontName(urls: string[]) {
const fileNames = urls.map(url => {
const { pathname } = new URL(url);
const fileName = pathname.replace(/\.[^/.]+$/, "");
return fileName.split("/").pop();
}).filter(Boolean) as string[];
const shortest = fileNames.reduce((shortest, name) => {
return name.length < shortest.length ? name : shortest;
}, fileNames[0] || "");
return shortest?.replace(/-[A-Za-z]*$/, "") || null;
}
function RevengeFontsExtractor({ fonts, setName }: {
fonts: Record<string, string>;
setName: (name: string) => void;
}) {
const currentTheme = getCurrentTheme()!.data;
// @ts-ignore
const themeFonts = currentTheme!.fonts as Record<string, string>;
const [fontName, setFontName] = useState(guessFontName(Object.values(themeFonts)));
const [error, setError] = useState<string | void>(undefined);
return <View style={{ padding: 8, paddingBottom: 16, gap: 12 }}>
<TextInput
autoFocus
size="md"
label={Strings.FONT_NAME}
value={fontName}
placeholder={fontName || "Whitney"}
onChange={setFontName}
errorMessage={error}
status={error ? "error" : void 0}
/>
<Text variant="text-xs/normal" color="text-muted">
{formatString("THEME_EXTRACTOR_DESC", {
fonts: Object.keys(themeFonts).join(Strings.SEPARATOR)
})}
</Text>
<Button
size="md"
variant="primary"
text={Strings.EXTRACT}
disabled={!fontName}
onPress={() => {
if (!fontName) return;
try {
validateFont({
spec: 1,
name: fontName,
main: themeFonts
});
setName(fontName);
Object.assign(fonts, themeFonts);
actionSheet.hideActionSheet();
} catch (e) {
setError(String(e));
}
}}
/>
</View>;
}
function JsonFontImporter({ fonts, setName }: {
fonts: Record<string, string>;
setName: (name: string) => void;
}) {
const [fontLink, setFontLink] = useState<string>("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | void>(undefined);
return <View style={{ padding: 8, paddingBottom: 16, gap: 12 }}>
<TextInput
autoFocus
size="md"
label={"Font Link"}
value={fontLink}
placeholder={"https://link.to/font/pack.json"}
onChange={setFontLink}
errorMessage={error}
status={error ? "error" : void 0}
/>
<Button
size="md"
variant="primary"
text={"Import"}
disabled={!fontLink || saving}
loading={saving}
onPress={() => {
setSaving(true);
(async () => {
const res = await safeFetch(fontLink, { cache: "no-store" });
const json = await res.json() as FontDefinition;
validateFont(json);
setName(json.name);
Object.assign(fonts, json.main);
})()
.then(() => actionSheet.hideActionSheet())
.catch(e => setError(String(e)))
.finally(() => setSaving(false));
}}
/>
</View>;
}
function promptActionSheet(Component: any, fontEntries: Record<string, string>, setName: (name: string) => void) {
actionSheet.openLazy(
Promise.resolve({
default: () => (
<ErrorBoundary>
<ActionSheet>
<BottomSheetTitleHeader title={"Import Font"} />
<Component fonts={fontEntries} setName={setName} />
</ActionSheet>
</ErrorBoundary>
)
}),
"FontImporterActionSheet"
);
}
function EntryRow({ fontEntry }: { fontEntry: Record<string, string>; }) {
const nameRef = useRef<string>();
const urlRef = useRef<string>();
const [nameSet, setNameSet] = useState(false);
const [error, setError] = useState<string | void>();
return <View style={{ flexDirection: "row", gap: 8, justifyContent: "flex-start" }}>
<View style={{ flex: 1 }}>
<TextInput
isRound
size="md"
label={nameSet ? nameRef.current : void 0}
placeholder={nameSet ? "https://path.to/the/file.ttf" : "PostScript name (e.g. ggsans-Bold)"}
leadingIcon={() => nameSet ? null : <TableRow.Icon source={getAssetIDByName("PlusSmallIcon")} />}
leadingText={nameSet ? nameRef.current : ""}
onChange={(text: string) => (nameSet ? urlRef : nameRef).current = text}
errorMessage={error}
state={error ? "error" : void 0}
/>
</View>
{nameSet && <IconButton
size="md"
variant="secondary"
onPress={() => {
nameRef.current = "";
setNameSet(false);
}}
icon={getAssetIDByName("TrashIcon")}
/>}
<IconButton
size="md"
variant="primary"
onPress={() => {
if (!nameSet && nameRef.current) {
setNameSet(true);
} else if (nameSet && nameRef.current && urlRef.current) {
try {
const parsedUrl = new URL(urlRef.current);
if (!parsedUrl.protocol || !parsedUrl.host) {
throw "Invalid URL";
}
fontEntry[nameRef.current] = urlRef.current;
nameRef.current = undefined;
urlRef.current = undefined;
setNameSet(false);
} catch (e) {
setError(String(e));
}
}
}}
icon={getAssetIDByName(nameSet ? "PlusSmallIcon" : "ArrowLargeRightIcon")}
/>
</View>;
}
export default function FontImporter() {
const [name, setName] = useState<string>();
const [importing, setIsImporting] = useState<boolean>(false);
const memoEntry = useMemo(() => createProxy({}).proxy, []);
const fontEntries: Record<string, string> = useProxy(memoEntry);
const navigation = useNavigation();
return <ScrollView style={{ flex: 1 }} contentContainerStyle={{ paddingBottom: 38 }}>
<Stack style={{ paddingVertical: 24, paddingHorizontal: 12 }} spacing={12}>
<TableRowGroup title="Import">
{/** @ts-ignore */}
{getCurrentTheme()?.data?.fonts && <TableRow
label={Strings.LABEL_EXTRACT_FONTS_FROM_THEME}
subLabel={Strings.DESC_EXTRACT_FONTS_FROM_THEME}
icon={<TableRow.Icon source={getAssetIDByName("HammerIcon")} />}
onPress={() => promptActionSheet(RevengeFontsExtractor, fontEntries, setName)}
/>}
<TableRow
label={"Import font entries from a link"}
subLabel={"Directly import from a link with a pre-configured JSON file"}
icon={<TableRow.Icon source={getAssetIDByName("LinkIcon")} />}
onPress={() => promptActionSheet(JsonFontImporter, fontEntries, setName)}
/>
</TableRowGroup>
<TextInput
size="md"
value={name}
label={Strings.FONT_NAME}
placeholder={"ggsans"}
onChange={setName}
/>
<TableRowGroup title={"Font Entries"}>
{Object.entries(fontEntries).map(([name, url]) => {
return <TableRow
label={name}
subLabel={url}
trailing={<IconButton
size="sm"
variant="secondary"
icon={getAssetIDByName("TrashIcon")}
onPress={() => delete fontEntries[name]}
/>}
/>;
})}
<TableRow label={<EntryRow fontEntry={fontEntries} />} />
</TableRowGroup>
<View style={{ flexDirection: "row", justifyContent: "flex-end", bottom: 0, left: 0 }}>
<Button
size="lg"
loading={importing}
disabled={importing || !name || Object.keys(fontEntries).length === 0}
variant="primary"
text={"Import"}
onPress={async () => {
if (!name) return;
setIsImporting(true);
saveFont({
spec: 1,
name: name,
main: fontEntries
})
.then(() => navigation.goBack())
.finally(() => setIsImporting(false));
}}
icon={getAssetIDByName("DownloadIcon")}
style={{ marginLeft: 8 }}
/>
</View>
</Stack>
</ScrollView>;
}

View file

@ -1,95 +1,15 @@
import { formatString, Strings } from "@core/i18n";
import { Strings } from "@core/i18n";
import AddonPage from "@core/ui/components/AddonPage";
import FontCard from "@core/ui/components/FontCard";
import { getAssetIDByName } from "@lib/api/assets";
import { useProxy } from "@lib/api/storage";
import { FontDefinition, fonts, installFont, saveFont } from "@lib/managers/fonts";
import { getCurrentTheme } from "@lib/managers/themes";
import { findByProps } from "@lib/metro/filters";
import { FontDefinition, fonts, installFont } from "@lib/managers/fonts";
import { settings } from "@lib/settings";
import { ErrorBoundary } from "@lib/ui/components";
import { FormText } from "@lib/ui/components/discord/Forms";
import { ActionSheet, BottomSheetTitleHeader, Button, RowButton, TableRow, Text, TextInput, useNavigation } from "@lib/ui/components/discord/Redesign";
import { useNavigation } from "@lib/ui/components/discord/Redesign";
import { useEffect, useState } from "react";
import { TouchableOpacity, View } from "react-native";
import { TouchableOpacity } from "react-native";
const actionSheet = findByProps("hideActionSheet");
function guessFontName(urls: string[]) {
const fileNames = urls.map(url => {
const { pathname } = new URL(url);
const fileName = pathname.replace(/\.[^/.]+$/, "");
return fileName.split("/").pop();
}).filter(Boolean) as string[];
const shortest = fileNames.reduce((shortest, name) => {
return name.length < shortest.length ? name : shortest;
}, fileNames[0] || "");
return shortest?.replace(/-[A-Za-z]*$/, "") || null;
}
function ExtractFontsComponent({ fonts }: { fonts: Record<string, string>; }) {
const [fontName, setFontName] = useState(guessFontName(Object.values(fonts)));
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | void>(undefined);
return <View style={{ padding: 8, paddingBottom: 16, gap: 12 }}>
<TextInput
size="md"
label={Strings.FONT_NAME}
value={fontName}
placeholder={fontName || "Whitney"}
onChange={setFontName}
errorMessage={error}
status={error ? "error" : void 0}
/>
<Text variant="text-xs/normal" color="text-muted">
{formatString("THEME_EXTRACTOR_DESC", {
fonts: Object.keys(fonts).join(Strings.SEPARATOR)
})}
</Text>
<Button
size="md"
variant="primary"
text={Strings.EXTRACT}
disabled={!fontName || saving}
loading={saving}
onPress={() => {
setSaving(true);
saveFont({
spec: 1,
name: fontName!.trim(),
main: fonts
})
.then(() => actionSheet.hideActionSheet())
.catch(e => setError(String(e)))
.finally(() => setSaving(false));
}}
/>
</View>;
}
function promptFontExtractor() {
const currentTheme = getCurrentTheme()?.data;
if (!currentTheme || !("fonts" in currentTheme)) return;
const fonts = currentTheme.fonts as Record<string, string>;
actionSheet.openLazy(
Promise.resolve({
default: () => (
<ErrorBoundary>
<ActionSheet>
<BottomSheetTitleHeader title={Strings.LABEL_EXTRACT_FONTS_FROM_THEME} />
<ExtractFontsComponent fonts={fonts} />
</ActionSheet>
</ErrorBoundary>
)
}),
"FontsFromThemeExtractorActionSheet"
);
}
import FontImporter from "./FontImporter";
export default function Plugins() {
useProxy(settings);
@ -122,17 +42,12 @@ export default function Plugins() {
safeModeMessage={Strings.SAFE_MODE_NOTICE_FONTS}
isRemoveMode={removeMode}
card={FontCard}
headerComponent={<>
{/* @ts-ignore */}
{getCurrentTheme()?.data?.fonts && <View style={{ marginVertical: 8 }}>
<RowButton
label={Strings.LABEL_EXTRACT_FONTS_FROM_THEME}
subLabel={Strings.DESC_EXTRACT_FONTS_FROM_THEME}
icon={<TableRow.Icon source={getAssetIDByName("HammerIcon")} />}
onPress={promptFontExtractor}
/>
</View>}
</>}
onFABPress={() => {
navigation.push("VendettaCustomPage", {
title: "Font Importer",
render: () => <FontImporter />
});
}}
/>
);
}

View file

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

View file

@ -72,7 +72,9 @@ export async function saveFont(data: string | FontDefinition, selected = false)
}
export async function installFont(url: string, selected = false) {
if (typeof url !== "string" || Object.values(fonts).some(f => typeof f === "object" && f.__source === url)
if (
typeof url !== "string"
|| Object.values(fonts).some(f => typeof f === "object" && f.__source === url)
) {
throw new Error("Invalid source or font was already installed");
}