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