chore: merge dev to main (#54)

This commit is contained in:
Palm 2024-10-27 01:19:07 +07:00 • committed by GitHub
commit fd5eaa3cbd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 587 additions and 219 deletions

View file

@ -1,3 +1,16 @@
# [1.4.0-dev.1](https://github.com/revenge-mod/revenge-bundle/compare/v1.3.1...v1.4.0-dev.1) (2024-10-26)
### Bug Fixes
* actually fix improper FAB insets ([e987723](https://github.com/revenge-mod/revenge-bundle/commit/e9877234befc837126f4a24b23c837e7ed8f6b5b))
### Features
* port ErrorBoundary from dev ([f447910](https://github.com/revenge-mod/revenge-bundle/commit/f44791049a762fbce777b03e9d930fba0c0c6570))
* **settings/pages/Developer:** show assets types, mark non-previewable assets in AssetBrowser ([b7aacf7](https://github.com/revenge-mod/revenge-bundle/commit/b7aacf734e665a7af613aa5eaae8507ea31be5cd))
## [1.3.1](https://github.com/revenge-mod/revenge-bundle/compare/v1.3.0...v1.3.1) (2024-10-23)

View file

@ -20,7 +20,8 @@ const metroDeps = await (async () => {
const args = yargs(process.argv.slice(2));
const {
"release-branch": releaseBranch,
"build-minify": buildMinify
"build-minify": buildMinify,
"dev": dev
} = args;
let context = null;
@ -44,7 +45,7 @@ const config = {
".png": "dataurl"
},
define: {
__DEV__: JSON.stringify(releaseBranch !== "main")
__DEV__: dev ?? JSON.stringify(releaseBranch !== "main")
},
inject: ["./shims/asyncIteratorSymbol.js", "./shims/promiseAllSettled.js"],
legalComments: "none",

View file

@ -0,0 +1,21 @@
import ErrorBoundaryScreen from "@core/ui/reporter/components/ErrorBoundaryScreen";
import { after } from "@lib/api/patcher";
import { _lazyContextSymbol } from "@metro/lazy";
import { LazyModuleContext } from "@metro/types";
import { findByNameLazy } from "@metro/wrappers";
function getErrorBoundaryContext() {
const ctxt: LazyModuleContext = findByNameLazy("ErrorBoundary")[_lazyContextSymbol];
return new Promise(resolve => ctxt.getExports(exp => resolve(exp.prototype)));
}
export default function patchErrorBoundary() {
return after.await("render", getErrorBoundaryContext(), function (this: any) {
if (!this.state.error) return;
return <ErrorBoundaryScreen
error={this.state.error}
rerender={() => this.setState({ info: null, error: null })}
/>;
});
}

View file

@ -4,10 +4,10 @@ import { findAssetId } from "@lib/api/assets";
import { settings } from "@lib/api/settings";
import AlertModal, { AlertActionButton } from "@lib/ui/components/wrappers/AlertModal";
import isValidHttpUrl from "@lib/utils/isValidHttpUrl";
import { lazyDestructure } from "@lib/utils/lazy";
import { lazyDestructure, proxyLazy } from "@lib/utils/lazy";
import { findByProps } from "@metro";
import { clipboard } from "@metro/common";
import { Button, FlashList, FloatingActionButton, HelpMessage, IconButton, SafeAreaView, Stack, Text, TextInput } from "@metro/common/components";
import { Button, FlashList, FloatingActionButton, HelpMessage, IconButton, Stack, Text, TextInput } from "@metro/common/components";
import { ErrorBoundary, Search } from "@ui/components";
import fuzzysort from "fuzzysort";
import { ComponentType, ReactNode, useCallback, useMemo } from "react";
@ -39,6 +39,8 @@ interface AddonPageProps<T extends object, I = any> {
ListFooterComponent?: ComponentType<any>;
}
const useSafeAreaInsets = proxyLazy(() => findByProps("useSafeAreaInsets").useSafeAreaInsets);
function InputAlert(props: { label: string, fetchFn: (url: string) => Promise<void>; }) {
const [value, setValue] = React.useState("");
const [error, setError] = React.useState("");
@ -111,6 +113,7 @@ export default function AddonPage<T extends object>({ CardComponent, ...props }:
const [search, setSearch] = React.useState("");
const [sortFn, setSortFn] = React.useState<((a: unknown, b: unknown) => number) | null>(() => null);
const insets = useSafeAreaInsets();
const results = useMemo(() => {
let values = props.items;
@ -198,12 +201,13 @@ export default function AddonPage<T extends object>({ CardComponent, ...props }:
renderItem={({ item }: any) => <CardComponent item={item.obj} result={item} />}
/>
{props.installAction && (
<SafeAreaView>
<View style={{ paddingBottom: insets.bottom }}>
<FloatingActionButton
positionBottom={insets.bottom + 16}
icon={findAssetId("PlusLargeIcon")}
onPress={onInstallPress}
/>
</SafeAreaView>
</View>
)}
</ErrorBoundary>
);

View file

@ -0,0 +1,52 @@
import { hasStack, isComponentStack } from "@core/ui/reporter/utils/isStack";
import { getDebugInfo, toggleSafeMode } from "@lib/api/debug";
import { BundleUpdaterManager } from "@lib/api/native/modules";
import { settings } from "@lib/api/settings";
import { Codeblock, ErrorBoundary } from "@lib/ui/components";
import { createStyles } from "@lib/ui/styles";
import { tokens } from "@metro/common";
import { Button, Card, SafeAreaView, Text } from "@metro/common/components";
import { ScrollView, View } from "react-native";
import ErrorComponentStackCard from "./ErrorComponentStackCard";
import ErrorStackCard from "./ErrorStackCard";
const useStyles = createStyles({
container: {
flex: 1,
backgroundColor: tokens.colors.BG_BASE_SECONDARY,
paddingHorizontal: 16,
height: "100%",
padding: 8,
gap: 12
}
});
export default function ErrorBoundaryScreen(props: {
error: Error;
rerender: () => void;
}) {
const styles = useStyles();
const debugInfo = getDebugInfo();
return <ErrorBoundary>
<SafeAreaView style={styles.container}>
<View style={{ gap: 4 }}>
<Text variant="display-lg">Uh oh.</Text>
<Text variant="text-md/normal">A crash occured while rendering a component. This could be caused by a plugin, Bunny or Discord itself.</Text>
<Text variant="text-sm/normal" color="text-muted">{debugInfo.os.name}; {debugInfo.discord.build} ({debugInfo.discord.version}); {debugInfo.bunny.version}</Text>
</View>
<ScrollView fadingEdgeLength={64} contentContainerStyle={{ gap: 12 }}>
<Codeblock selectable={true}>{props.error.message}</Codeblock>
{hasStack(props.error) && <ErrorStackCard error={props.error} />}
{isComponentStack(props.error) ? <ErrorComponentStackCard componentStack={props.error.componentStack} /> : null}
</ScrollView>
<Card style={{ gap: 6 }}>
<Button text="Reload Discord" onPress={() => BundleUpdaterManager.reload()} />
{!settings.safeMode?.enabled && <Button text="Reload in Safe Mode" onPress={() => toggleSafeMode()} />}
<Button variant="destructive" text="Retry Render" onPress={() => props.rerender()} />
</Card>
</SafeAreaView>
</ErrorBoundary>;
}

View file

@ -0,0 +1,44 @@
import { Strings } from "@core/i18n";
import { Codeblock } from "@lib/ui/components";
import { showSheet } from "@lib/ui/sheets";
import { Button, Card, Stack, Text, TwinButtons } from "@metro/common/components";
import { ReactNode } from "react";
import ErrorDetailsActionSheet from "./ErrorDetailsActionSheet";
export const INDEX_BUNDLE_FILE: string = window.HermesInternal.getFunctionLocation(window.__r).fileName;
interface ErrorCardProps {
error: unknown;
header?: string | ReactNode;
onRetryRender?: () => void;
}
export default function ErrorCard(props: ErrorCardProps) {
return <Card>
<Stack>
{props.header && typeof props.header !== "string"
? props.header
: <Text variant="heading-lg/bold">{props.header ?? Strings.UH_OH}</Text>
}
<Codeblock selectable={true}>{String(props.error)}</Codeblock>
<TwinButtons>
{props.onRetryRender && <Button
variant="destructive"
// icon={findAssetId("RetryIcon")}
text={Strings.RETRY_RENDER}
onPress={props.onRetryRender}
/>}
{props.error instanceof Error ? <Button
text={"Details"}
// icon={findAssetId("CircleInformationIcon-primary")}
onPress={() => showSheet(
"BunnyErrorDetailsActionSheet",
ErrorDetailsActionSheet,
{ error: props.error as Error }
)}
/> : null}
</TwinButtons>
</Stack>
</Card>;
}

View file

@ -0,0 +1,53 @@
import { parseComponentStack } from "@core/ui/reporter/utils/parseComponentStack";
import { findAssetId } from "@lib/api/assets";
import { clipboard } from "@metro/common";
import { Button, Card, Text } from "@metro/common/components";
import { useState } from "react";
import { Image, View } from "react-native";
export default function ErrorComponentStackCard(props: {
componentStack: string;
}) {
const [collapsed, setCollapsed] = useState(true);
let stack: string[];
try {
stack = parseComponentStack(props.componentStack);
stack = collapsed ? stack.slice(0, 4) : stack;
} catch {
return;
}
return <Card>
<View style={{ gap: 8 }}>
<Text variant="heading-lg/bold">
Component Stack
</Text>
<View style={{ gap: 4 }}>
{stack.map(component => (
<View style={{ flexDirection: "row" }}>
<Text variant="text-md/bold" color="text-muted">{"<"}</Text>
<Text variant="text-md/bold">{component}</Text>
<Text variant="text-md/bold" color="text-muted">{"/>"}</Text>
</View>
))}
</View>
{collapsed && <Text>...</Text>}
<View style={{ gap: 8, flexDirection: "row", justifyContent: "center", alignItems: "center" }}>
<Button
variant="secondary"
text={`Show ${collapsed ? "more" : "less"}`}
icon={collapsed ? findAssetId("down_arrow") : <Image
style={{ transform: [{ rotate: `${collapsed ? 0 : 180}deg` }] }}
source={findAssetId("down_arrow")!}
/>}
onPress={() => setCollapsed(v => !v)} />
<Button
variant="secondary"
text="Copy"
icon={findAssetId("CopyIcon")}
onPress={() => clipboard.setString(props.componentStack)} />
</View>
</View>
</Card>;
}

View file

@ -0,0 +1,20 @@
import { hasStack, isComponentStack } from "@core/ui/reporter/utils/isStack";
import { Codeblock } from "@lib/ui/components";
import { ActionSheet, Text } from "@metro/common/components";
import { View } from "react-native";
import ErrorComponentStackCard from "./ErrorComponentStackCard";
import ErrorStackCard from "./ErrorStackCard";
export default function ErrorDetailsActionSheet(props: {
error: Error;
}) {
return <ActionSheet>
<View style={{ gap: 12, paddingVertical: 12 }}>
<Text variant="heading-lg/extrabold">Error</Text>
<Codeblock selectable={true}>{props.error.message}</Codeblock>
{hasStack(props.error) && <ErrorStackCard error={props.error} />}
{isComponentStack(props.error) ? <ErrorComponentStackCard componentStack={props.error.componentStack} /> : null}
</View>
</ActionSheet>;
}

View file

@ -0,0 +1,63 @@
import parseErrorStack, { StackFrame } from "@core/ui/reporter/utils/parseErrorStack";
import { findAssetId } from "@lib/api/assets";
import { clipboard, constants } from "@metro/common";
import { Button, Card, Text } from "@metro/common/components";
import { useState } from "react";
import { Image, Pressable, View } from "react-native";
import { INDEX_BUNDLE_FILE } from "./ErrorCard";
export default function ErrorStackCard(props: {
error: Error & { stack: string };
}) {
const [collapsed, setCollapsed] = useState(true);
let stack: StackFrame[];
try {
const parsedErrorStack = parseErrorStack(props.error.stack);
stack = collapsed ? parsedErrorStack.slice(0, 4) : parsedErrorStack;
} catch {
return null;
}
return <Card>
<View style={{ gap: 12 }}>
<Text variant="heading-lg/bold">
Call Stack
</Text>
<View style={{ gap: 4 }}>
{stack.map((f, id) => <Line id={id} frame={f} />)}
</View>
{collapsed && <Text>...</Text>}
<View style={{ gap: 8, flexDirection: "row", justifyContent: "center", alignItems: "center" }}>
<Button
variant="secondary"
text={`Show ${collapsed ? "more" : "less"}`}
icon={collapsed ? findAssetId("down_arrow") : <Image
style={{ transform: [{ rotate: `${collapsed ? 0 : 180}deg` }] }}
source={findAssetId("down_arrow")!}
/>}
onPress={() => setCollapsed(v => !v)} />
<Button
variant="secondary"
text="Copy"
icon={findAssetId("CopyIcon")}
onPress={() => clipboard.setString(props.error.stack)} />
</View>
</View>
</Card>;
}
function Line(props: { id: number, frame: StackFrame }) {
const [collapsed, setCollapsed] = useState(true);
return <Pressable onPress={() => setCollapsed(v => !v)} key={props.id}>
<Text style={{ fontFamily: constants.Fonts.CODE_BOLD }}>
{props.frame.methodName}
</Text>
<Text style={{ fontFamily: constants.Fonts.CODE_NORMAL }} ellipsizeMode="middle" numberOfLines={collapsed ? 1 : undefined}>
<Text color="text-muted">{props.frame.file === INDEX_BUNDLE_FILE ? "jsbundle" : props.frame.file}:{props.frame.lineNumber}:{props.frame.column}</Text>
</Text>
</Pressable>;
}

View file

@ -0,0 +1,6 @@
export function isComponentStack(error: Error): error is Error & { componentStack: string; } {
return "componentStack" in error && typeof error.componentStack === "string";
}
export function hasStack(error: Error): error is Error & { stack: string; } {
return !!error.stack;
}

View file

@ -0,0 +1,3 @@
export function parseComponentStack(componentStack: string) {
return componentStack.split(/[\s|\n]+?in /).filter(Boolean);
}

View file

@ -0,0 +1,202 @@
/**
* MIT License
*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* * furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
export type StackFrame = {
column?: number,
file?: string,
lineNumber?: number,
methodName: string,
collapse?: boolean,
};
type HermesStackLocationNative = {
readonly type: "NATIVE",
};
type HermesStackLocationSource = Readonly<{
type: "SOURCE",
sourceUrl: string,
line1Based: number,
column1Based: number,
}>;
type HermesStackLocationInternalBytecode = Readonly<{
type: "INTERNAL_BYTECODE",
sourceUrl: string,
line1Based: number,
virtualOffset0Based: number,
}>;
type HermesStackLocationBytecode = Readonly<{
type: "BYTECODE",
sourceUrl: string,
line1Based: number,
virtualOffset0Based: number,
}>;
type HermesStackLocation =
| HermesStackLocationNative
| HermesStackLocationSource
| HermesStackLocationInternalBytecode
| HermesStackLocationBytecode;
type HermesStackEntryFrame = Readonly<{
type: "FRAME",
location: HermesStackLocation,
functionName: string,
}>;
type HermesStackEntrySkipped = Readonly<{
type: "SKIPPED",
count: number,
}>;
type HermesStackEntry = HermesStackEntryFrame | HermesStackEntrySkipped;
export type HermesParsedStack = Readonly<{
message: string,
entries: ReadonlyArray<HermesStackEntry>,
}>;
// Capturing groups:
// 1. function name
// 2. is this a native stack frame?
// 3. is this a bytecode address or a source location?
// 4. source URL (filename)
// 5. line number (1 based)
// 6. column number (1 based) or virtual offset (0 based)
const RE_FRAME =
/^ {4}at (.+?)(?: \((native)\)?| \((address at )?(.*?):(\d+):(\d+)\))$/;
// Capturing groups:
// 1. count of skipped frames
const RE_SKIPPED = /^ {4}... skipping (\d+) frames$/;
const RE_COMPONENT_NO_STACK = /^ {4}at .*$/;
function isInternalBytecodeSourceUrl(sourceUrl: string): boolean {
// See https://github.com/facebook/hermes/blob/3332fa020cae0bab751f648db7c94e1d687eeec7/lib/VM/Runtime.cpp#L1100
return sourceUrl === "InternalBytecode.js";
}
function parseLine(line: string): HermesStackEntry | void {
const asFrame = line.match(RE_FRAME);
if (asFrame) {
return {
type: "FRAME",
functionName: asFrame[1],
location:
asFrame[2] === "native"
? { type: "NATIVE" }
: asFrame[3] === "address at "
? isInternalBytecodeSourceUrl(asFrame[4])
? {
type: "INTERNAL_BYTECODE",
sourceUrl: asFrame[4],
line1Based: Number.parseInt(asFrame[5], 10),
virtualOffset0Based: Number.parseInt(asFrame[6], 10),
}
: {
type: "BYTECODE",
sourceUrl: asFrame[4],
line1Based: Number.parseInt(asFrame[5], 10),
virtualOffset0Based: Number.parseInt(asFrame[6], 10),
}
: {
type: "SOURCE",
sourceUrl: asFrame[4],
line1Based: Number.parseInt(asFrame[5], 10),
column1Based: Number.parseInt(asFrame[6], 10),
},
};
}
const asSkipped = line.match(RE_SKIPPED);
if (asSkipped) {
return {
type: "SKIPPED",
count: Number.parseInt(asSkipped[1], 10),
};
}
}
function parseHermesStack(stack: string): HermesParsedStack {
const lines = stack.split(/\n/);
let entries: Array<HermesStackEntryFrame | HermesStackEntrySkipped> = [];
let lastMessageLine = -1;
for (let i = 0; i < lines.length; ++i) {
const line = lines[i];
if (!line) {
continue;
}
const entry = parseLine(line);
if (entry) {
entries.push(entry);
continue;
}
if (RE_COMPONENT_NO_STACK.test(line)) {
// Skip component stacks without source location.
// TODO: This will not be displayed, not sure how to handle it.
continue;
}
// No match - we're still in the message
lastMessageLine = i;
entries = [];
}
const message = lines.slice(0, lastMessageLine + 1).join("\n");
return { message, entries };
}
function convertHermesStack(stack: HermesParsedStack): Array<StackFrame> {
const frames: Array<StackFrame> = [];
for (const entry of stack.entries) {
if (entry.type !== "FRAME") {
continue;
}
const { location, functionName } = entry;
if (location.type === "NATIVE" || location.type === "INTERNAL_BYTECODE") {
continue;
}
frames.push({
methodName: functionName,
file: location.sourceUrl,
lineNumber: location.line1Based,
column:
location.type === "SOURCE"
? location.column1Based - 1
: location.virtualOffset0Based,
});
}
return frames;
}
export default function parseErrorStack(errorStack?: string): Array<StackFrame> {
if (errorStack == null) {
return [];
}
const parsedStack = Array.isArray(errorStack)
? errorStack
: convertHermesStack(parseHermesStack(errorStack));
return parsedStack;
}

View file

@ -1,5 +1,6 @@
import AssetDisplay from "@core/ui/settings/pages/Developer/AssetDisplay";
import { assetsMap } from "@lib/api/assets";
import { Text } from "@metro/common/components";
import { ErrorBoundary, Search } from "@ui/components";
import { FlatList, View } from "react-native";
@ -14,6 +15,7 @@ export default function AssetBrowser() {
onChangeText={(v: string) => setSearch(v)}
/>
<View style={{ flex: 1, borderRadius: 16, paddingHorizontal: 12, overflow: 'hidden', backgroundColor: 'transparent' }}>
<Text variant='text-sm/medium' color='text-danger' style={{ marginBottom: 16 }}>Some assets types cannot be displayed and will be marked in red.</Text>
<FlatList
data={Object.values(assetsMap).filter(a => a.name.includes(search) || a.id.toString() === search)}
renderItem={({ item }: any) => <AssetDisplay asset={item} />}

View file

@ -1,35 +1,60 @@
import { Asset } from "@lib/api/assets";
import { Asset, findAssetId } from "@lib/api/assets";
import { lazyDestructure } from "@lib/utils/lazy";
import { findByProps } from "@metro";
import { clipboard } from "@metro/common";
import { Stack, TableRow } from "@metro/common/components";
import { Stack, TableRow, Text } from "@metro/common/components";
import { showToast } from "@ui/toasts";
import { Image } from "react-native";
const { openAlert } = lazyDestructure(() => findByProps("openAlert", "dismissAlert"));
const { AlertModal, AlertActionButton } = lazyDestructure(() => findByProps("AlertModal", "AlertActions"));
const displayable = new Set(['png', 'jpg', 'svg']);
const iconMap = {
jsona: 'ic_file_text',
lottie: 'ic_image',
webm: 'CirclePlayIcon-primary',
ttf: 'ic_add_text',
default: 'UnknownGameIcon'
};
interface AssetDisplayProps { asset: Asset; }
export default function AssetDisplay({ asset }: AssetDisplayProps) {
return (
<TableRow
variant={displayable.has(asset.type) ? 'default' : 'danger'}
label={asset.name}
subLabel={`Index: ${asset.index}`}
icon={<Image source={asset.index} style={{ width: 32, height: 32 }} />}
onPress={() => {
subLabel={`Index: ${asset.index} Type: ${asset.type}`}
icon={
displayable.has(asset.type)
? <Image source={asset.index} style={{ width: 32, height: 32 }} />
: <TableRow.Icon
variant='danger'
source={findAssetId(asset.type in iconMap ? iconMap[asset.type as keyof typeof iconMap] : iconMap.default)}
/>
}
onPress={() =>
openAlert("revenge-asset-display-details", <AlertModal
title={asset.name}
content={`Index: ${asset.index}\nModule ID: ${asset.moduleId}`}
extraContent={<Image resizeMode="contain" source={asset.index} style={{ flex: 1, width: 'auto', height: 192 }} />}
content={`Index: ${asset.index}\nModule ID: ${asset.moduleId}\nType: ${asset.type}`}
extraContent={
displayable.has(asset.type)
? <Image resizeMode="contain" source={asset.index} style={{ flex: 1, width: 'auto', height: 192 }} />
: (<Text variant='text-sm/medium' color="text-danger" style={{ width: '100%', textAlign: 'center' }}>
Asset type {asset.type.toUpperCase()} is not supported for preview.
</Text>)
}
actions={
<Stack>
<AlertActionButton text="Copy asset name" variant="primary" onPress={() => copyToClipboard(asset.name)} />
<AlertActionButton text="Copy asset index" variant="secondary" onPress={() => copyToClipboard(asset.index.toString())} />
</Stack>
}
/>);
}}
/>)
}
/>
);
}

View file

@ -3,8 +3,6 @@ import { createProxy, useProxy } from "@core/vendetta/storage";
import { FontDefinition, fonts, removeFont, saveFont, updateFont, validateFont } from "@lib/addons/fonts";
import { getCurrentTheme } from "@lib/addons/themes";
import { findAssetId } from "@lib/api/assets";
import { semanticColors } from "@lib/ui/color";
import { createStyles, TextStyleSheet } from "@lib/ui/styles";
import { safeFetch } from "@lib/utils";
import { lazyDestructure } from "@lib/utils/lazy";
import { NavigationNative } from "@metro/common";
@ -14,13 +12,6 @@ import { ErrorBoundary } from "@ui/components";
import { useMemo, useRef, useState } from "react";
import { ScrollView, View } from "react-native";
const useStyles = createStyles({
errorText: {
...TextStyleSheet["text-xs/medium"],
color: semanticColors.TEXT_DANGER,
},
});
const actionSheet = findByPropsLazy("hideActionSheet");
const { openAlert } = lazyDestructure(() => findByProps("openAlert", "dismissAlert"));
const { AlertModal, AlertActionButton } = lazyDestructure(() => findByProps("AlertModal", "AlertActions"));
@ -278,8 +269,6 @@ export default function FontEditor(props: {
const [importing, setIsImporting] = useState<boolean>(false);
const [errors, setErrors] = useState<Array<Error | undefined> | undefined>();
const styles = useStyles();
const memoEntry = useMemo(() => {
return createProxy(props.name ? { ...fonts[props.name].main } : {}).proxy;
}, [props.name]);
@ -343,7 +332,7 @@ export default function FontEditor(props: {
return <TableRow
label={name}
subLabel={error ? <Text style={styles.errorText}>{error.message}</Text> : url}
subLabel={error ? <Text variant='text-xs/medium' color='text-danger'>{error.message}</Text> : url}
trailing={<Stack spacing={8} direction="horizontal">
<IconButton
size="sm"
@ -374,7 +363,7 @@ export default function FontEditor(props: {
})}
<TableRow label={<NewEntryRow fontName={props.name} fontEntry={fontEntries} />} />
</TableRowGroup>
{errors && <Text style={styles.errorText}>Some font entries cannot be imported. Please modify the entries and try again.</Text>}
{errors && <Text variant='text-sm/medium' color='text-danger'>Some font entries cannot be imported. Please modify the entries and try again.</Text>}
<View style={{ flexDirection: "row", justifyContent: "flex-end", bottom: 0, left: 0 }}>
<Button
size="lg"

View file

@ -1,3 +1,4 @@
import patchErrorBoundary from "@core/debug/patches/patchErrorBoundary";
import initFixes from "@core/fixes";
import { initFetchI18nStrings } from "@core/i18n";
import initSettings from "@core/ui/settings";
@ -13,7 +14,6 @@ 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 initSafeMode from "@ui/safeMode";
import { patchSettings } from "@ui/settings";
import * as lib from "./lib";
@ -46,7 +46,7 @@ export default async () => {
initFetchI18nStrings(),
initSettings(),
initFixes(),
initSafeMode(),
patchErrorBoundary(),
updatePlugins()
]).then(
// Push them all to unloader

View file

@ -45,6 +45,7 @@ export const assetsMap: Record<string, Asset> = new Proxy<any>({}, {
export interface Asset {
/** @deprecated */
id: number;
type: 'jsona' | 'jpg' | 'png' | 'svg' | 'lottie' | 'ttf' | 'webm'
index: number;
name: string;
moduleId: number;

View file

@ -1,9 +1,7 @@
import { Strings } from "@core/i18n";
import ErrorCard from "@core/ui/reporter/components/ErrorCard";
import { React } from "@metro/common";
import { Button, LegacyFormText } from "@metro/common/components";
import { Codeblock } from "@ui/components";
import { createThemedStyleSheet } from "@ui/styles";
import { Falsy, ScrollView } from "react-native";
import { ThemeContext } from "@ui/styles";
import { Falsy } from "react-native";
type ErrorBoundaryState = {
hasErr: false;
@ -16,45 +14,23 @@ export interface ErrorBoundaryProps {
children: JSX.Element | Falsy | (JSX.Element | Falsy)[];
}
const styles = createThemedStyleSheet({
view: {
flex: 1,
flexDirection: "column",
margin: 10,
},
title: {
fontSize: 20,
textAlign: "center",
marginBottom: 5,
},
});
export default class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasErr: false };
}
static contextType = ThemeContext;
static getDerivedStateFromError = (error: Error) => ({ hasErr: true, error });
render() {
if (!this.state.hasErr) return this.props.children;
return (
<ScrollView style={styles.view}>
<LegacyFormText style={styles.title}>{Strings.UH_OH}</LegacyFormText>
<Codeblock selectable style={{ marginBottom: 5 }}>{this.state.error.name}</Codeblock>
<Codeblock selectable style={{ marginBottom: 5 }}>{this.state.error.message}</Codeblock>
{this.state.error.stack && <ScrollView style={{ maxHeight: 420, marginBottom: 5 }}>
<Codeblock selectable>{this.state.error.stack}</Codeblock>
</ScrollView>}
<Button
size="md"
variant="destructive"
onPress={() => this.setState({ hasErr: false })}
text={Strings.RETRY}
/>
</ScrollView>
<ErrorCard
error={this.state.error}
onRetryRender={() => this.setState({ hasErr: false })}
/>
);
}
}

View file

@ -1,151 +0,0 @@
import { Strings } from "@core/i18n";
import { toggleSafeMode } from "@lib/api/debug";
import { DeviceManager } from "@lib/api/native/modules";
import { after } from "@lib/api/patcher";
import { settings } from "@lib/api/settings";
import { lazyDestructure } from "@lib/utils/lazy";
import { Button, CompatButton, SafeAreaView } from "@metro/common/components";
import { _lazyContextSymbol } from "@metro/lazy";
import { LazyModuleContext } from "@metro/types";
import { findByNameLazy, findByProps } from "@metro/wrappers";
import { semanticColors } from "@ui/color";
import { Codeblock, ErrorBoundary as _ErrorBoundary } from "@ui/components";
import { createThemedStyleSheet, TextStyleSheet } from "@ui/styles";
import { Text, View } from "react-native";
const ErrorBoundary = findByNameLazy("ErrorBoundary");
// Let's just pray they have this.
const { BadgableTabBar } = lazyDestructure(() => findByProps("BadgableTabBar"));
const styles = 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: () => Strings.MESSAGE },
{ id: "stack", title: () => Strings.STACK_TRACE },
{ id: "componentStack", title: () => Strings.COMPONENT, trimWhitespace: true },
];
function getErrorBoundaryContext() {
const ctxt: LazyModuleContext = findByNameLazy("ErrorBoundary")[_lazyContextSymbol];
return new Promise(resolve => {
ctxt.getExports(exp => {
resolve(exp.prototype);
});
});
}
export default () => after.await("render", getErrorBoundaryContext(), 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: Strings.RELOAD_DISCORD, onPress: this.handleReload },
...!settings.safeMode?.enabled ? [{ text: Strings.RELOAD_IN_SAFE_MODE, onPress: toggleSafeMode }] : [],
{ text: Strings.RETRY_RENDER, color: "red", onPress: () => this.setState({ info: null, error: null }) },
];
return (
<_ErrorBoundary>
<SafeAreaView style={styles.container}>
<View style={styles.header}>
<ret.props.Illustration style={{ transform: [{ scale: 0.6 }], marginLeft: -40, marginRight: -80 }} />
<View style={{ flex: 2, paddingLeft: 24 }}>
<Text style={styles.headerTitle}>{ret.props.title}</Text>
<Text style={styles.headerDescription}>{ret.props.body}</Text>
</View>
</View>
<View style={{ flex: 6 }}>
<View style={{ paddingBottom: 8 }}>
{/* Are errors caught by ErrorBoundary guaranteed to have the component stack? */}
<BadgableTabBar
tabs={tabs.map(t => ({ ...t, title: t.title() }))}
activeTab={this.state.activeTab}
onTabSelected={(tab: string) => { this.setState({ activeTab: tab }); }}
/>
</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>
</View>
<View style={styles.footer}>
{buttons.map(button => {
const buttonIndex = buttons.indexOf(button) !== 0 ? 8 : 0;
return <CompatButton
text={button.text}
color={button.color ?? "brand"}
size={button.size ?? "small"}
onPress={button.onPress}
style={{
...(DeviceManager.isTablet ? {
flex: `0.${buttons.length}`,
marginLeft: buttonIndex
} : {
marginTop: buttonIndex
}),
borderRadius: 16
}}
/>;
})}
</View>
</SafeAreaView>
</_ErrorBoundary>
);
});

View file

@ -4,9 +4,10 @@ const actionSheet = findByPropsLazy("openLazy", "hideActionSheet");
export function showSheet<T extends React.ComponentType<any>>(
key: string,
lazyImport: Promise<{ default: T; }>,
lazyImport: Promise<{ default: T; }> | T,
props?: React.ComponentProps<T>
) {
if (!("then" in lazyImport)) lazyImport = Promise.resolve({ default: lazyImport });
actionSheet.openLazy(lazyImport, key, props ?? {});
}

View file

@ -6,18 +6,60 @@ import { ImageStyle, StyleSheet, TextStyle, ViewStyle } from "react-native";
type NamedStyles<T> = { [P in keyof T]: ViewStyle | TextStyle | ImageStyle };
const CompatfulRedesign = findByPropsLazy("createStyles");
const Styles = findByPropsLazy("createStyles");
export const { ThemeContext } = lazyDestructure(() => findByProps("ThemeContext"), { hint: "object" });
export const { TextStyleSheet } = lazyDestructure(() => findByProps("TextStyleSheet")) as unknown as {
TextStyleSheet: { [key in DiscordTextStyles]: TextStyle; };
TextStyleSheet: Record<DiscordTextStyles, TextStyle>;
};
/**
* Get themed styles based on the current theme
* @returns A hook that returns the themed stylesheet
* @example
* const useStyles = createStyles({
* container: {
* flex: 1,
* backgroundColor: tokens.colors.BACKGROUND_PRIMARY,
* },
* });
*
* function MyComponent() {
* const styles = useStyles();
* return <View style={styles.container} />;
* }
*/
export function createStyles<T extends NamedStyles<T>>(sheet: T | ((props: any) => T)): () => T {
return proxyLazy(() => CompatfulRedesign.createStyles(sheet));
return proxyLazy(() => Styles.createStyles(sheet));
}
// Reimplementation of Discord's createThemedStyleSheet, which was removed since 204201
// Not exactly a 1:1 reimplementation, but sufficient to keep compatibility with existing plugins
/**
* Get themed styles based on the current theme for class components
* @example
* const getStyles = createStyles({
* container: {
* flex: 1,
* backgroundColor: tokens.colors.BACKGROUND_PRIMARY,
* },
* });
*
* class MyComponent extends React.Component {
* static contextType = ThemeContext;
* render() {
* const styles = getStyles(this.context);
* return <View style={styles.container} />;
* }
* }
*/
export function createLegacyClassComponentStyles<T extends NamedStyles<T>>(sheet: T | ((props: any) => T)): (ctxt: typeof ThemeContext) => T {
return proxyLazy(() => Styles.createLegacyClassComponentStyles(sheet));
}
/**
* Reimplementation of Discord's createThemedStyleSheet, which was removed since 204201
* Not exactly a 1:1 reimplementation, but sufficient to keep compatibility with existing plugins
* @deprecated Use createStyles or createLegacyClassComponentStyles instead
*/
export function createThemedStyleSheet<T extends StyleSheet.NamedStyles<T>>(sheet: T) {
for (const key in sheet) {
// @ts-ignore

View file

@ -133,6 +133,7 @@ interface FABProps {
icon: ImageSourcePropType | ReactNode;
style?: Style;
onPress: () => void;
positionBottom: number;
}
export type FloatingActionButton = React.FC<FABProps>;