[SafeMode] Initial implementation (#61)
* [UI > Components] Add Discord's button * [SafeMode] Initial work, basic ErrorBoundary patch * [SafeMode] Custom error boundary (#57) * [SafeMode] Add mostly complete custom error boundary * [SafeMode] Use Button from @ui/components in error boundary * [SafeMode] Wrap the error boundary in our own error boundary * [SafeMode > ErrorBoundary] Code-style changes --------- Co-authored-by: Beef <beefers@riseup.net> * [TS] Add basic type for Discord's button * [UI] Move Codeblock to components, and use it * [UI > Settings] Allow disabling the ErrorBoundary in CustomPage * [UI > Settings] Move the ErrorBoundary triggers to Developer * [TS] Add Codeblock to types * [TS] Use ButtonColors in Button type * [SafeMode > ErrorBoundary] Rework * [UI] Add HelpMessage to components * [SafeMode] Proper implementation * [Global] SafeMode is optional (#59) * [UI > Developer] Restore the balance * [SafeMode > ErrorBoundary] Optimise for tablet UI * [SafeMode] Last-minute fixes --------- Co-authored-by: Jack <30497388+FieryFlames@users.noreply.github.com> Co-authored-by: Jack Matthews <jm5112356@gmail.com>
This commit is contained in:
parent
f85fc4b00c
commit
5344f0017a
16 changed files with 293 additions and 47 deletions
15
src/def.d.ts
vendored
15
src/def.d.ts
vendored
|
@ -18,7 +18,13 @@ interface SummaryProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ErrorBoundaryProps {
|
interface ErrorBoundaryProps {
|
||||||
children: JSX.Element | JSX.Element[],
|
children: JSX.Element | JSX.Element[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CodeblockProps {
|
||||||
|
selectable?: boolean;
|
||||||
|
style?: _RN.TextStyle;
|
||||||
|
children?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper types for API functions
|
// Helper types for API functions
|
||||||
|
@ -125,6 +131,10 @@ interface Theme {
|
||||||
interface Settings {
|
interface Settings {
|
||||||
debuggerUrl: string;
|
debuggerUrl: string;
|
||||||
developerSettings: boolean;
|
developerSettings: boolean;
|
||||||
|
safeMode?: {
|
||||||
|
enabled: boolean;
|
||||||
|
currentThemeId?: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ApplicationCommand {
|
interface ApplicationCommand {
|
||||||
|
@ -404,9 +414,12 @@ interface VendettaObject {
|
||||||
General: PropIntellisense<"Button" | "Text" | "View">;
|
General: PropIntellisense<"Button" | "Text" | "View">;
|
||||||
Search: _React.ComponentType;
|
Search: _React.ComponentType;
|
||||||
Alert: _React.ComponentType;
|
Alert: _React.ComponentType;
|
||||||
|
Button: _React.ComponentType<any> & { Looks: any, Colors: ButtonColors, Sizes: any };
|
||||||
|
HelpMessage: _React.ComponentType;
|
||||||
// Vendetta
|
// Vendetta
|
||||||
Summary: _React.ComponentType<SummaryProps>;
|
Summary: _React.ComponentType<SummaryProps>;
|
||||||
ErrorBoundary: _React.ComponentType<ErrorBoundaryProps>;
|
ErrorBoundary: _React.ComponentType<ErrorBoundaryProps>;
|
||||||
|
Codeblock: _React.ComponentType<CodeblockProps>;
|
||||||
}
|
}
|
||||||
toasts: {
|
toasts: {
|
||||||
showToast: (content: string, asset: number) => void;
|
showToast: (content: string, asset: number) => void;
|
||||||
|
|
|
@ -3,6 +3,7 @@ import { patchCommands } from "@lib/commands";
|
||||||
import { initPlugins } from "@lib/plugins";
|
import { initPlugins } from "@lib/plugins";
|
||||||
import { patchAssets } from "@ui/assets";
|
import { patchAssets } from "@ui/assets";
|
||||||
import initQuickInstall from "@ui/quickInstall";
|
import initQuickInstall from "@ui/quickInstall";
|
||||||
|
import initSafeMode from "@ui/safeMode";
|
||||||
import initSettings from "@ui/settings";
|
import initSettings from "@ui/settings";
|
||||||
import initFixes from "@lib/fixes";
|
import initFixes from "@lib/fixes";
|
||||||
import logger from "@lib/logger";
|
import logger from "@lib/logger";
|
||||||
|
@ -15,6 +16,7 @@ export default async () => {
|
||||||
patchAssets(),
|
patchAssets(),
|
||||||
patchCommands(),
|
patchCommands(),
|
||||||
initFixes(),
|
initFixes(),
|
||||||
|
initSafeMode(),
|
||||||
initSettings(),
|
initSettings(),
|
||||||
initQuickInstall(),
|
initQuickInstall(),
|
||||||
]);
|
]);
|
||||||
|
|
|
@ -1,12 +1,27 @@
|
||||||
import { RNConstants } from "@types";
|
import { RNConstants } from "@types";
|
||||||
import { ReactNative as RN } from "@metro/common";
|
import { ReactNative as RN } from "@metro/common";
|
||||||
import { after } from "@lib/patcher";
|
import { after } from "@lib/patcher";
|
||||||
import { ClientInfoManager, DeviceManager } from "@lib/native";
|
import { ClientInfoManager, DeviceManager, BundleUpdaterManager } from "@lib/native";
|
||||||
|
import { getCurrentTheme, selectTheme } from "@lib/themes";
|
||||||
import { getAssetIDByName } from "@ui/assets";
|
import { getAssetIDByName } from "@ui/assets";
|
||||||
import { showToast } from "@ui/toasts";
|
import { showToast } from "@ui/toasts";
|
||||||
|
import settings from "@lib/settings";
|
||||||
import logger from "@lib/logger";
|
import logger from "@lib/logger";
|
||||||
export let socket: WebSocket;
|
export let socket: WebSocket;
|
||||||
|
|
||||||
|
export async function toggleSafeMode() {
|
||||||
|
settings.safeMode = { ...settings.safeMode, enabled: !settings.safeMode?.enabled }
|
||||||
|
if (window.__vendetta_loader?.features.themes) {
|
||||||
|
if (getCurrentTheme()?.id) settings.safeMode!.currentThemeId = getCurrentTheme()!.id;
|
||||||
|
if (settings.safeMode?.enabled) {
|
||||||
|
await selectTheme("default");
|
||||||
|
} else if (settings.safeMode?.currentThemeId) {
|
||||||
|
await selectTheme(settings.safeMode?.currentThemeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setTimeout(BundleUpdaterManager.reload);
|
||||||
|
}
|
||||||
|
|
||||||
export function connectToDebugger(url: string) {
|
export function connectToDebugger(url: string) {
|
||||||
if (socket !== undefined && socket.readyState !== WebSocket.CLOSED) socket.close();
|
if (socket !== undefined && socket.readyState !== WebSocket.CLOSED) socket.close();
|
||||||
|
|
||||||
|
@ -16,7 +31,7 @@ export function connectToDebugger(url: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
socket = new WebSocket(`ws://${url}`);
|
socket = new WebSocket(`ws://${url}`);
|
||||||
|
|
||||||
socket.addEventListener("open", () => showToast("Connected to debugger.", getAssetIDByName("Check")));
|
socket.addEventListener("open", () => showToast("Connected to debugger.", getAssetIDByName("Check")));
|
||||||
socket.addEventListener("message", (message: any) => {
|
socket.addEventListener("message", (message: any) => {
|
||||||
try {
|
try {
|
||||||
|
@ -32,7 +47,7 @@ export function connectToDebugger(url: string) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function patchLogHook() {
|
export function patchLogHook() {
|
||||||
const unpatch = after("nativeLoggingHook", globalThis, (args) => {
|
const unpatch = after("nativeLoggingHook", globalThis, (args) => {
|
||||||
if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ message: args[0], level: args[1] }));
|
if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ message: args[0], level: args[1] }));
|
||||||
logger.log(args[0]);
|
logger.log(args[0]);
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { Indexable, PluginManifest, Plugin } from "@types";
|
import { Indexable, PluginManifest, Plugin } from "@types";
|
||||||
import { awaitSyncWrapper, createMMKVBackend, createStorage, wrapSync } from "@lib/storage";
|
import { awaitSyncWrapper, createMMKVBackend, createStorage, wrapSync } from "@lib/storage";
|
||||||
import { MMKVManager } from "@lib/native";
|
import { MMKVManager } from "@lib/native";
|
||||||
|
import settings from "@lib/settings";
|
||||||
import logger, { logModule } from "@lib/logger";
|
import logger, { logModule } from "@lib/logger";
|
||||||
import safeFetch from "@utils/safeFetch";
|
import safeFetch from "@utils/safeFetch";
|
||||||
|
|
||||||
|
@ -76,9 +77,11 @@ export async function startPlugin(id: string) {
|
||||||
if (!plugin) throw new Error("Attempted to start non-existent plugin");
|
if (!plugin) throw new Error("Attempted to start non-existent plugin");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const pluginRet: EvaledPlugin = await evalPlugin(plugin);
|
if (!settings.safeMode?.enabled) {
|
||||||
loadedPlugins[id] = pluginRet;
|
const pluginRet: EvaledPlugin = await evalPlugin(plugin);
|
||||||
pluginRet.onLoad?.();
|
loadedPlugins[id] = pluginRet;
|
||||||
|
pluginRet.onLoad?.();
|
||||||
|
}
|
||||||
plugin.enabled = true;
|
plugin.enabled = true;
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
logger.error(`Plugin ${plugin.id} errored whilst loading, and will be unloaded`, e);
|
logger.error(`Plugin ${plugin.id} errored whilst loading, and will be unloaded`, e);
|
||||||
|
@ -99,15 +102,17 @@ export function stopPlugin(id: string, disable = true) {
|
||||||
const plugin = plugins[id];
|
const plugin = plugins[id];
|
||||||
const pluginRet = loadedPlugins[id];
|
const pluginRet = loadedPlugins[id];
|
||||||
if (!plugin) throw new Error("Attempted to stop non-existent plugin");
|
if (!plugin) throw new Error("Attempted to stop non-existent plugin");
|
||||||
if (!pluginRet) throw new Error("Attempted to stop a non-started plugin");
|
|
||||||
|
|
||||||
try {
|
if (!settings.safeMode?.enabled) {
|
||||||
pluginRet.onUnload?.();
|
try {
|
||||||
} catch(e) {
|
pluginRet?.onUnload?.();
|
||||||
logger.error(`Plugin ${plugin.id} errored whilst unloading`, e);
|
} catch(e) {
|
||||||
|
logger.error(`Plugin ${plugin.id} errored whilst unloading`, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
delete loadedPlugins[id];
|
||||||
}
|
}
|
||||||
|
|
||||||
delete loadedPlugins[id];
|
|
||||||
disable && (plugin.enabled = false);
|
disable && (plugin.enabled = false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -120,13 +125,16 @@ export function removePlugin(id: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function initPlugins() {
|
export async function initPlugins() {
|
||||||
|
await awaitSyncWrapper(settings);
|
||||||
await awaitSyncWrapper(plugins);
|
await awaitSyncWrapper(plugins);
|
||||||
const allIds = Object.keys(plugins);
|
const allIds = Object.keys(plugins);
|
||||||
|
|
||||||
// Loop over any plugin that is enabled, update it if allowed, then start it.
|
if (!settings.safeMode?.enabled) {
|
||||||
await Promise.allSettled(allIds.filter(pl => plugins[pl].enabled).map(async (pl) => (plugins[pl].update && await fetchPlugin(pl), await startPlugin(pl))));
|
// Loop over any plugin that is enabled, update it if allowed, then start it.
|
||||||
// Wait for the above to finish, then update all disabled plugins that are allowed to.
|
await Promise.allSettled(allIds.filter(pl => plugins[pl].enabled).map(async (pl) => (plugins[pl].update && await fetchPlugin(pl), await startPlugin(pl))));
|
||||||
allIds.filter(pl => !plugins[pl].enabled && plugins[pl].update).forEach(pl => fetchPlugin(pl));
|
// Wait for the above to finish, then update all disabled plugins that are allowed to.
|
||||||
|
allIds.filter(pl => !plugins[pl].enabled && plugins[pl].update).forEach(pl => fetchPlugin(pl));
|
||||||
|
};
|
||||||
|
|
||||||
return stopAllPlugins;
|
return stopAllPlugins;
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,7 +22,7 @@ export default async (unloads: any[]): Promise<VendettaObject> => ({
|
||||||
metro: { ...metro, common: { ...common } },
|
metro: { ...metro, common: { ...common } },
|
||||||
constants,
|
constants,
|
||||||
utils,
|
utils,
|
||||||
debug: utils.without(debug, "versionHash", "patchLogHook"),
|
debug: utils.without(debug, "versionHash", "patchLogHook", "toggleSafeMode"),
|
||||||
ui: {
|
ui: {
|
||||||
components,
|
components,
|
||||||
toasts,
|
toasts,
|
||||||
|
|
30
src/ui/components/Codeblock.tsx
Normal file
30
src/ui/components/Codeblock.tsx
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
import { CodeblockProps } from "@types";
|
||||||
|
import { ReactNative as RN, stylesheet, constants } from "@metro/common";
|
||||||
|
import { semanticColors } from "@ui/color";
|
||||||
|
|
||||||
|
const styles = stylesheet.createThemedStyleSheet({
|
||||||
|
codeBlock: {
|
||||||
|
fontFamily: constants.Fonts.CODE_SEMIBOLD,
|
||||||
|
fontSize: 12,
|
||||||
|
textAlignVertical: "center",
|
||||||
|
backgroundColor: semanticColors.BACKGROUND_SECONDARY,
|
||||||
|
color: semanticColors.TEXT_NORMAL,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 4,
|
||||||
|
borderColor: semanticColors.BACKGROUND_TERTIARY,
|
||||||
|
padding: 10,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// iOS doesn't support the selectable property on RN.Text...
|
||||||
|
const InputBasedCodeblock = ({ style, children }: CodeblockProps) => <RN.TextInput editable={false} multiline style={[styles.codeBlock, style && style]} value={children} />
|
||||||
|
const TextBasedCodeblock = ({ selectable, style, children }: CodeblockProps) => <RN.Text selectable={selectable} style={[styles.codeBlock, style && style]}>{children}</RN.Text>
|
||||||
|
|
||||||
|
export default function Codeblock({ selectable, style, children }: CodeblockProps) {
|
||||||
|
if (!selectable) return <TextBasedCodeblock style={style} children={children} />;
|
||||||
|
|
||||||
|
return RN.Platform.select({
|
||||||
|
ios: <InputBasedCodeblock style={style} children={children} />,
|
||||||
|
default: <TextBasedCodeblock style={style} children={children} selectable />,
|
||||||
|
});
|
||||||
|
}
|
|
@ -1,7 +1,6 @@
|
||||||
import { ErrorBoundaryProps } from "@types";
|
import { ErrorBoundaryProps } from "@types";
|
||||||
import { React, ReactNative as RN, stylesheet, constants } from "@metro/common";
|
import { React, ReactNative as RN, stylesheet, constants } from "@metro/common";
|
||||||
import { findByProps } from "@metro/filters";
|
import { Forms, Button, Codeblock } from "@ui/components";
|
||||||
import { Forms } from "@ui/components";
|
|
||||||
import { semanticColors } from "@ui/color";
|
import { semanticColors } from "@ui/color";
|
||||||
|
|
||||||
interface ErrorBoundaryState {
|
interface ErrorBoundaryState {
|
||||||
|
@ -9,8 +8,6 @@ interface ErrorBoundaryState {
|
||||||
errText?: string;
|
errText?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Button = findByProps("Looks", "Colors", "Sizes") as any;
|
|
||||||
|
|
||||||
const styles = stylesheet.createThemedStyleSheet({
|
const styles = stylesheet.createThemedStyleSheet({
|
||||||
view: {
|
view: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
@ -22,15 +19,6 @@ const styles = stylesheet.createThemedStyleSheet({
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
marginBottom: 5,
|
marginBottom: 5,
|
||||||
},
|
},
|
||||||
codeblock: {
|
|
||||||
fontFamily: constants.Fonts.CODE_SEMIBOLD,
|
|
||||||
includeFontPadding: false,
|
|
||||||
fontSize: 12,
|
|
||||||
backgroundColor: semanticColors.BACKGROUND_SECONDARY,
|
|
||||||
padding: 5,
|
|
||||||
borderRadius: 5,
|
|
||||||
marginBottom: 5,
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export default class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
export default class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||||
|
@ -47,7 +35,7 @@ export default class ErrorBoundary extends React.Component<ErrorBoundaryProps, E
|
||||||
return (
|
return (
|
||||||
<RN.ScrollView style={styles.view}>
|
<RN.ScrollView style={styles.view}>
|
||||||
<Forms.FormText style={styles.title}>Uh oh.</Forms.FormText>
|
<Forms.FormText style={styles.title}>Uh oh.</Forms.FormText>
|
||||||
<Forms.FormText style={styles.codeblock}>{this.state.errText}</Forms.FormText>
|
<Codeblock selectable style={{ marginBottom: 5 }}>{this.state.errText}</Codeblock>
|
||||||
<Button
|
<Button
|
||||||
color={Button.Colors.RED}
|
color={Button.Colors.RED}
|
||||||
size={Button.Sizes.MEDIUM}
|
size={Button.Sizes.MEDIUM}
|
||||||
|
|
|
@ -5,7 +5,10 @@ export const Forms = findByProps("Form", "FormSection");
|
||||||
export const General = findByProps("Button", "Text", "View");
|
export const General = findByProps("Button", "Text", "View");
|
||||||
export const Search = findByName("StaticSearchBarContainer");
|
export const Search = findByName("StaticSearchBarContainer");
|
||||||
export const Alert = findByProps("alertDarkStyles", "alertLightStyles").default;
|
export const Alert = findByProps("alertDarkStyles", "alertLightStyles").default;
|
||||||
|
export const Button = findByProps("Looks", "Colors", "Sizes") as React.ComponentType<any> & { Looks: any, Colors: any, Sizes: any };
|
||||||
|
export const HelpMessage = findByName("HelpMessage");
|
||||||
|
|
||||||
// Vendetta
|
// Vendetta
|
||||||
export { default as Summary } from "@ui/components/Summary";
|
export { default as Summary } from "@ui/components/Summary";
|
||||||
export { default as ErrorBoundary } from "@ui/components/ErrorBoundary";
|
export { default as ErrorBoundary } from "@ui/components/ErrorBoundary";
|
||||||
|
export { default as Codeblock } from "@ui/components/Codeblock";
|
133
src/ui/safeMode.tsx
Normal file
133
src/ui/safeMode.tsx
Normal file
|
@ -0,0 +1,133 @@
|
||||||
|
import { ButtonColors } from "@types";
|
||||||
|
import { ReactNative as RN, stylesheet } from "@metro/common";
|
||||||
|
import { findByName, findByProps, findByStoreName } from "@metro/filters";
|
||||||
|
import { after } from "@lib/patcher";
|
||||||
|
import { DeviceManager } from "@lib/native";
|
||||||
|
import { toggleSafeMode } from "@lib/debug";
|
||||||
|
import { semanticColors } from "@ui/color";
|
||||||
|
import { Button, Codeblock, ErrorBoundary as _ErrorBoundary } from "@ui/components";
|
||||||
|
import settings from "@lib/settings";
|
||||||
|
|
||||||
|
const ErrorBoundary = findByName("ErrorBoundary");
|
||||||
|
|
||||||
|
// React Native's included SafeAreaView only adds padding on iOS.
|
||||||
|
const { SafeAreaView } = findByProps("useSafeAreaInsets");
|
||||||
|
// Let's just pray they have this.
|
||||||
|
const { BadgableTabBar } = findByProps("BadgableTabBar");
|
||||||
|
|
||||||
|
const ThemeStore = findByStoreName("ThemeStore");
|
||||||
|
|
||||||
|
const { TextStyleSheet } = findByProps("TextStyleSheet");
|
||||||
|
const styles = stylesheet.createThemedStyleSheet({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: semanticColors.BACKGROUND_PRIMARY,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flex: 1,
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
marginVertical: 8,
|
||||||
|
},
|
||||||
|
headerTitle: {
|
||||||
|
...TextStyleSheet["heading-md/semibold"],
|
||||||
|
textAlign: "center",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: semanticColors.HEADER_PRIMARY,
|
||||||
|
},
|
||||||
|
headerDescription: {
|
||||||
|
...TextStyleSheet["text-sm/medium"],
|
||||||
|
textAlign: "center",
|
||||||
|
color: semanticColors.TEXT_MUTED,
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
flexDirection: DeviceManager.isTablet ? "row" : "column",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
marginVertical: 8,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
interface Tab {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
trimWhitespace?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Button {
|
||||||
|
text: string;
|
||||||
|
// TODO: Proper types for the below
|
||||||
|
color?: string;
|
||||||
|
size?: string;
|
||||||
|
onPress: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabs: Tab[] = [
|
||||||
|
{ id: "message",title: "Message" },
|
||||||
|
{ id: "stack", title: "Stack Trace" },
|
||||||
|
{ id: "componentStack", title: "Component", trimWhitespace: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default () => after("render", ErrorBoundary.prototype, function (this: any, _, ret) {
|
||||||
|
if (!this.state.error) return;
|
||||||
|
|
||||||
|
// Not using setState here as we don't want to cause a re-render, we want this to be set in the initial render
|
||||||
|
this.state.activeTab ??= "message";
|
||||||
|
const tabData = tabs.find(t => t.id === this.state.activeTab);
|
||||||
|
const errorText: string = this.state.error[this.state.activeTab];
|
||||||
|
|
||||||
|
// This is in the patch and not outside of it so that we can use `this`, e.g. for setting state
|
||||||
|
const buttons: Button[] = [
|
||||||
|
{ text: "Restart Discord", onPress: this.handleReload },
|
||||||
|
...!settings.safeMode?.enabled ? [{ text: "Restart in Safe Mode", onPress: toggleSafeMode }] : [],
|
||||||
|
{ text: "Retry Render", color: ButtonColors.RED, onPress: () => this.setState({ info: null, error: null }) },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<_ErrorBoundary>
|
||||||
|
<SafeAreaView style={styles.container}>
|
||||||
|
<RN.View style={styles.header}>
|
||||||
|
<RN.Image style={{ flex: 1, resizeMode: "contain", maxHeight: 96, paddingRight: 4 }} source={ThemeStore.theme === "light" ? ret.props.lightSource : ret.props.darkSource} />
|
||||||
|
<RN.View style={{ flex: 2, paddingLeft: 4 }}>
|
||||||
|
<RN.Text style={styles.headerTitle}>{ret.props.title}</RN.Text>
|
||||||
|
<RN.Text style={styles.headerDescription}>{ret.props.body}</RN.Text>
|
||||||
|
</RN.View>
|
||||||
|
</RN.View>
|
||||||
|
<RN.View style={{ flex: 6 }}>
|
||||||
|
<RN.View style={{ paddingBottom: 8 }}>
|
||||||
|
{/* Are errors caught by ErrorBoundary guaranteed to have the component stack? */}
|
||||||
|
<BadgableTabBar
|
||||||
|
tabs={tabs}
|
||||||
|
activeTab={this.state.activeTab}
|
||||||
|
onTabSelected={(tab: string) => { this.setState({ activeTab: tab }) }}
|
||||||
|
/>
|
||||||
|
</RN.View>
|
||||||
|
<Codeblock
|
||||||
|
selectable
|
||||||
|
style={{ flex: 1, textAlignVertical: "top" }}
|
||||||
|
>
|
||||||
|
{/*
|
||||||
|
TODO: I tried to get this working as intended using regex and failed.
|
||||||
|
When trimWhitespace is true, each line should have it's whitespace removed but with it's spaces kept.
|
||||||
|
*/}
|
||||||
|
{tabData?.trimWhitespace ? errorText.split("\n").filter(i => i.length !== 0).map(i => i.trim()).join("\n") : errorText}
|
||||||
|
</Codeblock>
|
||||||
|
</RN.View>
|
||||||
|
<RN.View style={styles.footer}>
|
||||||
|
{buttons.map(button => {
|
||||||
|
const buttonIndex = buttons.indexOf(button) !== 0 ? 8 : 0;
|
||||||
|
|
||||||
|
return <Button
|
||||||
|
text={button.text}
|
||||||
|
color={button.color ?? ButtonColors.BRAND}
|
||||||
|
size={button.size ?? "small"}
|
||||||
|
onPress={button.onPress}
|
||||||
|
style={DeviceManager.isTablet ? { flex: `0.${buttons.length}`, marginLeft: buttonIndex } : { marginTop: buttonIndex }}
|
||||||
|
/>
|
||||||
|
})}
|
||||||
|
</RN.View>
|
||||||
|
</SafeAreaView>
|
||||||
|
</_ErrorBoundary>
|
||||||
|
)
|
||||||
|
});
|
|
@ -48,7 +48,7 @@ interface CardProps {
|
||||||
index?: number;
|
index?: number;
|
||||||
headerLabel: string | React.ComponentType;
|
headerLabel: string | React.ComponentType;
|
||||||
headerIcon?: string;
|
headerIcon?: string;
|
||||||
toggleType: "switch" | "radio";
|
toggleType?: "switch" | "radio";
|
||||||
toggleValue?: boolean;
|
toggleValue?: boolean;
|
||||||
onToggleChange?: (v: boolean) => void;
|
onToggleChange?: (v: boolean) => void;
|
||||||
descriptionLabel?: string | React.ComponentType;
|
descriptionLabel?: string | React.ComponentType;
|
||||||
|
@ -66,7 +66,7 @@ export default function Card(props: CardProps) {
|
||||||
style={styles.header}
|
style={styles.header}
|
||||||
label={props.headerLabel}
|
label={props.headerLabel}
|
||||||
leading={props.headerIcon && <FormRow.Icon source={getAssetIDByName(props.headerIcon)} />}
|
leading={props.headerIcon && <FormRow.Icon source={getAssetIDByName(props.headerIcon)} />}
|
||||||
trailing={props.toggleType === "switch" ?
|
trailing={props.toggleType && (props.toggleType === "switch" ?
|
||||||
(<FormSwitch
|
(<FormSwitch
|
||||||
style={RN.Platform.OS === "android" && { marginVertical: -15 }}
|
style={RN.Platform.OS === "android" && { marginVertical: -15 }}
|
||||||
value={props.toggleValue}
|
value={props.toggleValue}
|
||||||
|
@ -80,7 +80,7 @@ export default function Card(props: CardProps) {
|
||||||
{/* TODO: Look into making this respect brand color */}
|
{/* TODO: Look into making this respect brand color */}
|
||||||
<FormRadio selected={props.toggleValue} />
|
<FormRadio selected={props.toggleValue} />
|
||||||
</RN.Pressable>)
|
</RN.Pressable>)
|
||||||
}
|
)}
|
||||||
/>
|
/>
|
||||||
<FormRow
|
<FormRow
|
||||||
label={props.descriptionLabel}
|
label={props.descriptionLabel}
|
||||||
|
|
|
@ -2,9 +2,11 @@ import { ButtonColors, Theme } from "@types";
|
||||||
import { clipboard } from "@metro/common";
|
import { clipboard } from "@metro/common";
|
||||||
import { fetchTheme, removeTheme, selectTheme } from "@lib/themes";
|
import { fetchTheme, removeTheme, selectTheme } from "@lib/themes";
|
||||||
import { BundleUpdaterManager } from "@lib/native";
|
import { BundleUpdaterManager } from "@lib/native";
|
||||||
|
import { useProxy } from "@lib/storage";
|
||||||
import { getAssetIDByName } from "@ui/assets";
|
import { getAssetIDByName } from "@ui/assets";
|
||||||
import { showConfirmationAlert } from "@ui/alerts";
|
import { showConfirmationAlert } from "@ui/alerts";
|
||||||
import { showToast } from "@ui/toasts";
|
import { showToast } from "@ui/toasts";
|
||||||
|
import settings from "@lib/settings";
|
||||||
import Card from "@ui/settings/components/Card";
|
import Card from "@ui/settings/components/Card";
|
||||||
|
|
||||||
interface ThemeCardProps {
|
interface ThemeCardProps {
|
||||||
|
@ -18,6 +20,7 @@ async function selectAndReload(value: boolean, id: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ThemeCard({ theme, index }: ThemeCardProps) {
|
export default function ThemeCard({ theme, index }: ThemeCardProps) {
|
||||||
|
useProxy(settings);
|
||||||
const [removed, setRemoved] = React.useState(false);
|
const [removed, setRemoved] = React.useState(false);
|
||||||
|
|
||||||
// This is needed because of React™
|
// This is needed because of React™
|
||||||
|
@ -30,7 +33,7 @@ export default function ThemeCard({ theme, index }: ThemeCardProps) {
|
||||||
index={index}
|
index={index}
|
||||||
headerLabel={`${theme.data.name} ${authors ? `by ${authors.map(i => i.name).join(", ")}` : ""}`}
|
headerLabel={`${theme.data.name} ${authors ? `by ${authors.map(i => i.name).join(", ")}` : ""}`}
|
||||||
descriptionLabel={theme.data.description ?? "No description."}
|
descriptionLabel={theme.data.description ?? "No description."}
|
||||||
toggleType="radio"
|
toggleType={!settings.safeMode?.enabled ? "radio" : undefined}
|
||||||
toggleValue={theme.selected}
|
toggleValue={theme.selected}
|
||||||
onToggleChange={(v: boolean) => {
|
onToggleChange={(v: boolean) => {
|
||||||
selectAndReload(v, theme.id);
|
selectAndReload(v, theme.id);
|
||||||
|
|
|
@ -3,6 +3,7 @@ import { findByName } from "@metro/filters";
|
||||||
import { after } from "@lib/patcher";
|
import { after } from "@lib/patcher";
|
||||||
import { installPlugin } from "@lib/plugins";
|
import { installPlugin } from "@lib/plugins";
|
||||||
import { installTheme } from "@lib/themes";
|
import { installTheme } from "@lib/themes";
|
||||||
|
import { Forms } from "@ui/components";
|
||||||
import findInReactTree from "@utils/findInReactTree";
|
import findInReactTree from "@utils/findInReactTree";
|
||||||
import without from "@utils/without";
|
import without from "@utils/without";
|
||||||
import ErrorBoundary from "@ui/components/ErrorBoundary";
|
import ErrorBoundary from "@ui/components/ErrorBoundary";
|
||||||
|
@ -13,7 +14,7 @@ import Plugins from "@ui/settings/pages/Plugins";
|
||||||
import Themes from "@ui/settings/pages/Themes";
|
import Themes from "@ui/settings/pages/Themes";
|
||||||
import Developer from "@ui/settings/pages/Developer";
|
import Developer from "@ui/settings/pages/Developer";
|
||||||
import AssetBrowser from "@ui/settings/pages/AssetBrowser";
|
import AssetBrowser from "@ui/settings/pages/AssetBrowser";
|
||||||
import { Forms } from "@ui/components";
|
import settings from "@lib/settings";
|
||||||
|
|
||||||
const screensModule = findByName("getScreens", false);
|
const screensModule = findByName("getScreens", false);
|
||||||
const settingsModule = findByName("UserSettingsOverviewWrapper", false);
|
const settingsModule = findByName("UserSettingsOverviewWrapper", false);
|
||||||
|
@ -36,7 +37,7 @@ export default function initSettings() {
|
||||||
VendettaThemes: {
|
VendettaThemes: {
|
||||||
title: "Themes",
|
title: "Themes",
|
||||||
render: Themes,
|
render: Themes,
|
||||||
headerRight: () => <InstallButton alertTitle="Install Theme" installFunction={installTheme} />,
|
headerRight: !settings.safeMode?.enabled && (() => <InstallButton alertTitle="Install Theme" installFunction={installTheme} />),
|
||||||
},
|
},
|
||||||
VendettaDeveloper: {
|
VendettaDeveloper: {
|
||||||
title: "Developer",
|
title: "Developer",
|
||||||
|
@ -48,11 +49,10 @@ export default function initSettings() {
|
||||||
},
|
},
|
||||||
VendettaCustomPage: {
|
VendettaCustomPage: {
|
||||||
title: "Vendetta Page",
|
title: "Vendetta Page",
|
||||||
render: ({ render: PageView, ...options }: { render: React.ComponentType } & Record<string, object>) => {
|
render: ({ render: PageView, noErrorBoundary, ...options }: { render: React.ComponentType, noErrorBoundary: boolean } & Record<string, object>) => {
|
||||||
const navigation = NavigationNative.useNavigation();
|
const navigation = NavigationNative.useNavigation();
|
||||||
React.useEffect(() => options && navigation.setOptions(without(options, "render")), []);
|
React.useEffect(() => options && navigation.setOptions(without(options, "render", "noErrorBoundary")), []);
|
||||||
// TODO: Is wrapping this in ErrorBoundary a good idea?
|
return noErrorBoundary ? <PageView /> : <ErrorBoundary><PageView /></ErrorBoundary>;
|
||||||
return <ErrorBoundary><PageView /></ErrorBoundary>;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import { ReactNative as RN, NavigationNative } from "@metro/common";
|
import { ReactNative as RN, NavigationNative } from "@metro/common";
|
||||||
|
import { findByProps } from "@metro/filters";
|
||||||
import { Forms } from "@ui/components";
|
import { Forms } from "@ui/components";
|
||||||
import { getAssetIDByName } from "@ui/assets";
|
import { getAssetIDByName } from "@ui/assets";
|
||||||
import { connectToDebugger } from "@lib/debug";
|
import { connectToDebugger } from "@lib/debug";
|
||||||
|
@ -7,6 +8,8 @@ import settings, { loaderConfig } from "@lib/settings";
|
||||||
import ErrorBoundary from "@ui/components/ErrorBoundary";
|
import ErrorBoundary from "@ui/components/ErrorBoundary";
|
||||||
|
|
||||||
const { FormSection, FormRow, FormSwitchRow, FormInput, FormDivider } = Forms;
|
const { FormSection, FormRow, FormSwitchRow, FormInput, FormDivider } = Forms;
|
||||||
|
const { hideActionSheet } = findByProps("openLazy", "hideActionSheet");
|
||||||
|
const { showSimpleActionSheet } = findByProps("showSimpleActionSheet");
|
||||||
|
|
||||||
export default function Developer() {
|
export default function Developer() {
|
||||||
const navigation = NavigationNative.useNavigation();
|
const navigation = NavigationNative.useNavigation();
|
||||||
|
@ -79,6 +82,26 @@ export default function Developer() {
|
||||||
trailing={FormRow.Arrow}
|
trailing={FormRow.Arrow}
|
||||||
onPress={() => navigation.push("VendettaAssetBrowser")}
|
onPress={() => navigation.push("VendettaAssetBrowser")}
|
||||||
/>
|
/>
|
||||||
|
<FormDivider />
|
||||||
|
<FormRow
|
||||||
|
label="ErrorBoundary Tools"
|
||||||
|
leading={<FormRow.Icon source={getAssetIDByName("ic_warning_24px")} />}
|
||||||
|
trailing={FormRow.Arrow}
|
||||||
|
onPress={() => showSimpleActionSheet({
|
||||||
|
key: "ErrorBoundaryTools",
|
||||||
|
header: {
|
||||||
|
title: "Which ErrorBoundary do you want to trip?",
|
||||||
|
icon: <FormRow.Icon style={{ marginRight: 8 }} source={getAssetIDByName("ic_warning_24px")} />,
|
||||||
|
onClose: () => hideActionSheet(),
|
||||||
|
},
|
||||||
|
options: [
|
||||||
|
// @ts-expect-error
|
||||||
|
// Of course, to trigger an error, we need to do something incorrectly. The below will do!
|
||||||
|
{ label: "Vendetta", onPress: () => navigation.push("VendettaCustomPage", { render: () => <undefined /> }) },
|
||||||
|
{ label: "Discord", isDestructive: true, onPress: () => navigation.push("VendettaCustomPage", { noErrorBoundary: true }) },
|
||||||
|
],
|
||||||
|
})}
|
||||||
|
/>
|
||||||
</FormSection>
|
</FormSection>
|
||||||
</RN.ScrollView>
|
</RN.ScrollView>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
|
|
|
@ -2,7 +2,7 @@ import { ReactNative as RN, url } from "@metro/common";
|
||||||
import { getAssetIDByName } from "@ui/assets";
|
import { getAssetIDByName } from "@ui/assets";
|
||||||
import { Forms, Summary } from "@ui/components";
|
import { Forms, Summary } from "@ui/components";
|
||||||
import { DISCORD_SERVER, GITHUB } from "@lib/constants";
|
import { DISCORD_SERVER, GITHUB } from "@lib/constants";
|
||||||
import { getDebugInfo } from "@lib/debug";
|
import { getDebugInfo, toggleSafeMode } from "@lib/debug";
|
||||||
import { useProxy } from "@lib/storage";
|
import { useProxy } from "@lib/storage";
|
||||||
import { BundleUpdaterManager } from "@lib/native";
|
import { BundleUpdaterManager } from "@lib/native";
|
||||||
import settings from "@lib/settings";
|
import settings from "@lib/settings";
|
||||||
|
@ -106,6 +106,13 @@ export default function General() {
|
||||||
onPress={() => BundleUpdaterManager.reload()}
|
onPress={() => BundleUpdaterManager.reload()}
|
||||||
/>
|
/>
|
||||||
<FormDivider />
|
<FormDivider />
|
||||||
|
<FormRow
|
||||||
|
label={settings.safeMode?.enabled ? "Return to Normal Mode" : "Reload in Safe Mode"}
|
||||||
|
subLabel={`This will reload Discord ${settings.safeMode?.enabled ? "normally." : "without loading plugins."}`}
|
||||||
|
leading={<FormRow.Icon source={getAssetIDByName("ic_privacy_24px")} />}
|
||||||
|
onPress={toggleSafeMode}
|
||||||
|
/>
|
||||||
|
<FormDivider />
|
||||||
<FormSwitchRow
|
<FormSwitchRow
|
||||||
label="Developer Settings"
|
label="Developer Settings"
|
||||||
leading={<FormRow.Icon source={getAssetIDByName("ic_progress_wrench_24px")} />}
|
leading={<FormRow.Icon source={getAssetIDByName("ic_progress_wrench_24px")} />}
|
||||||
|
|
|
@ -1,15 +1,21 @@
|
||||||
import { ReactNative as RN } from "@metro/common";
|
import { ReactNative as RN } from "@metro/common";
|
||||||
import { useProxy } from "@lib/storage";
|
import { useProxy } from "@lib/storage";
|
||||||
import { plugins } from "@lib/plugins";
|
import { plugins } from "@lib/plugins";
|
||||||
|
import { HelpMessage } from "@ui/components";
|
||||||
|
import settings from "@lib/settings";
|
||||||
import PluginCard from "@ui/settings/components/PluginCard";
|
import PluginCard from "@ui/settings/components/PluginCard";
|
||||||
import ErrorBoundary from "@ui/components/ErrorBoundary";
|
import ErrorBoundary from "@ui/components/ErrorBoundary";
|
||||||
|
|
||||||
export default function Plugins() {
|
export default function Plugins() {
|
||||||
|
useProxy(settings)
|
||||||
useProxy(plugins);
|
useProxy(plugins);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<RN.View style={{ flex: 1 }}>
|
<RN.View style={{ flex: 1 }}>
|
||||||
|
{settings.safeMode?.enabled && <RN.View style={{ margin: 10 }}>
|
||||||
|
<HelpMessage messageType={0}>You are in Safe Mode, so plugins cannot be loaded. Disable any misbehaving plugins, then return to Normal Mode from the General settings page. </HelpMessage>
|
||||||
|
</RN.View>}
|
||||||
<RN.FlatList
|
<RN.FlatList
|
||||||
data={Object.values(plugins)}
|
data={Object.values(plugins)}
|
||||||
renderItem={({ item, index }) => <PluginCard plugin={item} index={index} />}
|
renderItem={({ item, index }) => <PluginCard plugin={item} index={index} />}
|
||||||
|
|
|
@ -1,15 +1,30 @@
|
||||||
import { themes } from "@/lib/themes";
|
import { ButtonColors } from "@types";
|
||||||
import { useProxy } from "@lib/storage";
|
|
||||||
import { ReactNative as RN } from "@metro/common";
|
import { ReactNative as RN } from "@metro/common";
|
||||||
import ErrorBoundary from "@ui/components/ErrorBoundary";
|
import { themes } from "@lib/themes";
|
||||||
|
import { useProxy } from "@lib/storage";
|
||||||
|
import { ErrorBoundary, Button, HelpMessage } from "@ui/components";
|
||||||
|
import settings from "@lib/settings";
|
||||||
import ThemeCard from "@ui/settings/components/ThemeCard";
|
import ThemeCard from "@ui/settings/components/ThemeCard";
|
||||||
|
|
||||||
export default function Themes() {
|
export default function Themes() {
|
||||||
|
useProxy(settings);
|
||||||
useProxy(themes);
|
useProxy(themes);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<RN.View style={{ flex: 1 }}>
|
<RN.View style={{ flex: 1 }}>
|
||||||
|
{settings.safeMode?.enabled && <RN.View style={{ margin: 10 }}>
|
||||||
|
<HelpMessage messageType={0}>You are in Safe Mode, meaning themes have been temporarily disabled.{settings.safeMode?.currentThemeId && " If a theme appears to be causing the issue, you can press below to disable it persistently."}</HelpMessage>
|
||||||
|
{settings.safeMode?.currentThemeId && <Button
|
||||||
|
text="Disable Theme"
|
||||||
|
color={ButtonColors.BRAND}
|
||||||
|
size="small"
|
||||||
|
onPress={() => {
|
||||||
|
delete settings.safeMode?.currentThemeId;
|
||||||
|
}}
|
||||||
|
style={{ marginTop: 8 }}
|
||||||
|
/>}
|
||||||
|
</RN.View>}
|
||||||
<RN.FlatList
|
<RN.FlatList
|
||||||
data={Object.values(themes)}
|
data={Object.values(themes)}
|
||||||
renderItem={({ item, index }) => <ThemeCard theme={item} index={index} />}
|
renderItem={({ item, index }) => <ThemeCard theme={item} index={index} />}
|
||||||
|
|
Loading…
Reference in a new issue