Merge branch 'dev'
This commit is contained in:
commit
9a82dfec85
61 changed files with 1507 additions and 774 deletions
|
|
@ -25,6 +25,7 @@ const {
|
|||
|
||||
let context = null;
|
||||
|
||||
/** @type {import("esbuild").BuildOptions} */
|
||||
const config = {
|
||||
entryPoints: ["src/entry.ts"],
|
||||
bundle: true,
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ if (args.adb && isADBAvailableAndAppInstalled()) {
|
|||
|
||||
if (key.name === "r") {
|
||||
console.info(chalk.yellow(`${chalk.bold("↻ Reloading")} ${packageName}`));
|
||||
restartAppFromADB(server.port)
|
||||
restartAppFromADB(server.address().port)
|
||||
.then(() => console.info(chalk.greenBright(`${chalk.bold("✔ Executed")} reload command`)))
|
||||
.catch(e => console.error(e));
|
||||
}
|
||||
|
|
|
|||
17
src/core/debug/safeMode.ts
Normal file
17
src/core/debug/safeMode.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { getCurrentTheme, writeThemeToNative } from "@lib/addons/themes";
|
||||
import { BundleUpdaterManager } from "@lib/api/native/modules";
|
||||
import { settings } from "@lib/api/settings";
|
||||
|
||||
export function isSafeMode() {
|
||||
return settings.safeMode?.enabled === true;
|
||||
}
|
||||
|
||||
export async function toggleSafeMode({
|
||||
to = !isSafeMode(),
|
||||
reload = true
|
||||
} = {}) {
|
||||
const enabled = (settings.safeMode ??= { enabled: to }).enabled = to;
|
||||
const currentColor = getCurrentTheme();
|
||||
await writeThemeToNative(enabled ? {} : currentColor?.data ?? {});
|
||||
if (reload) setTimeout(() => BundleUpdaterManager.reload(), 500);
|
||||
}
|
||||
|
|
@ -1,28 +1,28 @@
|
|||
import { CardWrapper } from "@core/ui/components/AddonCard";
|
||||
import { useProxy } from "@core/vendetta/storage";
|
||||
import { findAssetId } from "@lib/api/assets";
|
||||
import { settings } from "@lib/api/settings";
|
||||
import AlertModal, { AlertActionButton } from "@lib/ui/components/wrappers/AlertModal";
|
||||
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 } from "@metro/common";
|
||||
import { Button, FlashList, FloatingActionButton, HelpMessage, IconButton, Stack, Text, TextInput, useSafeAreaInsets } from "@metro/common/components";
|
||||
import { clipboard, NavigationNative } from "@metro/common";
|
||||
import { AlertActionButton, AlertModal, Button, FlashList, FloatingActionButton, HelpMessage, IconButton, Stack, Text, TextInput, useSafeAreaInsets } from "@metro/common/components";
|
||||
import { ErrorBoundary, Search } from "@ui/components";
|
||||
import { isNotNil } from "es-toolkit";
|
||||
import fuzzysort from "fuzzysort";
|
||||
import { ComponentType, ReactNode, useCallback, useMemo } from "react";
|
||||
import { ComponentType, ReactNode, useCallback, useEffect, useMemo } from "react";
|
||||
import { Image, ScrollView, View } from "react-native";
|
||||
|
||||
const { showSimpleActionSheet, hideActionSheet } = lazyDestructure(() => findByProps("showSimpleActionSheet"));
|
||||
const { openAlert, dismissAlert } = lazyDestructure(() => findByProps("openAlert", "dismissAlert"));
|
||||
|
||||
type SearchKeywords = Array<string | ((obj: any & {}) => string)>;
|
||||
type SearchKeywords<T> = Array<string | ((obj: T & {}) => string)>;
|
||||
|
||||
interface AddonPageProps<T extends object, I = any> {
|
||||
title: string;
|
||||
items: I[];
|
||||
searchKeywords: SearchKeywords;
|
||||
sortOptions?: Record<string, (a: I, b: I) => number>;
|
||||
searchKeywords: SearchKeywords<T>;
|
||||
sortOptions?: Record<string, (a: T, b: T) => number>;
|
||||
resolveItem?: (value: I) => T | undefined;
|
||||
safeModeHint?: {
|
||||
message?: string;
|
||||
|
|
@ -34,6 +34,9 @@ interface AddonPageProps<T extends object, I = any> {
|
|||
fetchFn?: (url: string) => Promise<void>;
|
||||
onPress?: () => void;
|
||||
};
|
||||
|
||||
OptionsActionSheetComponent?: ComponentType<any>;
|
||||
|
||||
CardComponent: ComponentType<CardWrapper<T>>;
|
||||
ListHeaderComponent?: ComponentType<any>;
|
||||
ListFooterComponent?: ComponentType<any>;
|
||||
|
|
@ -55,7 +58,7 @@ function InputAlert(props: { label: string, fetchFn: (url: string) => Promise<vo
|
|||
|
||||
return <AlertModal
|
||||
title={props.label}
|
||||
content="Enter the URL of the source you want to install from:"
|
||||
content="Type in the source URL you want to install from:"
|
||||
extraContent={
|
||||
<Stack style={{ marginTop: -12 }}>
|
||||
<TextInput
|
||||
|
|
@ -107,16 +110,28 @@ function InputAlert(props: { label: string, fetchFn: (url: string) => Promise<vo
|
|||
}
|
||||
|
||||
export default function AddonPage<T extends object>({ CardComponent, ...props }: AddonPageProps<T>) {
|
||||
useProxy(settings);
|
||||
|
||||
const [search, setSearch] = React.useState("");
|
||||
const [sortFn, setSortFn] = React.useState<((a: unknown, b: unknown) => number) | null>(() => null);
|
||||
const [sortFn, setSortFn] = React.useState<((a: T, b: T) => number) | null>(() => null);
|
||||
const { bottom: bottomInset } = useSafeAreaInsets();
|
||||
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);
|
||||
const items = values.filter(i => i && typeof i === "object");
|
||||
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 });
|
||||
|
|
@ -135,7 +150,7 @@ export default function AddonPage<T extends object>({ CardComponent, ...props }:
|
|||
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")} />
|
||||
<Image source={findAssetId("empty_quick_switcher")!} />
|
||||
<Text variant="text-lg/semibold" color="text-normal">
|
||||
Oops! Nothing to see here… yet!
|
||||
</Text>
|
||||
|
|
@ -176,7 +191,7 @@ export default function AddonPage<T extends object>({ CardComponent, ...props }:
|
|||
})}
|
||||
/>}
|
||||
</View>
|
||||
{props.ListHeaderComponent && !search && <props.ListHeaderComponent />}
|
||||
{props.ListHeaderComponent && <props.ListHeaderComponent />}
|
||||
</View>
|
||||
);
|
||||
|
||||
|
|
@ -188,12 +203,12 @@ export default function AddonPage<T extends object>({ CardComponent, ...props }:
|
|||
estimatedItemSize={136}
|
||||
ListHeaderComponent={headerElement}
|
||||
ListEmptyComponent={() => <View style={{ gap: 12, padding: 12, alignItems: "center" }}>
|
||||
<Image source={findAssetId("devices_not_found")} />
|
||||
<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 }}
|
||||
contentContainerStyle={{ padding: 8, paddingHorizontal: 12, paddingBottom: 90 }}
|
||||
ItemSeparatorComponent={() => <View style={{ height: 8 }} />}
|
||||
ListFooterComponent={props.ListFooterComponent}
|
||||
renderItem={({ item }: any) => <CardComponent item={item.obj} result={item} />}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ export enum CheckState {
|
|||
export function useFileExists(path: string, prefix?: string): [CheckState, typeof fs] {
|
||||
const [state, setState] = useState<CheckState>(CheckState.LOADING);
|
||||
|
||||
const check = () => fs.fileExists(path, prefix)
|
||||
const check = () => fs.fileExists(path, { prefix })
|
||||
.then(exists => setState(exists ? CheckState.TRUE : CheckState.FALSE))
|
||||
.catch(() => setState(CheckState.ERROR));
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
import AssetDisplay from "@core/ui/settings/pages/Developer/AssetDisplay";
|
||||
import { assetsMap } from "@lib/api/assets";
|
||||
import { iterateAssets } from "@lib/api/assets";
|
||||
import { LegacyFormDivider } from "@metro/common/components";
|
||||
import { ErrorBoundary, Search } from "@ui/components";
|
||||
import { useMemo } from "react";
|
||||
import { FlatList, View } from "react-native";
|
||||
|
||||
export default function AssetBrowser() {
|
||||
const [search, setSearch] = React.useState("");
|
||||
const all = useMemo(() => Array.from(iterateAssets()), []);
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
|
|
@ -15,7 +17,7 @@ export default function AssetBrowser() {
|
|||
onChangeText={(v: string) => setSearch(v)}
|
||||
/>
|
||||
<FlatList
|
||||
data={Object.values(assetsMap).filter(a => a.name.includes(search) || a.id.toString() === search)}
|
||||
data={all.filter(a => a.name.includes(search) || a.id.toString() === search)}
|
||||
renderItem={({ item }) => <AssetDisplay asset={item} />}
|
||||
ItemSeparatorComponent={LegacyFormDivider}
|
||||
keyExtractor={item => item.name}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { Strings } from "@core/i18n";
|
||||
import { PyoncordIcon } from "@core/ui/settings";
|
||||
import Version from "@core/ui/settings/pages/General/Version";
|
||||
import { useProxy } from "@core/vendetta/storage";
|
||||
import { getDebugInfo } from "@lib/api/debug";
|
||||
|
|
@ -14,7 +15,7 @@ export default function About() {
|
|||
{
|
||||
label: Strings.BUNNY,
|
||||
version: debugInfo.bunny.version,
|
||||
icon: "ic_progress_wrench_24px",
|
||||
icon: { uri: PyoncordIcon },
|
||||
},
|
||||
{
|
||||
label: "Discord",
|
||||
|
|
@ -24,17 +25,17 @@ export default function About() {
|
|||
{
|
||||
label: "React",
|
||||
version: debugInfo.react.version,
|
||||
icon: "ic_category_16px",
|
||||
icon: "ScienceIcon",
|
||||
},
|
||||
{
|
||||
label: "React Native",
|
||||
version: debugInfo.react.nativeVersion,
|
||||
icon: "mobile",
|
||||
icon: "MobilePhoneIcon",
|
||||
},
|
||||
{
|
||||
label: Strings.BYTECODE,
|
||||
version: debugInfo.hermes.bytecodeVersion,
|
||||
icon: "ic_server_security_24px",
|
||||
icon: "TopicsIcon",
|
||||
},
|
||||
];
|
||||
|
||||
|
|
@ -42,37 +43,37 @@ export default function About() {
|
|||
{
|
||||
label: Strings.LOADER,
|
||||
version: `${debugInfo.bunny.loader.name} (${debugInfo.bunny.loader.version})`,
|
||||
icon: "ic_download_24px",
|
||||
icon: "DownloadIcon",
|
||||
},
|
||||
{
|
||||
label: Strings.OPERATING_SYSTEM,
|
||||
version: `${debugInfo.os.name} ${debugInfo.os.version}`,
|
||||
icon: "ic_cog_24px"
|
||||
icon: "ScreenIcon"
|
||||
},
|
||||
...(debugInfo.os.sdk ? [{
|
||||
label: "SDK",
|
||||
version: debugInfo.os.sdk,
|
||||
icon: "pencil"
|
||||
icon: "StaffBadgeIcon"
|
||||
}] : []),
|
||||
{
|
||||
label: Strings.MANUFACTURER,
|
||||
version: debugInfo.device.manufacturer,
|
||||
icon: "ic_badge_staff"
|
||||
icon: "WrenchIcon"
|
||||
},
|
||||
{
|
||||
label: Strings.BRAND,
|
||||
version: debugInfo.device.brand,
|
||||
icon: "ic_settings_boost_24px"
|
||||
icon: "SparklesIcon"
|
||||
},
|
||||
{
|
||||
label: Strings.MODEL,
|
||||
version: debugInfo.device.model,
|
||||
icon: "ic_phonelink_24px"
|
||||
icon: "MobilePhoneIcon"
|
||||
},
|
||||
{
|
||||
label: Platform.select({ android: Strings.CODENAME, ios: Strings.MACHINE_ID })!,
|
||||
version: debugInfo.device.codename,
|
||||
icon: "ic_compose_24px"
|
||||
icon: "TagIcon"
|
||||
}
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -2,18 +2,19 @@ import { findAssetId } from "@lib/api/assets";
|
|||
import { clipboard } from "@metro/common";
|
||||
import { LegacyFormText, TableRow } from "@metro/common/components";
|
||||
import { showToast } from "@ui/toasts";
|
||||
import { ImageURISource } from "react-native";
|
||||
|
||||
interface VersionProps {
|
||||
label: string;
|
||||
version: string;
|
||||
icon: string;
|
||||
icon: string | ImageURISource;
|
||||
}
|
||||
|
||||
export default function Version({ label, version, icon }: VersionProps) {
|
||||
return (
|
||||
<TableRow
|
||||
label={label}
|
||||
icon={<TableRow.Icon source={findAssetId(icon)} />}
|
||||
icon={<TableRow.Icon source={typeof icon === "string" ? findAssetId(icon) : icon} />}
|
||||
trailing={<LegacyFormText>{version}</LegacyFormText>}
|
||||
onPress={() => {
|
||||
clipboard.setString(`${label} - ${version}`);
|
||||
|
|
|
|||
|
|
@ -1,14 +1,17 @@
|
|||
import { isSafeMode, toggleSafeMode } from "@core/debug/safeMode";
|
||||
import { Strings } from "@core/i18n";
|
||||
import { PyoncordIcon } from "@core/ui/settings";
|
||||
import About from "@core/ui/settings/pages/General/About";
|
||||
import { useProxy } from "@core/vendetta/storage";
|
||||
import { findAssetId } from "@lib/api/assets";
|
||||
import { getDebugInfo, toggleSafeMode } from "@lib/api/debug";
|
||||
import { getDebugInfo } from "@lib/api/debug";
|
||||
import { BundleUpdaterManager } from "@lib/api/native/modules";
|
||||
import { settings } from "@lib/api/settings";
|
||||
import { openAlert } from "@lib/ui/alerts";
|
||||
import { DISCORD_SERVER, GITHUB } from "@lib/utils/constants";
|
||||
import { NavigationNative, url } from "@metro/common";
|
||||
import { Stack, TableRow, TableRowGroup, TableSwitchRow } from "@metro/common/components";
|
||||
import { NativeModules, ScrollView } from "react-native";
|
||||
import { AlertActionButton, AlertActions, AlertModal, Stack, TableRow, TableRowGroup, TableSwitchRow } from "@metro/common/components";
|
||||
import { ScrollView } from "react-native";
|
||||
|
||||
export default function General() {
|
||||
useProxy(settings);
|
||||
|
|
@ -27,14 +30,13 @@ export default function General() {
|
|||
/>
|
||||
<TableRow
|
||||
label={"Discord"}
|
||||
icon={<TableRow.Icon source={findAssetId("Discord")} />}
|
||||
icon={<TableRow.Icon source={findAssetId("Discord")!} />}
|
||||
trailing={<TableRow.TrailingText text={`${debugInfo.discord.version} (${debugInfo.discord.build})`} />}
|
||||
/>
|
||||
<TableRow
|
||||
arrow
|
||||
label={Strings.ABOUT}
|
||||
icon={<TableRow.Icon source={findAssetId("CircleInformationIcon-primary")} />}
|
||||
trailing={TableRow.Arrow}
|
||||
icon={<TableRow.Icon source={findAssetId("CircleInformationIcon-primary")!} />}
|
||||
onPress={() => navigation.push("BUNNY_CUSTOM_PAGE", {
|
||||
title: Strings.ABOUT,
|
||||
render: () => <About />,
|
||||
|
|
@ -43,33 +45,51 @@ export default function General() {
|
|||
</TableRowGroup>
|
||||
<TableRowGroup title={Strings.LINKS}>
|
||||
<TableRow
|
||||
arrow={true}
|
||||
label={Strings.DISCORD_SERVER}
|
||||
icon={<TableRow.Icon source={findAssetId("Discord")} />}
|
||||
trailing={TableRow.Arrow}
|
||||
icon={<TableRow.Icon source={findAssetId("Discord")!} />}
|
||||
onPress={() => url.openDeeplink(DISCORD_SERVER)}
|
||||
/>
|
||||
<TableRow
|
||||
arrow={true}
|
||||
label={Strings.GITHUB}
|
||||
icon={<TableRow.Icon source={findAssetId("img_account_sync_github_white")} />}
|
||||
trailing={TableRow.Arrow}
|
||||
icon={<TableRow.Icon source={findAssetId("img_account_sync_github_white")!} />}
|
||||
onPress={() => url.openURL(GITHUB)}
|
||||
/>
|
||||
</TableRowGroup>
|
||||
<TableRowGroup title={Strings.ACTIONS}>
|
||||
<TableRow
|
||||
label={Strings.RELOAD_DISCORD}
|
||||
icon={<TableRow.Icon source={findAssetId("ic_message_retry")} />}
|
||||
onPress={() => NativeModules.BundleUpdaterManager.reload()}
|
||||
icon={<TableRow.Icon source={findAssetId("RetryIcon")!} />}
|
||||
onPress={() => BundleUpdaterManager.reload()}
|
||||
/>
|
||||
<TableRow
|
||||
label={settings.safeMode?.enabled ? Strings.RELOAD_IN_NORMAL_MODE : Strings.RELOAD_IN_SAFE_MODE}
|
||||
subLabel={settings.safeMode?.enabled ? Strings.RELOAD_IN_NORMAL_MODE_DESC : Strings.RELOAD_IN_SAFE_MODE_DESC}
|
||||
icon={<TableRow.Icon source={findAssetId("ic_privacy_24px")} />}
|
||||
onPress={toggleSafeMode}
|
||||
<TableSwitchRow
|
||||
label={"Safe Mode"}
|
||||
subLabel={"Load Bunny without loading add-ons"}
|
||||
icon={<TableRow.Icon source={findAssetId("ShieldIcon")!} />}
|
||||
value={isSafeMode()}
|
||||
onValueChange={(to: boolean) => {
|
||||
toggleSafeMode({ to, reload: false });
|
||||
openAlert(
|
||||
"bunny-reload-safe-mode",
|
||||
<AlertModal
|
||||
title="Reload now?"
|
||||
content={!to ? "All add-ons will load normally." : "All add-ons will be temporarily disabled upon reload."}
|
||||
actions={<AlertActions>
|
||||
<AlertActionButton
|
||||
text="Reload Now"
|
||||
variant="destructive"
|
||||
onPress={() => BundleUpdaterManager.reload()}
|
||||
/>
|
||||
<AlertActionButton text="Later" variant="secondary" />
|
||||
</AlertActions>}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<TableSwitchRow
|
||||
label={Strings.DEVELOPER_SETTINGS}
|
||||
icon={<TableRow.Icon source={findAssetId("ic_progress_wrench_24px")} />}
|
||||
icon={<TableRow.Icon source={findAssetId("WrenchIcon")!} />}
|
||||
value={settings.developerSettings}
|
||||
onValueChange={(v: boolean) => {
|
||||
settings.developerSettings = v;
|
||||
|
|
@ -80,7 +100,7 @@ export default function General() {
|
|||
<TableSwitchRow
|
||||
label={Strings.SETTINGS_ACTIVATE_DISCORD_EXPERIMENTS}
|
||||
subLabel={Strings.SETTINGS_ACTIVATE_DISCORD_EXPERIMENTS_DESC}
|
||||
icon={<TableRow.Icon source={findAssetId("ic_progress_wrench_24px")} />}
|
||||
icon={<TableRow.Icon source={findAssetId("WrenchIcon")!} />}
|
||||
value={settings.enableDiscordDeveloperSettings}
|
||||
onValueChange={(v: boolean) => {
|
||||
settings.enableDiscordDeveloperSettings = v;
|
||||
|
|
|
|||
|
|
@ -1,42 +1,24 @@
|
|||
import { installPlugin, isPluginInstalled, uninstallPlugin } from "@lib/addons/plugins";
|
||||
import { BunnyPluginManifest } from "@lib/addons/plugins/types";
|
||||
import { deleteRepository, installPlugin, isCorePlugin, isPluginInstalled, pluginRepositories, registeredPlugins, uninstallPlugin, updateAllRepository, updateRepository } from "@lib/addons/plugins";
|
||||
import { BunnyPluginManifestInternal } from "@lib/addons/plugins/types";
|
||||
import { findAssetId } from "@lib/api/assets";
|
||||
import { dismissAlert, openAlert } from "@lib/ui/alerts";
|
||||
import { AlertActionButton } from "@lib/ui/components/wrappers";
|
||||
import { hideSheet, showSheet } from "@lib/ui/sheets";
|
||||
import { showToast } from "@lib/ui/toasts";
|
||||
import { safeFetch } from "@lib/utils";
|
||||
import { OFFICIAL_PLUGINS_REPO_URL } from "@lib/utils/constants";
|
||||
import { Button, Card, FlashList, IconButton, Stack, Text } from "@metro/common/components";
|
||||
import isValidHttpUrl from "@lib/utils/isValidHttpUrl";
|
||||
import { clipboard, NavigationNative } from "@metro/common";
|
||||
import { ActionSheet, AlertActions, AlertModal, Button, Card, FlashList, IconButton, Stack, TableRow, TableRowGroup, Text, TextInput } from "@metro/common/components";
|
||||
import { QueryClient, QueryClientProvider, useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { chunk } from "es-toolkit";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { View } from "react-native";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
async function arrayFromAsync<T>(iterableOrArrayLike: AsyncIterable<T>): Promise<T[]> {
|
||||
const arr: T[] = [];
|
||||
for await (const element of iterableOrArrayLike) arr.push(element);
|
||||
return arr;
|
||||
}
|
||||
|
||||
async function fetchManifest(repoURL: string, id: string) {
|
||||
const url = new URL(`plugins/${id}/manifest.json`, repoURL);
|
||||
const data = await safeFetch(url).then(d => d.json());
|
||||
|
||||
queryClient.setQueryData(["plugin-manifest-dist", { id }], data);
|
||||
|
||||
return data as BunnyPluginManifest;
|
||||
}
|
||||
|
||||
async function* getManifests(repoUrl: string) {
|
||||
const rawResponse = await safeFetch(repoUrl);
|
||||
const pluginIds = Object.keys(await rawResponse.json());
|
||||
|
||||
for (const idChunks of chunk(pluginIds, 5)) {
|
||||
const manifests = idChunks.map(id => fetchManifest(OFFICIAL_PLUGINS_REPO_URL, id));
|
||||
for (const manifest of manifests) {
|
||||
yield await manifest;
|
||||
}
|
||||
}
|
||||
async function getManifests() {
|
||||
await updateAllRepository();
|
||||
const plugins = [...registeredPlugins.values()];
|
||||
return plugins.filter(p => !isCorePlugin(p.id));
|
||||
}
|
||||
|
||||
function InstallButton(props: { id: string; }) {
|
||||
|
|
@ -67,7 +49,15 @@ function TrailingButtons(props: { id: string; }) {
|
|||
return <Stack spacing={8} direction="horizontal">
|
||||
<IconButton
|
||||
size="sm"
|
||||
onPress={() => { }}
|
||||
onPress={() => {
|
||||
showSheet("plugin-info", () => {
|
||||
return <ActionSheet>
|
||||
<TableRowGroup title="Plugin Info">
|
||||
<TableRow label="ID" subLabel={props.id} />
|
||||
</TableRowGroup>
|
||||
</ActionSheet>;
|
||||
});
|
||||
}}
|
||||
variant="secondary"
|
||||
icon={findAssetId("CircleInformationIcon")}
|
||||
/>
|
||||
|
|
@ -75,46 +65,52 @@ function TrailingButtons(props: { id: string; }) {
|
|||
</Stack>;
|
||||
}
|
||||
|
||||
function PluginCard(props: { repoUrl: string, id: string, manifest: BunnyPluginManifest; }) {
|
||||
const { isPending, error, data: plugin } = useQuery({
|
||||
queryKey: ["plugin-manifest-dist", { id: props.id }],
|
||||
queryFn: () => fetchManifest(props.repoUrl, props.id)
|
||||
});
|
||||
function PluginCard(props: { manifest: BunnyPluginManifestInternal; }) {
|
||||
const { display, version } = props.manifest;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
{!plugin && <View style={{ justifyContent: "center", alignItems: "center" }}>
|
||||
<Text color="text-muted" variant="heading-lg/semibold">
|
||||
{isPending && "Loading..."}
|
||||
{error && `An error has occured while fetching plugin: ${error.message}`}
|
||||
</Text>
|
||||
</View>}
|
||||
{plugin && <Stack spacing={16}>
|
||||
<Stack spacing={16}>
|
||||
<View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<View style={{ flexShrink: 1 }}>
|
||||
<Text numberOfLines={1} variant="heading-lg/semibold">
|
||||
{plugin.name}
|
||||
{display.name}
|
||||
</Text>
|
||||
<Text variant="text-md/semibold" color="text-muted">
|
||||
by {plugin.authors.map(a => typeof a === "string" ? a : a.name).join(", ")}
|
||||
by {display.authors?.map(a => a.name).join(", ") || "Unknown"} ({version})
|
||||
</Text>
|
||||
</View>
|
||||
<View>
|
||||
<TrailingButtons id={props.id} />
|
||||
<TrailingButtons id={props.manifest.id} />
|
||||
</View>
|
||||
</View>
|
||||
<Text variant="text-md/medium">
|
||||
{plugin.description}
|
||||
{display.description}
|
||||
</Text>
|
||||
</Stack>}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function BrowserPage() {
|
||||
const navigation = NavigationNative.useNavigation();
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
title: "Plugin Browser",
|
||||
headerRight: () => <IconButton
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
icon={findAssetId("PlusSmallIcon")}
|
||||
onPress={() => {
|
||||
showSheet("plugin-browser-options", PluginBrowserOptions);
|
||||
}}
|
||||
/>
|
||||
});
|
||||
}, [navigation]);
|
||||
|
||||
const { data, error, isPending, refetch } = useQuery({
|
||||
queryKey: ["plugins-repo-fetch"],
|
||||
queryFn: () => arrayFromAsync(getManifests(OFFICIAL_PLUGINS_REPO_URL))
|
||||
queryFn: () => getManifests()
|
||||
});
|
||||
|
||||
if (error) {
|
||||
|
|
@ -149,12 +145,108 @@ function BrowserPage() {
|
|||
contentContainerStyle={{ paddingBottom: 90, paddingHorizontal: 5 }}
|
||||
renderItem={({ item: manifest }: any) => (
|
||||
<View style={{ paddingVertical: 6, paddingHorizontal: 8 }}>
|
||||
<PluginCard repoUrl={OFFICIAL_PLUGINS_REPO_URL} id={manifest.id} manifest={manifest} />
|
||||
<PluginCard manifest={manifest} />
|
||||
</View>
|
||||
)}
|
||||
/>;
|
||||
}
|
||||
|
||||
function AddRepositoryAlert() {
|
||||
const [value, setValue] = useState("");
|
||||
|
||||
return <AlertModal
|
||||
title="Add Repository"
|
||||
content="Enter the URL of the repository you want to add."
|
||||
extraContent={<TextInput
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
placeholder="https://example.com/repo.json" />}
|
||||
actions={<AlertActions>
|
||||
<AlertActionButton
|
||||
text="Add"
|
||||
variant="primary"
|
||||
disabled={!isValidHttpUrl(value)}
|
||||
onPress={async () => {
|
||||
try {
|
||||
await updateRepository(value);
|
||||
showToast("Added repository!", findAssetId("Check"));
|
||||
} catch (e) {
|
||||
showToast("Failed to add repository!", findAssetId("Small"));
|
||||
} finally {
|
||||
dismissAlert("bunny-add-plugin-repository");
|
||||
showSheet("plugin-browser-options", PluginBrowserOptions);
|
||||
}
|
||||
}} />
|
||||
</AlertActions>} />;
|
||||
}
|
||||
|
||||
function PluginBrowserOptions() {
|
||||
return <ActionSheet>
|
||||
<TableRowGroup title="Repositories">
|
||||
{Object.keys(pluginRepositories).map(url => {
|
||||
return <RepositoryRow key={url} url={url} />;
|
||||
})}
|
||||
<TableRow
|
||||
label="Add Repository..."
|
||||
icon={<TableRow.Icon source={findAssetId("PlusMediumIcon")} />}
|
||||
onPress={() => {
|
||||
openAlert("bunny-add-plugin-repository", <AddRepositoryAlert />);
|
||||
hideSheet("plugin-browser-options");
|
||||
}} />
|
||||
</TableRowGroup>
|
||||
</ActionSheet>;
|
||||
}
|
||||
|
||||
function RepositoryRow(props: { url: string; }) {
|
||||
const repo = pluginRepositories[props.url];
|
||||
const isOfficial = props.url === OFFICIAL_PLUGINS_REPO_URL;
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
label={isOfficial ? "Bunny's Repository" : (repo.$meta?.name ?? "Unknown")}
|
||||
subLabel={props.url}
|
||||
trailing={(
|
||||
<Stack direction="horizontal">
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
icon={findAssetId("LinkIcon")}
|
||||
onPress={() => {
|
||||
clipboard.setString(props.url);
|
||||
showToast.showCopyToClipboard();
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={isOfficial}
|
||||
icon={findAssetId("TrashIcon")}
|
||||
onPress={() => {
|
||||
openAlert("bunny-remove-repository", <AlertModal
|
||||
title="Remove Repository"
|
||||
content="Are you sure you want to remove this repository?"
|
||||
extraContent={<Card>
|
||||
<Text variant="text-md/normal">{props.url}</Text>
|
||||
</Card>}
|
||||
actions={<AlertActions>
|
||||
<AlertActionButton
|
||||
text="Remove"
|
||||
variant="destructive"
|
||||
onPress={async () => {
|
||||
await deleteRepository(props.url);
|
||||
showToast("Removed repository!", findAssetId("Trash"));
|
||||
dismissAlert("bunny-remove-repository");
|
||||
}}
|
||||
/>
|
||||
</AlertActions>}
|
||||
/>);
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
)} />
|
||||
);
|
||||
}
|
||||
|
||||
export default function PluginBrowser() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { CardWrapper } from "@core/ui/components/AddonCard";
|
||||
import { UnifiedPluginModel } from "@core/ui/settings/pages/Plugins/models";
|
||||
import { usePluginCardStyles } from "@core/ui/settings/pages/Plugins/usePluginCardStyles";
|
||||
import { findAssetId } from "@lib/api/assets";
|
||||
import { NavigationNative, tokens } from "@metro/common";
|
||||
|
|
@ -8,8 +9,6 @@ import chroma from "chroma-js";
|
|||
import { createContext, useContext, useMemo } from "react";
|
||||
import { Image, View } from "react-native";
|
||||
|
||||
import { UnifiedPluginModel } from "..";
|
||||
|
||||
const CardContext = createContext<{ plugin: UnifiedPluginModel, result: Fuzzysort.KeysResult<UnifiedPluginModel>; }>(null!);
|
||||
const useCardContext = () => useContext(CardContext);
|
||||
|
||||
|
|
@ -50,6 +49,8 @@ function Title() {
|
|||
|
||||
function Authors() {
|
||||
const { plugin, result } = useCardContext();
|
||||
const styles = usePluginCardStyles();
|
||||
|
||||
if (!plugin.authors) return null;
|
||||
|
||||
// could be empty if the author(s) are irrelevant with the search!
|
||||
|
|
@ -59,24 +60,23 @@ function Authors() {
|
|||
</Text>
|
||||
);
|
||||
|
||||
if (highlightedNode.length > 0) return (
|
||||
<Text variant="text-md/semibold" color="text-muted">
|
||||
by {highlightedNode}
|
||||
</Text>
|
||||
const badges = plugin.getBadges();
|
||||
const authorText = highlightedNode.length > 0 ? highlightedNode : plugin.authors.map(a => a.name).join(", ");
|
||||
|
||||
return (
|
||||
<View style={{ flexDirection: "row", flexWrap: "wrap", flexShrink: 1, gap: 4 }}>
|
||||
<Text variant="text-sm/semibold" color="text-muted">
|
||||
by {authorText}
|
||||
</Text>
|
||||
{badges.length > 0 && <View style={styles.badgesContainer}>
|
||||
{badges.map((b, i) => <Image
|
||||
key={i}
|
||||
source={b.source}
|
||||
style={styles.badgeIcon}
|
||||
/>)}
|
||||
</View>}
|
||||
</View>
|
||||
);
|
||||
|
||||
const children = ["by "];
|
||||
|
||||
for (const author of plugin.authors) {
|
||||
children.push(typeof author === "string" ? author : author.name);
|
||||
children.push(", ");
|
||||
}
|
||||
|
||||
children.pop();
|
||||
|
||||
return <Text variant="text-md/semibold" color="text-muted">
|
||||
{children}
|
||||
</Text>;
|
||||
}
|
||||
|
||||
function Description() {
|
||||
|
|
|
|||
|
|
@ -13,28 +13,14 @@ import { BUNNY_PROXY_PREFIX, VD_PROXY_PREFIX } from "@lib/utils/constants";
|
|||
import { lazyDestructure } from "@lib/utils/lazy";
|
||||
import { findByProps } from "@metro";
|
||||
import { NavigationNative } from "@metro/common";
|
||||
import { Card, FlashList, IconButton, Text } from "@metro/common/components";
|
||||
import { Button, Card, FlashList, IconButton, Text } from "@metro/common/components";
|
||||
import { ComponentProps } from "react";
|
||||
import { View } from "react-native";
|
||||
|
||||
import { UnifiedPluginModel } from "./models";
|
||||
import unifyBunnyPlugin from "./models/bunny";
|
||||
import unifyVdPlugin from "./models/vendetta";
|
||||
|
||||
export interface UnifiedPluginModel {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
authors?: Author[];
|
||||
icon?: string;
|
||||
|
||||
isEnabled(): boolean;
|
||||
usePluginState(): void;
|
||||
isInstalled(): boolean;
|
||||
toggle(start: boolean): void;
|
||||
resolveSheetComponent(): Promise<{ default: React.ComponentType<any>; }>;
|
||||
getPluginSettingsComponent(): React.ComponentType<any> | null | undefined;
|
||||
}
|
||||
|
||||
const { openAlert } = lazyDestructure(() => findByProps("openAlert", "dismissAlert"));
|
||||
const { AlertModal, AlertActions, AlertActionButton } = lazyDestructure(() => findByProps("AlertModal", "AlertActions"));
|
||||
|
||||
|
|
@ -53,7 +39,7 @@ function PluginPage(props: PluginPageProps) {
|
|||
"description",
|
||||
p => p.authors?.map(
|
||||
(a: Author | string) => typeof a === "string" ? a : a.name
|
||||
).join()
|
||||
).join() || ""
|
||||
]}
|
||||
sortOptions={{
|
||||
"Name (A-Z)": (a, b) => a.name.localeCompare(b.name),
|
||||
|
|
@ -119,6 +105,20 @@ export default function Plugins() {
|
|||
</Card>
|
||||
</View>;
|
||||
}}
|
||||
ListFooterComponent={() => __DEV__ && (
|
||||
<View style={{ alignItems: "center", justifyContent: "center", paddingTop: 16, gap: 12 }}><Button
|
||||
size="lg"
|
||||
text="Browse Plugins"
|
||||
icon={findAssetId("CompassIcon")}
|
||||
onPress={() => {
|
||||
navigation.push("BUNNY_CUSTOM_PAGE", {
|
||||
title: "Plugin Browser",
|
||||
render: React.lazy(() => import("../PluginBrowser")),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
installAction={{
|
||||
label: "Install a plugin",
|
||||
fetchFn: async (url: string) => {
|
||||
|
|
|
|||
|
|
@ -1,30 +1,43 @@
|
|||
import { disablePlugin, enablePlugin, getId, getPluginSettingsComponent, isPluginEnabled, pluginSettings } from "@lib/addons/plugins";
|
||||
import { PyoncordIcon } from "@core/ui/settings";
|
||||
import { disablePlugin, enablePlugin, getPluginSettingsComponent, isPluginEnabled, pluginSettings } from "@lib/addons/plugins";
|
||||
import { BunnyPluginManifest } from "@lib/addons/plugins/types";
|
||||
import { useObservable } from "@lib/api/storage";
|
||||
|
||||
import { UnifiedPluginModel } from "..";
|
||||
import { UnifiedPluginModel } from ".";
|
||||
|
||||
export default function unifyBunnyPlugin(manifest: BunnyPluginManifest): UnifiedPluginModel {
|
||||
return {
|
||||
id: manifest.id,
|
||||
name: manifest.name,
|
||||
description: manifest.description,
|
||||
authors: manifest.authors,
|
||||
isEnabled: () => isPluginEnabled(getId(manifest)),
|
||||
name: manifest.display.name,
|
||||
description: manifest.display.description,
|
||||
authors: manifest.display.authors,
|
||||
|
||||
getBadges() {
|
||||
return [
|
||||
{ source: { uri: PyoncordIcon } },
|
||||
// { source: findAssetId("CheckmarkLargeBoldIcon")! }
|
||||
];
|
||||
},
|
||||
isEnabled: () => isPluginEnabled(manifest.id),
|
||||
isInstalled: () => manifest.id in pluginSettings,
|
||||
usePluginState() {
|
||||
useObservable([pluginSettings]);
|
||||
},
|
||||
toggle(start: boolean) {
|
||||
start
|
||||
? enablePlugin(getId(manifest), true)
|
||||
: disablePlugin(getId(manifest));
|
||||
try {
|
||||
start
|
||||
? enablePlugin(manifest.id, true)
|
||||
: disablePlugin(manifest.id);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
// showToast("Failed to toggle plugin " + e, findAssetId("Small"));
|
||||
}
|
||||
},
|
||||
resolveSheetComponent() {
|
||||
return import("../sheets/PluginInfoActionSheet");
|
||||
},
|
||||
getPluginSettingsComponent() {
|
||||
return getPluginSettingsComponent(getId(manifest));
|
||||
return getPluginSettingsComponent(manifest.id);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
23
src/core/ui/settings/pages/Plugins/models/index.ts
Normal file
23
src/core/ui/settings/pages/Plugins/models/index.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { Author } from "@lib/addons/types";
|
||||
import { ImageSourcePropType } from "react-native";
|
||||
|
||||
interface Badge {
|
||||
source: ImageSourcePropType;
|
||||
color?: string;
|
||||
onPress?: () => void;
|
||||
}
|
||||
|
||||
export interface UnifiedPluginModel {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
authors?: Author[];
|
||||
icon?: string;
|
||||
getBadges(): Badge[];
|
||||
isEnabled(): boolean;
|
||||
usePluginState(): void;
|
||||
isInstalled(): boolean;
|
||||
toggle(start: boolean): void;
|
||||
resolveSheetComponent(): Promise<{ default: React.ComponentType<any>; }>;
|
||||
getPluginSettingsComponent(): React.ComponentType<any> | null | undefined;
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { VdPluginManager, VendettaPlugin } from "@core/vendetta/plugins";
|
||||
import { useProxy } from "@core/vendetta/storage";
|
||||
|
||||
import { UnifiedPluginModel } from "..";
|
||||
import { UnifiedPluginModel } from ".";
|
||||
|
||||
export default function unifyVdPlugin(vdPlugin: VendettaPlugin): UnifiedPluginModel {
|
||||
return {
|
||||
|
|
@ -11,6 +11,9 @@ export default function unifyVdPlugin(vdPlugin: VendettaPlugin): UnifiedPluginMo
|
|||
authors: vdPlugin.manifest.authors,
|
||||
icon: vdPlugin.manifest.vendetta?.icon,
|
||||
|
||||
getBadges() {
|
||||
return [];
|
||||
},
|
||||
isEnabled: () => vdPlugin.enabled,
|
||||
isInstalled: () => Boolean(vdPlugin && VdPluginManager.plugins[vdPlugin.id]),
|
||||
usePluginState() {
|
||||
|
|
|
|||
|
|
@ -1,45 +1,106 @@
|
|||
import { startPlugin } from "@lib/addons/plugins";
|
||||
import { findAssetId } from "@lib/api/assets";
|
||||
import { hideSheet } from "@lib/ui/sheets";
|
||||
import { ActionSheet, Button, Text } from "@metro/common/components";
|
||||
import { ActionSheet, Card, ContextMenu, IconButton, Text } from "@metro/common/components";
|
||||
import { ComponentProps } from "react";
|
||||
import { ScrollView, View } from "react-native";
|
||||
|
||||
import { PluginInfoActionSheetProps } from "./common";
|
||||
import TitleComponent from "./TitleComponent";
|
||||
|
||||
function PluginInfoIconButton(props: ComponentProps<typeof IconButton>) {
|
||||
const { onPress } = props;
|
||||
props.onPress &&= () => {
|
||||
hideSheet("PluginInfoActionSheet");
|
||||
onPress?.();
|
||||
};
|
||||
|
||||
return <IconButton {...props} />;
|
||||
}
|
||||
|
||||
export default function PluginInfoActionSheet({ plugin, navigation }: PluginInfoActionSheetProps) {
|
||||
plugin.usePluginState();
|
||||
|
||||
return <ActionSheet>
|
||||
<ScrollView contentContainerStyle={{ gap: 8, marginBottom: 12 }}>
|
||||
<View style={{ flexDirection: "row", alignItems: "center", paddingVertical: 24 }}>
|
||||
<View style={{ gap: 4 }}>
|
||||
<Text variant="heading-xl/semibold">
|
||||
{plugin.name}
|
||||
</Text>
|
||||
<Text variant="text-md/medium" color="text-muted">
|
||||
{plugin.description}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={{ marginLeft: "auto" }}>
|
||||
{plugin.getPluginSettingsComponent() && <Button
|
||||
size="md"
|
||||
text="Configure"
|
||||
<ScrollView contentContainerStyle={{ gap: 12, marginBottom: 12 }}>
|
||||
<View style={{ flexDirection: "row", alignItems: "center", gap: 8, paddingVertical: 24, justifyContent: "space-between", width: "100%" }}>
|
||||
<TitleComponent plugin={plugin} />
|
||||
<ContextMenu
|
||||
items={[
|
||||
{
|
||||
label: "Details",
|
||||
iconSource: findAssetId("CircleInformationIcon-primary"),
|
||||
action: () => {
|
||||
}
|
||||
},
|
||||
// {
|
||||
// label: true ? "Disable Updates" : "Enable Updates",
|
||||
// iconSource: true ? findAssetId("ClockXIcon") : findAssetId("ClockIcon"),
|
||||
// action: () => {
|
||||
|
||||
// }
|
||||
// },
|
||||
{
|
||||
label: "Clear Data",
|
||||
iconSource: findAssetId("FileIcon"),
|
||||
variant: "destructive",
|
||||
action: () => {
|
||||
}
|
||||
},
|
||||
{
|
||||
label: "Uninstall",
|
||||
iconSource: findAssetId("TrashIcon"),
|
||||
variant: "destructive",
|
||||
action: () => {
|
||||
}
|
||||
}
|
||||
]}
|
||||
>
|
||||
{props => <IconButton
|
||||
{...props}
|
||||
icon={findAssetId("MoreHorizontalIcon")}
|
||||
variant="secondary"
|
||||
icon={findAssetId("WrenchIcon")}
|
||||
onPress={() => {
|
||||
hideSheet("PluginInfoActionSheet");
|
||||
navigation.push("BUNNY_CUSTOM_PAGE", {
|
||||
title: plugin.name,
|
||||
render: plugin.getPluginSettingsComponent(),
|
||||
});
|
||||
}}
|
||||
size="sm"
|
||||
/>}
|
||||
</View>
|
||||
</ContextMenu>
|
||||
</View>
|
||||
<View style={{ flexDirection: "row", justifyContent: "center", alignContent: "center" }}>
|
||||
<Text variant="text-lg/medium">
|
||||
Oops, you shouldn't see this!
|
||||
<View style={{ flexDirection: "row", justifyContent: "space-around", alignContent: "center" }}>
|
||||
<PluginInfoIconButton
|
||||
label="Configure"
|
||||
variant="secondary"
|
||||
disabled={!plugin.getPluginSettingsComponent()}
|
||||
icon={findAssetId("WrenchIcon")}
|
||||
onPress={() => {
|
||||
navigation.push("BUNNY_CUSTOM_PAGE", {
|
||||
title: plugin.name,
|
||||
render: plugin.getPluginSettingsComponent(),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<PluginInfoIconButton
|
||||
label="Refetch"
|
||||
variant="secondary"
|
||||
icon={findAssetId("RetryIcon")}
|
||||
onPress={() => {
|
||||
startPlugin(plugin.id);
|
||||
}}
|
||||
/>
|
||||
<PluginInfoIconButton
|
||||
label="Copy URL"
|
||||
variant="secondary"
|
||||
icon={findAssetId("LinkIcon")}
|
||||
onPress={() => {
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
<Card>
|
||||
<Text variant="text-md/semibold" color="text-primary" style={{ marginBottom: 4 }}>
|
||||
Description
|
||||
</Text>
|
||||
</View>
|
||||
<Text variant="text-md/medium">
|
||||
{plugin.description}
|
||||
</Text>
|
||||
</Card>
|
||||
</ScrollView>
|
||||
</ActionSheet>;
|
||||
}
|
||||
|
|
|
|||
57
src/core/ui/settings/pages/Plugins/sheets/TitleComponent.tsx
Normal file
57
src/core/ui/settings/pages/Plugins/sheets/TitleComponent.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { UnifiedPluginModel } from "@core/ui/settings/pages/Plugins/models";
|
||||
import { lazyDestructure } from "@lib/utils/lazy";
|
||||
import { findByNameLazy, findByProps } from "@metro";
|
||||
import { FluxUtils } from "@metro/common";
|
||||
import { Avatar, AvatarPile, Text } from "@metro/common/components";
|
||||
import { UserStore } from "@metro/common/stores";
|
||||
import { View } from "react-native";
|
||||
|
||||
const showUserProfileActionSheet = findByNameLazy("showUserProfileActionSheet");
|
||||
const { getUser: maybeFetchUser } = lazyDestructure(() => findByProps("getUser", "fetchProfile"));
|
||||
|
||||
export default function TitleComponent({ plugin }: { plugin: UnifiedPluginModel; }) {
|
||||
const users: any[] = FluxUtils.useStateFromStoresArray([UserStore], () => {
|
||||
plugin.authors?.forEach(a => a.id && maybeFetchUser(a.id));
|
||||
return plugin.authors?.map(a => UserStore.getUser(a.id));
|
||||
});
|
||||
|
||||
const { authors } = plugin;
|
||||
const authorTextNode = [];
|
||||
|
||||
if (authors) {
|
||||
for (const author of authors) {
|
||||
authorTextNode.push(<Text
|
||||
onPress={() => showUserProfileActionSheet({ userId: author.id })}
|
||||
variant="text-md/medium"
|
||||
>
|
||||
{author.name}
|
||||
</Text>);
|
||||
|
||||
authorTextNode.push(", ");
|
||||
}
|
||||
|
||||
authorTextNode.pop();
|
||||
}
|
||||
|
||||
return <View style={{ gap: 4 }}>
|
||||
<View>
|
||||
<Text variant="heading-xl/semibold">
|
||||
{plugin.name}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={{ flexDirection: "row", flexShrink: 1 }}>
|
||||
{authors?.length && <View style={{ flexDirection: "row", gap: 8, alignItems: "center", paddingVertical: 4, paddingHorizontal: 8, backgroundColor: "#00000016", borderRadius: 32 }}>
|
||||
{users.length && <AvatarPile
|
||||
size="xxsmall"
|
||||
names={plugin.authors?.map(a => a.name)}
|
||||
totalCount={plugin.authors?.length}
|
||||
>
|
||||
{users.map(a => <Avatar size="xxsmall" user={a} />)}
|
||||
</AvatarPile>}
|
||||
<Text variant="text-md/medium">
|
||||
{authorTextNode}
|
||||
</Text>
|
||||
</View>}
|
||||
</View>
|
||||
</View>;
|
||||
}
|
||||
|
|
@ -59,7 +59,7 @@ export default function PluginInfoActionSheet({ plugin, navigation }: PluginInfo
|
|||
/>
|
||||
<ActionSheetRow
|
||||
label={Strings.COPY_URL}
|
||||
icon={<TableRow.Icon source={findAssetId("copy")} />}
|
||||
icon={<TableRow.Icon source={findAssetId("LinkIcon")} />}
|
||||
onPress={() => {
|
||||
clipboard.setString(plugin.id);
|
||||
showToast.showCopyToClipboard();
|
||||
|
|
@ -67,7 +67,7 @@ export default function PluginInfoActionSheet({ plugin, navigation }: PluginInfo
|
|||
/>
|
||||
<ActionSheetRow
|
||||
label={vdPlugin.update ? Strings.DISABLE_UPDATES : Strings.ENABLE_UPDATES}
|
||||
icon={<TableRow.Icon source={findAssetId("ic_download_24px")} />}
|
||||
icon={<TableRow.Icon source={findAssetId("DownloadIcon")} />}
|
||||
onPress={() => {
|
||||
vdPlugin.update = !vdPlugin.update;
|
||||
showToast(formatString("TOASTS_PLUGIN_UPDATE", {
|
||||
|
|
@ -78,7 +78,8 @@ export default function PluginInfoActionSheet({ plugin, navigation }: PluginInfo
|
|||
/>
|
||||
<ActionSheetRow
|
||||
label={Strings.CLEAR_DATA}
|
||||
icon={<TableRow.Icon source={findAssetId("ic_duplicate")} />}
|
||||
icon={<TableRow.Icon variant="danger" source={findAssetId("CopyIcon")} />}
|
||||
variant="danger"
|
||||
onPress={() => showConfirmationAlert({
|
||||
title: Strings.HOLD_UP,
|
||||
content: formatString("ARE_YOU_SURE_TO_CLEAR_DATA", { name: plugin.name }),
|
||||
|
|
@ -115,7 +116,8 @@ export default function PluginInfoActionSheet({ plugin, navigation }: PluginInfo
|
|||
/>
|
||||
<ActionSheetRow
|
||||
label={Strings.DELETE}
|
||||
icon={<TableRow.Icon source={findAssetId("ic_message_delete")} />}
|
||||
icon={<TableRow.Icon variant="danger" source={findAssetId("TrashIcon")} />}
|
||||
variant="danger"
|
||||
onPress={() => showConfirmationAlert({
|
||||
title: Strings.HOLD_UP,
|
||||
content: formatString("ARE_YOU_SURE_TO_DELETE_PLUGIN", { name: plugin.name }),
|
||||
|
|
|
|||
|
|
@ -6,5 +6,17 @@ export const usePluginCardStyles = createStyles({
|
|||
tintColor: tokens.colors.LOGO_PRIMARY,
|
||||
height: 18,
|
||||
width: 18,
|
||||
},
|
||||
badgeIcon: {
|
||||
tintColor: tokens.colors.LOGO_PRIMARY,
|
||||
height: 12,
|
||||
width: 12,
|
||||
},
|
||||
badgesContainer: {
|
||||
flexWrap: "wrap",
|
||||
flexDirection: "row",
|
||||
gap: 6,
|
||||
borderRadius: 6,
|
||||
padding: 4
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,22 +2,21 @@ import { formatString, Strings } from "@core/i18n";
|
|||
import AddonCard, { CardWrapper } from "@core/ui/components/AddonCard";
|
||||
import { showConfirmationAlert } from "@core/vendetta/alerts";
|
||||
import { useProxy } from "@core/vendetta/storage";
|
||||
import { applyTheme, fetchTheme, removeTheme, selectTheme, Theme, themes } from "@lib/addons/themes";
|
||||
import { fetchTheme, removeTheme, selectTheme, themes, VdThemeInfo } from "@lib/addons/themes";
|
||||
import { findAssetId } from "@lib/api/assets";
|
||||
import { settings } from "@lib/api/settings";
|
||||
import { clipboard } from "@metro/common";
|
||||
import { showToast } from "@ui/toasts";
|
||||
|
||||
function selectAndApply(value: boolean, theme: Theme) {
|
||||
function selectAndApply(value: boolean, theme: VdThemeInfo) {
|
||||
try {
|
||||
selectTheme(value ? theme : null);
|
||||
applyTheme(value ? theme : null);
|
||||
} catch (e: any) {
|
||||
console.error("Error while selectAndApply,", e);
|
||||
}
|
||||
}
|
||||
|
||||
export default function ThemeCard({ item: theme }: CardWrapper<Theme>) {
|
||||
export default function ThemeCard({ item: theme }: CardWrapper<VdThemeInfo>) {
|
||||
useProxy(theme);
|
||||
|
||||
const [removed, setRemoved] = React.useState(false);
|
||||
|
|
|
|||
|
|
@ -2,26 +2,31 @@ import { formatString, Strings } from "@core/i18n";
|
|||
import AddonPage from "@core/ui/components/AddonPage";
|
||||
import ThemeCard from "@core/ui/settings/pages/Themes/ThemeCard";
|
||||
import { useProxy } from "@core/vendetta/storage";
|
||||
import { installTheme, Theme, themes } from "@lib/addons/themes";
|
||||
import { getCurrentTheme, installTheme, themes, VdThemeInfo } from "@lib/addons/themes";
|
||||
import { colorsPref } from "@lib/addons/themes/colors/preferences";
|
||||
import { updateBunnyColor } from "@lib/addons/themes/colors/updater";
|
||||
import { Author } from "@lib/addons/types";
|
||||
import { findAssetId } from "@lib/api/assets";
|
||||
import { settings } from "@lib/api/settings";
|
||||
import { Button } from "@metro/common/components";
|
||||
import { useObservable } from "@lib/api/storage";
|
||||
import { ActionSheet, BottomSheetTitleHeader, Button, TableRadioGroup, TableRadioRow, TableRowIcon } from "@metro/common/components";
|
||||
import { View } from "react-native";
|
||||
|
||||
export default function Themes() {
|
||||
useProxy(settings);
|
||||
useProxy(themes);
|
||||
|
||||
return (
|
||||
<AddonPage<Theme>
|
||||
<AddonPage<VdThemeInfo>
|
||||
title={Strings.THEMES}
|
||||
searchKeywords={[
|
||||
"data.name",
|
||||
"data.description",
|
||||
p => p.data.authors?.map((a: Author) => a.name).join(", ")
|
||||
p => p.data.authors?.map((a: Author) => a.name).join(", ") ?? ""
|
||||
]}
|
||||
sortOptions={{
|
||||
"Name (A-Z)": (a, b) => a.name.localeCompare(b.name),
|
||||
"Name (Z-A)": (a, b) => b.name.localeCompare(a.name)
|
||||
"Name (A-Z)": (a, b) => a.data.name.localeCompare(b.data.name),
|
||||
"Name (Z-A)": (a, b) => b.data.name.localeCompare(a.data.name)
|
||||
}}
|
||||
installAction={{
|
||||
label: "Install a theme",
|
||||
|
|
@ -38,6 +43,39 @@ export default function Themes() {
|
|||
/>
|
||||
}}
|
||||
CardComponent={ThemeCard}
|
||||
OptionsActionSheetComponent={() => {
|
||||
useObservable([colorsPref]);
|
||||
|
||||
return <ActionSheet>
|
||||
<BottomSheetTitleHeader title="Options" />
|
||||
<View style={{ paddingVertical: 20, gap: 12 }}>
|
||||
<TableRadioGroup
|
||||
title="Override Theme Type"
|
||||
value={colorsPref.type ?? "auto"}
|
||||
hasIcons={true}
|
||||
onChange={type => {
|
||||
colorsPref.type = type !== "auto" ? type as "dark" | "light" : undefined;
|
||||
getCurrentTheme()?.data && updateBunnyColor(getCurrentTheme()!.data!, { update: true });
|
||||
}}
|
||||
>
|
||||
<TableRadioRow icon={<TableRowIcon source={findAssetId("RobotIcon")} />} label="Auto" value="auto" />
|
||||
<TableRadioRow icon={<TableRowIcon source={findAssetId("ThemeDarkIcon")} />} label="Dark" value="dark" />
|
||||
<TableRadioRow icon={<TableRowIcon source={findAssetId("ThemeLightIcon")} />} label="Light" value="light" />
|
||||
</TableRadioGroup>
|
||||
<TableRadioGroup
|
||||
title="Chat Background"
|
||||
value={colorsPref.customBackground ?? "shown"}
|
||||
hasIcons={true}
|
||||
onChange={type => {
|
||||
colorsPref.customBackground = type !== "shown" ? type as "hidden" : null;
|
||||
}}
|
||||
>
|
||||
<TableRadioRow icon={<TableRowIcon source={findAssetId("ImageIcon")} />} label="Show" value={"shown"} />
|
||||
<TableRadioRow icon={<TableRowIcon source={findAssetId("DenyIcon")} />} label="Hide" value={"hidden"} />
|
||||
</TableRadioGroup>
|
||||
</View>
|
||||
</ActionSheet>;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -168,7 +168,26 @@ export const initVendettaObject = (): any => {
|
|||
showInputAlert: (options: any) => alerts.showInputAlert(options)
|
||||
},
|
||||
assets: {
|
||||
all: assets.assetsMap,
|
||||
all: new Proxy<any>({}, {
|
||||
get(cache, p) {
|
||||
if (typeof p !== "string") return undefined;
|
||||
if (cache[p]) return cache[p];
|
||||
|
||||
for (const asset of assets.iterateAssets()) {
|
||||
if (asset.name) return cache[p] = asset;
|
||||
}
|
||||
},
|
||||
ownKeys(cache) {
|
||||
const keys = new Set<string>();
|
||||
|
||||
for (const asset of assets.iterateAssets()) {
|
||||
cache[asset.name] = asset;
|
||||
keys.add(asset.name);
|
||||
}
|
||||
|
||||
return [...keys];
|
||||
},
|
||||
}),
|
||||
find: (filter: (a: any) => boolean) => assets.findAsset(filter),
|
||||
getAssetByName: (name: string) => assets.findAsset(name),
|
||||
getAssetByID: (id: number) => assets.findAsset(id),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Emitter, EmitterEvent, EmitterListener, EmitterListenerData } from "@core/vendetta/Emitter";
|
||||
import { FileManager, MMKVManager } from "@lib/api/native/modules";
|
||||
import { NativeCacheModule, NativeFileModule } from "@lib/api/native/modules";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
const emitterSymbol = Symbol.for("vendetta.storage.emitter");
|
||||
|
|
@ -142,7 +142,8 @@ export function wrapSync<T extends Promise<any>>(store: T): Awaited<T> {
|
|||
|
||||
export function awaitStorage(...stores: any[]) {
|
||||
return Promise.all(
|
||||
stores.map(store => new Promise<void>(res => store[syncAwaitSymbol](res)))
|
||||
stores.map(store =>
|
||||
new Promise<void>(res => store[syncAwaitSymbol](res)))
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -150,11 +151,14 @@ export interface StorageBackend {
|
|||
get: () => unknown | Promise<unknown>;
|
||||
set: (data: unknown) => void | Promise<void>;
|
||||
}
|
||||
|
||||
const ILLEGAL_CHARS_REGEX = /[<>:"/\\|?*]/g;
|
||||
|
||||
const filePathFixer = (file: string): string => Platform.select({
|
||||
default: file,
|
||||
ios: FileManager.saveFileToGallery ? file : `Documents/${file}`,
|
||||
ios: NativeFileModule.saveFileToGallery ? file : `Documents/${file}`,
|
||||
});
|
||||
|
||||
const getMMKVPath = (name: string): string => {
|
||||
if (ILLEGAL_CHARS_REGEX.test(name)) {
|
||||
// Replace forbidden characters with hyphens
|
||||
|
|
@ -165,13 +169,13 @@ const getMMKVPath = (name: string): string => {
|
|||
};
|
||||
|
||||
export const purgeStorage = async (store: string) => {
|
||||
if (await MMKVManager.getItem(store)) {
|
||||
MMKVManager.removeItem(store);
|
||||
if (await NativeCacheModule.getItem(store)) {
|
||||
NativeCacheModule.removeItem(store);
|
||||
}
|
||||
|
||||
const mmkvPath = getMMKVPath(store);
|
||||
if (await FileManager.fileExists(`${FileManager.getConstants().DocumentsDirPath}/${mmkvPath}`)) {
|
||||
await FileManager.removeFile?.("documents", mmkvPath);
|
||||
if (await NativeFileModule.fileExists(`${NativeFileModule.getConstants().DocumentsDirPath}/${mmkvPath}`)) {
|
||||
await NativeFileModule.removeFile?.("documents", mmkvPath);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -180,16 +184,16 @@ export const createMMKVBackend = (store: string, defaultData = {}) => {
|
|||
const defaultStr = JSON.stringify(defaultData);
|
||||
|
||||
return createFileBackend(mmkvPath, defaultData, (async () => {
|
||||
const path = `${FileManager.getConstants().DocumentsDirPath}/${mmkvPath}`;
|
||||
if (await FileManager.fileExists(path)) return;
|
||||
const path = `${NativeFileModule.getConstants().DocumentsDirPath}/${mmkvPath}`;
|
||||
if (await NativeFileModule.fileExists(path)) return;
|
||||
|
||||
let oldData = await MMKVManager.getItem(store) ?? defaultStr;
|
||||
let oldData = await NativeCacheModule.getItem(store) ?? defaultStr;
|
||||
|
||||
// From the testing on Android, it seems to return this if the data is too large
|
||||
if (oldData === "!!LARGE_VALUE!!") {
|
||||
const cachePath = `${FileManager.getConstants().CacheDirPath}/mmkv/${store}`;
|
||||
if (await FileManager.fileExists(cachePath)) {
|
||||
oldData = await FileManager.readFile(cachePath, "utf8");
|
||||
const cachePath = `${NativeFileModule.getConstants().CacheDirPath}/mmkv/${store}`;
|
||||
if (await NativeFileModule.fileExists(cachePath)) {
|
||||
oldData = await NativeFileModule.readFile(cachePath, "utf8");
|
||||
} else {
|
||||
console.log(`${store}: Experienced data loss :(`);
|
||||
oldData = defaultStr;
|
||||
|
|
@ -203,9 +207,9 @@ export const createMMKVBackend = (store: string, defaultData = {}) => {
|
|||
oldData = defaultStr;
|
||||
}
|
||||
|
||||
await FileManager.writeFile("documents", filePathFixer(mmkvPath), oldData, "utf8");
|
||||
if (await MMKVManager.getItem(store) !== null) {
|
||||
MMKVManager.removeItem(store);
|
||||
await NativeFileModule.writeFile("documents", filePathFixer(mmkvPath), oldData, "utf8");
|
||||
if (await NativeCacheModule.getItem(store) !== null) {
|
||||
NativeCacheModule.removeItem(store);
|
||||
console.log(`Successfully migrated ${store} store from MMKV storage to fs`);
|
||||
}
|
||||
})());
|
||||
|
|
@ -215,10 +219,10 @@ export const createFileBackend = (file: string, defaultData = {}, migratePromise
|
|||
return {
|
||||
get: async () => {
|
||||
await migratePromise;
|
||||
const path = `${FileManager.getConstants().DocumentsDirPath}/${file}`;
|
||||
const path = `${NativeFileModule.getConstants().DocumentsDirPath}/${file}`;
|
||||
|
||||
if (await FileManager.fileExists(path)) {
|
||||
const content = await FileManager.readFile(path, "utf8");
|
||||
if (await NativeFileModule.fileExists(path)) {
|
||||
const content = await NativeFileModule.readFile(path, "utf8");
|
||||
try {
|
||||
return JSON.parse(content);
|
||||
} catch {
|
||||
|
|
@ -226,12 +230,12 @@ export const createFileBackend = (file: string, defaultData = {}, migratePromise
|
|||
}
|
||||
}
|
||||
|
||||
await FileManager.writeFile("documents", filePathFixer(file), JSON.stringify(defaultData), "utf8");
|
||||
return JSON.parse(await FileManager.readFile(path, "utf8"));
|
||||
await NativeFileModule.writeFile("documents", filePathFixer(file), JSON.stringify(defaultData), "utf8");
|
||||
return JSON.parse(await NativeFileModule.readFile(path, "utf8"));
|
||||
},
|
||||
set: async data => {
|
||||
await migratePromise;
|
||||
await FileManager.writeFile("documents", filePathFixer(file), JSON.stringify(data), "utf8");
|
||||
await NativeFileModule.writeFile("documents", filePathFixer(file), JSON.stringify(data), "utf8");
|
||||
}
|
||||
};
|
||||
};
|
||||
|
|
|
|||
21
src/index.ts
21
src/index.ts
|
|
@ -6,41 +6,24 @@ import { initVendettaObject } from "@core/vendetta/api";
|
|||
import { VdPluginManager } from "@core/vendetta/plugins";
|
||||
import { updateFonts } from "@lib/addons/fonts";
|
||||
import { initPlugins, updatePlugins } from "@lib/addons/plugins";
|
||||
import { initThemes, patchChatBackground } from "@lib/addons/themes";
|
||||
import { initThemes } from "@lib/addons/themes";
|
||||
import { patchCommands } from "@lib/api/commands";
|
||||
import { patchLogHook } from "@lib/api/debug";
|
||||
import { injectFluxInterceptor } from "@lib/api/flux";
|
||||
import { writeFile } from "@lib/api/native/fs";
|
||||
import { isPyonLoader, isThemeSupported } from "@lib/api/native/loader";
|
||||
import { patchJsx } from "@lib/api/react/jsx";
|
||||
import { logger } from "@lib/utils/logger";
|
||||
import { patchSettings } from "@ui/settings";
|
||||
|
||||
import * as lib from "./lib";
|
||||
|
||||
function maybeLoadThemes() {
|
||||
if (!isThemeSupported()) return;
|
||||
|
||||
try {
|
||||
if (isPyonLoader()) {
|
||||
writeFile("../vendetta_theme.json", "null");
|
||||
}
|
||||
initThemes();
|
||||
} catch (e) {
|
||||
console.error("Failed to initialize themes", e);
|
||||
}
|
||||
}
|
||||
|
||||
export default async () => {
|
||||
maybeLoadThemes();
|
||||
|
||||
// Load everything in parallel
|
||||
await Promise.all([
|
||||
initThemes(),
|
||||
injectFluxInterceptor(),
|
||||
patchSettings(),
|
||||
patchLogHook(),
|
||||
patchCommands(),
|
||||
patchChatBackground(),
|
||||
patchJsx(),
|
||||
initVendettaObject(),
|
||||
initFetchI18nStrings(),
|
||||
|
|
|
|||
|
|
@ -8,14 +8,21 @@ import { BunnyPluginObject } from "./types";
|
|||
|
||||
type DisposableFn = (...props: any[]) => () => unknown;
|
||||
function shimDisposableFn<F extends DisposableFn>(unpatches: (() => void)[], f: F): F {
|
||||
return ((...props: Parameters<F>) => {
|
||||
const dummy = ((...props: Parameters<F>) => {
|
||||
const up = f(...props);
|
||||
unpatches.push(up);
|
||||
return up;
|
||||
}) as F;
|
||||
|
||||
for (const key in f) if (typeof f[key] === "function") {
|
||||
// @ts-ignore
|
||||
dummy[key] = shimDisposableFn(unpatches, f[key] as DisposableFn);
|
||||
}
|
||||
|
||||
return dummy;
|
||||
}
|
||||
|
||||
export function createBunnyPluginAPI(id: string) {
|
||||
export function createBunnyPluginApi(id: string) {
|
||||
const disposers = new Array<DisposableFn>;
|
||||
|
||||
// proxying this would be a good idea
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import { getCorePlugins } from "@core/plugins";
|
||||
import { readFile, removeFile, writeFile } from "@lib/api/native/fs";
|
||||
import { awaitStorage, createStorage, getPreloadedStorage, preloadStorageIfExists, updateStorage } from "@lib/api/storage";
|
||||
import { awaitStorage, createStorage, getPreloadedStorage, preloadStorageIfExists, purgeStorage, updateStorage } from "@lib/api/storage";
|
||||
import { safeFetch } from "@lib/utils";
|
||||
import { OFFICIAL_PLUGINS_REPO_URL } from "@lib/utils/constants";
|
||||
import { semver } from "@metro/common";
|
||||
|
||||
import { createBunnyPluginAPI } from "./api";
|
||||
import { createBunnyPluginApi } from "./api";
|
||||
import * as t from "./types";
|
||||
|
||||
type PluginInstantiator = (
|
||||
|
|
@ -20,13 +20,11 @@ export const corePluginInstances = new Map<string, t.PluginInstanceInternal>();
|
|||
|
||||
export const registeredPlugins = new Map<string, t.BunnyPluginManifest>();
|
||||
export const pluginInstances = new Map<string, t.PluginInstanceInternal>();
|
||||
export const apiObjects = new Map<string, ReturnType<typeof createBunnyPluginAPI>>();
|
||||
export const apiObjects = new Map<string, ReturnType<typeof createBunnyPluginApi>>();
|
||||
|
||||
export const pluginRepositories = createStorage<t.PluginRepoStorage>("plugins/repositories.json");
|
||||
export const pluginSettings = createStorage<t.PluginSettingsStorage>("plugins/settings.json");
|
||||
|
||||
const manifestToId = new WeakMap<t.BunnyPluginManifest, string>();
|
||||
|
||||
const _fetch = (repoUrl: string, path: string) => safeFetch(new URL(path, repoUrl), { cache: "no-store" });
|
||||
const fetchJS = (repoUrl: string, path: string) => _fetch(repoUrl, path).then(r => r.text());
|
||||
const fetchJSON = (repoUrl: string, path: string) => _fetch(repoUrl, path).then(r => r.json());
|
||||
|
|
@ -38,9 +36,10 @@ function assert<T>(condition: T, id: string, attempt: string): asserts condition
|
|||
/**
|
||||
* Checks if a version is newer than the other. However, this comes with an additional logic,
|
||||
* where if the version are equal, one with prerelease "tag" will be considered "newer"
|
||||
* @internal
|
||||
* @returns Whether the version is newer
|
||||
*/
|
||||
function newerThan(v1: string, v2: string) {
|
||||
export function isGreaterVersion(v1: string, v2: string) {
|
||||
if (semver.gt(v1, v2)) return true;
|
||||
const coerced = semver.coerce(v1);
|
||||
if (coerced == null) return false;
|
||||
|
|
@ -55,12 +54,6 @@ export function isCorePlugin(id: string) {
|
|||
return corePluginInstances.has(id);
|
||||
}
|
||||
|
||||
export function getId<T extends t.BunnyPluginManifest>(manifest: T): string {
|
||||
const id = manifestToId.get(manifest);
|
||||
assert(id, manifest?.name ?? "unknown", "getting ID from an unregistered/invalid manifest");
|
||||
return id;
|
||||
}
|
||||
|
||||
export function getPluginSettingsComponent(id: string): React.ComponentType<any> | null {
|
||||
const instance = pluginInstances.get(id);
|
||||
if (!instance) return null;
|
||||
|
|
@ -84,7 +77,7 @@ export function isPluginEnabled(id: string) {
|
|||
* @returns The newly fetched plugin manifest
|
||||
*/
|
||||
export async function updateAndWritePlugin(repoUrl: string, id: string, fetchScript: boolean) {
|
||||
const manifest: t.BunnyPluginManifestInternal = await fetchJSON(repoUrl, `plugins/${id}/manifest.json`);
|
||||
const manifest: t.BunnyPluginManifestInternal = await fetchJSON(repoUrl, `builds/${id}/manifest.json`);
|
||||
|
||||
// @ts-expect-error - Setting a readonly property
|
||||
manifest.parentRepository = repoUrl;
|
||||
|
|
@ -93,7 +86,7 @@ export async function updateAndWritePlugin(repoUrl: string, id: string, fetchScr
|
|||
// @ts-expect-error - Setting a readonly property
|
||||
manifest.jsPath = `plugins/scripts/${id}.js`;
|
||||
|
||||
const js: string = await fetchJS(repoUrl, `plugins/${id}/index.js`);
|
||||
const js: string = await fetchJS(repoUrl, `builds/${id}/index.js`);
|
||||
await writeFile(manifest.jsPath, js);
|
||||
}
|
||||
|
||||
|
|
@ -126,7 +119,6 @@ export async function refreshPlugin(id: string, repoUrl?: string) {
|
|||
|
||||
registeredPlugins.delete(id);
|
||||
registeredPlugins.set(id, manifest);
|
||||
manifestToId.set(manifest, id);
|
||||
|
||||
await startPlugin(id);
|
||||
}
|
||||
|
|
@ -160,33 +152,33 @@ export async function updateRepository(repoUrl: string) {
|
|||
}
|
||||
}
|
||||
|
||||
await Promise.all(Object.keys(repo).map(async pluginId => {
|
||||
if (!storedRepo || !storedRepo[pluginId] || repo[pluginId].alwaysFetch || newerThan(repo[pluginId].version, storedRepo[pluginId].version)) {
|
||||
const pluginIds = Object.keys(repo).filter(id => !id.startsWith("$"));
|
||||
await Promise.all(pluginIds.map(async pluginId => {
|
||||
if (!storedRepo || !storedRepo[pluginId] || repo[pluginId].alwaysFetch || isGreaterVersion(repo[pluginId].version, storedRepo[pluginId].version)) {
|
||||
updated = true;
|
||||
pluginRepositories[repoUrl][pluginId] = repo[pluginId];
|
||||
await updateAndWritePlugin(repoUrl, pluginId, Boolean(storedRepo && pluginSettings[pluginId]));
|
||||
} else {
|
||||
const manifest = await preloadStorageIfExists(`plugins/manifests/${pluginId}.json`);
|
||||
if (manifest === undefined) { // File does not exist, so do refetch and stuff
|
||||
if (!manifest) { // File does not exist, so do refetch and stuff
|
||||
await updateAndWritePlugin(repoUrl, pluginId, Boolean(storedRepo && pluginSettings[pluginId]));
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// Register plugins in this repository
|
||||
for (const id in repo) {
|
||||
for (const id of pluginIds) {
|
||||
const manifest = getPreloadedStorage<t.BunnyPluginManifest>(`plugins/manifests/${id}.json`);
|
||||
if (manifest === undefined) continue; // shouldn't happen, but just incase if it does
|
||||
|
||||
const existing = registeredPlugins.get(id);
|
||||
|
||||
// Skip if this version isn't any higher
|
||||
if (existing && !newerThan(manifest.version, existing.version)) {
|
||||
if (existing && !isGreaterVersion(manifest.version, existing.version)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
registeredPlugins.set(id, manifest);
|
||||
manifestToId.set(manifest, id);
|
||||
}
|
||||
|
||||
return updated;
|
||||
|
|
@ -210,11 +202,13 @@ export async function deleteRepository(repoUrl: string) {
|
|||
}
|
||||
|
||||
// Deregister all plugins under this repository
|
||||
promQueues.push(purgeStorage(`plugins/manifests/${id}.json`));
|
||||
registeredPlugins.delete(id);
|
||||
}
|
||||
|
||||
delete pluginRepositories[repoUrl];
|
||||
await Promise.all(promQueues);
|
||||
updateAllRepository();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -225,8 +219,8 @@ export async function deleteRepository(repoUrl: string) {
|
|||
export async function enablePlugin(id: string, start: boolean) {
|
||||
assert(isPluginInstalled(id), id, "enable a non-installed plugin");
|
||||
|
||||
pluginSettings[id]!!.enabled = true;
|
||||
if (start) await startPlugin(id);
|
||||
pluginSettings[id]!.enabled = true;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -237,7 +231,7 @@ export function disablePlugin(id: string) {
|
|||
assert(isPluginInstalled(id), id, "disable a non-installed plugin");
|
||||
|
||||
pluginInstances.has(id) && stopPlugin(id);
|
||||
pluginSettings[id]!!.enabled = false;
|
||||
pluginSettings[id]!.enabled = false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -273,6 +267,7 @@ export async function uninstallPlugin(id: string) {
|
|||
pluginInstances.has(id) && stopPlugin(id);
|
||||
delete pluginSettings[id];
|
||||
|
||||
await purgeStorage(`plugins/storage/${id}.json`);
|
||||
await removeFile(`plugins/scripts/${id}.js`);
|
||||
}
|
||||
|
||||
|
|
@ -280,13 +275,12 @@ export async function uninstallPlugin(id: string) {
|
|||
* Starts a registered, installed, enabled and unstarted plugin. Otherwise, would throw.
|
||||
* @param id The enabled plugin ID
|
||||
*/
|
||||
export async function startPlugin(id: string) {
|
||||
export async function startPlugin(id: string, { throwIfDisabled = false, disableWhenThrown = true } = {}) {
|
||||
const manifest = registeredPlugins.get(id);
|
||||
|
||||
// horror
|
||||
assert(manifest, id, "start a non-registered plugin");
|
||||
assert(isPluginInstalled(id), id, "start a non-installed plugin");
|
||||
assert(pluginSettings[id]?.enabled, id, "start a disabled plugin");
|
||||
assert(!throwIfDisabled || pluginSettings[id]?.enabled, id, "start a disabled plugin");
|
||||
assert(!pluginInstances.has(id), id, "start an already started plugin");
|
||||
|
||||
await preloadStorageIfExists(`plugins/storage/${id}.json`);
|
||||
|
|
@ -308,7 +302,7 @@ export async function startPlugin(id: string) {
|
|||
|
||||
// Stage two, load the plugin
|
||||
try {
|
||||
const api = createBunnyPluginAPI(id);
|
||||
const api = createBunnyPluginApi(id);
|
||||
pluginInstance = instantiator(api.object, p => {
|
||||
return Object.assign(p, {
|
||||
manifest
|
||||
|
|
@ -331,9 +325,10 @@ export async function startPlugin(id: string) {
|
|||
// Stage three (of external plugins), start the plugin
|
||||
try {
|
||||
pluginInstance.start?.();
|
||||
pluginSettings[id]!.enabled = true;
|
||||
} catch (error) {
|
||||
disableWhenThrown && disablePlugin(id);
|
||||
throw new Error("An error occured while starting the plugin", { cause: error });
|
||||
// TODO: stopPlugin? disablePlugin?
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -351,7 +346,7 @@ export function stopPlugin(id: string) {
|
|||
pluginInstances.delete(id);
|
||||
}
|
||||
|
||||
async function updateAllRepository() {
|
||||
export async function updateAllRepository() {
|
||||
try {
|
||||
await updateRepository(OFFICIAL_PLUGINS_REPO_URL);
|
||||
} catch (error) {
|
||||
|
|
@ -382,7 +377,6 @@ export async function updatePlugins() {
|
|||
};
|
||||
|
||||
registeredPlugins.set(id, instance.manifest);
|
||||
manifestToId.set(instance.manifest, id);
|
||||
corePluginInstances.set(id, instance);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,15 +1,17 @@
|
|||
import { Author } from "@lib/addons/types";
|
||||
import { BunnyManifest } from "@lib/addons/types";
|
||||
import { createStorage } from "@lib/api/storage";
|
||||
import { Logger } from "@lib/utils/logger";
|
||||
|
||||
export interface PluginRepo {
|
||||
[id: string]: {
|
||||
version: string;
|
||||
|
||||
// For plugin developing convenience, plugins with this on will always get fetched
|
||||
alwaysFetch?: boolean;
|
||||
export type PluginRepo = Record<string, {
|
||||
version: string;
|
||||
// For plugin developing convenience, plugins with this on will always get fetched
|
||||
alwaysFetch?: boolean;
|
||||
}> & {
|
||||
$meta: {
|
||||
name: string;
|
||||
description: string;
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export interface PluginRepoStorage {
|
||||
[repoUrl: string]: PluginRepo;
|
||||
|
|
@ -21,12 +23,10 @@ export interface PluginSettingsStorage {
|
|||
};
|
||||
}
|
||||
|
||||
export interface BunnyPluginManifest {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly description: string;
|
||||
readonly version: string;
|
||||
readonly authors: Author[];
|
||||
export interface BunnyPluginManifest extends BunnyManifest {
|
||||
readonly type: "plugin";
|
||||
readonly spec: 3;
|
||||
readonly main: string;
|
||||
}
|
||||
|
||||
export interface BunnyPluginManifestInternal extends BunnyPluginManifest {
|
||||
|
|
|
|||
19
src/lib/addons/themes/colors/index.ts
Normal file
19
src/lib/addons/themes/colors/index.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
|
||||
import patchChatBackground from "./patches/background";
|
||||
import patchDefinitionAndResolver from "./patches/resolver";
|
||||
import patchStorage from "./patches/storage";
|
||||
import { ColorManifest } from "./types";
|
||||
import { updateBunnyColor } from "./updater";
|
||||
|
||||
/** @internal */
|
||||
export default function initColors(manifest: ColorManifest | null) {
|
||||
const patches = [
|
||||
patchStorage(),
|
||||
patchDefinitionAndResolver(),
|
||||
patchChatBackground()
|
||||
];
|
||||
|
||||
if (manifest) updateBunnyColor(manifest, { update: false });
|
||||
|
||||
return () => patches.forEach(p => p());
|
||||
}
|
||||
138
src/lib/addons/themes/colors/parser.ts
Normal file
138
src/lib/addons/themes/colors/parser.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import { findByProps } from "@metro";
|
||||
import chroma from "chroma-js";
|
||||
import { omit } from "es-toolkit";
|
||||
import { Platform, processColor } from "react-native";
|
||||
|
||||
import { colorsPref } from "./preferences";
|
||||
import { ColorManifest, InternalColorDefinition } from "./types";
|
||||
|
||||
const tokenRef = findByProps("SemanticColor");
|
||||
|
||||
export function parseColorManifest(manifest: ColorManifest): InternalColorDefinition {
|
||||
const resolveType = (type = "dark") => (colorsPref.type ?? type) === "dark" ? "darker" : "light";
|
||||
|
||||
if (manifest.spec === 3) {
|
||||
const semanticColorDefinitions: InternalColorDefinition["semantic"] = {};
|
||||
|
||||
for (const [semanticColorKey, semanticColorValue] of Object.entries(manifest.main.semantic ?? {})) {
|
||||
if (typeof semanticColorValue === "object") {
|
||||
const { type, value, opacity: semanticColorOpacity } = semanticColorValue;
|
||||
|
||||
if (type === "raw") {
|
||||
semanticColorDefinitions[semanticColorKey] = {
|
||||
value,
|
||||
opacity: semanticColorOpacity ?? 1,
|
||||
};
|
||||
} else {
|
||||
const rawColorValue = tokenRef.RawColor[value];
|
||||
semanticColorDefinitions[semanticColorKey] = {
|
||||
value: rawColorValue,
|
||||
opacity: semanticColorOpacity ?? 1,
|
||||
};
|
||||
}
|
||||
} else if (typeof semanticColorValue === "string") {
|
||||
if (semanticColorValue.startsWith("#")) {
|
||||
semanticColorDefinitions[semanticColorKey] = {
|
||||
value: chroma.hex(semanticColorValue).hex(),
|
||||
opacity: 1,
|
||||
};
|
||||
} else {
|
||||
semanticColorDefinitions[semanticColorKey] = {
|
||||
value: tokenRef.RawColor[semanticColorValue],
|
||||
opacity: 1,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Invalid semantic definitions: ${semanticColorValue}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (Platform.OS === "android") applyAndroidAlphaKeys(manifest.main.raw);
|
||||
|
||||
return {
|
||||
spec: 3,
|
||||
reference: resolveType(manifest.type),
|
||||
semantic: semanticColorDefinitions,
|
||||
raw: manifest.main.raw ?? {},
|
||||
background: manifest.main.background,
|
||||
};
|
||||
} else if (manifest.spec === 2) { // is Vendetta theme
|
||||
const semanticDefinitions: InternalColorDefinition["semantic"] = {};
|
||||
const background: InternalColorDefinition["background"] | undefined = manifest.background ? {
|
||||
...omit(manifest.background, ["alpha"]),
|
||||
opacity: manifest.background.alpha
|
||||
} : undefined;
|
||||
|
||||
if (manifest.semanticColors) {
|
||||
for (const key in manifest.semanticColors) {
|
||||
const values = manifest.semanticColors[key].map(c => c || undefined).slice(0, 2);
|
||||
if (!values[0]) continue;
|
||||
|
||||
semanticDefinitions[key] = {
|
||||
value: normalizeToHex(values[resolveType() === "light" ? 1 : 0])!,
|
||||
opacity: 1
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (manifest.rawColors) {
|
||||
for (const key in manifest.rawColors) {
|
||||
const value = manifest.rawColors[key];
|
||||
if (!value) continue;
|
||||
manifest.rawColors[key] = normalizeToHex(value)!;
|
||||
}
|
||||
|
||||
if (Platform.OS === "android") applyAndroidAlphaKeys(manifest.rawColors);
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
spec: 2,
|
||||
reference: resolveType(),
|
||||
semantic: semanticDefinitions,
|
||||
raw: manifest.rawColors ?? {},
|
||||
background
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error("Invalid theme spec");
|
||||
}
|
||||
|
||||
export function applyAndroidAlphaKeys(rawColors?: Record<string, string>) {
|
||||
if (!rawColors) return;
|
||||
|
||||
// these are native Discord Android keys
|
||||
const alphaMap: Record<string, [string, number]> = {
|
||||
"BLACK_ALPHA_60": ["BLACK", 0.6],
|
||||
"BRAND_NEW_360_ALPHA_20": ["BRAND_360", 0.2],
|
||||
"BRAND_NEW_360_ALPHA_25": ["BRAND_360", 0.25],
|
||||
"BRAND_NEW_500_ALPHA_20": ["BRAND_500", 0.2],
|
||||
"PRIMARY_DARK_500_ALPHA_20": ["PRIMARY_500", 0.2],
|
||||
"PRIMARY_DARK_700_ALPHA_60": ["PRIMARY_700", 0.6],
|
||||
"STATUS_GREEN_500_ALPHA_20": ["GREEN_500", 0.2],
|
||||
"STATUS_RED_500_ALPHA_20": ["RED_500", 0.2],
|
||||
};
|
||||
|
||||
for (const key in alphaMap) {
|
||||
const [colorKey, alpha] = alphaMap[key];
|
||||
if (!rawColors[colorKey]) continue;
|
||||
rawColors[key] = chroma(rawColors[colorKey]).alpha(alpha).hex();
|
||||
}
|
||||
|
||||
return rawColors;
|
||||
}
|
||||
|
||||
export function normalizeToHex(colorString: string | undefined): string | undefined {
|
||||
if (colorString === undefined) return undefined;
|
||||
if (chroma.valid(colorString)) return chroma(colorString).hex();
|
||||
|
||||
const color = Number(processColor(colorString));
|
||||
|
||||
return chroma.rgb(
|
||||
color >> 16 & 0xff, // red
|
||||
color >> 8 & 0xff, // green
|
||||
color & 0xff, // blue
|
||||
color >> 24 & 0xff // alpha
|
||||
).hex();
|
||||
}
|
||||
|
||||
51
src/lib/addons/themes/colors/patches/background.tsx
Normal file
51
src/lib/addons/themes/colors/patches/background.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { colorsPref } from "@lib/addons/themes/colors/preferences";
|
||||
import { _colorRef } from "@lib/addons/themes/colors/updater";
|
||||
import { after } from "@lib/api/patcher";
|
||||
import { useObservable } from "@lib/api/storage";
|
||||
import { findInReactTree } from "@lib/utils";
|
||||
import { lazyDestructure } from "@lib/utils/lazy";
|
||||
import { findByNameLazy, findByProps } from "@metro";
|
||||
import chroma from "chroma-js";
|
||||
import { ImageBackground } from "react-native";
|
||||
|
||||
const MessagesWrapperConnected = findByNameLazy("MessagesWrapperConnected", false);
|
||||
const { MessagesWrapper } = lazyDestructure(() => findByProps("MessagesWrapper"));
|
||||
|
||||
export default function patchChatBackground() {
|
||||
const patches = [
|
||||
after("default", MessagesWrapperConnected, (_, ret) => {
|
||||
useObservable([colorsPref]);
|
||||
if (!_colorRef.current || !_colorRef.current.background?.url || colorsPref.customBackground === "hidden") return ret;
|
||||
|
||||
return <ImageBackground
|
||||
style={{ flex: 1, height: "100%" }}
|
||||
source={_colorRef.current.background?.url && { uri: _colorRef.current.background.url } || 0}
|
||||
blurRadius={typeof _colorRef.current.background?.blur === "number" ? _colorRef.current.background?.blur : 0}
|
||||
>
|
||||
{ret}
|
||||
</ImageBackground>;
|
||||
}),
|
||||
after("render", MessagesWrapper.prototype, (_, ret) => {
|
||||
if (!_colorRef.current || !_colorRef.current.background?.url) return;
|
||||
const messagesComponent = findInReactTree(
|
||||
ret,
|
||||
x => x && "HACK_fixModalInteraction" in x.props && x?.props?.style
|
||||
);
|
||||
|
||||
if (messagesComponent) {
|
||||
const backgroundColor = chroma(
|
||||
messagesComponent.props.style.backgroundColor || "black"
|
||||
).alpha(
|
||||
1 - (_colorRef.current.background?.opacity ?? 1)
|
||||
);
|
||||
|
||||
messagesComponent.props.style = [
|
||||
messagesComponent.props.style,
|
||||
{ backgroundColor }
|
||||
];
|
||||
}
|
||||
})
|
||||
];
|
||||
|
||||
return () => patches.forEach(x => x());
|
||||
}
|
||||
89
src/lib/addons/themes/colors/patches/resolver.ts
Normal file
89
src/lib/addons/themes/colors/patches/resolver.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { _colorRef } from "@lib/addons/themes/colors/updater";
|
||||
import { NativeThemeModule } from "@lib/api/native/modules";
|
||||
import { before, instead } from "@lib/api/patcher";
|
||||
import { findByProps } from "@metro";
|
||||
import { byMutableProp } from "@metro/filters";
|
||||
import { createLazyModule } from "@metro/lazy";
|
||||
import chroma from "chroma-js";
|
||||
|
||||
const tokenReference = findByProps("SemanticColor");
|
||||
const isThemeModule = createLazyModule(byMutableProp("isThemeDark"));
|
||||
|
||||
const SEMANTIC_FALLBACK_MAP: Record<string, string> = {
|
||||
"BG_BACKDROP": "BACKGROUND_FLOATING",
|
||||
"BG_BASE_PRIMARY": "BACKGROUND_PRIMARY",
|
||||
"BG_BASE_SECONDARY": "BACKGROUND_SECONDARY",
|
||||
"BG_BASE_TERTIARY": "BACKGROUND_SECONDARY_ALT",
|
||||
"BG_MOD_FAINT": "BACKGROUND_MODIFIER_ACCENT",
|
||||
"BG_MOD_STRONG": "BACKGROUND_MODIFIER_ACCENT",
|
||||
"BG_MOD_SUBTLE": "BACKGROUND_MODIFIER_ACCENT",
|
||||
"BG_SURFACE_OVERLAY": "BACKGROUND_FLOATING",
|
||||
"BG_SURFACE_OVERLAY_TMP": "BACKGROUND_FLOATING",
|
||||
"BG_SURFACE_RAISED": "BACKGROUND_MOBILE_PRIMARY"
|
||||
};
|
||||
|
||||
export default function patchDefinitionAndResolver() {
|
||||
const callback = ([theme]: any[]) => theme === _colorRef.key ? [_colorRef.current!.reference] : void 0;
|
||||
|
||||
Object.keys(tokenReference.RawColor).forEach(key => {
|
||||
Object.defineProperty(tokenReference.RawColor, key, {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get: () => {
|
||||
const ret = _colorRef.current?.raw[key];
|
||||
return ret || _colorRef.origRaw[key];
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const unpatches = [
|
||||
before("isThemeDark", isThemeModule, callback),
|
||||
before("isThemeLight", isThemeModule, callback),
|
||||
before("updateTheme", NativeThemeModule, callback),
|
||||
instead("resolveSemanticColor", tokenReference.default.meta ?? tokenReference.default.internal, (args: any[], orig: any) => {
|
||||
if (!_colorRef.current) return orig(...args);
|
||||
if (args[0] !== _colorRef.key) return orig(...args);
|
||||
|
||||
args[0] = _colorRef.current.reference;
|
||||
|
||||
const [name, colorDef] = extractInfo(_colorRef.current!.reference, args[1]);
|
||||
|
||||
let semanticDef = _colorRef.current.semantic[name];
|
||||
if (!semanticDef && _colorRef.current.spec === 2 && name in SEMANTIC_FALLBACK_MAP) {
|
||||
semanticDef = _colorRef.current.semantic[SEMANTIC_FALLBACK_MAP[name]];
|
||||
}
|
||||
|
||||
if (semanticDef?.value) {
|
||||
if (semanticDef.opacity === 1) return semanticDef.value;
|
||||
return chroma(semanticDef.value).alpha(semanticDef.opacity).hex();
|
||||
}
|
||||
|
||||
const rawValue = _colorRef.current.raw[colorDef.raw];
|
||||
if (rawValue) {
|
||||
// Set opacity if needed
|
||||
return colorDef.opacity === 1 ? rawValue : chroma(rawValue).alpha(colorDef.opacity).hex();
|
||||
}
|
||||
|
||||
// Fallback to default
|
||||
return orig(...args);
|
||||
}),
|
||||
() => {
|
||||
// Not the actual module but.. yeah.
|
||||
Object.defineProperty(tokenReference, "RawColor", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: _colorRef.origRaw
|
||||
});
|
||||
}
|
||||
];
|
||||
|
||||
return () => unpatches.forEach(p => p());
|
||||
}
|
||||
|
||||
function extractInfo(themeName: string, colorObj: any): [name: string, colorDef: any] {
|
||||
// @ts-ignore - assigning to extractInfo._sym
|
||||
const propName = colorObj[extractInfo._sym ??= Object.getOwnPropertySymbols(colorObj)[0]];
|
||||
const colorDef = tokenReference.SemanticColor[propName];
|
||||
|
||||
return [propName, colorDef[themeName]];
|
||||
}
|
||||
38
src/lib/addons/themes/colors/patches/storage.ts
Normal file
38
src/lib/addons/themes/colors/patches/storage.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { _colorRef } from "@lib/addons/themes/colors/updater";
|
||||
import { after, before } from "@lib/api/patcher";
|
||||
import { findInTree } from "@lib/utils";
|
||||
import { proxyLazy } from "@lib/utils/lazy";
|
||||
import { findByProps } from "@metro";
|
||||
|
||||
const mmkvStorage = proxyLazy(() => {
|
||||
const newModule = findByProps("impl");
|
||||
if (typeof newModule?.impl === "object") return newModule.impl;
|
||||
return findByProps("storage");
|
||||
});
|
||||
|
||||
export default function patchStorage() {
|
||||
const patchedKeys = new Set(["ThemeStore", "SelectivelySyncedUserSettingsStore"]);
|
||||
|
||||
const patches = [
|
||||
after("get", mmkvStorage, ([key], ret) => {
|
||||
if (!_colorRef.current || !patchedKeys.has(key)) return;
|
||||
|
||||
const state = findInTree(ret._state, s => typeof s.theme === "string");
|
||||
if (state) state.theme = _colorRef.key;
|
||||
}),
|
||||
before("set", mmkvStorage, ([key, value]) => {
|
||||
if (!patchedKeys.has(key)) return;
|
||||
|
||||
const json = JSON.stringify(value);
|
||||
const lastSetDiscordTheme = _colorRef.lastSetDiscordTheme ?? "darker";
|
||||
const replaced = json.replace(
|
||||
/"theme":"bn-theme-\d+"/,
|
||||
`"theme":${JSON.stringify(lastSetDiscordTheme)}`
|
||||
);
|
||||
|
||||
return [key, JSON.parse(replaced)];
|
||||
})
|
||||
];
|
||||
|
||||
return () => patches.forEach(p => p());
|
||||
}
|
||||
18
src/lib/addons/themes/colors/preferences.ts
Normal file
18
src/lib/addons/themes/colors/preferences.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { createStorage } from "@lib/api/storage";
|
||||
|
||||
interface BunnyColorPreferencesStorage {
|
||||
selected: string | null;
|
||||
type?: "dark" | "light" | null;
|
||||
customBackground: "hidden" | null;
|
||||
per?: Record<string, { autoUpdate?: string; } | undefined>;
|
||||
}
|
||||
|
||||
export const colorsPref = createStorage<BunnyColorPreferencesStorage>(
|
||||
"themes/colors/preferences.json",
|
||||
{
|
||||
dflt: {
|
||||
selected: null,
|
||||
customBackground: null
|
||||
}
|
||||
}
|
||||
);
|
||||
52
src/lib/addons/themes/colors/types.ts
Normal file
52
src/lib/addons/themes/colors/types.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { Author, BunnyManifest } from "@lib/addons/types";
|
||||
|
||||
interface SemanticReference {
|
||||
type: "color" | "raw";
|
||||
value: string;
|
||||
opacity?: number;
|
||||
}
|
||||
|
||||
interface BackgroundDefinition {
|
||||
url: string;
|
||||
blur?: number;
|
||||
opacity?: number;
|
||||
}
|
||||
|
||||
export interface BunnyColorManifest extends BunnyManifest {
|
||||
type: "color";
|
||||
spec: 3;
|
||||
main: {
|
||||
type: "dark" | "light";
|
||||
semantic?: Record<string, string | SemanticReference>;
|
||||
raw?: Record<string, string>;
|
||||
background?: BackgroundDefinition;
|
||||
}
|
||||
}
|
||||
|
||||
export interface VendettaThemeManifest {
|
||||
spec: 2;
|
||||
name: string;
|
||||
description?: string;
|
||||
authors?: Author[];
|
||||
semanticColors?: Record<string, (string | false)[]>;
|
||||
rawColors?: Record<string, string>;
|
||||
background?: {
|
||||
url: string;
|
||||
blur?: number;
|
||||
alpha?: number;
|
||||
};
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export interface InternalColorDefinition {
|
||||
spec: 2 | 3;
|
||||
reference: "darker" | "light";
|
||||
semantic: Record<string, {
|
||||
value: string;
|
||||
opacity: number;
|
||||
}>;
|
||||
raw: Record<string, string>;
|
||||
background?: BackgroundDefinition;
|
||||
}
|
||||
|
||||
export type ColorManifest = BunnyColorManifest | VendettaThemeManifest;
|
||||
59
src/lib/addons/themes/colors/updater.ts
Normal file
59
src/lib/addons/themes/colors/updater.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { settings } from "@lib/api/settings";
|
||||
import { findByProps, findByPropsLazy, findByStoreNameLazy } from "@metro";
|
||||
|
||||
import { parseColorManifest } from "./parser";
|
||||
import { ColorManifest, InternalColorDefinition } from "./types";
|
||||
|
||||
const tokenRef = findByProps("SemanticColor");
|
||||
const origRawColor = { ...tokenRef.RawColor };
|
||||
const AppearanceManager = findByPropsLazy("updateTheme");
|
||||
const ThemeStore = findByStoreNameLazy("ThemeStore");
|
||||
const FormDivider = findByPropsLazy("DIVIDER_COLORS");
|
||||
|
||||
let _inc = 1;
|
||||
|
||||
interface InternalColorRef {
|
||||
key: `bn-theme-${string}`;
|
||||
current: InternalColorDefinition | null;
|
||||
readonly origRaw: Record<string, string>;
|
||||
lastSetDiscordTheme: string;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export const _colorRef: InternalColorRef = {
|
||||
current: null,
|
||||
key: `bn-theme-${_inc}`,
|
||||
origRaw: origRawColor,
|
||||
lastSetDiscordTheme: "darker"
|
||||
};
|
||||
|
||||
export function updateBunnyColor(colorManifest: ColorManifest | null, { update = true }) {
|
||||
if (settings.safeMode?.enabled) return;
|
||||
|
||||
const internalDef = colorManifest ? parseColorManifest(colorManifest) : null;
|
||||
const ref = Object.assign(_colorRef, {
|
||||
current: internalDef,
|
||||
key: `bn-theme-${++_inc}`,
|
||||
lastSetDiscordTheme: !ThemeStore.theme.startsWith("bn-theme-")
|
||||
? ThemeStore.theme
|
||||
: _colorRef.lastSetDiscordTheme
|
||||
});
|
||||
|
||||
if (internalDef != null) {
|
||||
tokenRef.Theme[ref.key.toUpperCase()] = ref.key;
|
||||
FormDivider.DIVIDER_COLORS[ref.key] = FormDivider.DIVIDER_COLORS[ref.current!.reference];
|
||||
|
||||
Object.keys(tokenRef.Shadow).forEach(k => tokenRef.Shadow[k][ref.key] = tokenRef.Shadow[k][ref.current!.reference]);
|
||||
Object.keys(tokenRef.SemanticColor).forEach(k => {
|
||||
tokenRef.SemanticColor[k][ref.key] = {
|
||||
...tokenRef.SemanticColor[k][ref.current!.reference]
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
if (update) {
|
||||
AppearanceManager.setShouldSyncAppearanceSettings(false);
|
||||
AppearanceManager.updateTheme(internalDef != null ? ref.key : ref.lastSetDiscordTheme);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1,77 +1,28 @@
|
|||
/**
|
||||
* Theming system in Bunny is currently a prototype, expect an unreadable theme implementation below
|
||||
*/
|
||||
|
||||
import { awaitStorage, createFileBackend, createMMKVBackend, createStorage, wrapSync } from "@core/vendetta/storage";
|
||||
import { Author } from "@lib/addons/types";
|
||||
import { getStoredTheme, getThemeFilePath } from "@lib/api/native/loader";
|
||||
import { ThemeManager } from "@lib/api/native/modules";
|
||||
import { after, before, instead } from "@lib/api/patcher";
|
||||
import { findInReactTree, safeFetch } from "@lib/utils";
|
||||
import { lazyDestructure, proxyLazy } from "@lib/utils/lazy";
|
||||
import { byMutableProp } from "@metro/filters";
|
||||
import { createLazyModule } from "@metro/lazy";
|
||||
import { findByNameLazy, findByProps, findByPropsLazy, findByStoreNameLazy } from "@metro/wrappers";
|
||||
import chroma from "chroma-js";
|
||||
import { ImageBackground, Platform, processColor } from "react-native";
|
||||
import { writeFile } from "@lib/api/native/fs";
|
||||
import { getStoredTheme, getThemeFilePath, isPyonLoader, isThemeSupported } from "@lib/api/native/loader";
|
||||
import { awaitStorage as newAwaitStorage } from "@lib/api/storage";
|
||||
import { safeFetch } from "@lib/utils";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
export interface ThemeData {
|
||||
name: string;
|
||||
description?: string;
|
||||
authors?: Author[];
|
||||
spec: number;
|
||||
semanticColors?: Record<string, (string | false)[]>;
|
||||
rawColors?: Record<string, string>;
|
||||
background?: {
|
||||
url: string;
|
||||
blur?: number;
|
||||
/**
|
||||
* The alpha value of the background.
|
||||
* `CHAT_BACKGROUND` of semanticColors alpha value will be ignored when this is specified
|
||||
*/
|
||||
alpha?: number;
|
||||
};
|
||||
}
|
||||
import initColors from "./colors";
|
||||
import { applyAndroidAlphaKeys, normalizeToHex } from "./colors/parser";
|
||||
import { colorsPref } from "./colors/preferences";
|
||||
import { VendettaThemeManifest } from "./colors/types";
|
||||
import { updateBunnyColor } from "./colors/updater";
|
||||
|
||||
export interface Theme {
|
||||
export interface VdThemeInfo {
|
||||
id: string;
|
||||
selected: boolean;
|
||||
data: ThemeData;
|
||||
data: VendettaThemeManifest;
|
||||
}
|
||||
|
||||
//! As of 173.10, early-finding this does not work.
|
||||
// Somehow, this is late enough, though?
|
||||
export const color = findByPropsLazy("SemanticColor");
|
||||
export const themes = wrapSync(createStorage<Record<string, VdThemeInfo>>(createMMKVBackend("VENDETTA_THEMES")));
|
||||
|
||||
const mmkvStorage = proxyLazy(() => {
|
||||
const newModule = findByProps("impl");
|
||||
if (typeof newModule?.impl === "object") return newModule.impl;
|
||||
return findByProps("storage");
|
||||
});
|
||||
|
||||
const appearanceManager = findByPropsLazy("updateTheme");
|
||||
const ThemeStore = findByStoreNameLazy("ThemeStore");
|
||||
const formDividerModule = findByPropsLazy("DIVIDER_COLORS");
|
||||
const MessagesWrapperConnected = findByNameLazy("MessagesWrapperConnected", false);
|
||||
const { MessagesWrapper } = lazyDestructure(() => findByProps("MessagesWrapper"));
|
||||
const isThemeModule = createLazyModule(byMutableProp("isThemeDark"));
|
||||
|
||||
export const themes = wrapSync(createStorage<Record<string, Theme>>(createMMKVBackend("VENDETTA_THEMES")));
|
||||
|
||||
const semanticAlternativeMap: Record<string, string> = {
|
||||
"BG_BACKDROP": "BACKGROUND_FLOATING",
|
||||
"BG_BASE_PRIMARY": "BACKGROUND_PRIMARY",
|
||||
"BG_BASE_SECONDARY": "BACKGROUND_SECONDARY",
|
||||
"BG_BASE_TERTIARY": "BACKGROUND_SECONDARY_ALT",
|
||||
"BG_MOD_FAINT": "BACKGROUND_MODIFIER_ACCENT",
|
||||
"BG_MOD_STRONG": "BACKGROUND_MODIFIER_ACCENT",
|
||||
"BG_MOD_SUBTLE": "BACKGROUND_MODIFIER_ACCENT",
|
||||
"BG_SURFACE_OVERLAY": "BACKGROUND_FLOATING",
|
||||
"BG_SURFACE_OVERLAY_TMP": "BACKGROUND_FLOATING",
|
||||
"BG_SURFACE_RAISED": "BACKGROUND_MOBILE_PRIMARY"
|
||||
};
|
||||
|
||||
async function writeTheme(theme: Theme | {}) {
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export async function writeThemeToNative(theme: VdThemeInfo | {}) {
|
||||
if (typeof theme !== "object") throw new Error("Theme must be an object");
|
||||
|
||||
// Save the current theme as current-theme.json. When supported by loader,
|
||||
|
|
@ -79,59 +30,14 @@ async function writeTheme(theme: Theme | {}) {
|
|||
await createFileBackend(getThemeFilePath() || "theme.json").set(theme);
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export function patchChatBackground() {
|
||||
const patches = [
|
||||
after("default", MessagesWrapperConnected, (_, ret) => enabled ? React.createElement(ImageBackground, {
|
||||
style: { flex: 1, height: "100%" },
|
||||
source: currentTheme?.data?.background?.url && { uri: currentTheme.data.background.url } || 0,
|
||||
blurRadius: typeof currentTheme?.data?.background?.blur === "number" ? currentTheme?.data?.background?.blur : 0,
|
||||
children: ret,
|
||||
}) : ret),
|
||||
after("render", MessagesWrapper.prototype, (_, ret) => {
|
||||
if (!enabled || !currentTheme?.data?.background?.url) return;
|
||||
|
||||
// HORRIBLE
|
||||
const Messages = findInReactTree(ret, x => x && "HACK_fixModalInteraction" in x.props && x?.props?.style);
|
||||
if (Messages) {
|
||||
Messages.props.style = [
|
||||
Messages.props.style,
|
||||
{
|
||||
backgroundColor: chroma(Messages.props.style.backgroundColor || "black")
|
||||
.alpha(1 - (currentTheme?.data.background?.alpha ?? 1)).hex()
|
||||
},
|
||||
];
|
||||
}
|
||||
else console.error("Didn't find Messages when patching MessagesWrapper!");
|
||||
})
|
||||
];
|
||||
|
||||
return () => patches.forEach(x => x());
|
||||
}
|
||||
|
||||
function normalizeToHex(colorString: string): string {
|
||||
if (chroma.valid(colorString)) return chroma(colorString).hex();
|
||||
|
||||
const color = Number(processColor(colorString));
|
||||
|
||||
return chroma.rgb(
|
||||
color >> 16 & 0xff, // red
|
||||
color >> 8 & 0xff, // green
|
||||
color & 0xff, // blue
|
||||
color >> 24 & 0xff // alpha
|
||||
).hex();
|
||||
}
|
||||
|
||||
// Process data for some compatiblity with native side
|
||||
function processData(data: ThemeData) {
|
||||
function processData(data: VendettaThemeManifest) {
|
||||
if (data.semanticColors) {
|
||||
const { semanticColors } = data;
|
||||
|
||||
for (const key in semanticColors) {
|
||||
for (const index in semanticColors[key]) {
|
||||
semanticColors[key][index] &&= normalizeToHex(semanticColors[key][index] as string);
|
||||
semanticColors[key][index] &&= normalizeToHex(semanticColors[key][index] as string) || false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -140,7 +46,8 @@ function processData(data: ThemeData) {
|
|||
const { rawColors } = data;
|
||||
|
||||
for (const key in rawColors) {
|
||||
data.rawColors[key] = normalizeToHex(rawColors[key]);
|
||||
const normalized = normalizeToHex(rawColors[key]);
|
||||
if (normalized) data.rawColors[key] = normalized;
|
||||
}
|
||||
|
||||
if (Platform.OS === "android") applyAndroidAlphaKeys(rawColors);
|
||||
|
|
@ -153,63 +60,55 @@ function processData(data: ThemeData) {
|
|||
return data;
|
||||
}
|
||||
|
||||
function applyAndroidAlphaKeys(rawColors: Record<string, string>) {
|
||||
// these are native Discord Android keys
|
||||
const alphaMap: Record<string, [string, number]> = {
|
||||
"BLACK_ALPHA_60": ["BLACK", 0.6],
|
||||
"BRAND_NEW_360_ALPHA_20": ["BRAND_360", 0.2],
|
||||
"BRAND_NEW_360_ALPHA_25": ["BRAND_360", 0.25],
|
||||
"BRAND_NEW_500_ALPHA_20": ["BRAND_500", 0.2],
|
||||
"PRIMARY_DARK_500_ALPHA_20": ["PRIMARY_500", 0.2],
|
||||
"PRIMARY_DARK_700_ALPHA_60": ["PRIMARY_700", 0.6],
|
||||
"STATUS_GREEN_500_ALPHA_20": ["GREEN_500", 0.2],
|
||||
"STATUS_RED_500_ALPHA_20": ["RED_500", 0.2],
|
||||
};
|
||||
function validateTheme(themeJSON: any): boolean {
|
||||
if (typeof themeJSON !== "object" || themeJSON === null) return false;
|
||||
if (themeJSON.spec !== 2 && themeJSON.spec !== 3) return false;
|
||||
if (themeJSON.spec === 3 && !themeJSON.main) return false;
|
||||
|
||||
for (const key in alphaMap) {
|
||||
const [colorKey, alpha] = alphaMap[key];
|
||||
if (!rawColors[colorKey]) continue;
|
||||
rawColors[key] = chroma(rawColors[colorKey]).alpha(alpha).hex();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function fetchTheme(id: string, selected = false) {
|
||||
export async function fetchTheme(url: string, selected = false) {
|
||||
let themeJSON: any;
|
||||
|
||||
try {
|
||||
themeJSON = await (await safeFetch(id, { cache: "no-store" })).json();
|
||||
themeJSON = await (await safeFetch(url, { cache: "no-store" })).json();
|
||||
} catch {
|
||||
throw new Error(`Failed to fetch theme at ${id}`);
|
||||
throw new Error(`Failed to fetch theme at ${url}`);
|
||||
}
|
||||
|
||||
themes[id] = {
|
||||
id: id,
|
||||
// Validate theme
|
||||
if (!validateTheme(themeJSON)) throw new Error(`Invalid theme at ${url}`);
|
||||
|
||||
themes[url] = {
|
||||
id: url,
|
||||
selected: selected,
|
||||
data: processData(themeJSON),
|
||||
};
|
||||
|
||||
// TODO: Should we prompt when the selected theme is updated?
|
||||
if (selected) {
|
||||
writeTheme(themes[id]);
|
||||
applyTheme(themes[id], vdThemeFallback);
|
||||
writeThemeToNative(themes[url]);
|
||||
updateBunnyColor(themes[url].data, { update: true });
|
||||
}
|
||||
}
|
||||
|
||||
export async function installTheme(id: string) {
|
||||
if (typeof id !== "string" || id in themes) throw new Error("Theme already installed");
|
||||
await fetchTheme(id);
|
||||
export async function installTheme(url: string) {
|
||||
if (typeof url !== "string" || url in themes) throw new Error("Theme already installed");
|
||||
await fetchTheme(url);
|
||||
}
|
||||
|
||||
export function selectTheme(theme: Theme | null, write = true) {
|
||||
export function selectTheme(theme: VdThemeInfo | null, write = true) {
|
||||
if (theme) theme.selected = true;
|
||||
Object.keys(themes).forEach(
|
||||
k => themes[k].selected = themes[k].id === theme?.id
|
||||
);
|
||||
|
||||
if (theme == null && write) {
|
||||
return writeTheme({});
|
||||
updateBunnyColor(null, { update: true });
|
||||
return writeThemeToNative({});
|
||||
} else if (theme) {
|
||||
return writeTheme(theme);
|
||||
updateBunnyColor(theme.data, { update: true });
|
||||
return writeThemeToNative(theme);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -221,13 +120,6 @@ export async function removeTheme(id: string) {
|
|||
return theme.selected;
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export function getThemeFromLoader(): Theme | null {
|
||||
return getStoredTheme();
|
||||
}
|
||||
|
||||
export async function updateThemes() {
|
||||
await awaitStorage(themes);
|
||||
const currentTheme = getThemeFromLoader();
|
||||
|
|
@ -235,176 +127,34 @@ export async function updateThemes() {
|
|||
}
|
||||
|
||||
export function getCurrentTheme() {
|
||||
return currentTheme;
|
||||
}
|
||||
|
||||
const origRawColor = { ...color.RawColor };
|
||||
|
||||
let inc = 0;
|
||||
let vdKey = "vd-theme";
|
||||
|
||||
let vdThemeFallback = "darker";
|
||||
let enabled = false;
|
||||
let currentTheme: Theme | null;
|
||||
let storageResolved = false;
|
||||
|
||||
const discordThemes = new Set(["darker", "midnight", "dark", "light"]);
|
||||
function isDiscordTheme(name: string) {
|
||||
return discordThemes.has(name);
|
||||
}
|
||||
|
||||
function patchColor() {
|
||||
const callback = ([theme]: any[]) => theme === vdKey ? [vdThemeFallback] : void 0;
|
||||
|
||||
Object.keys(color.RawColor).forEach(k => {
|
||||
Object.defineProperty(color.RawColor, k, {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get: () => {
|
||||
return enabled ? currentTheme?.data?.rawColors?.[k] ?? origRawColor[k] : origRawColor[k];
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
before("isThemeDark", isThemeModule, callback);
|
||||
before("isThemeLight", isThemeModule, callback);
|
||||
before("updateTheme", ThemeManager, callback);
|
||||
|
||||
after("get", mmkvStorage, ([a], ret) => {
|
||||
if (a === "SelectivelySyncedUserSettingsStore") {
|
||||
storageResolved = true;
|
||||
if (ret?._state?.appearance?.settings?.theme && enabled) {
|
||||
vdThemeFallback = ret._state.appearance.settings.theme;
|
||||
ret._state.appearance.settings.theme = vdKey;
|
||||
}
|
||||
} else if (a === "ThemeStore") {
|
||||
storageResolved = true;
|
||||
if (ret?._state?.theme && enabled) {
|
||||
vdThemeFallback = ret._state.theme;
|
||||
ret._state.theme = vdKey;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Prevent setting to real Discord settings
|
||||
before("set", mmkvStorage, args => {
|
||||
if (!args[1]) return;
|
||||
|
||||
const key = args[0];
|
||||
const value = JSON.parse(JSON.stringify(args[1]));
|
||||
|
||||
const interceptors: Record<string, () => void> = ({
|
||||
SelectivelySyncedUserSettingsStore: () => {
|
||||
if (value._state?.appearance?.settings?.theme) {
|
||||
const { theme } = value._state?.appearance?.settings ?? {};
|
||||
if (isDiscordTheme(theme)) {
|
||||
vdThemeFallback = theme;
|
||||
} else {
|
||||
value._state.appearance.settings.theme = vdThemeFallback;
|
||||
}
|
||||
}
|
||||
},
|
||||
ThemeStore: () => {
|
||||
if (value._state?.theme) {
|
||||
const { theme } = value._state;
|
||||
if (isDiscordTheme(theme)) {
|
||||
vdThemeFallback = theme;
|
||||
} else {
|
||||
value._state.theme = vdThemeFallback;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!(key in interceptors)) return args;
|
||||
|
||||
interceptors[key]();
|
||||
return [key, value];
|
||||
});
|
||||
|
||||
instead("resolveSemanticColor", color.default.meta ?? color.default.internal, (args, orig) => {
|
||||
if (!enabled || !currentTheme) return orig(...args);
|
||||
if (args[0] !== vdKey) return orig(...args);
|
||||
|
||||
args[0] = vdThemeFallback;
|
||||
|
||||
const [name, colorDef] = extractInfo(vdThemeFallback, args[1]);
|
||||
|
||||
const themeIndex = vdThemeFallback === "midnight" ? 2 : vdThemeFallback === "light" ? 1 : 0;
|
||||
|
||||
//! As of 192.7, Tabs v2 uses BG_ semantic colors instead of BACKGROUND_ ones
|
||||
const alternativeName = semanticAlternativeMap[name] ?? name;
|
||||
|
||||
const semanticColorVal = (currentTheme.data?.semanticColors?.[name] ?? currentTheme.data?.semanticColors?.[alternativeName])?.[themeIndex];
|
||||
|
||||
if (semanticColorVal) return semanticColorVal;
|
||||
|
||||
const rawValue = currentTheme.data?.rawColors?.[colorDef.raw];
|
||||
if (rawValue) {
|
||||
// Set opacity if needed
|
||||
return colorDef.opacity === 1 ? rawValue : chroma(rawValue).alpha(colorDef.opacity).hex();
|
||||
}
|
||||
|
||||
// Fallback to default
|
||||
return orig(...args);
|
||||
});
|
||||
}
|
||||
|
||||
function getDefaultFallbackTheme(fallback: string = vdThemeFallback) {
|
||||
const theme = ThemeStore.theme.toLowerCase() as string;
|
||||
|
||||
if (isDiscordTheme(theme)) {
|
||||
return theme;
|
||||
} else {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export function applyTheme(appliedTheme: Theme | null, fallbackTheme?: string) {
|
||||
if (!fallbackTheme) fallbackTheme = getDefaultFallbackTheme();
|
||||
|
||||
currentTheme = appliedTheme;
|
||||
enabled = !!currentTheme;
|
||||
vdThemeFallback = fallbackTheme!!;
|
||||
vdKey = `vd-theme-${inc++}-${fallbackTheme}`;
|
||||
|
||||
if (appliedTheme) {
|
||||
color.Theme[vdKey.toUpperCase()] = vdKey;
|
||||
|
||||
formDividerModule.DIVIDER_COLORS[vdKey] = formDividerModule.DIVIDER_COLORS[vdThemeFallback];
|
||||
|
||||
Object.keys(color.Shadow).forEach(k => color.Shadow[k][vdKey] = color.Shadow[k][vdThemeFallback]);
|
||||
Object.keys(color.SemanticColor).forEach(k => {
|
||||
color.SemanticColor[k][vdKey] = {
|
||||
...color.SemanticColor[k][vdThemeFallback],
|
||||
override: appliedTheme?.data?.semanticColors?.[k]?.[0]
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
if (storageResolved) {
|
||||
appearanceManager.setShouldSyncAppearanceSettings(false);
|
||||
appearanceManager.updateTheme(appliedTheme ? vdKey : fallbackTheme);
|
||||
}
|
||||
return Object.values(themes).find(t => t.selected) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export function initThemes() {
|
||||
const currentTheme = getThemeFromLoader();
|
||||
enabled = Boolean(currentTheme);
|
||||
|
||||
patchColor();
|
||||
applyTheme(currentTheme, vdThemeFallback);
|
||||
|
||||
updateThemes().catch(e => console.error("Failed to update themes", e));
|
||||
export function getThemeFromLoader(): VdThemeInfo | null {
|
||||
return getStoredTheme();
|
||||
}
|
||||
|
||||
function extractInfo(themeName: string, colorObj: any): [name: string, colorDef: any] {
|
||||
// @ts-ignore - assigning to extractInfo._sym
|
||||
const propName = colorObj[extractInfo._sym ??= Object.getOwnPropertySymbols(colorObj)[0]];
|
||||
const colorDef = color.SemanticColor[propName];
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export async function initThemes() {
|
||||
if (!isThemeSupported()) return;
|
||||
|
||||
return [propName, colorDef[themeName]];
|
||||
try {
|
||||
if (isPyonLoader()) {
|
||||
writeFile("../vendetta_theme.json", "null");
|
||||
}
|
||||
|
||||
await newAwaitStorage(colorsPref);
|
||||
|
||||
const currentTheme = getThemeFromLoader();
|
||||
initColors(currentTheme?.data ?? null);
|
||||
|
||||
updateThemes().catch(e => console.error("Failed to update themes", e));
|
||||
} catch (e) {
|
||||
console.error("Failed to initialize themes", e);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,17 @@
|
|||
export interface Author {
|
||||
name: string;
|
||||
id: string;
|
||||
export type Author = { name: string, id?: `${bigint}`; };
|
||||
|
||||
export interface BunnyManifest {
|
||||
readonly id: string;
|
||||
readonly spec: number;
|
||||
readonly version: string;
|
||||
readonly type: string;
|
||||
readonly display: {
|
||||
readonly name: string;
|
||||
readonly description?: string;
|
||||
readonly authors?: Author[];
|
||||
};
|
||||
readonly main: unknown;
|
||||
readonly extras?: {
|
||||
readonly [key: string]: any;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,94 +0,0 @@
|
|||
import { after } from "@lib/api/patcher";
|
||||
import { getMetroCache, indexAssetName } from "@metro/internals/caches";
|
||||
import { getImportingModuleId, requireModule } from "@metro/internals/modules";
|
||||
|
||||
// TODO: Deprecate this map, make another that maps to an array of assets (Asset[]) instead
|
||||
/**
|
||||
* @internal
|
||||
* Pitfall: Multiple assets may have the same name, this is fine if we require the asset only for display,\
|
||||
* but not when used to get the registered id/index. In some condition, this would break some plugins like HideGiftButton that gets id by name for patching.\
|
||||
*/
|
||||
export const assetsMap: Record<string, Asset> = new Proxy<any>({}, {
|
||||
get(cache, p) {
|
||||
if (typeof p !== "string") return undefined;
|
||||
if (cache[p]) return cache[p];
|
||||
|
||||
const moduleIds = getMetroCache().assetsIndex[p];
|
||||
if (moduleIds == null) return undefined;
|
||||
|
||||
for (const id in moduleIds) {
|
||||
const assetIndex = requireModule(Number(id));
|
||||
if (typeof assetIndex !== "number") continue;
|
||||
|
||||
const assetDefinition = assetsModule.getAssetByID(assetIndex);
|
||||
if (!assetDefinition) continue;
|
||||
|
||||
assetDefinition.index ??= assetDefinition.id ??= assetIndex;
|
||||
assetDefinition.moduleId ??= id;
|
||||
|
||||
// ??= is intended, we only assign to the first asset registered
|
||||
cache[p] ??= assetDefinition;
|
||||
}
|
||||
|
||||
return cache[p];
|
||||
},
|
||||
ownKeys(cache) {
|
||||
const keys = [] as Array<string>;
|
||||
for (const key in getMetroCache().assetsIndex) {
|
||||
cache[key] = this.get!(cache, key, {});
|
||||
if (cache[key]) keys.push(key);
|
||||
}
|
||||
return keys;
|
||||
},
|
||||
});
|
||||
|
||||
export interface Asset {
|
||||
/** @deprecated */
|
||||
id: number;
|
||||
index: number;
|
||||
name: string;
|
||||
moduleId: number;
|
||||
}
|
||||
|
||||
interface AssetModule {
|
||||
registerAsset(assetDefinition: any): number;
|
||||
getAssetByID(id: number): any;
|
||||
}
|
||||
|
||||
let assetsModule: AssetModule;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export function patchAssets(module: AssetModule) {
|
||||
if (assetsModule) return;
|
||||
assetsModule = module;
|
||||
|
||||
const unpatch = after("registerAsset", assetsModule, ([asset]: Asset[]) => {
|
||||
const moduleId = getImportingModuleId();
|
||||
if (moduleId !== -1) indexAssetName(asset.name, moduleId);
|
||||
});
|
||||
|
||||
return unpatch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the first asset registry by its registry id (number), name (string) or given filter (function)
|
||||
*/
|
||||
export function findAsset(id: number): Asset | undefined;
|
||||
export function findAsset(name: string): Asset | undefined;
|
||||
export function findAsset(filter: (a: Asset) => boolean): Asset | undefined;
|
||||
|
||||
export function findAsset(param: number | string | ((a: Asset) => boolean)) {
|
||||
if (typeof param === "number") return assetsModule.getAssetByID(param);
|
||||
if (typeof param === "string") return assetsMap[param];
|
||||
return Object.values(assetsMap).find(param);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the first asset ID in the registry with the given name
|
||||
*/
|
||||
export function findAssetId(name: string) {
|
||||
return assetsMap[name]?.index;
|
||||
}
|
||||
|
||||
78
src/lib/api/assets/index.ts
Normal file
78
src/lib/api/assets/index.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { getMetroCache } from "@metro/internals/caches";
|
||||
import { ModuleFlags } from "@metro/internals/enums";
|
||||
import { requireModule } from "@metro/internals/modules";
|
||||
|
||||
import { assetsModule } from "./patches";
|
||||
|
||||
export interface Asset {
|
||||
id: number;
|
||||
name: string;
|
||||
moduleId: number;
|
||||
}
|
||||
|
||||
// Cache common usage
|
||||
const _nameToAssetCache = {} as Record<string, Asset>;
|
||||
|
||||
export function* iterateAssets() {
|
||||
const { flagsIndex } = getMetroCache();
|
||||
const yielded = new Set<number>();
|
||||
|
||||
for (const id in flagsIndex) {
|
||||
if (flagsIndex[id] & ModuleFlags.ASSET) {
|
||||
const assetId = requireModule(Number(id));
|
||||
if (typeof assetId !== "number" || yielded.has(assetId)) continue;
|
||||
yield getAssetById(assetId);
|
||||
yielded.add(assetId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply additional properties for convenience
|
||||
function getAssetById(id: number): Asset {
|
||||
const asset = assetsModule.getAssetByID(id);
|
||||
if (!asset) return asset;
|
||||
return Object.assign(asset, { id });
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the first asset registry by its registry id (number), name (string) or given filter (function)
|
||||
*/
|
||||
export function findAsset(id: number): Asset | undefined;
|
||||
export function findAsset(name: string): Asset | undefined;
|
||||
export function findAsset(filter: (a: Asset) => boolean): Asset | undefined;
|
||||
|
||||
export function findAsset(param: number | string | ((a: Asset) => boolean)) {
|
||||
if (typeof param === "number") return getAssetById(param);
|
||||
|
||||
if (typeof param === "string" && _nameToAssetCache[param]) {
|
||||
return _nameToAssetCache[param];
|
||||
}
|
||||
|
||||
for (const asset of iterateAssets()) {
|
||||
if (typeof param === "string" && asset.name === param) {
|
||||
_nameToAssetCache[param] = asset;
|
||||
return asset;
|
||||
} else if (typeof param === "function" && param(asset)) {
|
||||
return asset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function filterAssets(param: string | ((a: Asset) => boolean)) {
|
||||
const filteredAssets = [] as Array<Asset>;
|
||||
|
||||
for (const asset of iterateAssets()) {
|
||||
if (typeof param === "string" ? asset.name === param : param(asset)) {
|
||||
filteredAssets.push(asset);
|
||||
}
|
||||
}
|
||||
|
||||
return filteredAssets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the first asset ID in the registry with the given name
|
||||
*/
|
||||
export function findAssetId(name: string) {
|
||||
return findAsset(name)?.id;
|
||||
}
|
||||
25
src/lib/api/assets/patches.ts
Normal file
25
src/lib/api/assets/patches.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { after } from "@lib/api/patcher";
|
||||
import { indexAssetModuleFlag } from "@metro/internals/caches";
|
||||
import { getImportingModuleId } from "@metro/internals/modules";
|
||||
|
||||
interface AssetModule {
|
||||
registerAsset(assetDefinition: any): number;
|
||||
getAssetByID(id: number): any;
|
||||
}
|
||||
|
||||
export let assetsModule: AssetModule;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export function patchAssets(module: AssetModule) {
|
||||
if (assetsModule) return;
|
||||
assetsModule = module;
|
||||
|
||||
const unpatch = after("registerAsset", assetsModule, () => {
|
||||
const moduleId = getImportingModuleId();
|
||||
if (moduleId !== -1) indexAssetModuleFlag(moduleId);
|
||||
});
|
||||
|
||||
return unpatch;
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { getThemeFromLoader, selectTheme, themes } from "@lib/addons/themes";
|
||||
import { findAssetId } from "@lib/api/assets";
|
||||
import { getLoaderName, getLoaderVersion, isThemeSupported } from "@lib/api/native/loader";
|
||||
import { BundleUpdaterManager, ClientInfoManager, DeviceManager } from "@lib/api/native/modules";
|
||||
import { BundleUpdaterManager, NativeClientInfoModule, NativeDeviceModule } from "@lib/api/native/modules";
|
||||
import { after } from "@lib/api/patcher";
|
||||
import { settings } from "@lib/api/settings";
|
||||
import { logger } from "@lib/utils/logger";
|
||||
|
|
@ -113,8 +113,8 @@ export function getDebugInfo() {
|
|||
}
|
||||
},
|
||||
discord: {
|
||||
version: ClientInfoManager.Version,
|
||||
build: ClientInfoManager.Build,
|
||||
version: NativeClientInfoModule.Version,
|
||||
build: NativeClientInfoModule.Build,
|
||||
},
|
||||
react: {
|
||||
version: React.version,
|
||||
|
|
@ -149,15 +149,15 @@ export function getDebugInfo() {
|
|||
manufacturer: PlatformConstants.Manufacturer,
|
||||
brand: PlatformConstants.Brand,
|
||||
model: PlatformConstants.Model,
|
||||
codename: DeviceManager.device
|
||||
codename: NativeDeviceModule.device
|
||||
}
|
||||
},
|
||||
ios: {
|
||||
device: {
|
||||
manufacturer: DeviceManager.deviceManufacturer,
|
||||
brand: DeviceManager.deviceBrand,
|
||||
model: DeviceManager.deviceModel,
|
||||
codename: DeviceManager.device
|
||||
manufacturer: NativeDeviceModule.deviceManufacturer,
|
||||
brand: NativeDeviceModule.deviceBrand,
|
||||
model: NativeDeviceModule.deviceModel,
|
||||
codename: NativeDeviceModule.device
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import { FileManager } from "./modules";
|
||||
import { NativeFileModule } from "./modules";
|
||||
|
||||
/**
|
||||
* Removes all files in a directory from the path given
|
||||
* @param path Path to the targeted directory
|
||||
*/
|
||||
export async function clearFolder(path: string, { prefix = "pyoncord/" } = {}) {
|
||||
if (typeof FileManager.clearFolder !== "function") throw new Error("'fs.clearFolder' is not supported");
|
||||
return void await FileManager.clearFolder("documents", `${prefix}${path}`);
|
||||
if (typeof NativeFileModule.clearFolder !== "function") throw new Error("'fs.clearFolder' is not supported");
|
||||
return void await NativeFileModule.clearFolder("documents", `${prefix}${path}`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -14,8 +14,8 @@ export async function clearFolder(path: string, { prefix = "pyoncord/" } = {}) {
|
|||
* @param path Path to the file
|
||||
*/
|
||||
export async function removeFile(path: string, { prefix = "pyoncord/" } = {}) {
|
||||
if (typeof FileManager.removeFile !== "function") throw new Error("'fs.removeFile' is not supported");
|
||||
return void await FileManager.removeFile("documents", `${prefix}${path}`);
|
||||
if (typeof NativeFileModule.removeFile !== "function") throw new Error("'fs.removeFile' is not supported");
|
||||
return void await NativeFileModule.removeFile("documents", `${prefix}${path}`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -23,7 +23,7 @@ export async function removeFile(path: string, { prefix = "pyoncord/" } = {}) {
|
|||
* @param path Path to the file
|
||||
*/
|
||||
export async function fileExists(path: string, { prefix = "pyoncord/" } = {}) {
|
||||
return await FileManager.fileExists(`${FileManager.getConstants().DocumentsDirPath}/${prefix}${path}`);
|
||||
return await NativeFileModule.fileExists(`${NativeFileModule.getConstants().DocumentsDirPath}/${prefix}${path}`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -33,7 +33,7 @@ export async function fileExists(path: string, { prefix = "pyoncord/" } = {}) {
|
|||
*/
|
||||
export async function writeFile(path: string, data: string, { prefix = "pyoncord/" } = {}): Promise<void> {
|
||||
if (typeof data !== "string") throw new Error("Argument 'data' must be a string");
|
||||
return void await FileManager.writeFile("documents", `${prefix}${path}`, data, "utf8");
|
||||
return void await NativeFileModule.writeFile("documents", `${prefix}${path}`, data, "utf8");
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -43,7 +43,7 @@ export async function writeFile(path: string, data: string, { prefix = "pyoncord
|
|||
*/
|
||||
export async function readFile(path: string, { prefix = "pyoncord/" } = {}): Promise<string> {
|
||||
try {
|
||||
return await FileManager.readFile(`${FileManager.getConstants().DocumentsDirPath}/${prefix}${path}`, "utf8");
|
||||
return await NativeFileModule.readFile(`${NativeFileModule.getConstants().DocumentsDirPath}/${prefix}${path}`, "utf8");
|
||||
} catch (err) {
|
||||
throw new Error(`An error occured while writing to '${path}'`, { cause: err });
|
||||
}
|
||||
|
|
@ -63,5 +63,5 @@ export async function downloadFile(url: string, path: string, { prefix = "pyonco
|
|||
const arrayBuffer = await response.arrayBuffer();
|
||||
const data = Buffer.from(arrayBuffer).toString("base64");
|
||||
|
||||
return void await FileManager.writeFile("documents", `${prefix}${path}`, data, "base64");
|
||||
await NativeFileModule.writeFile("documents", `${prefix}${path}`, data, "base64");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Theme } from "@lib/addons/themes";
|
||||
import { VdThemeInfo } from "@lib/addons/themes";
|
||||
|
||||
// @ts-ignore
|
||||
const pyonLoaderIdentity = globalThis.__PYON_LOADER__;
|
||||
|
|
@ -124,7 +124,7 @@ export function isThemeSupported() {
|
|||
return false;
|
||||
}
|
||||
|
||||
export function getStoredTheme(): Theme | null {
|
||||
export function getStoredTheme(): VdThemeInfo | null {
|
||||
if (isPyonLoader()) {
|
||||
return pyonLoaderIdentity.storedTheme;
|
||||
} else if (isVendettaLoader()) {
|
||||
|
|
|
|||
|
|
@ -2,9 +2,10 @@ import { RNModules } from "./types";
|
|||
|
||||
const nmp = window.nativeModuleProxy;
|
||||
|
||||
export const MMKVManager = (nmp.NativeCacheModule ?? nmp.MMKVManager) as RNModules.MMKVManager;
|
||||
export const FileManager = (nmp.NativeFileModule ?? nmp.RTNFileManager ?? nmp.DCDFileManager) as RNModules.FileManager;
|
||||
export const ClientInfoManager = nmp.NativeClientInfoModule ?? nmp.RTNClientInfoManager ?? nmp.InfoDictionaryManager;
|
||||
export const DeviceManager = nmp.NativeDeviceModule ?? nmp.RTNDeviceManager ?? nmp.DCDDeviceManager;
|
||||
export const NativeCacheModule = (nmp.NativeCacheModule ?? nmp.MMKVManager) as RNModules.MMKVManager;
|
||||
export const NativeFileModule = (nmp.NativeFileModule ?? nmp.RTNFileManager ?? nmp.DCDFileManager) as RNModules.FileManager;
|
||||
export const NativeClientInfoModule = nmp.NativeClientInfoModule ?? nmp.RTNClientInfoManager ?? nmp.InfoDictionaryManager;
|
||||
export const NativeDeviceModule = nmp.NativeDeviceModule ?? nmp.RTNDeviceManager ?? nmp.DCDDeviceManager;
|
||||
export const NativeThemeModule = nmp.NativeThemeModule ?? nmp.RTNThemeManager ?? nmp.DCDTheme;
|
||||
|
||||
export const { BundleUpdaterManager } = nmp;
|
||||
export const ThemeManager = nmp.NativeThemeModule ?? nmp.RTNThemeManager ?? nmp.DCDTheme;
|
||||
|
|
|
|||
|
|
@ -169,10 +169,10 @@ export async function preloadStorageIfExists(path: string): Promise<boolean> {
|
|||
const backend = createFileBackend(path);
|
||||
if (await backend.exists()) {
|
||||
_loadedStorage[path] = await backend.get();
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function purgeStorage(path: string) {
|
||||
|
|
|
|||
4
src/lib/ui/alerts.ts
Normal file
4
src/lib/ui/alerts.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import { lazyDestructure } from "@lib/utils/lazy";
|
||||
import { findByProps } from "@metro";
|
||||
|
||||
export const { openAlert, dismissAlert } = lazyDestructure(() => findByProps("openAlert", "dismissAlert"));
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
import { color } from "@lib/addons/themes";
|
||||
import { constants } from "@metro/common";
|
||||
import { findByStoreNameLazy } from "@metro/wrappers";
|
||||
import { findByProps, findByStoreNameLazy } from "@metro/wrappers";
|
||||
|
||||
//! This module is only found on 165.0+, under the assumption that iOS 165.0 is the same as Android 165.0.
|
||||
//* In 167.1, most if not all traces of the old color modules were removed.
|
||||
|
|
@ -10,6 +9,8 @@ import { findByStoreNameLazy } from "@metro/wrappers";
|
|||
// ? These comments are preserved for historical purposes.
|
||||
// const colorModule = findByPropsProxy("colors", "meta");
|
||||
|
||||
const color = findByProps("SemanticColor");
|
||||
|
||||
// ? SemanticColor and default.colors are effectively ThemeColorMap
|
||||
export const semanticColors = (color?.default?.colors ?? constants?.ThemeColorMap) as Record<string, any>;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
|
||||
export * as alerts from "./alerts";
|
||||
export * as components from "./components";
|
||||
export * as settings from "./settings";
|
||||
export * as sheets from "./sheets";
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { lazyDestructure, proxyLazy } from "@lib/utils/lazy";
|
||||
import { findByProps, findByPropsLazy } from "@metro/wrappers";
|
||||
import { isSemanticColor, resolveSemanticColor } from "@ui/color";
|
||||
import { DiscordTextStyles } from "@ui/types";
|
||||
import { TextStyles } from "@ui/types";
|
||||
import { ImageStyle, StyleSheet, TextStyle, ViewStyle } from "react-native";
|
||||
|
||||
type NamedStyles<T> = { [P in keyof T]: ViewStyle | TextStyle | ImageStyle };
|
||||
|
|
@ -10,7 +10,7 @@ const Styles = findByPropsLazy("createStyles");
|
|||
|
||||
export const { ThemeContext } = lazyDestructure(() => findByProps("ThemeContext"), { hint: "object" });
|
||||
export const { TextStyleSheet } = lazyDestructure(() => findByProps("TextStyleSheet")) as unknown as {
|
||||
TextStyleSheet: Record<DiscordTextStyles, TextStyle>;
|
||||
TextStyleSheet: Record<TextStyles, TextStyle>;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
1
src/lib/utils/types.ts
Normal file
1
src/lib/utils/types.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export type Nullish = null | undefined;
|
||||
|
|
@ -12,7 +12,7 @@ const bySingularProp = createFilterDefinition<[string]>(
|
|||
);
|
||||
|
||||
const findSingular = (prop: string) => proxyLazy(() => findExports(bySingularProp(prop))?.[prop]);
|
||||
const findProp = (prop: string) => proxyLazy(() => findByProps(prop)[prop]);
|
||||
const findProp = (...props: string[]) => proxyLazy(() => findByProps(...props)[props[0]]);
|
||||
|
||||
// Discord
|
||||
export const LegacyAlert = findByDisplayNameLazy("FluxContainer(Alert)");
|
||||
|
|
@ -27,18 +27,22 @@ export const ActionSheetRow = findProp("ActionSheetRow");
|
|||
|
||||
// Buttons
|
||||
export const Button = findSingular("Button") as t.Button;
|
||||
export const TwinButtons = findProp("TwinButtons");
|
||||
export const TwinButtons = findProp("TwinButtons") as t.TwinButtons;
|
||||
export const IconButton = findSingular("IconButton") as t.IconButton;
|
||||
export const RowButton = findProp("RowButton") as t.RowButton;
|
||||
|
||||
export const PressableScale = findProp("PressableScale");
|
||||
|
||||
// Tables
|
||||
export const TableRow = findProp("TableRow");
|
||||
export const TableRowIcon = findProp("TableRowIcon");
|
||||
export const TableRowTrailingText = findProp("TableRowTrailingText");
|
||||
export const TableRowGroup = findProp("TableRowGroup");
|
||||
export const TableSwitchRow = findProp("TableSwitchRow");
|
||||
export const TableRow = findProp("TableRow") as t.TableRow;
|
||||
export const TableRowIcon = findProp("TableRowIcon") as t.TableRowIcon;
|
||||
export const TableRowTrailingText = findProp("TableRowTrailingText") as t.TableRowTrailingText;
|
||||
export const TableRowGroup = findProp("TableRowGroup") as t.TableRowGroup;
|
||||
export const TableRadioGroup = findProp("TableRadioGroup") as t.TableRadioGroup;
|
||||
export const TableRadioRow = findProp("TableRadioRow") as t.TableRadioRow;
|
||||
export const TableSwitchRow = findProp("TableSwitchRow") as t.TableSwitchRow;
|
||||
export const TableCheckboxRow = findProp("TableCheckboxRow") as t.TableCheckboxRow;
|
||||
|
||||
export const TableSwitch = findSingular("FormSwitch");
|
||||
export const TableRadio = findSingular("FormRadio");
|
||||
export const TableCheckbox = findSingular("FormCheckbox");
|
||||
|
|
@ -51,11 +55,23 @@ export const FormCheckbox = findSingular("FormCheckbox");
|
|||
export const Card = findProp("Card");
|
||||
export const RedesignCompat = proxyLazy(() => findByProps("RedesignCompat").RedesignCompat);
|
||||
|
||||
// Alert
|
||||
export const AlertModal = findProp("AlertModal");
|
||||
export const AlertActionButton = findProp("AlertActionButton");
|
||||
export const AlertActions = findProp("AlertActions");
|
||||
|
||||
// Pile
|
||||
export const AvatarPile = findSingular("AvatarPile");
|
||||
|
||||
// Misc.
|
||||
export const ContextMenu = findProp("ContextMenu") as t.ContextMenu;
|
||||
export const Stack = findProp("Stack") as t.Stack;
|
||||
export const Avatar = findProp("default", "AvatarSizes", "getStatusSize");
|
||||
|
||||
|
||||
// Inputs
|
||||
export const TextInput = findSingular("TextInput") as t.TextInput;
|
||||
export const TextArea = findSingular("TextArea");
|
||||
|
||||
// SegmentedControl
|
||||
export const SegmentedControl = findProp("SegmentedControl") as t.SegmentedControl;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { lazyDestructure } from "@lib/utils/lazy";
|
||||
import { findByFilePathLazy, findByProps, findByPropsLazy } from "@metro/wrappers";
|
||||
|
||||
import type { Dispatcher } from "./types/flux";
|
||||
|
|
@ -18,13 +19,16 @@ export const toasts = findByFilePathLazy("modules/toast/native/ToastActionCreato
|
|||
export const messageUtil = findByPropsLazy("sendBotMessage");
|
||||
export const navigationStack = findByPropsLazy("createStackNavigator");
|
||||
export const NavigationNative = findByPropsLazy("NavigationContainer");
|
||||
export const tokens = findByPropsLazy("colors", "unsafe_rawColors");
|
||||
export const semver = findByPropsLazy("parse", "clean");
|
||||
|
||||
export const tokens = findByPropsLazy("unsafe_rawColors", "colors");
|
||||
export const { useToken } = lazyDestructure(() => findByProps("useToken"));
|
||||
|
||||
// Flux
|
||||
export const Flux = findByPropsLazy("connectStores");
|
||||
// TODO: Making this a proxy/lazy fuck things up for some reason
|
||||
export const FluxDispatcher = findByProps("_interceptors") as Dispatcher;
|
||||
export const FluxUtils = findByProps("useStateFromStores");
|
||||
|
||||
// React
|
||||
export const React = window.React = findByPropsLazy("createElement") as typeof import("react");
|
||||
|
|
|
|||
3
src/metro/common/stores.ts
Normal file
3
src/metro/common/stores.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { findByStoreNameLazy } from "@metro/wrappers";
|
||||
|
||||
export const UserStore = findByStoreNameLazy("UserStore");
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
import { DiscordTextStyles } from "@ui/types";
|
||||
import { MutableRefObject, ReactNode, RefObject } from "react";
|
||||
import { Nullish } from "@lib/utils/types";
|
||||
import { TextStyles, ThemeColors } from "@ui/types";
|
||||
import { Falsey } from "lodash";
|
||||
import { FC, MutableRefObject, PropsWithoutRef, ReactNode, RefObject } from "react";
|
||||
import type * as RN from "react-native";
|
||||
import { ImageSourcePropType, PressableProps } from "react-native";
|
||||
import { SharedValue } from "react-native-reanimated";
|
||||
|
|
@ -29,6 +31,12 @@ interface ButtonProps {
|
|||
|
||||
export type Button = React.ForwardRefExoticComponent<ButtonProps>;
|
||||
|
||||
interface TwinButtonsProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export type TwinButtons = React.ComponentType<TwinButtonsProps>;
|
||||
|
||||
// Segmented Control
|
||||
interface SegmentedControlItem {
|
||||
id: string;
|
||||
|
|
@ -103,7 +111,7 @@ interface TextInputProps extends Omit<RN.TextInputProps, "onChange" | "onChangeT
|
|||
trailingIcon?: React.FC<any>;
|
||||
trailingPressableProps?: PressableProps;
|
||||
trailingText?: string;
|
||||
value?: string | RN.Falsy;
|
||||
value?: string | Nullish;
|
||||
}
|
||||
|
||||
export type TextInput = React.FC<TextInputProps>;
|
||||
|
|
@ -134,6 +142,7 @@ interface FABProps {
|
|||
style?: Style;
|
||||
onPress: () => void;
|
||||
positionBottom?: number;
|
||||
|
||||
}
|
||||
|
||||
export type FloatingActionButton = React.FC<FABProps>;
|
||||
|
|
@ -145,8 +154,8 @@ interface ActionSheetProps {
|
|||
export type ActionSheet = React.FC<React.PropsWithChildren<ActionSheetProps>>;
|
||||
|
||||
type TextProps = React.ComponentProps<typeof RN.Text> & {
|
||||
variant?: DiscordTextStyles;
|
||||
color?: string; // TODO: type this
|
||||
variant?: TextStyles;
|
||||
color?: ThemeColors;
|
||||
lineClamp?: number;
|
||||
maxFontSizeMultiplier?: number;
|
||||
style?: RN.TextStyle;
|
||||
|
|
@ -155,6 +164,7 @@ type TextProps = React.ComponentProps<typeof RN.Text> & {
|
|||
export type Text = React.FC<TextProps>;
|
||||
|
||||
interface IconButtonProps {
|
||||
label?: string;
|
||||
icon: ImageSourcePropType | ReactNode;
|
||||
onPress: () => void;
|
||||
disabled?: boolean;
|
||||
|
|
@ -164,3 +174,93 @@ interface IconButtonProps {
|
|||
}
|
||||
|
||||
export type IconButton = React.FC<IconButtonProps>;
|
||||
|
||||
export type PressableScale = React.FC<PropsWithoutRef<typeof RN.Pressable>>;
|
||||
|
||||
interface TableRowBaseProps {
|
||||
arrow?: boolean;
|
||||
label: string | ReactNode;
|
||||
subLabel?: string | ReactNode;
|
||||
variant?: LiteralUnion<"danger", string>;
|
||||
icon?: JSX.Element | Falsey;
|
||||
disabled?: boolean;
|
||||
trailing?: ReactNode | React.ComponentType<any>;
|
||||
}
|
||||
|
||||
interface TableRowProps extends TableRowBaseProps {
|
||||
onPress?: () => void;
|
||||
}
|
||||
|
||||
export type TableRow = React.FC<TableRowProps> & {
|
||||
Icon: TableRowIcon;
|
||||
TrailingText: TableRowTrailingText;
|
||||
Arrow: FC<{}>;
|
||||
};
|
||||
|
||||
interface TableRowTrailingTextProps {
|
||||
text: string;
|
||||
}
|
||||
|
||||
export type TableRowTrailingText = FC<TableRowTrailingTextProps>;
|
||||
|
||||
interface TableRowIconProps {
|
||||
style?: RN.ImageStyle;
|
||||
variant?: LiteralUnion<"danger", string>,
|
||||
source: ImageSourcePropType | undefined;
|
||||
}
|
||||
|
||||
export type TableRowIcon = React.FC<TableRowIconProps>;
|
||||
|
||||
interface TableRowGroupProps {
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export type TableRowGroup = React.FC<TableRowGroupProps>;
|
||||
|
||||
interface TableRadioGroupProps {
|
||||
title: string;
|
||||
value: string;
|
||||
hasIcons?: boolean;
|
||||
onChange: <T extends string>(type: T) => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export type TableRadioGroup = FC<TableRadioGroupProps>;
|
||||
|
||||
interface TableRadioRowProps extends TableRowBaseProps {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export type TableRadioRow = FC<TableRadioRowProps>;
|
||||
|
||||
interface TableSwitchRowProps extends TableRowBaseProps {
|
||||
value: boolean;
|
||||
onValueChange: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export type TableSwitchRow = FC<TableSwitchRowProps>;
|
||||
|
||||
interface TableCheckboxRowProps extends TableRowBaseProps {
|
||||
checked: boolean;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
export type TableCheckboxRow = FC<TableCheckboxRowProps>;
|
||||
|
||||
interface ContextMenuItem {
|
||||
label: string;
|
||||
variant?: LiteralUnion<"destructive", string>;
|
||||
iconSource?: number;
|
||||
action: () => unknown;
|
||||
}
|
||||
|
||||
interface ContextMenuProps {
|
||||
triggerOnLongPress?: boolean;
|
||||
items: ContextMenuItem[] | ContextMenuItem[][];
|
||||
align?: "left" | "right" | "above" | "below" | "auto" | null;
|
||||
title?: string;
|
||||
children: React.FC<Record<"onPress" | "onLongPress" | "accessibilityActions" | "onAccessibilityAction", any>>;
|
||||
}
|
||||
|
||||
export type ContextMenu = FC<ContextMenuProps>;
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ export function findModuleId<A extends unknown[]>(filter: FilterFn<A>) {
|
|||
export function findExports<A extends unknown[]>(filter: FilterFn<A>) {
|
||||
const { id, defaultExport } = findModule(filter);
|
||||
if (id == null) return;
|
||||
|
||||
return defaultExport ? requireModule(id).default : requireModule(id);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { ClientInfoManager, MMKVManager } from "@lib/api/native/modules";
|
||||
import { fileExists, readFile, writeFile } from "@lib/api/native/fs";
|
||||
import { NativeClientInfoModule } from "@lib/api/native/modules";
|
||||
import { debounce } from "es-toolkit";
|
||||
|
||||
import { ModuleFlags, ModulesMapInternal } from "./enums";
|
||||
|
||||
const CACHE_VERSION = 52;
|
||||
const BUNNY_METRO_CACHE_KEY = "__bunny_metro_cache_key__";
|
||||
const CACHE_VERSION = 100;
|
||||
const BUNNY_METRO_CACHE_PATH = "caches/metro_modules.json";
|
||||
|
||||
type ModulesMap = {
|
||||
[flag in number | `_${ModulesMapInternal}`]?: ModuleFlags;
|
||||
|
|
@ -17,16 +18,15 @@ export const getMetroCache = window.__getMetroCache = () => _metroCache;
|
|||
function buildInitCache() {
|
||||
const cache = {
|
||||
_v: CACHE_VERSION,
|
||||
_buildNumber: ClientInfoManager.Build as number,
|
||||
_buildNumber: NativeClientInfoModule.Build as number,
|
||||
_modulesCount: Object.keys(window.modules).length,
|
||||
exportsIndex: {} as Record<string, number>,
|
||||
flagsIndex: {} as Record<string, number>,
|
||||
findIndex: {} as Record<string, ModulesMap | undefined>,
|
||||
polyfillIndex: {} as Record<string, ModulesMap | undefined>,
|
||||
assetsIndex: {} as Record<string, ModulesMap | undefined>
|
||||
polyfillIndex: {} as Record<string, ModulesMap | undefined>
|
||||
} as const;
|
||||
|
||||
// Force load all modules so useful modules are pre-cached. Delay by a second
|
||||
// because force loading it all will results in an unexpected crash.
|
||||
// Force load all modules so useful modules are pre-cached. Add a minor
|
||||
// delay so the cache is initialized before the modules are loaded.
|
||||
setTimeout(() => {
|
||||
for (const id in window.modules) {
|
||||
require("./modules").requireModule(id);
|
||||
|
|
@ -37,11 +37,10 @@ function buildInitCache() {
|
|||
return cache;
|
||||
}
|
||||
|
||||
// TODO: Store in file system... is a better idea?
|
||||
/** @internal */
|
||||
export async function initMetroCache() {
|
||||
const rawCache = await MMKVManager.getItem(BUNNY_METRO_CACHE_KEY);
|
||||
if (rawCache == null) return void buildInitCache();
|
||||
if (!await fileExists(BUNNY_METRO_CACHE_PATH)) return void buildInitCache();
|
||||
const rawCache = await readFile(BUNNY_METRO_CACHE_PATH);
|
||||
|
||||
try {
|
||||
_metroCache = JSON.parse(rawCache);
|
||||
|
|
@ -49,7 +48,7 @@ export async function initMetroCache() {
|
|||
_metroCache = null!;
|
||||
throw "cache invalidated; cache version outdated";
|
||||
}
|
||||
if (_metroCache._buildNumber !== ClientInfoManager.Build) {
|
||||
if (_metroCache._buildNumber !== NativeClientInfoModule.Build) {
|
||||
_metroCache = null!;
|
||||
throw "cache invalidated; version mismatch";
|
||||
}
|
||||
|
|
@ -63,7 +62,7 @@ export async function initMetroCache() {
|
|||
}
|
||||
|
||||
const saveCache = debounce(() => {
|
||||
MMKVManager.setItem(BUNNY_METRO_CACHE_KEY, JSON.stringify(_metroCache));
|
||||
writeFile(BUNNY_METRO_CACHE_PATH, JSON.stringify(_metroCache));
|
||||
}, 1000);
|
||||
|
||||
function extractExportsFlags(moduleExports: any) {
|
||||
|
|
@ -77,13 +76,18 @@ function extractExportsFlags(moduleExports: any) {
|
|||
export function indexExportsFlags(moduleId: number, moduleExports: any) {
|
||||
const flags = extractExportsFlags(moduleExports);
|
||||
if (flags && flags !== ModuleFlags.EXISTS) {
|
||||
_metroCache.exportsIndex[moduleId] = flags;
|
||||
_metroCache.flagsIndex[moduleId] = flags;
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export function indexBlacklistFlag(id: number) {
|
||||
_metroCache.exportsIndex[id] |= ModuleFlags.BLACKLISTED;
|
||||
_metroCache.flagsIndex[id] |= ModuleFlags.BLACKLISTED;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export function indexAssetModuleFlag(id: number) {
|
||||
_metroCache.flagsIndex[id] |= ModuleFlags.ASSET;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
|
|
@ -124,11 +128,3 @@ export function getPolyfillModuleCacher(name: string) {
|
|||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export function indexAssetName(name: string, moduleId: number) {
|
||||
if (!isNaN(moduleId)) {
|
||||
(_metroCache.assetsIndex[name] ??= {})[moduleId] = 1;
|
||||
saveCache();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
export enum ModuleFlags {
|
||||
EXISTS = 1 << 0,
|
||||
BLACKLISTED = 1 << 1
|
||||
BLACKLISTED = 1 << 1,
|
||||
ASSET = 1 << 2,
|
||||
}
|
||||
|
||||
export enum ModulesMapInternal {
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ for (const key in metroModules) {
|
|||
const id = Number(key);
|
||||
const metroModule = metroModules[id];
|
||||
|
||||
const cache = getMetroCache().exportsIndex[id];
|
||||
const cache = getMetroCache().flagsIndex[id];
|
||||
if (cache & ModuleFlags.BLACKLISTED) {
|
||||
blacklistModule(id);
|
||||
continue;
|
||||
|
|
@ -86,20 +86,23 @@ function onModuleRequire(moduleExports: any, id: Metro.ModuleID) {
|
|||
moduleExports.default.track = () => Promise.resolve();
|
||||
|
||||
if (moduleExports.registerAsset) {
|
||||
require("@lib/api/assets").patchAssets(moduleExports);
|
||||
require("@lib/api/assets/patches").patchAssets(moduleExports);
|
||||
}
|
||||
|
||||
// There are modules registering the same native component
|
||||
if (!patchedNativeComponentRegistry && ["customBubblingEventTypes", "customDirectEventTypes", "register", "get"].every(x => moduleExports[x])) {
|
||||
instead("register", moduleExports, (args: any, origFunc: any) => {
|
||||
instead("register", moduleExports, ([name, cb]: any, origFunc: any) => {
|
||||
try {
|
||||
return origFunc(...args);
|
||||
} catch { }
|
||||
return origFunc(name, cb);
|
||||
} catch {
|
||||
return name;
|
||||
}
|
||||
});
|
||||
|
||||
patchedNativeComponentRegistry = true;
|
||||
}
|
||||
|
||||
|
||||
// Hook DeveloperExperimentStore
|
||||
if (moduleExports?.default?.constructor?.displayName === "DeveloperExperimentStore") {
|
||||
moduleExports.default = new Proxy(moduleExports.default, {
|
||||
|
|
@ -130,7 +133,7 @@ function onModuleRequire(moduleExports: any, id: Metro.ModuleID) {
|
|||
patchedInspectSource = true;
|
||||
}
|
||||
|
||||
// Explosion (no, I can't explain this, don't ask) ((hi rosie))
|
||||
//
|
||||
if (moduleExports.findHostInstance_DEPRECATED) {
|
||||
const prevExports = metroModules[id - 1]?.publicModule.exports;
|
||||
const inc = prevExports.default?.reactProfilingEnabled ? 1 : -1;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
type Nullish = null | undefined;
|
||||
import { Nullish } from "@lib/utils/types";
|
||||
|
||||
/** @see {@link https://github.com/facebook/metro/blob/c2d7539dfc10aacb2f99fcc2f268a3b53e867a90/packages/metro-runtime/src/polyfills/require.js} */
|
||||
export namespace Metro {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { byDisplayName, byFilePath,byName, byProps, byStoreName, byTypeName } from "./filters";
|
||||
import { findAllExports,findExports } from "./finders";
|
||||
import { byDisplayName, byFilePath, byName, byProps, byStoreName, byTypeName } from "./filters";
|
||||
import { findAllExports, findExports } from "./finders";
|
||||
import { createLazyModule } from "./lazy";
|
||||
|
||||
export const findByProps = (...props: string[]) => findExports(byProps(...props));
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue