feat: add nexx lib and better Header text

nexx shouldn't be mad, right?
This commit is contained in:
kmmiio99o 2026-01-25 17:09:24 +01:00
parent 27b037fe9a
commit b98ce51645
No known key found for this signature in database
GPG key ID: 163FE39F7772D2EC
26 changed files with 2370 additions and 21 deletions

View file

@ -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<any>, 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>
<ActionSheetTitleHeader
title={props.title}
trailing={
<ActionSheetCloseButton
onPress={props.onClose
?? (() => {
hideActionSheet();
})}
/>
}
/>
<RN.View {...without(props, "title", "onClose")} />
</_ActionSheet>
);
}) as {
(props: ActionSheetProps): JSX.Element;
open: <Sheet extends React.FunctionComponent<any>>(
sheet: Sheet,
props: Parameters<Sheet>[0],
) => void;
};
ActionSheet.open = (sheet, props) => {
openLazy(
new Promise(res => {
res({
default: sheet,
});
}) as any,
"ActionSheet",
props,
);
};

View file

@ -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 (
<TableRow
label={label}
icon={<RN.Image style={styles.icon} source={icon} />}
onPress={onPress}
/>
);
}
return (
<FormRow
label={label}
leading={<FormRow.Icon source={icon} />}
onPress={onPress}
/>
);
}

View file

@ -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 (
<UseCompontent
android_ripple={styles.androidRipple}
onPress={onPress}
style={{
marginBottom: 8,
marginHorizontal: padding ? 16 : 0,
marginTop: padding ? 8 : 0,
gap: 4,
flexDirection: "row",
alignItems: "center",
}}
>
{icon && (
<RN.Image
style={styles.icon}
source={icon}
resizeMode="cover"
/>
)}
<Text variant="text-sm/semibold" color="TEXT_MUTED">
{title}
</Text>
</UseCompontent>
);
}
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 (
<RN.View style={{ marginHorizontal: 16, marginTop: nearby ? 8 : 16 }}>
{title
? (
typeof title === "string"
? (
<BetterTableRowTitle
title={title}
onPress={onTitlePress}
icon={icon}
/>
)
: title
)
: null}
<RN.View style={styles.main}>
{padding
? (
<RN.View
style={{ paddingHorizontal: 16, paddingVertical: 16 }}
>
{children}
</RN.View>
)
: children}
</RN.View>
</RN.View>
);
}

View file

@ -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 (
<RN.Pressable
accessibilityLabel={label}
accessibilityRole="checkbox"
accessibilityState={{
selected: value,
}}
onPress={() => {
setValue(!value);
}}
style={{
flexDirection: "row",
alignItems: "center",
gap: 8,
marginTop: 12,
}}
>
<FormCheckbox checked={value} />
<Text variant="text-md/normal" color="TEXT_DEFAULT">
{label}
</Text>
</RN.Pressable>
);
}

View file

@ -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 (
<Navigator
initialRouteName={props.mkey}
screens={{
[props.mkey]: Object.assign(
without(props, "mkey", "children"),
{
headerLeft: modalCloseButton?.(() => popModal(props.mkey)),
render: () => props.children,
},
),
}}
/>
);
}

View file

@ -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 (
<Text variant={"text-md/bold"} onPress={onPress}>
{children}
</Text>
);
}
export function Underline({
children,
onPress,
}: React.PropsWithChildren<{
onPress?: () => void;
}>) {
return (
<RN.Text
style={{ textDecorationLine: "underline" }}
onPress={onPress}
>
{children}
</RN.Text>
);
}
}

View file

@ -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 (
<Reanimated.default.View
style={[baseStyle.skeleton, style, { opacity }]}
/>
);
}

View file

@ -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 (
<RN.Pressable
android_ripple={styles.androidRipple}
accessible={false}
onPress={onPress}
>
<RN.View>
<RN.Image
style={styles.iconSecondary}
source={getAssetIDByName(`${icon}-secondary`)}
/>
<RN.Image
style={styles.icon}
source={getAssetIDByName(`${icon}-primary`)}
/>
</RN.View>
</RN.Pressable>
);
}

View file

@ -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 | string>(
null,
);
React.useEffect(
() =>
!loadedUsername
&& loadUsername
&& (UserStore.getUser(userId)
? setLoadedUsername(UserStore.getUser(userId).username)
: fetchProfile(userId).then(x => {
setLoadedUsername(x.user.username);
})),
[loadUsername],
);
return (
<Text
variant="text-md/bold"
color={color ?? "TEXT_DEFAULT"}
onPress={() =>
UserStore.getUser(userId)
? showUserProfile({ userId })
: fetchProfile(userId).then(() => showUserProfile({ userId }))}
>
{loadUsername ? `@${loadedUsername ?? "..."}` : children}
</Text>
);
}

View file

@ -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 (
<RN.TouchableOpacity onPress={onPress} onLongPress={onLongPress}>
<RN.Image
style={[
typeof style === "string"
? style === "header"
? styles.headerStyleIcon
: styles.cardStyleIcon
: style,
destructive && styles.destructiveIcon,
color && { tintColor: color },
].filter(x => !!x)}
source={icon}
/>
</RN.TouchableOpacity>
);
}

View file

@ -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<object>) {
return (
<Text variant="text-md/medium" color="TEXT_MUTED">
{children}
</Text>
);
}
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 (
<RN.Text
style={[
variant && TextStyleSheet[variant],
color
? {
color: resolveSemanticColor(
semanticColors[color],
themeContext?.theme,
),
}
: {},
align && { textAlign: align },
style ?? {},
]}
numberOfLines={lineClamp}
onPress={onPress}
ellipsizeMode={ellipsis}
>
{getChildren?.() ?? children}
</RN.Text>
);
}

View file

@ -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 (
<RN.View
style={[styles.main, style]}
onLayout={layout => {
setWidth(layout.nativeEvent.layout.width * 0.5 + 4);
}}
>
{shiny && width !== 0 && (
<Reanimated.default.View
style={[
styles.shiner,
{
transform: [
{
rotate: "-22deg",
},
{ translateX: shinyTranslate },
],
},
]}
/>
)}
<Text
variant="text-xxs/bold"
align="center"
style={styles.text}
>
{children}
</Text>
</RN.View>
);
}

View file

@ -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 (
<ActionSheet title={title}>
{options.map((x) => (
<FormRow
key={x.value}
label={x.name}
subLabel={x.description}
trailing={x.iconComponent
? x.iconComponent
: x.icon && (
<RN.Image
source={x.icon}
resizeMode="cover"
style={{
width: 24,
height: 24,
tintColor: x.iconColor,
}}
/>
)}
leading={<FormRow.Radio selected={x.value === value} />}
onPress={() => {
setValue(x.value);
callback(x.value);
}}
/>
))}
</ActionSheet>
);
}

227
nexxstuff/deps.ts Normal file
View file

@ -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<T = any>(...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<StateStorage>(
"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<boolean>;
/**
* 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<boolean>;
/**
* @param path **Full** path to file
* @param encoding Set to `base64` in order to encode response
*/
readFile(path: string, encoding: "base64" | "utf8"): Promise<string>;
saveFileToGallery?(uri: string, fileName: string, fileType: "PNG" | "JPEG"): Promise<string>;
/**
* @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<string>;
/**
* 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<unknown>;
/**
* 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<boolean>;
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",
)!;

View file

@ -0,0 +1,62 @@
import { React } from "@vendetta/metro/common";
type PromiseRet<Res> =
| {
fulfilled: false;
}
| {
fulfilled: true;
success: false;
error: any;
}
| {
fulfilled: true;
success: true;
response: Res;
};
export default function usePromise<Res>(
promise: (signal: AbortSignal) => Promise<Res>,
deps: any[] = [],
): PromiseRet<Res> {
const [res, setRes] = React.useState<PromiseRet<Res>>({
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;
}

View file

@ -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<string, string>;
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, (values: Record<string, string>) => 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;

View file

@ -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();
}
};
}

View file

@ -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 (
<FormRow
label={tabs.title()}
leading={<FormRow.Icon source={tabs.icon} />}
trailing={
<>
{tabs.trailing ? tabs.trailing : null}
<FormRow.Arrow />
</>
}
// 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: () => <Component />,
});
}}
/>
);
}
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,
<Section tabs={tabs} />,
);
}
},
),
);
},
true,
),
);
} catch {
logger.info("Panel UI patch failed graciously 💔");
}
}

View file

@ -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 && (() => <TableRowIcon source={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: () => <Component />,
});
},
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];
}
}
}),
);
}
}

View file

@ -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 => (
<RN.View style={{ marginHorizontal: 16, marginTop: 16, marginBottom: 12 }}>
<_Slider
{...props}
onValueChange={(val: any) => props.value !== val && props.onValueChange?.(val)}
/>
</RN.View>
)) 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;

View file

@ -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<PrimitiveButtonIcon>;
};
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<any>;
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<TextInputProps, "onChange"> & {
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<ViewProps>;
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;

17
nexxstuff/lib/ui.ts Normal file
View file

@ -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]);
}

294
nexxstuff/migration.tsx Normal file
View file

@ -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<string, string>;
const allowedPluginIDs = [
PLUGINS_LIST,
Object.keys(specialIDs),
].flat();
const invalidDomains: InvalidDomain[] = [{
match: /vendetta\.nexpid\.xyz\/(?<plugin>.+)\/$/i,
label: "vendetta.nexpid.xyz",
}, {
match: /dev\.bunny\.nexpid\.xyz\/(?<plugin>.+)\/$/i,
label: "dev.bunny.nexpid.xyz",
}, {
match: /bunny\.nexpid\.xyz\/(?<plugin>.+)\/$/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 (
<Stack direction="vertical" spacing={8}>
<Stack
direction="horizontal"
spacing={14}
style={{
justifyContent: "center",
alignItems: "center",
marginBottom: 18,
marginHorizontal: 8,
}}
>
<Pressable
style={{ width: 80, height: 80 }}
pointerEvents="box-only"
onPress={() => setToggle(!toggle)}
accessibilityRole="togglebutton"
accessibilityLabel="toggle jumping animation"
>
<Reanimated.default.Image
source={{
uri: "https://api.lanyard.rest/853550207039832084.webp?size=128",
width: 128,
height: 128,
}}
style={[{
borderRadius: 40,
width: 80,
height: 80,
position: "absolute",
bottom: 0,
}, {
bottom: jumper,
transform: [{ scaleX: squisherX }, { scaleY: squisherY }, {
rotate: spinner,
}],
}]}
/>
</Pressable>
<Stack direction="vertical" style={{ flex: 1 }}>
<Text
variant="heading-xl/extrabold"
color="TEXT_DEFAULT"
style={{ flex: 0 }}
>
Hey, it's me, nexpid!
</Text>
<Text
variant="heading-md/medium"
color="TEXT_DEFAULT"
style={{ flex: 0 }}
>
You might know me. I made {affectedPlugin
? (
<>
a plugin you use, <RichText.Bold>{affectedPlugin}</RichText.Bold>
</>
)
: "some of the plugins you use"}!
</Text>
</Stack>
</Stack>
<Text
variant="text-md/medium"
color="TEXT_DEFAULT"
>
{affectedPlugin
? "The plugin you use has"
: "Some of my plugins you use have"} one of these domains:
</Text>
<Text
variant="text-md/bold"
color="TEXT_DEFAULT"
style={{ marginHorizontal: 10 }}
>
{domains.map(label => `• ${label}`).join("\n")}
</Text>
<Text
variant="text-md/medium"
color="TEXT_DEFAULT"
>
These domains are being deprecated and plugins on them won't receive any new updates. Please
use the new domain instead:
</Text>
<Text
variant="text-lg/bold"
color="TEXT_DEFAULT"
style={{ textDecorationLine: "underline" }}
align="center"
>
revenge.nexpid.xyz
</Text>
</Stack>
);
}
// 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<string>();
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: (
<MigrationModal
affectedPlugin={affected.length === 1 ? affected[0].name : undefined}
domains={[...isOutdated.values()]}
/>
),
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");
},
});
}
}

262
nexxstuff/types.tsx Normal file
View file

@ -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<T> = [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<void> {
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<any>,
>(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<any, any>) {
return typeof x === "object" && !Array.isArray(x);
}

177
nexxstuff/typings.d.ts vendored Normal file
View file

@ -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<string, string>;
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<string, PlusColorResolvable>;
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<Type extends ContentRow["type"]> = Extract<ContentRow, { type: Type }>;
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;

View file

@ -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() {
</RN.Pressable>
<RN.View style={styles.textContainer}>
<RN.Text style={styles.title}>Commands</RN.Text>
<RN.Text style={styles.subtitle}>
<Text variant="display-md" color="TEXT_DEFAULT" align="left">
Commands
</Text>
<Text variant="text-md/bold" color="TEXT_MUTED" align="left">
A collection of useful commands for Vendetta like clients
</RN.Text>
</Text>
</RN.View>
</RN.View>
</RN.View>