Rewrite settings patch and add to api

This commit is contained in:
amsyarasyiq 2024-03-08 01:06:25 +08:00
parent 91246366c6
commit 218d09a5a3
14 changed files with 255 additions and 312 deletions

View file

@ -33,6 +33,7 @@
color
toasts
styles
settings
}
utils {
constants

BIN
src/assets/icons/pyon64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 810 B

View file

@ -1,155 +0,0 @@
import { Strings } from "@core/i18n";
import InstallButton from "@core/ui/components/InstallButton";
import Developer from "@core/ui/settings/pages/Developer";
import General from "@core/ui/settings/pages/General";
import Plugins from "@core/ui/settings/pages/Plugins";
import Themes from "@core/ui/settings/pages/Themes";
import { getAssetIDByName } from "@lib/api/assets";
import { isThemeSupported } from "@lib/api/native/loader";
import { useProxy } from "@lib/api/storage";
import { installPlugin } from "@lib/managers/plugins";
import { installTheme } from "@lib/managers/themes";
import { findByProps } from "@lib/metro/filters";
import { settings } from "@lib/settings";
import { without } from "@lib/utils";
import { PROXY_PREFIX } from "@lib/utils/constants";
import { NavigationNative } from "@metro/common";
import { showConfirmationAlert } from "@ui/alerts";
import { semanticColors } from "@ui/color";
import ErrorBoundary from "@ui/components/ErrorBoundary";
import { createStyles } from "@ui/styles";
import { showToast } from "@ui/toasts";
import { View } from "react-native";
const { useSafeAreaInsets } = findByProps("useSafeAreaInsets");
interface Screen {
[index: string]: any;
key: string;
title: () => string;
icon?: string;
shouldRender?: () => boolean;
options?: Record<string, any>;
render: React.ComponentType<any>;
}
const useStyles = createStyles({
container: {
flex: 1,
backgroundColor: semanticColors.BACKGROUND_MOBILE_PRIMARY,
}
});
// If a function is passed, it is called with the screen object, and the return value is mapped. If a string is passed, we map to the value of the property with that name on the screen. Else, just map to the given data.
// Question: Isn't this overengineered?
// Answer: Maybe.
const keyMap = (screens: Screen[], data: string | ((s: Screen) => any) | null) => Object.fromEntries(screens.map(s => [s.key, typeof data === "function" ? data(s) : typeof data === "string" ? s[data] : data]));
export const getScreens = (): Screen[] => [
{
key: "VendettaSettings",
title: () => Strings.GENERAL,
icon: "settings",
render: General,
},
{
key: "VendettaPlugins",
title: () => Strings.PLUGINS,
icon: "debug",
options: {
headerRight: () => (
<InstallButton
alertTitle={Strings.INSTALL_PLUGIN}
installFunction={async input => {
if (!input.startsWith(PROXY_PREFIX) && !settings.developerSettings)
setImmediate(() => showConfirmationAlert({
title: Strings.MODAL_UNPROXIED_PLUGIN_HEADER,
content: Strings.MODAL_UNPROXIED_PLUGIN_DESC,
confirmText: Strings.INSTALL,
onConfirm: () =>
installPlugin(input)
.then(() => showToast(Strings.TOASTS_INSTALLED_PLUGIN, getAssetIDByName("Check")))
.catch((x: any) => showToast(x?.message ?? `${x}`, getAssetIDByName("Small"))),
cancelText: Strings.CANCEL,
}));
else return await installPlugin(input);
}}
/>
),
},
render: Plugins,
},
{
key: "VendettaThemes",
title: () => Strings.THEMES,
icon: "ic_theme_24px",
// TODO: bad
shouldRender: () => isThemeSupported(),
options: {
headerRight: () => !settings.safeMode?.enabled && <InstallButton alertTitle={Strings.INSTALL_THEME} installFunction={installTheme} />,
},
render: Themes,
},
{
key: "VendettaDeveloper",
title: () => Strings.DEVELOPER,
icon: "ic_progress_wrench_24px",
shouldRender: () => settings.developerSettings ?? false,
render: Developer,
},
{
key: "VendettaCustomPage",
title: () => "Vendetta Page",
shouldRender: () => false,
render: ({ render: PageView, noErrorBoundary, ...options }: { render: React.ComponentType; noErrorBoundary: boolean; } & Record<string, object>) => {
const navigation = NavigationNative.useNavigation();
navigation.addListener("focus", () => navigation.setOptions(without(options, "render", "noErrorBoundary")));
return noErrorBoundary ? <PageView /> : <ErrorBoundary><PageView /></ErrorBoundary>;
},
},
];
export const getPanelsScreens = () => keyMap(getScreens(), s => ({
title: s.title(),
render: s.render,
...s.options,
}));
export const getYouData = () => {
const screens = getScreens();
return {
getLayout: () => ({
// This is an old key, keeping it as "Vendetta" is good as some plugins actually checks
// to determine if this section is Vendetta's
title: "Vendetta",
label: Strings.BUNNY,
// We can't use our keyMap function here since `settings` is an array not an object
settings: screens.map(s => s.key)
}),
titleConfig: keyMap(screens, s => s.title()),
relationships: keyMap(screens, null),
rendererConfigs: keyMap(screens, s => {
const WrappedComponent = React.memo(({ navigation, route }: any) => {
const styles = useStyles();
const { bottom: paddingBottom } = useSafeAreaInsets();
navigation.addListener("focus", () => navigation.setOptions(s.options));
return <View style={[{ paddingBottom }, styles.container]}>
<s.render {...route.params} />
</View>;
});
return {
type: "route",
title: s.title,
icon: s.icon ? getAssetIDByName(s.icon) : null,
usePredicate: s.shouldRender && (() => useProxy(settings) && s.shouldRender!!()),
screen: {
route: s.key,
getComponent: () => WrappedComponent,
}
};
}),
};
};

View file

@ -1,12 +1,47 @@
// TODO: make settings patches part of the api
import patchPanels from "@core/ui/settings/patches/panels";
import patchYou from "@core/ui/settings/patches/you";
import { Strings } from "@core/i18n";
import { getAssetIDByName } from "@lib/api/assets";
import { isThemeSupported } from "@lib/api/native/loader";
import { useProxy } from "@lib/api/storage";
import { settings } from "@lib/settings";
import { registerSection } from "@lib/ui/settings";
import { version } from "bunny-build";
// @ts-ignore
import PyoncordIcon from "../../../assets/icons/pyon64.png";
export default function initSettings() {
const patches = [
patchPanels(),
patchYou(),
];
return () => patches.forEach(p => p?.());
registerSection({
name: "Bunny",
items: [
{
key: "BUNNY",
title: () => Strings.BUNNY,
icon: { uri: PyoncordIcon },
render: () => import("@core/ui/settings/pages/General"),
rawTabsConfig: {
useTrailing: () => `(${version})`
}
},
{
key: "BUNNY_PLUGINS",
title: () => Strings.PLUGINS,
icon: getAssetIDByName("ActivitiesIcon"),
render: () => import("@core/ui/settings/pages/Plugins")
},
{
key: "BUNNY_THEMES",
title: () => Strings.THEMES,
icon: getAssetIDByName("PaintPaletteIcon"),
render: () => import("@core/ui/settings/pages/Themes"),
usePredicate: () => isThemeSupported()
},
{
key: "BUNNY_DEVELOPER",
title: () => Strings.DEVELOPER,
icon: getAssetIDByName("WrenchIcon"),
render: () => import("@core/ui/settings/pages/Developer"),
usePredicate: () => useProxy(settings).developerSettings ?? false
}
]
});
}

View file

@ -2,7 +2,7 @@ import { Strings } from "@core/i18n";
import Version from "@core/ui/components/Version";
import { getAssetIDByName } from "@lib/api/assets";
import { useProxy } from "@lib/api/storage";
import { getDebugInfo, _toggleSafeMode } from "@lib/debug";
import { _toggleSafeMode, getDebugInfo } from "@lib/debug";
import { settings } from "@lib/settings";
import { Stack, TableRow, TableRowGroup, TableSwitchRow } from "@lib/ui/components/discord/Redesign";
import { DISCORD_SERVER, GITHUB } from "@lib/utils/constants";

View file

@ -1,39 +0,0 @@
import SettingsSection from "@core/ui/components/SettingsSection";
import { getPanelsScreens } from "@core/ui/settings/data";
import { after } from "@lib/api/patcher";
import { findInReactTree } from "@lib/utils";
import { i18n } from "@metro/common";
import { findByName } from "@metro/filters";
const screensModule = findByName("getScreens", false);
const settingsModule = findByName("UserSettingsOverviewWrapper", false);
export default function patchPanels() {
const patches = new Array<Function>;
patches.push(after("default", screensModule, (_, existingScreens) => ({
...existingScreens,
...getPanelsScreens(),
})));
after("default", settingsModule, (_, ret) => {
const Overview = findInReactTree(ret.props.children, i => i.type && i.type.name === "UserSettingsOverview");
// Upload logs button gone
patches.push(after("renderSupportAndAcknowledgements", Overview.type.prototype, (_, { props: { children } }) => {
const index = children.findIndex((c: any) => c?.type?.name === "UploadLogsButton");
if (index !== -1) children.splice(index, 1);
}));
// TODO: Rewrite this whole patch, the index hasn't been properly found for months now
patches.push(after("render", Overview.type.prototype, (_, { props: { children } }) => {
const titles = [i18n.Messages.BILLING_SETTINGS, i18n.Messages.PREMIUM_SETTINGS];
//! Fix for Android 174201 and iOS 42188
({ children } = findInReactTree(children, i => i.children?.[1].type?.name === "FormSection"));
const index = children.findIndex((c: any) => titles.includes(c?.props.label));
children.splice(index === -1 ? 4 : index, 0, <SettingsSection />);
}));
}, true);
return () => patches.forEach(p => p());
}

View file

@ -1,106 +0,0 @@
import { Strings } from "@core/i18n";
import { getScreens, getYouData } from "@core/ui/settings/data";
import { after, before } from "@lib/api/patcher";
import { i18n } from "@lib/metro/common";
import { findByProps } from "@metro/filters";
export default function patchYou() {
const patches = new Array<Function>;
newYouPatch(patches) || oldYouPatch(patches);
return () => patches.forEach(p => p?.());
}
function oldYouPatch(patches: Function[]) {
const layoutModule = findByProps("useOverviewSettings");
const titleConfigModule = findByProps("getSettingTitleConfig");
const miscModule = findByProps("SETTING_RELATIONSHIPS", "SETTING_RENDERER_CONFIGS");
// Checks for 189.4 and above
// When dropping support for 189.3 and below, following can be done: (unless Discord changes things again)
// const gettersModule = findByProps("getSettingListItems");
const OLD_GETTER_FUNCTION = "getSettingSearchListItems";
const NEW_GETTER_FUNCTION = "getSettingListItems";
const oldGettersModule = findByProps(OLD_GETTER_FUNCTION);
const usingNewGettersModule = !oldGettersModule;
const getterFunctionName = usingNewGettersModule ? NEW_GETTER_FUNCTION : OLD_GETTER_FUNCTION;
const gettersModule = oldGettersModule ?? findByProps(NEW_GETTER_FUNCTION);
if (!gettersModule || !layoutModule) return;
const screens = getScreens();
const data = getYouData();
patches.push(after("useOverviewSettings", layoutModule, (_, ret) => manipulateSections(ret, data.getLayout())));
patches.push(after("getSettingTitleConfig", titleConfigModule, (_, ret) => ({
...ret,
...data.titleConfig,
})));
patches.push(after(getterFunctionName, gettersModule, ([settings], ret) => [
...(screens.filter(s => settings.includes(s.key) && (s.shouldRender?.() ?? true))).map(s => ({
type: "setting_search_result",
ancestorRendererData: data.rendererConfigs[s.key],
setting: s.key,
title: data.titleConfig[s.key],
breadcrumbs: [Strings.BUNNY],
icon: data.rendererConfigs[s.key].icon,
})),
// .filter can be removed when dropping support for 189.3 and below (unless Discord changes things again)
...ret.filter((i: any) => (usingNewGettersModule || !screens.map(s => s.key).includes(i.setting)))
].map((item, index, parent) => ({ ...item, index, total: parent.length }))));
const oldRelationships = miscModule.SETTING_RELATIONSHIPS;
const oldRendererConfigs = miscModule.SETTING_RENDERER_CONFIGS;
miscModule.SETTING_RELATIONSHIPS = { ...oldRelationships, ...data.relationships };
miscModule.SETTING_RENDERER_CONFIGS = { ...oldRendererConfigs, ...data.rendererConfigs };
patches.push(() => {
miscModule.SETTING_RELATIONSHIPS = oldRelationships;
miscModule.SETTING_RENDERER_CONFIGS = oldRendererConfigs;
});
return true;
}
function newYouPatch(patches: Function[]) {
const settingsListComponents = findByProps("SearchableSettingsList");
const settingConstantsModule = findByProps("SETTING_RENDERER_CONFIG");
const gettersModule = findByProps("getSettingListItems");
if (!gettersModule || !settingsListComponents || !settingConstantsModule) return false;
const screens = getScreens();
const data = getYouData();
patches.push(before("type", settingsListComponents.SearchableSettingsList, ([{ sections }]) => manipulateSections(sections, data.getLayout())));
patches.push(after("getSettingListSearchResultItems", gettersModule, (_, ret) => {
ret.forEach((s: any) => screens.some(b => b.key === s.setting) && (s.breadcrumbs = [Strings.BUNNY]));
}));
const oldRendererConfig = settingConstantsModule.SETTING_RENDERER_CONFIG;
settingConstantsModule.SETTING_RENDERER_CONFIG = { ...oldRendererConfig, ...data.rendererConfigs };
patches.push(() => {
settingConstantsModule.SETTING_RENDERER_CONFIG = oldRendererConfig;
});
return true;
}
const isLabel = (i: any, name: string) => i?.label === name || i?.title === name;
function manipulateSections(sections: any[], layout: any) {
if (!Array.isArray(sections) || sections.find((i: any) => isLabel(i, Strings.BUNNY))) return;
// Add our settings
const accountSettingsIndex = sections.findIndex((i: any) => isLabel(i, i18n.Messages.ACCOUNT_SETTINGS));
sections.splice(accountSettingsIndex + 1, 0, layout);
// Upload Logs button be gone
const supportCategory = sections.find((i: any) => isLabel(i, i18n.Messages.SUPPORT));
if (supportCategory) supportCategory.settings = supportCategory.settings.filter((s: string) => s !== "UPLOAD_DEBUG_LOGS");
}

View file

@ -8,6 +8,7 @@ import { isThemeSupported } from "@lib/api/native/loader";
import { _patchLogHook } from "@lib/debug";
import { _initPlugins } from "@lib/managers/plugins";
import { _initThemes, _patchChatBackground } from "@lib/managers/themes";
import { _patchSettings } from "@lib/ui/settings";
import { logger } from "@lib/utils/logger";
import initWindowObject from "@lib/windowObject";
import initSafeMode from "@ui/safeMode";
@ -25,13 +26,14 @@ export default async () => {
// Load everything in parallel
const unloads = await Promise.all([
_injectFluxInterceptor(),
_patchSettings(),
_patchLogHook(),
_patchAssets(),
_patchCommands(),
_patchChatBackground(),
initSettings(),
initFixes(),
initSafeMode(),
initSettings(),
initCorePlugins(),
]);

View file

@ -1,5 +1,6 @@
export * as alerts from "./alerts";
export * as color from "./color";
export * as components from "./components";
export * as settings from "./settings";
export * as styles from "./styles";
export * as toasts from "./toasts";

View file

@ -0,0 +1,33 @@
// Good luck reading this!
import type { ImageURISource } from "react-native";
import { patchPanelUI } from "./patches/panel";
import { patchTabsUI } from "./patches/tabs";
export interface RowConfig {
key: string;
title: () => string;
onPress?: () => any;
render?: Parameters<typeof React.lazy>[0];
icon?: ImageURISource | number;
usePredicate?: () => boolean,
rawTabsConfig?: Record<string, any>;
}
export const registeredSections = {} as {
[key: string]: RowConfig[];
};
export function registerSection(section: { name: string; items: RowConfig[]; }) {
registeredSections[section.name] = section.items;
return () => delete registeredSections[section.name];
}
export function _patchSettings() {
const unpatches = new Array<() => boolean>;
patchPanelUI(unpatches);
patchTabsUI(unpatches);
return () => unpatches.forEach(u => u());
}

View file

@ -0,0 +1,62 @@
import { findByName } from "@lib/metro";
import { i18n, NavigationNative } from "@lib/metro/common";
import { FormIcon, FormRow, FormSection } from "@lib/ui/components/discord/Forms";
import { registeredSections } from "@lib/ui/settings";
import { findInReactTree } from "@lib/utils";
import { after } from "spitroast";
import { CustomPageRenderer, wrapOnPress } from "./shared";
function SettingsSection() {
const navigation = NavigationNative.useNavigation();
return <>
{Object.keys(registeredSections).map(sect => (
<FormSection key={sect} title={sect}>
{ /** Is usePredicate here safe? */}
{registeredSections[sect].filter(r => r.usePredicate?.() ?? true).map(row => (
<FormRow
label={row.title()}
leading={<FormIcon source={row.icon} />}
trailing={FormRow.Arrow}
onPress={wrapOnPress(row.onPress, navigation, row.render, row.title())}
/>
))}
</FormSection>
))}
</>;
}
export function patchPanelUI(unpatches: (() => void | boolean)[]) {
unpatches.push(after("default", findByName("getScreens", false), (_a, screens) => ({
...screens,
VendettaCustomPage: {
title: "Bnuuy",
render: () => <CustomPageRenderer />
}
})));
const unpatch = after("default", findByName("UserSettingsOverviewWrapper", false), (_a, ret) => {
const UserSettingsOverview = findInReactTree(ret.props.children, n => n.type?.name === "UserSettingsOverview");
unpatches.push(after("renderSupportAndAcknowledgements", UserSettingsOverview.type.prototype, (_args, { props: { children } }) => {
const index = children.findIndex((c: any) => c?.type?.name === "UploadLogsButton");
if (index !== -1) children.splice(index, 1);
}));
unpatches.push(after("render", UserSettingsOverview.type.prototype, (_args, res) => {
const titles = [i18n.Messages.BILLING_SETTINGS, i18n.Messages.PREMIUM_SETTINGS];
const sections = findInReactTree(
res.props.children,
n => n?.children?.[1]?.type === FormSection
).children;
const index = sections.findIndex((c: any) => titles.includes(c?.props.label));
sections.splice(-~index || 4, 0, <SettingsSection />);
}));
}, true);
unpatches.push(unpatch);
}

View file

@ -0,0 +1,41 @@
import { NavigationNative } from "@lib/metro/common";
import { findByProps } from "@lib/metro/filters";
import { RowConfig } from "@lib/ui/settings";
const tabsNavigationRef = findByProps("getRootNavigationRef");
export const CustomPageRenderer = React.memo(() => {
const navigation = NavigationNative.useNavigation();
const route = NavigationNative.useRoute();
const { render: PageComponent, ...args } = route.params;
React.useEffect(() => void navigation.setOptions({ ...args }), []);
return <PageComponent />;
});
export function wrapOnPress(
onPress: (() => unknown) | undefined,
navigation?: any,
renderPromise?: RowConfig["render"],
screenOptions?: string | Record<string, any>,
props?: any,
) {
return async () => {
if (onPress) return void onPress();
const Component = await renderPromise!!().then(m => m.default);
if (typeof screenOptions === "string") {
screenOptions = { title: screenOptions };
}
navigation ??= tabsNavigationRef.getRootNavigationRef();
navigation.navigate("VendettaCustomPage", {
...screenOptions,
render: () => <Component {...props} />
});
};
}

View file

@ -0,0 +1,67 @@
import { findByName, findByProps } from "@lib/metro";
import { i18n } from "@lib/metro/common";
import { registeredSections } from "@lib/ui/settings";
import { findInReactTree } from "@lib/utils";
import { after } from "spitroast";
import { CustomPageRenderer, wrapOnPress } from "./shared";
const settingConstants = findByProps("SETTING_RENDERER_CONFIG");
const SettingsOverviewScreen = findByName("SettingsOverviewScreen", false);
function useIsFirstRender() {
let firstRender = false;
React.useEffect(() => void (firstRender = true), []);
return firstRender;
}
export function patchTabsUI(unpatches: (() => void | boolean)[]) {
settingConstants.SETTING_RENDERER_CONFIG.VendettaCustomPage = {
type: "route",
title: () => "Bnuuy",
screen: {
route: "VendettaCustomPage",
getComponent: () => CustomPageRenderer
}
};
const getRows = () => Object.values(registeredSections)
.flatMap(sect => sect.map(row => ({
[row.key]: {
type: "pressable",
title: row.title(),
icon: row.icon,
usePredicate: row.usePredicate,
onPress: wrapOnPress(row.onPress, null, row.render, row.title()),
withArrow: true,
...row.rawTabsConfig
}
})))
.reduce((a, c) => Object.assign(a, c));
const origRendererConfig = settingConstants.SETTING_RENDERER_CONFIG;
settingConstants.SETTING_RENDERER_CONFIG = new Proxy(origRendererConfig, {
get: (target, prop: string, reciever) => Reflect.get({ ...target, ...getRows() }, prop, reciever),
getOwnPropertyDescriptor: (target, prop: string) => Reflect.getOwnPropertyDescriptor({ ...target, ...getRows() }, prop),
ownKeys: target => [...Reflect.ownKeys(target), ...Object.keys(getRows())]
});
unpatches.push(() => {
settingConstants.SETTING_RENDERER_CONFIG = origRendererConfig;
delete settingConstants.SETTING_RENDERER_CONFIG.VendettaCustomPage;
});
unpatches.push(after("default", SettingsOverviewScreen, (_, ret) => {
if (useIsFirstRender()) return; // :shrug:
const { sections } = findInReactTree(ret, i => i.props?.sections).props;
let index = -~sections.findIndex((i: any) => i.label === i18n.Messages.ACCOUNT_SETTINGS) || 1;
Object.keys(registeredSections).forEach(sect => {
sections.splice(index++, 0, {
label: sect,
settings: registeredSections[sect].map(a => a.key)
});
});
}));
}

View file

@ -88,7 +88,8 @@ const _bunnyObject = () => ({ // this function is never used, only exists for ty
alerts: { ...ui.alerts },
color: { ...ui.color },
toasts: { ...ui.toasts },
styles: { ...ui.styles }
styles: { ...ui.styles },
settings: { ...ui.settings }
},
utils: {
...utils,