[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:
Beef 2023-04-13 18:42:14 +00:00 committed by GitHub
parent f85fc4b00c
commit 5344f0017a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 293 additions and 47 deletions

15
src/def.d.ts vendored
View file

@ -18,7 +18,13 @@ interface SummaryProps {
}
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
@ -125,6 +131,10 @@ interface Theme {
interface Settings {
debuggerUrl: string;
developerSettings: boolean;
safeMode?: {
enabled: boolean;
currentThemeId?: string;
};
}
interface ApplicationCommand {
@ -404,9 +414,12 @@ interface VendettaObject {
General: PropIntellisense<"Button" | "Text" | "View">;
Search: _React.ComponentType;
Alert: _React.ComponentType;
Button: _React.ComponentType<any> & { Looks: any, Colors: ButtonColors, Sizes: any };
HelpMessage: _React.ComponentType;
// Vendetta
Summary: _React.ComponentType<SummaryProps>;
ErrorBoundary: _React.ComponentType<ErrorBoundaryProps>;
Codeblock: _React.ComponentType<CodeblockProps>;
}
toasts: {
showToast: (content: string, asset: number) => void;

View file

@ -3,6 +3,7 @@ import { patchCommands } from "@lib/commands";
import { initPlugins } from "@lib/plugins";
import { patchAssets } from "@ui/assets";
import initQuickInstall from "@ui/quickInstall";
import initSafeMode from "@ui/safeMode";
import initSettings from "@ui/settings";
import initFixes from "@lib/fixes";
import logger from "@lib/logger";
@ -15,6 +16,7 @@ export default async () => {
patchAssets(),
patchCommands(),
initFixes(),
initSafeMode(),
initSettings(),
initQuickInstall(),
]);

View file

@ -1,12 +1,27 @@
import { RNConstants } from "@types";
import { ReactNative as RN } from "@metro/common";
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 { showToast } from "@ui/toasts";
import settings from "@lib/settings";
import logger from "@lib/logger";
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) {
if (socket !== undefined && socket.readyState !== WebSocket.CLOSED) socket.close();
@ -16,7 +31,7 @@ export function connectToDebugger(url: string) {
}
socket = new WebSocket(`ws://${url}`);
socket.addEventListener("open", () => showToast("Connected to debugger.", getAssetIDByName("Check")));
socket.addEventListener("message", (message: any) => {
try {
@ -32,7 +47,7 @@ export function connectToDebugger(url: string) {
});
}
export function patchLogHook() {
export function patchLogHook() {
const unpatch = after("nativeLoggingHook", globalThis, (args) => {
if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ message: args[0], level: args[1] }));
logger.log(args[0]);

View file

@ -1,6 +1,7 @@
import { Indexable, PluginManifest, Plugin } from "@types";
import { awaitSyncWrapper, createMMKVBackend, createStorage, wrapSync } from "@lib/storage";
import { MMKVManager } from "@lib/native";
import settings from "@lib/settings";
import logger, { logModule } from "@lib/logger";
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");
try {
const pluginRet: EvaledPlugin = await evalPlugin(plugin);
loadedPlugins[id] = pluginRet;
pluginRet.onLoad?.();
if (!settings.safeMode?.enabled) {
const pluginRet: EvaledPlugin = await evalPlugin(plugin);
loadedPlugins[id] = pluginRet;
pluginRet.onLoad?.();
}
plugin.enabled = true;
} catch(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 pluginRet = loadedPlugins[id];
if (!plugin) throw new Error("Attempted to stop non-existent plugin");
if (!pluginRet) throw new Error("Attempted to stop a non-started plugin");
try {
pluginRet.onUnload?.();
} catch(e) {
logger.error(`Plugin ${plugin.id} errored whilst unloading`, e);
if (!settings.safeMode?.enabled) {
try {
pluginRet?.onUnload?.();
} catch(e) {
logger.error(`Plugin ${plugin.id} errored whilst unloading`, e);
}
delete loadedPlugins[id];
}
delete loadedPlugins[id];
disable && (plugin.enabled = false);
}
@ -120,13 +125,16 @@ export function removePlugin(id: string) {
}
export async function initPlugins() {
await awaitSyncWrapper(settings);
await awaitSyncWrapper(plugins);
const allIds = Object.keys(plugins);
// Loop over any plugin that is enabled, update it if allowed, then start it.
await Promise.allSettled(allIds.filter(pl => plugins[pl].enabled).map(async (pl) => (plugins[pl].update && await fetchPlugin(pl), await startPlugin(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));
if (!settings.safeMode?.enabled) {
// Loop over any plugin that is enabled, update it if allowed, then start it.
await Promise.allSettled(allIds.filter(pl => plugins[pl].enabled).map(async (pl) => (plugins[pl].update && await fetchPlugin(pl), await startPlugin(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;
}

View file

@ -22,7 +22,7 @@ export default async (unloads: any[]): Promise<VendettaObject> => ({
metro: { ...metro, common: { ...common } },
constants,
utils,
debug: utils.without(debug, "versionHash", "patchLogHook"),
debug: utils.without(debug, "versionHash", "patchLogHook", "toggleSafeMode"),
ui: {
components,
toasts,

View 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 />,
});
}

View file

@ -1,7 +1,6 @@
import { ErrorBoundaryProps } from "@types";
import { React, ReactNative as RN, stylesheet, constants } from "@metro/common";
import { findByProps } from "@metro/filters";
import { Forms } from "@ui/components";
import { Forms, Button, Codeblock } from "@ui/components";
import { semanticColors } from "@ui/color";
interface ErrorBoundaryState {
@ -9,8 +8,6 @@ interface ErrorBoundaryState {
errText?: string;
}
const Button = findByProps("Looks", "Colors", "Sizes") as any;
const styles = stylesheet.createThemedStyleSheet({
view: {
flex: 1,
@ -22,15 +19,6 @@ const styles = stylesheet.createThemedStyleSheet({
textAlign: "center",
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> {
@ -47,7 +35,7 @@ export default class ErrorBoundary extends React.Component<ErrorBoundaryProps, E
return (
<RN.ScrollView style={styles.view}>
<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
color={Button.Colors.RED}
size={Button.Sizes.MEDIUM}

View file

@ -5,7 +5,10 @@ export const Forms = findByProps("Form", "FormSection");
export const General = findByProps("Button", "Text", "View");
export const Search = findByName("StaticSearchBarContainer");
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
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
View 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>
)
});

View file

@ -48,7 +48,7 @@ interface CardProps {
index?: number;
headerLabel: string | React.ComponentType;
headerIcon?: string;
toggleType: "switch" | "radio";
toggleType?: "switch" | "radio";
toggleValue?: boolean;
onToggleChange?: (v: boolean) => void;
descriptionLabel?: string | React.ComponentType;
@ -66,7 +66,7 @@ export default function Card(props: CardProps) {
style={styles.header}
label={props.headerLabel}
leading={props.headerIcon && <FormRow.Icon source={getAssetIDByName(props.headerIcon)} />}
trailing={props.toggleType === "switch" ?
trailing={props.toggleType && (props.toggleType === "switch" ?
(<FormSwitch
style={RN.Platform.OS === "android" && { marginVertical: -15 }}
value={props.toggleValue}
@ -80,7 +80,7 @@ export default function Card(props: CardProps) {
{/* TODO: Look into making this respect brand color */}
<FormRadio selected={props.toggleValue} />
</RN.Pressable>)
}
)}
/>
<FormRow
label={props.descriptionLabel}

View file

@ -2,9 +2,11 @@ import { ButtonColors, Theme } from "@types";
import { clipboard } from "@metro/common";
import { fetchTheme, removeTheme, selectTheme } from "@lib/themes";
import { BundleUpdaterManager } from "@lib/native";
import { useProxy } from "@lib/storage";
import { getAssetIDByName } from "@ui/assets";
import { showConfirmationAlert } from "@ui/alerts";
import { showToast } from "@ui/toasts";
import settings from "@lib/settings";
import Card from "@ui/settings/components/Card";
interface ThemeCardProps {
@ -18,6 +20,7 @@ async function selectAndReload(value: boolean, id: string) {
}
export default function ThemeCard({ theme, index }: ThemeCardProps) {
useProxy(settings);
const [removed, setRemoved] = React.useState(false);
// This is needed because of React™
@ -30,7 +33,7 @@ export default function ThemeCard({ theme, index }: ThemeCardProps) {
index={index}
headerLabel={`${theme.data.name} ${authors ? `by ${authors.map(i => i.name).join(", ")}` : ""}`}
descriptionLabel={theme.data.description ?? "No description."}
toggleType="radio"
toggleType={!settings.safeMode?.enabled ? "radio" : undefined}
toggleValue={theme.selected}
onToggleChange={(v: boolean) => {
selectAndReload(v, theme.id);

View file

@ -3,6 +3,7 @@ import { findByName } from "@metro/filters";
import { after } from "@lib/patcher";
import { installPlugin } from "@lib/plugins";
import { installTheme } from "@lib/themes";
import { Forms } from "@ui/components";
import findInReactTree from "@utils/findInReactTree";
import without from "@utils/without";
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 Developer from "@ui/settings/pages/Developer";
import AssetBrowser from "@ui/settings/pages/AssetBrowser";
import { Forms } from "@ui/components";
import settings from "@lib/settings";
const screensModule = findByName("getScreens", false);
const settingsModule = findByName("UserSettingsOverviewWrapper", false);
@ -36,7 +37,7 @@ export default function initSettings() {
VendettaThemes: {
title: "Themes",
render: Themes,
headerRight: () => <InstallButton alertTitle="Install Theme" installFunction={installTheme} />,
headerRight: !settings.safeMode?.enabled && (() => <InstallButton alertTitle="Install Theme" installFunction={installTheme} />),
},
VendettaDeveloper: {
title: "Developer",
@ -48,11 +49,10 @@ export default function initSettings() {
},
VendettaCustomPage: {
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();
React.useEffect(() => options && navigation.setOptions(without(options, "render")), []);
// TODO: Is wrapping this in ErrorBoundary a good idea?
return <ErrorBoundary><PageView /></ErrorBoundary>;
React.useEffect(() => options && navigation.setOptions(without(options, "render", "noErrorBoundary")), []);
return noErrorBoundary ? <PageView /> : <ErrorBoundary><PageView /></ErrorBoundary>;
}
}
}

View file

@ -1,4 +1,5 @@
import { ReactNative as RN, NavigationNative } from "@metro/common";
import { findByProps } from "@metro/filters";
import { Forms } from "@ui/components";
import { getAssetIDByName } from "@ui/assets";
import { connectToDebugger } from "@lib/debug";
@ -7,6 +8,8 @@ import settings, { loaderConfig } from "@lib/settings";
import ErrorBoundary from "@ui/components/ErrorBoundary";
const { FormSection, FormRow, FormSwitchRow, FormInput, FormDivider } = Forms;
const { hideActionSheet } = findByProps("openLazy", "hideActionSheet");
const { showSimpleActionSheet } = findByProps("showSimpleActionSheet");
export default function Developer() {
const navigation = NavigationNative.useNavigation();
@ -79,6 +82,26 @@ export default function Developer() {
trailing={FormRow.Arrow}
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>
</RN.ScrollView>
</ErrorBoundary>

View file

@ -2,7 +2,7 @@ import { ReactNative as RN, url } from "@metro/common";
import { getAssetIDByName } from "@ui/assets";
import { Forms, Summary } from "@ui/components";
import { DISCORD_SERVER, GITHUB } from "@lib/constants";
import { getDebugInfo } from "@lib/debug";
import { getDebugInfo, toggleSafeMode } from "@lib/debug";
import { useProxy } from "@lib/storage";
import { BundleUpdaterManager } from "@lib/native";
import settings from "@lib/settings";
@ -106,6 +106,13 @@ export default function General() {
onPress={() => BundleUpdaterManager.reload()}
/>
<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
label="Developer Settings"
leading={<FormRow.Icon source={getAssetIDByName("ic_progress_wrench_24px")} />}

View file

@ -1,15 +1,21 @@
import { ReactNative as RN } from "@metro/common";
import { useProxy } from "@lib/storage";
import { plugins } from "@lib/plugins";
import { HelpMessage } from "@ui/components";
import settings from "@lib/settings";
import PluginCard from "@ui/settings/components/PluginCard";
import ErrorBoundary from "@ui/components/ErrorBoundary";
export default function Plugins() {
useProxy(settings)
useProxy(plugins);
return (
<ErrorBoundary>
<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
data={Object.values(plugins)}
renderItem={({ item, index }) => <PluginCard plugin={item} index={index} />}

View file

@ -1,15 +1,30 @@
import { themes } from "@/lib/themes";
import { useProxy } from "@lib/storage";
import { ButtonColors } from "@types";
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";
export default function Themes() {
useProxy(settings);
useProxy(themes);
return (
<ErrorBoundary>
<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
data={Object.values(themes)}
renderItem={({ item, index }) => <ThemeCard theme={item} index={index} />}