From b98ce51645e2892946ea8353271cf8ba946af331 Mon Sep 17 00:00:00 2001 From: kmmiio99o <164557248+kmmiio99o@users.noreply.github.com> Date: Sun, 25 Jan 2026 17:09:24 +0100 Subject: [PATCH] feat: add nexx lib and better Header text nexx shouldn't be mad, right? --- nexxstuff/components/ActionSheet.tsx | 82 +++++ nexxstuff/components/AutoRow.tsx | 50 +++ nexxstuff/components/BetterTableRow.tsx | 111 +++++++ nexxstuff/components/InlineCheckbox.tsx | 43 +++ nexxstuff/components/Modal.tsx | 27 ++ nexxstuff/components/RichText.tsx | 34 ++ nexxstuff/components/Skeleton.tsx | 36 +++ nexxstuff/components/SliderIcon.tsx | 50 +++ nexxstuff/components/SmartMention.tsx | 49 +++ nexxstuff/components/SuperAwesomeIcon.tsx | 53 ++++ nexxstuff/components/Text.tsx | 78 +++++ nexxstuff/components/TextBadge.tsx | 104 +++++++ nexxstuff/components/sheets/ChooseSheet.tsx | 58 ++++ nexxstuff/deps.ts | 227 ++++++++++++++ nexxstuff/hooks/usePromise.ts | 62 ++++ nexxstuff/lib/intlProxy.ts | 68 ++++ nexxstuff/lib/pinToSettings/index.ts | 30 ++ nexxstuff/lib/pinToSettings/panel.tsx | 91 ++++++ nexxstuff/lib/pinToSettings/tabs.tsx | 105 +++++++ nexxstuff/lib/redesign/index.tsx | 75 +++++ nexxstuff/lib/redesign/types.ts | 175 +++++++++++ nexxstuff/lib/ui.ts | 17 + nexxstuff/migration.tsx | 294 ++++++++++++++++++ nexxstuff/types.tsx | 262 ++++++++++++++++ nexxstuff/typings.d.ts | 177 +++++++++++ .../Commands/settings/components/Header.tsx | 33 +- 26 files changed, 2370 insertions(+), 21 deletions(-) create mode 100644 nexxstuff/components/ActionSheet.tsx create mode 100644 nexxstuff/components/AutoRow.tsx create mode 100644 nexxstuff/components/BetterTableRow.tsx create mode 100644 nexxstuff/components/InlineCheckbox.tsx create mode 100644 nexxstuff/components/Modal.tsx create mode 100644 nexxstuff/components/RichText.tsx create mode 100644 nexxstuff/components/Skeleton.tsx create mode 100644 nexxstuff/components/SliderIcon.tsx create mode 100644 nexxstuff/components/SmartMention.tsx create mode 100644 nexxstuff/components/SuperAwesomeIcon.tsx create mode 100644 nexxstuff/components/Text.tsx create mode 100644 nexxstuff/components/TextBadge.tsx create mode 100644 nexxstuff/components/sheets/ChooseSheet.tsx create mode 100644 nexxstuff/deps.ts create mode 100644 nexxstuff/hooks/usePromise.ts create mode 100644 nexxstuff/lib/intlProxy.ts create mode 100644 nexxstuff/lib/pinToSettings/index.ts create mode 100644 nexxstuff/lib/pinToSettings/panel.tsx create mode 100644 nexxstuff/lib/pinToSettings/tabs.tsx create mode 100644 nexxstuff/lib/redesign/index.tsx create mode 100644 nexxstuff/lib/redesign/types.ts create mode 100644 nexxstuff/lib/ui.ts create mode 100644 nexxstuff/migration.tsx create mode 100644 nexxstuff/types.tsx create mode 100644 nexxstuff/typings.d.ts diff --git a/nexxstuff/components/ActionSheet.tsx b/nexxstuff/components/ActionSheet.tsx new file mode 100644 index 0000000..118d276 --- /dev/null +++ b/nexxstuff/components/ActionSheet.tsx @@ -0,0 +1,82 @@ +import { find, findByProps } from "@vendetta/metro"; +import { ReactNative as RN } from "@vendetta/metro/common"; +import { without } from "@vendetta/utils"; +import type { ImageSourcePropType, ViewProps } from "react-native"; + +const _ActionSheet = findByProps("ActionSheet")?.ActionSheet + ?? find(x => x.render?.name === "ActionSheet"); // thank you to @pylixonly for fixing this + +const { ActionSheetTitleHeader, ActionSheetCloseButton } = findByProps( + "ActionSheetTitleHeader", + "ActionSheetCloseButton", +); + +export const LazyActionSheet = findByProps("openLazy", "hideActionSheet") as { + openLazy: (component: Promise, key: string, props?: object) => void; + hideActionSheet: () => void; +}; +export const { openLazy, hideActionSheet } = LazyActionSheet; + +export const { showSimpleActionSheet } = findByProps( + "showSimpleActionSheet", +) as { + showSimpleActionSheet: (props: { + key: "CardOverflow"; + header: { + title: string; + subtitle?: string; + icon?: React.ReactNode; + onClose?: () => void; + }; + options: { + label: string; + icon?: ImageSourcePropType; + isDestructive?: boolean; + onPress?: () => void; + }[]; + }) => void; +}; + +type ActionSheetProps = React.PropsWithChildren< + ViewProps & { + title: string; + onClose?: () => void; + } +>; + +export const ActionSheet = ((props: ActionSheetProps) => { + return ( + <_ActionSheet> + { + hideActionSheet(); + })} + /> + } + /> + + + ); +}) as { + (props: ActionSheetProps): JSX.Element; + open: >( + sheet: Sheet, + props: Parameters[0], + ) => void; +}; + +ActionSheet.open = (sheet, props) => { + openLazy( + new Promise(res => { + res({ + default: sheet, + }); + }) as any, + "ActionSheet", + props, + ); +}; diff --git a/nexxstuff/components/AutoRow.tsx b/nexxstuff/components/AutoRow.tsx new file mode 100644 index 0000000..42e72ef --- /dev/null +++ b/nexxstuff/components/AutoRow.tsx @@ -0,0 +1,50 @@ +import { findByProps } from "@vendetta/metro"; +import { ReactNative as RN, stylesheet } from "@vendetta/metro/common"; +import { semanticColors } from "@vendetta/ui"; +import { Forms } from "@vendetta/ui/components"; + +const { FormRow } = Forms; +const { TableRow } = findByProps("TableRow"); + +export const { useInMainTabsExperiment } = findByProps( + "useInMainTabsExperiment", + "isInMainTabsExperiment", +); + +export default function AutoRow({ + label, + icon, + onPress, +}: { + label: string; + icon: number; + onPress?: () => void; +}) { + const styles = stylesheet.createThemedStyleSheet({ + icon: { + width: 24, + height: 24, + tintColor: semanticColors.INTERACTIVE_ICON_DEFAULT, + opacity: 0.6, + }, + }); + const tabbed = useInMainTabsExperiment(); + + if (tabbed) { + return ( + } + onPress={onPress} + /> + ); + } + + return ( + } + onPress={onPress} + /> + ); +} diff --git a/nexxstuff/components/BetterTableRow.tsx b/nexxstuff/components/BetterTableRow.tsx new file mode 100644 index 0000000..42f7f4a --- /dev/null +++ b/nexxstuff/components/BetterTableRow.tsx @@ -0,0 +1,111 @@ +import { ReactNative as RN, stylesheet } from "@vendetta/metro/common"; +import { semanticColors } from "@vendetta/ui"; + +import Text from "./Text"; + +export function BetterTableRowTitle({ + title, + onPress, + icon, + padding, +}: { + title: string; + onPress?: () => void; + icon?: number; + padding?: boolean; +}) { + const styles = stylesheet.createThemedStyleSheet({ + androidRipple: { + color: semanticColors.ANDROID_RIPPLE, + cornerRadius: 4, + } as any, + icon: { + width: 16, + height: 16, + marginTop: 1.5, + tintColor: semanticColors.TEXT_MUTED, + }, + }); + const UseCompontent = onPress ? RN.Pressable : RN.View; + + return ( + + {icon && ( + + )} + + {title} + + + ); +} + +export function BetterTableRowGroup({ + title, + onTitlePress, + icon, + children, + padding, + nearby, +}: React.PropsWithChildren<{ + title?: string | React.ReactNode; + onTitlePress?: () => void; + icon?: number; + padding?: boolean; + nearby?: boolean; +}>) { + const styles = stylesheet.createThemedStyleSheet({ + main: { + backgroundColor: semanticColors.CARD_BACKGROUND_DEFAULT, + borderColor: semanticColors.BORDER_MUTED, + borderWidth: 1, + borderRadius: 16, + overflow: "hidden", + flex: 1, + }, + }); + + return ( + + {title + ? ( + typeof title === "string" + ? ( + + ) + : title + ) + : null} + + {padding + ? ( + + {children} + + ) + : children} + + + ); +} diff --git a/nexxstuff/components/InlineCheckbox.tsx b/nexxstuff/components/InlineCheckbox.tsx new file mode 100644 index 0000000..fea51c4 --- /dev/null +++ b/nexxstuff/components/InlineCheckbox.tsx @@ -0,0 +1,43 @@ +import { find } from "@vendetta/metro"; +import { React, ReactNative as RN } from "@vendetta/metro/common"; + +import Text from "./Text"; + +const { FormCheckbox } = find(x => x?.FormCheckbox && !x?.FormCardSection); + +export default function InlineCheckbox({ + label, + initialValue, + update, +}: { + label: string; + initialValue: boolean; + update: (val: boolean) => void; +}) { + const [value, setValue] = React.useState(initialValue); + update(value); + + return ( + { + setValue(!value); + }} + style={{ + flexDirection: "row", + alignItems: "center", + gap: 8, + marginTop: 12, + }} + > + + + {label} + + + ); +} diff --git a/nexxstuff/components/Modal.tsx b/nexxstuff/components/Modal.tsx new file mode 100644 index 0000000..06744f2 --- /dev/null +++ b/nexxstuff/components/Modal.tsx @@ -0,0 +1,27 @@ +import { without } from "@vendetta/utils"; + +import { modalCloseButton, Navigator, popModal } from "../types"; + +export default function Modal( + props: React.PropsWithChildren<{ + mkey: string; + headerRight?: React.FunctionComponent; + title?: string; + }>, +) { + if (!Navigator || !modalCloseButton) return null; + return ( + popModal(props.mkey)), + render: () => props.children, + }, + ), + }} + /> + ); +} diff --git a/nexxstuff/components/RichText.tsx b/nexxstuff/components/RichText.tsx new file mode 100644 index 0000000..b8b5535 --- /dev/null +++ b/nexxstuff/components/RichText.tsx @@ -0,0 +1,34 @@ +import { ReactNative as RN } from "@vendetta/metro/common"; + +import Text from "./Text"; + +export namespace RichText { + export function Bold({ + children, + onPress, + }: React.PropsWithChildren<{ + onPress?: () => void; + }>) { + return ( + + {children} + + ); + } + + export function Underline({ + children, + onPress, + }: React.PropsWithChildren<{ + onPress?: () => void; + }>) { + return ( + + {children} + + ); + } +} diff --git a/nexxstuff/components/Skeleton.tsx b/nexxstuff/components/Skeleton.tsx new file mode 100644 index 0000000..5b22eba --- /dev/null +++ b/nexxstuff/components/Skeleton.tsx @@ -0,0 +1,36 @@ +import { React, stylesheet } from "@vendetta/metro/common"; +import { semanticColors } from "@vendetta/ui"; +import type { DimensionValue, ViewStyle } from "react-native"; + +import { Reanimated } from "$/deps"; + +export default function Skeleton({ + height, + style, +}: { + height: DimensionValue; + style?: ViewStyle; +}) { + const baseStyle = stylesheet.createThemedStyleSheet({ + skeleton: { + height, + borderRadius: 16, + backgroundColor: semanticColors.BACKGROUND_MOD_STRONG, + }, + }); + + const opacity = Reanimated.useSharedValue(1); + React.useEffect(() => { + opacity.value = Reanimated.withRepeat( + Reanimated.withTiming(0.5, { duration: 1000 }), + -1, + true, + ); + }, []); + + return ( + + ); +} diff --git a/nexxstuff/components/SliderIcon.tsx b/nexxstuff/components/SliderIcon.tsx new file mode 100644 index 0000000..8711215 --- /dev/null +++ b/nexxstuff/components/SliderIcon.tsx @@ -0,0 +1,50 @@ +import { ReactNative as RN, stylesheet } from "@vendetta/metro/common"; +import { semanticColors } from "@vendetta/ui"; +import { getAssetIDByName } from "@vendetta/ui/assets"; + +export default function SliderIcon({ + side, + onPress, +}: { + side: "start" | "end"; + onPress: () => void; +}) { + const styles = stylesheet.createThemedStyleSheet({ + androidRipple: { + color: semanticColors.ANDROID_RIPPLE, + }, + iconSecondary: { + width: 24, + height: 24, + tintColor: "transparent", + }, + icon: { + width: 24, + height: 24, + tintColor: semanticColors.INTERACTIVE_ICON_DEFAULT, + position: "absolute", + top: 0, + }, + }); + + const icon = side === "start" ? "CircleMinusIcon" : "CirclePlusIcon"; + + return ( + + + + + + + ); +} diff --git a/nexxstuff/components/SmartMention.tsx b/nexxstuff/components/SmartMention.tsx new file mode 100644 index 0000000..7f8b320 --- /dev/null +++ b/nexxstuff/components/SmartMention.tsx @@ -0,0 +1,49 @@ +import { findByProps, findByStoreName } from "@vendetta/metro"; +import { React } from "@vendetta/metro/common"; + +import Text from "./Text"; + +const { showUserProfile } = findByProps("showUserProfile"); +const { fetchProfile } = findByProps("fetchProfile"); + +const UserStore = findByStoreName("UserStore"); + +export default function SmartMention({ + userId, + color, + loadUsername, + children, +}: React.PropsWithChildren<{ + userId: string; + color?: string; + loadUsername?: boolean; +}>) { + const [loadedUsername, setLoadedUsername] = React.useState( + null, + ); + + React.useEffect( + () => + !loadedUsername + && loadUsername + && (UserStore.getUser(userId) + ? setLoadedUsername(UserStore.getUser(userId).username) + : fetchProfile(userId).then(x => { + setLoadedUsername(x.user.username); + })), + [loadUsername], + ); + + return ( + + UserStore.getUser(userId) + ? showUserProfile({ userId }) + : fetchProfile(userId).then(() => showUserProfile({ userId }))} + > + {loadUsername ? `@${loadedUsername ?? "..."}` : children} + + ); +} diff --git a/nexxstuff/components/SuperAwesomeIcon.tsx b/nexxstuff/components/SuperAwesomeIcon.tsx new file mode 100644 index 0000000..39d2ac8 --- /dev/null +++ b/nexxstuff/components/SuperAwesomeIcon.tsx @@ -0,0 +1,53 @@ +import { ReactNative as RN, stylesheet } from "@vendetta/metro/common"; +import { semanticColors } from "@vendetta/ui"; + +export default function SuperAwesomeIcon({ + onPress, + onLongPress, + icon, + style, + destructive, + color, +}: { + onPress?: () => void; + onLongPress?: () => void; + destructive?: boolean; + color?: any; + icon: number; + style: "header" | "card" | import("react-native").ImageStyle; +}) { + const styles = stylesheet.createThemedStyleSheet({ + headerStyleIcon: { + width: 24, + height: 24, + marginRight: 10, + tintColor: semanticColors.TEXT_STRONG, + }, + cardStyleIcon: { + width: 22, + height: 22, + marginLeft: 5, + tintColor: semanticColors.INTERACTIVE_ICON_DEFAULT, + }, + destructiveIcon: { + tintColor: semanticColors.TEXT_FEEDBACK_CRITICAL, + }, + }); + + return ( + + !!x)} + source={icon} + /> + + ); +} diff --git a/nexxstuff/components/Text.tsx b/nexxstuff/components/Text.tsx new file mode 100644 index 0000000..366a6df --- /dev/null +++ b/nexxstuff/components/Text.tsx @@ -0,0 +1,78 @@ +import { findByProps } from "@vendetta/metro"; +import { React, ReactNative as RN } from "@vendetta/metro/common"; +import { semanticColors } from "@vendetta/ui"; +import type { TextProps } from "react-native"; + +import { resolveSemanticColor, TextStyleSheet, type TextStyleSheetVariant } from "../types"; + +export function TrailingText({ children }: React.PropsWithChildren) { + return ( + + {children} + + ); +} + +const { useThemeContext } = findByProps("useThemeContext"); + +export default function Text({ + variant, + lineClamp, + color, + align, + style, + onPress, + getChildren, + children, + liveUpdate, + ellipsis, +}: React.PropsWithChildren<{ + variant?: TextStyleSheetVariant; + color?: string; + getChildren?: () => React.ReactNode | undefined; + liveUpdate?: boolean; + + lineClamp?: TextProps["numberOfLines"]; + align?: "left" | "right" | "center"; + style?: TextProps["style"]; + onPress?: TextProps["onPress"]; + ellipsis?: TextProps["ellipsizeMode"]; +}>) { + const themeContext = useThemeContext(); + const [_, forceUpdate] = React.useReducer(x => ~x, 0); + + React.useEffect(() => { + if (!liveUpdate) return; + const nextSecond = () => Date.now() - new Date().setMilliseconds(1000); + + let timeout = setTimeout(function update() { + forceUpdate(); + timeout = setTimeout(update, nextSecond()); + }, nextSecond()); + + return () => clearTimeout(timeout); + }, []); + + return ( + + {getChildren?.() ?? children} + + ); +} diff --git a/nexxstuff/components/TextBadge.tsx b/nexxstuff/components/TextBadge.tsx new file mode 100644 index 0000000..630f44b --- /dev/null +++ b/nexxstuff/components/TextBadge.tsx @@ -0,0 +1,104 @@ +import { React, ReactNative as RN, stylesheet } from "@vendetta/metro/common"; +import { semanticColors } from "@vendetta/ui"; +import type { ViewStyle } from "react-native"; + +import Text from "$/components/Text"; +import { Reanimated } from "$/deps"; + +export default function TextBadge({ + style, + variant, + children, + shiny, +}: React.PropsWithChildren<{ + style?: ViewStyle; + variant: "primary" | "destructive"; + shiny?: boolean; +}>) { + const [width, setWidth] = React.useState(0); + + const styles = stylesheet.createThemedStyleSheet({ + main: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + borderRadius: 2147483647, + paddingHorizontal: 6, + paddingVertical: 3, + backgroundColor: semanticColors[ + `REDESIGN_BUTTON_${variant.toUpperCase()}_BACKGROUND` + ], + marginTop: 3, + overflow: "hidden", + }, + shiner: { + position: "absolute", + width: 7, + height: 80, + backgroundColor: "#fff4", + }, + text: { + color: semanticColors[ + `REDESIGN_BUTTON_${variant.toUpperCase()}_TEXT` + ], + textTransform: "uppercase", + }, + }); + + const shinyTranslate = Reanimated.useSharedValue(0); + const randomness = React.useRef( + 1500 + Math.floor(Math.random() * 696), + ).current; + + React.useEffect(() => { + if (width !== 0 && shiny) { + shinyTranslate.value = Reanimated.withRepeat( + Reanimated.withSequence( + Reanimated.withTiming(-width, { duration: 0 }), + Reanimated.withDelay( + randomness, + Reanimated.withTiming(width, { + duration: 800, + easing: Reanimated.Easing.inOut( + Reanimated.Easing.cubic, + ), + }), + ), + ), + 0, + ); + } else shinyTranslate.value = 500; + }, [shiny, width]); + + return ( + { + setWidth(layout.nativeEvent.layout.width * 0.5 + 4); + }} + > + {shiny && width !== 0 && ( + + )} + + {children} + + + ); +} diff --git a/nexxstuff/components/sheets/ChooseSheet.tsx b/nexxstuff/components/sheets/ChooseSheet.tsx new file mode 100644 index 0000000..65deeaa --- /dev/null +++ b/nexxstuff/components/sheets/ChooseSheet.tsx @@ -0,0 +1,58 @@ +import { React, ReactNative as RN } from "@vendetta/metro/common"; +import { Forms } from "@vendetta/ui/components"; +import type { ImageSourcePropType } from "react-native"; + +import { ActionSheet } from "../ActionSheet"; + +const { FormRow } = Forms; + +export default function ChooseSheet({ + title, + value: _value, + options, + callback, +}: { + title: string; + value: string | number | boolean | null; + options: { + name: string; + description?: string; + value: typeof _value; + icon?: ImageSourcePropType; + iconColor?: any; + iconComponent?: React.ReactNode; + }[]; + callback: (v: typeof _value) => void; +}) { + const [value, setValue] = React.useState(_value); + + return ( + + {options.map((x) => ( + + )} + leading={} + onPress={() => { + setValue(x.value); + callback(x.value); + }} + /> + ))} + + ); +} diff --git a/nexxstuff/deps.ts b/nexxstuff/deps.ts new file mode 100644 index 0000000..fc93f24 --- /dev/null +++ b/nexxstuff/deps.ts @@ -0,0 +1,227 @@ +import type * as _FlashList from "@shopify/flash-list"; +import { find, findByName, findByProps } from "@vendetta/metro"; +import { ReactNative as RN } from "@vendetta/metro/common"; +import type * as _Reanimated from "react-native-reanimated"; +import type { StateStorage } from "zustand/middleware"; + +// +// JS deps +// +export const WebView = find((x: any) => x?.WebView && !x.default) + .WebView as typeof import("react-native-webview").default; + +export const Svg = findByProps("SvgXml") as typeof import("react-native-svg"); + +export const Reanimated = findByProps("useSharedValue") as typeof _Reanimated; + +export const FlashList = findByProps("FlashList") + .FlashList as typeof _FlashList.FlashList; + +export const Joi = findByProps("isJoi") as unknown as typeof import("joi"); + +export const zustand = (findByProps("create", "useStore") || { + create: findByName("create"), +}) as typeof import("zustand"); + +export const zustandMW = findByProps( + "createJSONStorage", + "persist", +) as typeof import("zustand/middleware"); + +export const DocumentPicker = findByProps( + "pickSingle", + "isCancel", +) as typeof import("react-native-document-picker"); +export const DocumentsNew = findByProps( + "pick", + "saveDocuments", +) as typeof import("@react-native-documents/picker"); + +const _MAS = findByProps("MobileAudioSound").MobileAudioSound; + +// Messy code +export class MobileAudioSound { + // Events + public onPlay?: () => void; + public onStop?: () => void; + public onEnd?: () => void; + public onLoad?: (loaded: boolean) => void; + + private mas: any; + + public duration?: number; + public isLoaded?: boolean; + public isPlaying?: boolean; + + /** Discord when it comes to not changing everything every fucking version!!! */ + private get ensureSoundGetter() { + return this.mas?._ensureSound || this.mas?.ensureSound; + } + + /** Preloads the audio, which automatically makes us better than Discord because they DON'T do that for some reason */ + private async _preloadSound(skip?: boolean) { + const { _duration } = await this.ensureSoundGetter.bind(this.mas)(); + this.duration = RN.Platform.select({ + ios: _duration ? _duration * 1000 : _duration, + default: _duration, + }); + this.isLoaded = !!_duration; + + if (!skip) this.onLoad?.(!!_duration); + return !!_duration; + } + + constructor( + public url: string, + public usage: "notification" | "voice" | "ring_tone" | "media", + public volume: number, + events?: { + onPlay?: () => void; + onStop?: () => void; + onEnd?: () => void; + onLoad?: (loaded: boolean) => void; + }, + ) { + this.mas = new _MAS( + url, + { + media: "vibing_wumpus", + notification: "activity_launch", + ring_tone: "call_ringing", + voice: "mute", + }[usage], + volume, + "default", + ); + this.mas.volume = volume; + + this._preloadSound(); + for (const [key, val] of Object.entries(events ?? {})) this[key] = val; + } + + private _playTimeout?: number; + + /** Plays the audio */ + async play() { + if (!this.isLoaded && this.isLoaded !== false) { + await this._preloadSound(); + } + if (!this.isLoaded) return; + + this.mas.volume = this.volume; + await this.mas.play(); + this.isPlaying = true; + this.onPlay?.(); + + clearTimeout(this._playTimeout); + this._playTimeout = setTimeout( + () => (this.onEnd?.(), this.stop()), + this.duration, + ) as any; + } + + /** Stops the audio */ + async stop() { + if (!this.isLoaded) return; + + this.mas.stop(); + this.isPlaying = false; + this.onStop?.(); + + clearTimeout(this._playTimeout); + await this._preloadSound(true); + } +} + +// +// raw native modules +// + +// ripped straight from https://github.com/revenge-mod/revenge-bundle/blob/0a90d762e5c9996bb6613edd136fafe2b8f25908/src/lib/api/native/modules/index.ts +// why the fuck isnt that exported +const nmp = window.nativeModuleProxy; + +function getNativeModule(...names: string[]): T | undefined { + for (const name of names) { + if (globalThis.__turboModuleProxy) { + const module = globalThis.__turboModuleProxy(name); + if (module) return module as T; + } + + if (nmp[name]) return nmp[name] as T; + } + + return undefined; +} + +export const RNCacheModule = getNativeModule( + "NativeCacheModule", + "MMKVManager", +)!; + +export const RNChatModule = getNativeModule<{ + updateRows: (id: string, json: string) => any; +}>( + "NativeChatModule", + "DCDChatManager", +)!; + +export const RNFileModule = getNativeModule<{ + /** + * @param path **Full** path to file + */ + fileExists: (path: string) => Promise; + /** + * Allowed URI schemes on Android: `file://`, `content://` ([See here](https://developer.android.com/reference/android/content/ContentResolver#accepts-the-following-uri-schemes:_3)) + */ + getSize: (uri: string) => Promise; + /** + * @param path **Full** path to file + * @param encoding Set to `base64` in order to encode response + */ + readFile(path: string, encoding: "base64" | "utf8"): Promise; + saveFileToGallery?(uri: string, fileName: string, fileType: "PNG" | "JPEG"): Promise; + /** + * @param storageDir Either `cache` or `documents`. + * @param path Path in `storageDir`, parents are recursively created. + * @param data The data to write to the file + * @param encoding Set to `base64` if `data` is base64 encoded. + * @returns Promise that resolves to path of the file once it got written + */ + writeFile( + storageDir: "cache" | "documents", + path: string, + data: string, + encoding: "base64" | "utf8", + ): Promise; + /** + * Removes a file from the path given. + * (!) On Android, this always returns false, regardless if it fails or not! + * @param storageDir Either `cache` or `documents` + * @param path Path to the file to be removed + */ + removeFile(storageDir: "cache" | "documents", path: string): Promise; + /** + * Clear the folder from the path given + * (!) On Android, this only clears all *files* and not subdirectories! + * @param storageDir Either `cache` or `documents` + * @param path Path to the folder to be cleared + * @returns Whether the clearance succeeded + */ + clearFolder(storageDir: "cache" | "documents", path: string): Promise; + getConstants: () => { + /** + * The path the `documents` storage dir (see {@link writeFile}) represents. + */ + DocumentsDirPath: string; + CacheDirPath: string; + }; + /** + * Will apparently cease to exist some time in the future so please use {@link getConstants} instead. + * @deprecated + */ + DocumentsDirPath: string; +}>( + "NativeFileModule", + "DCDFileManager", +)!; diff --git a/nexxstuff/hooks/usePromise.ts b/nexxstuff/hooks/usePromise.ts new file mode 100644 index 0000000..bff0648 --- /dev/null +++ b/nexxstuff/hooks/usePromise.ts @@ -0,0 +1,62 @@ +import { React } from "@vendetta/metro/common"; + +type PromiseRet = + | { + fulfilled: false; + } + | { + fulfilled: true; + success: false; + error: any; + } + | { + fulfilled: true; + success: true; + response: Res; + }; + +export default function usePromise( + promise: (signal: AbortSignal) => Promise, + deps: any[] = [], +): PromiseRet { + const [res, setRes] = React.useState>({ + fulfilled: false, + }); + const tracker = React.useRef(0); + + React.useEffect(() => { + const id = Date.now(); + tracker.current = id; + + const controller = new AbortController(); + + setRes({ + fulfilled: false, + }); + promise(controller.signal) + .then( + response => + tracker.current === id + && setRes({ + fulfilled: true, + success: true, + response, + }), + ) + .catch( + error => + tracker.current === id + && setRes({ + fulfilled: true, + success: false, + error, + }), + ); + + return () => { + controller.abort(); + }; + }, deps); + + return res; +} diff --git a/nexxstuff/lib/intlProxy.ts b/nexxstuff/lib/intlProxy.ts new file mode 100644 index 0000000..d0b879d --- /dev/null +++ b/nexxstuff/lib/intlProxy.ts @@ -0,0 +1,68 @@ +// a drop-in replacement for i18n +// https://github.com/discord/discord-intl + +import { findByProps } from "@vendetta/metro"; +import { i18n } from "@vendetta/metro/common"; + +const { intl, t: intlMap } = findByProps("intl") ?? {}; +const { runtimeHashMessageKey } = findByProps("runtimeHashMessageKey") ?? {}; + +export function getLocale() { + const lang = intl?.currentLocale ?? i18n.getLocale?.() ?? "en"; + return lang; +} + +const isUsingi18n = !!i18n.Messages.DISCORD || !intl || !intlMap + || !runtimeHashMessageKey; + +const _messages = {} as Record; +const intlProxy = Object.freeze( + new Proxy(_messages, { + get(trg, prop, receiv) { + const key = prop.toString(); + if (key in _messages) return Reflect.get(trg, key, receiv); + + if (isUsingi18n) return (_messages[key] = i18n.Messages[key]); + + const hash = runtimeHashMessageKey(key); + try { + return (_messages[key] = intl.string(intlMap[hash])); + } catch (e) { + console.warn( + `intlProxy: Failed to get intl message for key: ${key} (${hash})`, + e, + ); + return (_messages[key] = ""); + } + }, + }), +); + +const _format = {} as Record) => string>; +export const intlFormat = Object.freeze( + new Proxy(_format, { + get(trg, prop, receiv) { + const key = prop.toString(); + if (key in _format) return Reflect.get(trg, key, receiv); + + if (isUsingi18n) { + return (_format[key] = typeof i18n.Messages[key] === "string" + ? () => i18n.Messages[key] + : i18n.Messages[key].format); + } + + const hash = runtimeHashMessageKey(key); + try { + return (_format[key] = values => intl.format(intlMap[hash], values)); + } catch (e) { + console.warn( + `intlFormat: Failed to get intl format message for key: ${key} (${hash})`, + e, + ); + return (_format[key] = () => ""); + } + }, + }), +); + +export default intlProxy; diff --git a/nexxstuff/lib/pinToSettings/index.ts b/nexxstuff/lib/pinToSettings/index.ts new file mode 100644 index 0000000..ee8b9ba --- /dev/null +++ b/nexxstuff/lib/pinToSettings/index.ts @@ -0,0 +1,30 @@ +import { patchPanelUI } from "./panel"; +import { patchTabsUI } from "./tabs"; + +export interface PinToSettingsTabs { + key: string; + title: () => string; + icon?: number; + predicate?: () => boolean; + trailing?: () => React.ReactNode; + page: React.ComponentType; +} + +export function patchSettingsPin(tabs: PinToSettingsTabs): () => void { + const patches: (() => void)[] = []; + + let disabled = false; + + const realPredicate = tabs.predicate ?? (() => true); + tabs.predicate = () => (disabled ? false : realPredicate()); + + patchPanelUI(tabs, patches); + patchTabsUI(tabs, patches); + patches.push(() => (disabled = true)); + + return () => { + for (const x of patches) { + x(); + } + }; +} diff --git a/nexxstuff/lib/pinToSettings/panel.tsx b/nexxstuff/lib/pinToSettings/panel.tsx new file mode 100644 index 0000000..47e8580 --- /dev/null +++ b/nexxstuff/lib/pinToSettings/panel.tsx @@ -0,0 +1,91 @@ +// Based on: https://github.com/pyoncord/Bunny/blob/dev/src/lib/ui/settings/patches/panel.tsx + +import { logger } from "@vendetta"; +import { NavigationNative } from "@vendetta/metro/common"; +import { after } from "@vendetta/patcher"; +import { Forms } from "@vendetta/ui/components"; +import { findInReactTree } from "@vendetta/utils"; +import intlProxy from "../intlProxy"; +import type { PinToSettingsTabs } from "."; + +const { FormSection, FormRow } = Forms; + +function Section({ tabs }: { tabs: PinToSettingsTabs }) { + const navigation = NavigationNative.useNavigation(); + + return ( + } + trailing={ + <> + {tabs.trailing ? tabs.trailing : null} + + + } + // Based on: https://github.com/pyoncord/Bunny/blob/dev/src/lib/ui/settings/patches/shared.tsx + onPress={() => { + const Component = tabs.page; + + navigation.navigate("BUNNY_CUSTOM_PAGE", { + title: tabs.title(), + render: () => , + }); + }} + /> + ); +} + +export function patchPanelUI(tabs: PinToSettingsTabs, patches: (() => void)[]) { + const { bunny } = window as any; + + // panel settings were removed in version 264.5 💔 + try { + patches.push( + after( + "default", + bunny.metro.findByNameLazy( + "UserSettingsOverviewWrapper", + false, + ), + (_, ret) => { + const UserSettingsOverview = findInReactTree( + ret.props.children, + n => n.type?.name === "UserSettingsOverview", + ); + + patches.push( + after( + "render", + UserSettingsOverview.type.prototype, + (_args, res) => { + const titles = [ + intlProxy.BILLING_SETTINGS, + intlProxy.PREMIUM_SETTINGS, + ]; + + const sections = findInReactTree( + res.props.children, + n => n?.children?.[1]?.type === FormSection, + )?.children; + + if (sections) { + const index = sections.findIndex((c: any) => titles.includes(c?.props.label)); + + sections.splice( + -~index || 4, + 0, +
, + ); + } + }, + ), + ); + }, + true, + ), + ); + } catch { + logger.info("Panel UI patch failed graciously 💔"); + } +} diff --git a/nexxstuff/lib/pinToSettings/tabs.tsx b/nexxstuff/lib/pinToSettings/tabs.tsx new file mode 100644 index 0000000..89a08b0 --- /dev/null +++ b/nexxstuff/lib/pinToSettings/tabs.tsx @@ -0,0 +1,105 @@ +// Based on: https://github.com/pyoncord/Bunny/blob/dev/src/lib/ui/settings/patches/tabs.tsx + +import { findByProps } from "@vendetta/metro"; +import { after } from "@vendetta/patcher"; +import { findInReactTree } from "@vendetta/utils"; + +import type { PinToSettingsTabs } from "."; + +const { bunny } = window as any; + +const { TableRowIcon } = findByProps("TableRowIcon"); + +const tabsNavigationRef = bunny.metro.findByPropsLazy("getRootNavigationRef"); +const settingConstants = bunny.metro.findByPropsLazy("SETTING_RENDERER_CONFIG"); +const createListModule = bunny.metro.findByPropsLazy("createList"); +const SettingsOverviewScreen = bunny.metro.findByNameLazy( + "SettingsOverviewScreen", + false, +); + +export function patchTabsUI(tabs: PinToSettingsTabs, patches: (() => void)[]) { + const row = { + [tabs.key]: { + type: "pressable", + title: tabs.title, + icon: tabs.icon, + IconComponent: tabs.icon && (() => ), + usePredicate: tabs.predicate, + useTrailing: tabs.trailing, + onPress: () => { + const navigation = tabsNavigationRef.getRootNavigationRef(); + const Component = tabs.page; + + navigation.navigate("BUNNY_CUSTOM_PAGE", { + title: tabs.title(), + render: () => , + }); + }, + withArrow: true, + }, + }; + + let rendererConfigValue = settingConstants.SETTING_RENDERER_CONFIG; + + Object.defineProperty(settingConstants, "SETTING_RENDERER_CONFIG", { + enumerable: true, + configurable: true, + get: () => ({ + ...rendererConfigValue, + ...row, + }), + set: v => (rendererConfigValue = v), + }); + + const firstRender = Symbol("pinToSettings meow meow"); + + try { + if (!createListModule) return; + patches.push( + after("createList", createListModule, (args) => { + // shrug?? + if (!args[0][firstRender]) { + args[0][firstRender] = true; + + const [config] = args; + const sections = config.sections; + + const section = sections?.find((x: any) => + ["Bunny", "Revenge", "Kettu"].some( + mod => x.label === mod && x.title === mod, + ) + ); + + if (section?.settings) { + section.settings = [...section.settings, tabs.key]; + } + } + }), + ); + } catch { + if (!SettingsOverviewScreen) return; + patches.push( + after("default", SettingsOverviewScreen, (args, ret) => { + // shrug?? + if (!args[0][firstRender]) { + args[0][firstRender] = true; + + const { sections } = findInReactTree( + ret, + i => i.props?.sections, + ).props; + const section = sections?.find((x: any) => + ["Bunny", "Revenge", "Kettu"].some( + mod => x.label === mod && x.title === mod, + ) + ); + + if (section?.settings) { + section.settings = [...section.settings, tabs.key]; + } + } + }), + ); + } +} diff --git a/nexxstuff/lib/redesign/index.tsx b/nexxstuff/lib/redesign/index.tsx new file mode 100644 index 0000000..077ffdd --- /dev/null +++ b/nexxstuff/lib/redesign/index.tsx @@ -0,0 +1,75 @@ +import { findByProps } from "@vendetta/metro"; +import { React, ReactNative as RN } from "@vendetta/metro/common"; +import { showToast } from "@vendetta/ui/toasts"; + +import type * as t from "./types"; // shamelessly stolen from Bunny + +const NotFound = (prop: string, isFunction?: boolean) => () => { + const trigger = () => { + showToast(`${prop} not found! Search for PNF in Debug Logs!`); + console.warn( + `!! PNF ERROR !!\nMissing the redesign ${ + isFunction ? "function" : "component" + }: ${prop}. Please bug @nexpid about this on Discord!`, + ); + }; + + if (isFunction) trigger(); + else React.useEffect(trigger, []); + + return null; +}; + +const findProp = (...props: string[]) => findByProps(...props)?.[props[0]]; +const findPropPolyfill = (isFunction: boolean, ...props: string[]) => + findProp(...props) ?? NotFound(props[0], isFunction); + +// components + +// buttons +export const Button = findPropPolyfill(false, "Button") as t.Button; +export const RowButton = findPropPolyfill(false, "RowButton") as t.RowButton; +export const FloatingActionButton = findPropPolyfill( + false, + "FloatingActionButton", +) as t.FAB; +export const PressableScale = findPropPolyfill( + false, + "PressableScale", +) as typeof RN.Pressable; +export const IconButton = findPropPolyfill(false, "IconButton") as t.IconButton; +export const ContextMenu = findPropPolyfill( + false, + "ContextMenu", +) as t.ContextMenu; + +// inputs +const _Slider = findPropPolyfill(false, "Slider"); +export const Slider = + (props => ( + + <_Slider + {...props} + onValueChange={(val: any) => props.value !== val && props.onValueChange?.(val)} + /> + + )) as t.Slider; +export const TextInput = findPropPolyfill(false, "TextInput") as t.TextInput; + +// tabs +export const Tabs = findPropPolyfill(false, "Tabs") as t.Tabs; +export const SegmentedControlPages = findPropPolyfill( + false, + "SegmentedControlPages", +) as t.Tabs; + +// views +export const Card = findPropPolyfill(false, "Card") as t.Card; +export const Stack = findPropPolyfill(false, "Stack") as t.Stack; + +// functions + +export const useSegmentedControlState = findPropPolyfill( + true, + "useSegmentedControlState", +) as t.useSegmentedControlState; diff --git a/nexxstuff/lib/redesign/types.ts b/nexxstuff/lib/redesign/types.ts new file mode 100644 index 0000000..dc05547 --- /dev/null +++ b/nexxstuff/lib/redesign/types.ts @@ -0,0 +1,175 @@ +import type { + FlexAlignType, + ImageSourcePropType, + PressableProps, + TextInputProps, + ViewProps, +} from "react-native"; +import type { SharedValue } from "react-native-reanimated"; + +export type ButtonVariant = + | "primary" + | "secondary" + | "tertiary" + | "primary-overlay" + | "secondary-overlay" + | "destructive" + | "active"; + +export interface PrimitiveButton { + onPress?: () => void; + disabled?: boolean; + icon?: ImageSourcePropType | React.ReactNode; + style?: ViewProps["style"]; +} +export interface PrimitiveButtonIcon { + source: ImageSourcePropType; +} + +export type Button = + & React.FC< + PrimitiveButton & { + variant?: ButtonVariant; + size?: "sm" | "md" | "lg"; + text: string; + iconPosition?: "start" | "end"; + grow?: boolean; + loading?: boolean; + onPressIn?: () => void; + onPressOut?: () => void; + } + > + & { + Icon: React.FC; + }; + +export type RowButton = + & React.FC< + PrimitiveButton & { + icon?: ImageSourcePropType; + label: string; + subLabel?: string; + variant?: "primary" | "secondary"; + trailing?: React.ReactNode; + disabled?: boolean; + arrow?: boolean; + /** Requires onPress to bet set */ + onPressIn?: () => void; + /** Requires onPress to bet set */ + onPressOut?: () => void; + } + > + & { + Icon: React.FC< + PrimitiveButtonIcon & { + variant?: "secondary" | "danger" | "blurple" | "boosting-pink"; + } + >; + }; + +export type FAB = React.FC<{ + icon: ImageSourcePropType; + onPress?: () => void; + text?: string; + positionBottom?: number; + state?: { + collapseText?: SharedValue; + }; +}>; + +export type IconButton = React.FC<{ + icon: ImageSourcePropType | React.ReactNode; + onPress?: () => void; + onLongPress?: () => void; + loading?: boolean; + disabled?: boolean; + size?: "sm" | "md" | "lg"; + variant?: ButtonVariant; + style?: ViewProps["style"]; +}>; + +export type ContextMenu = React.FC<{ + title?: string; + triggerOnLongPress?: boolean; + align?: "left" | "right" | "above" | "below"; + items: { + label: string; + variant: "default" | "destructive"; + action: () => void; + iconSource?: ImageSourcePropType; + // TODO add IconComponent + }[]; + children: (data: { + ref: React.MutableRefObject; + onPress: (() => void) | undefined; + onLongPress: (() => void) | undefined; + onAccessibilityAction: () => void; + accessibilityActions: { name: string; label: string }[]; + }) => React.ReactNode | undefined; +}>; + +export type Slider = React.FC<{ + value: number; + accessibilityLabel?: string; + accessibilityValue?: { + text: string; + }; + step: number; + onValueChange?: (value: number) => void; + minimumValue: number; + maximumValue: number; + onSlidingStart?: () => void; + onSlidingComplete?: () => void; + startIcon?: React.ReactNode; + endIcon?: React.ReactNode; +}>; + +export type TextInput = React.FC< + Omit & { + onChange?: (value: string) => void; + size?: "sm" | "md" | "lg"; + label?: string; + description?: React.ReactNode; + focusable?: boolean; + isDisabled?: boolean; + leadingPressableProps?: PressableProps; + leadingIcon?: React.FC; + leadingText?: string; + trailingPressableProps?: PressableProps; + trailingIcon?: React.FC; + trailingText?: string; + isClearable?: boolean; + status?: "error" | "default"; + errorMessage?: string; + isCentered?: boolean; + grow?: boolean; + isRound?: boolean; + ref?: any; + } +>; + +type controlState = symbol; +export type Tabs = React.FC<{ + state: controlState; +}>; + +export type Card = React.FC; + +export type Stack = React.FC< + React.PropsWithChildren<{ + direction?: "horizontal" | "vertical"; + spacing?: number; + align?: FlexAlignType; + style?: ViewProps["style"]; + }> +>; + +export type useSegmentedControlState = (state: { + defaultIndex: number; + items: { + label: string; + id: string; + page: React.ReactNode; + }[]; + pageWidth: number; +}) => controlState; diff --git a/nexxstuff/lib/ui.ts b/nexxstuff/lib/ui.ts new file mode 100644 index 0000000..b3242dd --- /dev/null +++ b/nexxstuff/lib/ui.ts @@ -0,0 +1,17 @@ +import { NavigationNative, React } from "@vendetta/metro/common"; + +export function managePage( + options: { + title?: string; + headerLeft?: React.FC; + headerRight?: React.FC; + }, + navigation?: any, + ...deps: any[] +) { + if (!navigation) navigation = NavigationNative.useNavigation(); + + React.useEffect(() => { + navigation.setOptions(options); + }, [navigation, ...deps]); +} diff --git a/nexxstuff/migration.tsx b/nexxstuff/migration.tsx new file mode 100644 index 0000000..7421597 --- /dev/null +++ b/nexxstuff/migration.tsx @@ -0,0 +1,294 @@ +import { React, ReactNative } from "@vendetta/metro/common"; +import { installPlugin, plugins, removePlugin } from "@vendetta/plugins"; +import { createMMKVBackend } from "@vendetta/storage"; +import { showConfirmationAlert } from "@vendetta/ui/alerts"; +import { getAssetIDByName } from "@vendetta/ui/assets"; +import { showToast } from "@vendetta/ui/toasts"; +import { RichText } from "./components/RichText"; +import Text from "./components/Text"; +import { Reanimated, RNFileModule } from "./deps"; +import { Stack } from "./lib/redesign"; + +const { Pressable } = ReactNative; + +interface InvalidDomain { + match: RegExp; + label: string; +} + +const specialIDs = { + usrpfp: "userpfp", +} as Record; +const allowedPluginIDs = [ + PLUGINS_LIST, + Object.keys(specialIDs), +].flat(); + +const invalidDomains: InvalidDomain[] = [{ + match: /vendetta\.nexpid\.xyz\/(?.+)\/$/i, + label: "vendetta.nexpid.xyz", +}, { + match: /dev\.bunny\.nexpid\.xyz\/(?.+)\/$/i, + label: "dev.bunny.nexpid.xyz", +}, { + match: /bunny\.nexpid\.xyz\/(?.+)\/$/i, + label: "bunny.nexpid.xyz", +}]; + +const migrationLockFilePath = "nexpid/revengemigration.txt"; + +function MigrationModal( + { affectedPlugin, domains }: { + affectedPlugin?: string; + domains: string[]; + }, +) { + const jumper = Reanimated.useSharedValue(0); + const spinner = Reanimated.useSharedValue("0deg"); + const squisherX = Reanimated.useSharedValue(1); + const squisherY = Reanimated.useSharedValue(1); + const [toggle, setToggle] = React.useState(true); + + const duration = 300; + const fromY = -10; + const toY = 30; + const spinBetween = 11; + + const squishConfig = { duration: 0 }; + const squishVal = 1.25; + const releaseConfig = { + duration: duration * 2, + easing: Reanimated.Easing.out(Reanimated.Easing.quad), + }; + const releaseVal = 0.9; + + React.useEffect(() => { + if (!toggle) { + jumper.value = 0; + spinner.value = "0deg"; + squisherX.value = 1; + squisherY.value = 1; + return; + } + + jumper.value = Reanimated.withRepeat( + Reanimated.withSequence( + Reanimated.withTiming(toY, { + easing: Reanimated.Easing.out(Reanimated.Easing.quad), + duration, + }), + Reanimated.withTiming(fromY, { + easing: Reanimated.Easing.quad, + duration, + }), + ), + -1, + ); + spinner.value = Reanimated.withRepeat( + Reanimated.withSequence( + Reanimated.withTiming("0deg", { duration: (duration * 2) * spinBetween }), + Reanimated.withTiming("-360deg", { + duration: duration * 2, + easing: Reanimated.Easing.out(Reanimated.Easing.exp), + }), + ), + -1, + ); + + squisherX.value = Reanimated.withRepeat( + Reanimated.withSequence( + Reanimated.withTiming(squishVal, squishConfig), + Reanimated.withTiming(releaseVal, releaseConfig), + ), + -1, + ); + squisherY.value = Reanimated.withRepeat( + Reanimated.withSequence( + Reanimated.withTiming(1 / squishVal, squishConfig), + Reanimated.withTiming(1 / releaseVal, releaseConfig), + ), + -1, + ); + }, [toggle]); + + return ( + + + setToggle(!toggle)} + accessibilityRole="togglebutton" + accessibilityLabel="toggle jumping animation" + > + + + + + Hey, it's me, nexpid! + + + You might know me. I made {affectedPlugin + ? ( + <> + a plugin you use, {affectedPlugin} + + ) + : "some of the plugins you use"}! + + + + + {affectedPlugin + ? "The plugin you use has" + : "Some of my plugins you use have"} one of these domains: + + + {domains.map(label => `• ${label}`).join("\n")} + + + These domains are being deprecated and plugins on them won't receive any new updates. Please + use the new domain instead: + + + revenge.nexpid.xyz + + + ); +} + +// thanks to the shim, this only gets run once even if multiple plugins are installed +export async function runMigration() { + if ( + await RNFileModule.fileExists( + `${RNFileModule.DocumentsDirPath}/${migrationLockFilePath}`, + ) + ) return; + + const affected: { + id: string; + link: string; + name: string; + }[] = []; + const isOutdated = new Set(); + for (const plugin of Object.keys(plugins)) { + const parser = invalidDomains.find(x => x.match.test(plugin)); + const id = parser && plugin.match(parser.match)?.groups?.plugin; + + if (id && allowedPluginIDs.includes(id)) { + affected.push({ + id, + link: plugin, + name: plugins[plugin].manifest.name, + }); + isOutdated.add(parser.label); + + if (plugin.includes("bn-plugins.github.io/vd-proxy")) { + isOutdated.add("proxied plugin links"); + } + } + } + + if (isOutdated.size) { + showConfirmationAlert({ + // @ts-expect-error Missing from typings + children: ( + + ), + confirmText: "Migrate now", + async onConfirm() { + showToast("Starting migration"); + + console.log("MIGRATION STARTS HERE, AFFECTED", affected); + try { + // take all plugins that are bad & make good :blush: + for (const { id, link, name } of affected) { + const newPlugin = `https://revenge.nexpid.xyz/${specialIDs[id] ?? id}/`; + console.log( + "FROM", + link, + "TO", + newPlugin, + ); + + await createMMKVBackend(newPlugin).set(await createMMKVBackend(link).get()); + if (!plugins[newPlugin]) await installPlugin(newPlugin); + // returns a promise (bug in types) + await removePlugin(link); + showToast(`Migrated ${name}`); + } + console.log("MIGRATION ENDS HERE"); + } catch (e) { + console.error("Migration error", e); + return showToast( + `Got an error! ${e} (ping nexpid about this!!!)`, + getAssetIDByName("CircleXIcon-primary"), + ); + } + + showToast( + "Migrated successfully! Please reload your client to apply changes", + getAssetIDByName("CircleCheckIcon-primary"), + ); + }, + cancelText: "Remind me later", + secondaryConfirmText: "I understand (don't show again)", + onConfirmSecondary() { + RNFileModule.writeFile("documents", migrationLockFilePath, "revenged", "utf8"); + }, + }); + } +} diff --git a/nexxstuff/types.tsx b/nexxstuff/types.tsx new file mode 100644 index 0000000..ede70be --- /dev/null +++ b/nexxstuff/types.tsx @@ -0,0 +1,262 @@ +import { findByProps, findByStoreName } from "@vendetta/metro"; +import { FluxDispatcher, ReactNative as RN } from "@vendetta/metro/common"; +import { getAssetIDByName } from "@vendetta/ui/assets"; +import { showToast } from "@vendetta/ui/toasts"; +import type { StyleSheet } from "react-native"; + +import { plugin } from "@vendetta"; +import { before } from "@vendetta/patcher"; +import type Modal from "./components/Modal"; +import { RNChatModule } from "./deps"; +import type { ChatRowTypeMessage } from "./typings"; + +const ThemeStore = findByStoreName("ThemeStore"); +const { triggerHaptic } = findByProps("triggerHaptic"); + +const colorModule = findByProps("colors", "unsafe_rawColors"); +const colorResolver = colorModule?.internal ?? colorModule?.meta; + +const { useThemeContext } = findByProps("useThemeContext"); + +export const TextStyleSheet = findByProps("TextStyleSheet") + .TextStyleSheet as _TextStyleSheet; + +export const Navigator = findByProps("Navigator")?.Navigator; +export const modalCloseButton = findByProps( + "getHeaderCloseButton", +)?.getHeaderCloseButton; +export const { popModal, pushModal } = findByProps("popModal", "pushModal"); + +export type Entries = [keyof T, T[keyof T]]; + +// ... +export const getDiscordTheme = () => { + // getDefaultFallbackTheme is not exported :( + const { theme } = ThemeStore; + + if (theme.startsWith("vd-theme-")) return theme.split("-")[3]; + return theme; +}; + +export const resolveCustomSemantic = (dark: any, light: any, inverted?: boolean) => + getDiscordTheme() === "light" ? (inverted ? dark : light) : (inverted ? light : dark); + +export const lerp = (og: string, target: string, perc: number) => { + const hex2rgb = (hex: string) => + hex.match(/\w\w/g)?.map(x => Number.parseInt(x, 16)) ?? [0, 0, 0]; + const rgb2hex = (rgb: number[]) => `#${rgb.map(x => x.toString(16).padStart(2, "0")).join("")}`; + + const ogR = hex2rgb(og); + const targetR = hex2rgb(target); + + const result = ogR.map((ogC, i) => Math.round(ogC + (targetR[i] - ogC) * perc)); + + return rgb2hex(result); +}; + +export function resolveSemanticColor( + color: any, + theme: string = ThemeStore.theme, +) { + return (color && colorResolver?.resolveSemanticColor(theme, color)) || "#000000"; +} + +export function getUserAvatar( + user: { + discriminator: string; + avatar?: string; + id: string; + }, + animated?: boolean, +): string { + const isPomelo = user.discriminator === "0"; + + return user.avatar + ? `https://cdn.discordapp.com/avatars/${user.id}/${ + animated && user.avatar.startsWith("a_") + ? `${user.avatar}.gif` + : `${user.avatar}.png` + }` + : `https://cdn.discordapp.com/embed/avatars/${ + isPomelo + ? (Number.parseInt(user.id) >> 22) % 6 + : Number.parseInt(user.discriminator) % 5 + }`; +} + +export function openModal(key: string, modal: typeof Modal) { + const empty = Symbol("empty"); + if (!Navigator || !modalCloseButton) { + showToast( + `${ + [ + Navigator ? empty : "Navigator", + modalCloseButton ? empty : "modalCloseButton", + ] + .filter(x => x !== empty) + .join(", ") + } is missing! Please try reinstalling your client.`, + getAssetIDByName("CircleXIcon-primary"), + ); + return; + } + + pushModal({ + key, + modal: { + key, + modal, + animation: "slide-up", + shouldPersistUnderModals: false, + closable: true, + }, + }); +} + +export function doHaptic(dur: number): Promise { + triggerHaptic(); + const interval = setInterval(() => triggerHaptic(), 1); + return new Promise(res => setTimeout(() => res(clearInterval(interval)), dur)); +} + +export function androidifyColor(color: string, alpha = 255): number { + const [_, r, g, b] = color.match(/#([A-F0-9]{2})([A-F0-9]{2})([A-F0-9]{2})/i) ?? []; + if (!_) return 0; + + return ( + ((alpha & 0xff) << 24) + | ((Number.parseInt(r, 16) & 0xff) << 16) + | ((Number.parseInt(g, 16) & 0xff) << 8) + | (Number.parseInt(b, 16) & 0xff) + ); +} + +export function fluxSubscribe( + topic: string, + callback: (data: any) => void, + once?: boolean, +) { + const cback = (data: any) => ( + callback(data), once && FluxDispatcher.unsubscribe(topic, cback) + ); + FluxDispatcher.subscribe(topic, cback); + return () => FluxDispatcher.unsubscribe(topic, cback); +} + +export function formatBytes(bytes: number) { + const sizes = ["B", "KB", "MB", "GB", "TB"]; + if (bytes === 0) return "0 B"; + + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + return `${(bytes / 1024 ** i).toFixed(2)} ${sizes[i]}`; +} + +export function deepEquals(x: any, y: any) { + if (x === y) return true; + if (typeof x === "object" && typeof y === "object" && x && y) { + if (Object.keys(x).length !== Object.keys(y).length) return false; + + for (const prop in x) { + if (Object.hasOwn(y, prop)) { + if (!deepEquals(x[prop], y[prop])) return false; + } else return false; + } + + return true; + } +} + +export function formatDuration(duration: number) { + const seconds = duration % 60; + const minutes = Math.floor(duration / 60) % 60; + const hours = Math.floor(duration / 3600); + + if (hours > 0) { + return `${hours}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`; + } + return `${minutes}:${String(seconds).padStart(2, "0")}`; +} + +export function createThemeContextStyleSheet< + T extends StyleSheet.NamedStyles, +>(sheet: T) { + const themeContext = useThemeContext(); + + for (const key in sheet) { + // @ts-expect-error types + sheet[key] = new Proxy(RN.StyleSheet.flatten(sheet[key]), { + get(target, prop, receiver) { + const res = Reflect.get(target, prop, receiver); + return colorResolver.isSemanticColor(res) + ? resolveSemanticColor(res, themeContext?.theme) + : res; + }, + }); + } + + return sheet; +} + +export function patchRows(callback: (rows: ChatRowTypeMessage[]) => void) { + return before("updateRows", RNChatModule, (args) => { + const rows = JSON.parse(args[1]); + try { + callback(rows); + } catch (e: any) { + console.error(`[nexpid.patchRows][${plugin.id}]`, e.stack); + } + + args[1] = JSON.stringify(rows); + }); +} + +// ... + +export type TextStyleSheetCase = "normal" | "medium" | "semibold" | "bold"; + +type TextStyleSheetHasExtraBoldCase = + | "heading-sm" + | "heading-md" + | "heading-lg" + | "heading-xl" + | "heading-xxl" + | "heading-deprecated-12"; +export type TextStyleSheetHasCase = + | TextStyleSheetHasExtraBoldCase + | "text-xxs" + | "text-xs" + | "text-sm" + | "text-md" + | "text-lg" + | "redesign/message-preview" + | "redesign/channel-title"; + +export type TextStyleSheetVariant = + | `${TextStyleSheetHasCase}/${TextStyleSheetCase}` + | `${TextStyleSheetHasExtraBoldCase}/${TextStyleSheetCase | "extrabold"}` + | "eyebrow" + | "redesign/heading-18/bold" + | "display-sm" + | "display-md" + | "display-lg"; + +type _TextStyleSheet = Record< + TextStyleSheetVariant, + { + fontSize: number; + lineHeight: number; + textTransform: "none" | "capitalize" | "uppercase" | "lowercase"; + fontFamily: string; + includeFontPadding: boolean; + letterSpacing?: number; + } +>; + +export interface SearchContext { + type: string; + [key: PropertyKey]: any; +} + +export function isObject(x: Record) { + return typeof x === "object" && !Array.isArray(x); +} diff --git a/nexxstuff/typings.d.ts b/nexxstuff/typings.d.ts new file mode 100644 index 0000000..c8d1b1f --- /dev/null +++ b/nexxstuff/typings.d.ts @@ -0,0 +1,177 @@ +export interface DisplayProfileData { + displayProfile: { + userId: string; + guildId?: string; + banner?: string; + bio?: string; + pronouns: string; + accentColor?: number; + emoji?: any; + themeColors?: number[]; + popoutAnimationParticleType?: any; + _profileThemesExperimentBucket?: number; + _guildMemberProfile?: GuildMemberProfile; + }; + user: User; +} + +export interface User { + hasFlag: (flag: any) => boolean; + isStaff: () => boolean; + isStaffPersonal: () => boolean; + hasAnyStaffLevel: () => boolean; + id: string; + username: string; + discriminator: string; + avatar?: string; + avatarDecoration?: string; + email?: string; + verified: boolean; + bot: boolean; + system: boolean; + mfaEnabled: boolean; + mobile: boolean; + desktop: boolean; + premiumType?: number; + flags: number; + publicFlags: number; + purchasedFlags: number; + premiumUsageFlags: number; + phone?: string; + nsfwAllowed?: boolean; + guildMemberAvatars: Record; + hasBouncedEmail: boolean; // ??? + personalConnectionId?: any; // ??? + globalName: string; +} + +export interface GuildMemberProfile { + userId: string; + guildId: string; + avatar?: string; + banner?: string; + accentColor?: number; + emoji?: any; + themeColors?: number[]; + popoutAnimationParticleType?: any; + bio?: string; + pronouns?: string; + badges: any[]; +} + +export interface ReactionEvent { + channelId: string; + messageId: string; + userId: string; + emoji: { name: string; id?: string; animated?: boolean }; + burst: boolean; + colors: string[]; + messageAuthorId: string; +} + +type VendettaSysColor = [ + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, +]; +export type VendettaSysColors = Record< + "neutral1" | "neutral2" | "accent1" | "accent2" | "accent3", + VendettaSysColor +>; + +type PlusColorResolvable = + | string + | [string, string | undefined, string | undefined, string | undefined]; +export interface PlusStructureV0 { + icons?: Record; + mentionLineColor?: PlusColorResolvable; + customOverlays?: boolean; + iconpack?: string; + version: 0; +} + +export type PlusStructure = PlusStructureV0; + +export type ThemeWithPlus = Theme & { + data: ThemeDataWithPlus; +}; +export type ThemeDataWithPlus = ThemeData & { + plus?: PlusStructure; +}; + +// chat row stuff +export type ContentRow = { + type: "text"; + content: string; +} | { + type: + | "strong" + | "s" + | "italic" + | "em" + | "underline" + | "u" + | "strikethrough" + | "subtext"; + content: ContentRow[]; +} | { + type: "spoiler"; + channelId: string; + content: ContentRow[]; +} | { + type: "heading"; + level: number; + content: ContentRow[]; +} | { + type: "link"; + target: string; + content: ContentRow[]; +} | { + type: "list"; + ordered: boolean; + start?: number; + items: ContentRow[]; +} | { + type: "emoji"; + content: string; + surrogate: string; + jumboable?: boolean; +} | { + type: "customEmoji"; + id: string; + alt: string; + src: string; + frozenSrc: string; + jumboable?: boolean; +}; +export type ContentRowIs = Extract; + +interface MessageEmbed { + url: string; +} + +export interface ChatRowTypeMessage { + type: 1; + message: { + id: string; + authorId: string; + channelId: string; + guildId?: string; + content?: ContentRow[]; + embeds?: MessageEmbed[]; + codedLinks?: any[]; + communicationDisabled?: boolean; + }; +} + +export type ChatRow = ChatRowTypeMessage; diff --git a/plugins/Commands/settings/components/Header.tsx b/plugins/Commands/settings/components/Header.tsx index 3e60b44..0d40735 100644 --- a/plugins/Commands/settings/components/Header.tsx +++ b/plugins/Commands/settings/components/Header.tsx @@ -4,6 +4,7 @@ import { semanticColors } from "@vendetta/ui"; import { getAssetIDByName } from "@vendetta/ui/assets"; import { showToast } from "@vendetta/ui/toasts"; import { findByProps } from "@vendetta/metro"; +import Text from "../../../../nexxstuff/components/Text"; export default function Header() { const [clickCounter, setClickCounter] = React.useState(0); @@ -87,24 +88,7 @@ export default function Header() { textContainer: { flex: 1, marginLeft: 4, - }, - title: { - fontSize: 28, - fontWeight: "700", - color: semanticColors.MOBILE_TEXT_HEADING_PRIMARY, - marginBottom: 4, - textAlign: "left", - }, - subtitle: { - fontSize: 14, - color: semanticColors.TEXT_MUTED, - textAlign: "left", - lineHeight: 20, - }, - avatarPlaceholder: { - width: 48, - height: 48, - tintColor: semanticColors.INTERACTIVE_NORMAL, + justifyContent: "center", }, animatedIndicator: { position: "absolute", @@ -123,6 +107,11 @@ export default function Header() { fontSize: 10, fontWeight: "bold", }, + avatarPlaceholder: { + width: 48, + height: 48, + tintColor: semanticColors.INTERACTIVE_NORMAL, + }, }); // Fetch user's avatar on component mount @@ -257,10 +246,12 @@ export default function Header() { - Commands - + + Commands + + A collection of useful commands for Vendetta like clients - +