feat: Re-do Commands plugin settings page

- Re-do fully the Commands plugin settings page
- Add sidebar based of Multi Scrobbler one
- Rework header (because why not)
This commit is contained in:
kmmiio99o 2025-12-09 22:28:25 +01:00
parent 0a59dc8cc5
commit 22bbcbf91d
18 changed files with 2119 additions and 1458 deletions

View file

@ -25,7 +25,8 @@ import {
friendInviteViewCommand,
friendInviteRevokeCommand,
} from "./src/commands/friendinvites";
import settings from "./settings";
import settings from "./settings/settings";
import patchSidebar from "./sidebar";
if (!storage.factSettings) {
storage.factSettings = {
@ -76,6 +77,10 @@ if (!storage.hiddenSettings) {
konochanBypassNsfw: false,
};
}
// sidebar enabled setting
if (storage.sidebarEnabled === undefined) {
storage.sidebarEnabled = true;
}
const commandMap = {
catfact: catFactCommand,
@ -102,18 +107,60 @@ const commandMap = {
};
let commands: Array<() => void> = [];
let sidebarUnpatch: (() => void) | undefined;
export default {
onLoad: () => {
console.log("[Commands Plugin] Loading...");
// Patch sidebar if enabled
if (storage.sidebarEnabled !== false) {
try {
sidebarUnpatch = patchSidebar();
console.log("[Commands Plugin] Sidebar patched successfully");
} catch (error) {
console.error("[Commands Plugin] Failed to patch sidebar:", error);
}
}
// Register commands
for (const [key, command] of Object.entries(commandMap)) {
if (storage.enabledCommands[key]) {
commands.push(registerCommand(command));
try {
commands.push(registerCommand(command));
console.log(`[Commands Plugin] Registered command: ${key}`);
} catch (error) {
console.error(
`[Commands Plugin] Failed to register command ${key}:`,
error,
);
}
}
}
},
onUnload: () => {
commands.forEach((unregister) => unregister());
console.log("[Commands Plugin] Unloading...");
// Unregister commands
commands.forEach((unregister) => {
try {
unregister();
} catch (error) {
// Ignore errors during cleanup
}
});
commands = [];
// Unpatch sidebar
if (sidebarUnpatch) {
try {
sidebarUnpatch();
sidebarUnpatch = undefined;
console.log("[Commands Plugin] Sidebar unpatched");
} catch (error) {
console.error("[Commands Plugin] Failed to unpatch sidebar:", error);
}
}
},
settings,
};

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,72 @@
import { React, ReactNative as RN, stylesheet } from "@vendetta/metro/common";
import { semanticColors } from "@vendetta/ui";
import { getAssetIDByName } from "@vendetta/ui/assets";
export default function BetterTableRowGroup({
title,
icon,
children,
padding = false,
}: React.PropsWithChildren<{
title?: string;
icon?: number;
padding?: boolean;
}>) {
const styles = stylesheet.createThemedStyleSheet({
main: {
backgroundColor: semanticColors.CARD_PRIMARY_BG,
borderColor: semanticColors.BORDER_FAINT,
borderWidth: 1,
borderRadius: 16,
overflow: "hidden",
flex: 1,
maxWidth: "100%",
},
titleContainer: {
marginBottom: 8,
marginHorizontal: 16,
marginTop: 8,
gap: 4,
flexDirection: "row",
alignItems: "center",
},
icon: {
width: 16,
height: 16,
marginTop: 1.5,
tintColor: semanticColors.TEXT_MUTED,
},
titleText: {
fontSize: 14,
fontWeight: "600",
color: semanticColors.TEXT_MUTED,
},
wrapper: {
marginHorizontal: 16,
marginTop: 16,
maxWidth: "100%",
},
});
return (
<RN.View style={styles.wrapper}>
{title && (
<RN.View style={styles.titleContainer}>
{icon && (
<RN.Image style={styles.icon} source={icon} resizeMode="cover" />
)}
<RN.Text style={styles.titleText}>{title.toUpperCase()}</RN.Text>
</RN.View>
)}
<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,269 @@
import { storage } from "@vendetta/plugin";
import { useProxy } from "@vendetta/storage";
import { React, ReactNative as RN, stylesheet } from "@vendetta/metro/common";
import { semanticColors } from "@vendetta/ui";
import { getAssetIDByName } from "@vendetta/ui/assets";
import { showToast } from "@vendetta/ui/toasts";
import { findByProps } from "@vendetta/metro";
export default function Header() {
const [clickCounter, setClickCounter] = React.useState(0);
const [clickTimeout, setClickTimeout] = React.useState<NodeJS.Timeout | null>(
null,
);
const [avatarUrl, setAvatarUrl] = React.useState<string | null>(null);
const [isAnimatedAvatar, setIsAnimatedAvatar] = React.useState(false);
// Initialize sidebar enabled setting if not exists
React.useEffect(() => {
if (storage.sidebarEnabled === undefined) {
storage.sidebarEnabled = true;
}
}, []);
// Get current user from Discord's user store
const users = findByProps("getUser", "getCurrentUser");
const currentUser = users?.getCurrentUser?.();
// Get avatar URL utility
const { getUserAvatarURL } = findByProps("getUserAvatarURL") || {};
const { getDefaultAvatarURL } = findByProps("getDefaultAvatarURL") || {};
const styles = stylesheet.createThemedStyleSheet({
container: {
flexDirection: "row",
alignItems: "center",
paddingVertical: 24,
paddingHorizontal: 16,
width: "100%",
justifyContent: "center",
},
leftSection: {
flexDirection: "row",
alignItems: "center",
flex: 1,
gap: 20,
maxWidth: 500,
},
avatarContainer: {
position: "relative",
width: 96,
height: 96,
justifyContent: "center",
alignItems: "center",
},
avatarWrapper: {
width: 96,
height: 96,
borderRadius: 24,
backgroundColor: semanticColors.BACKGROUND_SECONDARY,
justifyContent: "center",
alignItems: "center",
overflow: "hidden",
borderWidth: 2,
borderColor: semanticColors.BACKGROUND_TERTIARY,
},
avatar: {
width: 92,
height: 92,
borderRadius: 22,
},
cogwheelBackground: {
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(0, 0, 0, 0.5)",
borderRadius: 24,
justifyContent: "center",
alignItems: "center",
opacity: 0,
},
cogwheelIcon: {
width: 32,
height: 32,
tintColor: semanticColors.INTERACTIVE_ACTIVE,
},
textContainer: {
flex: 1,
marginLeft: 4,
},
title: {
fontSize: 28,
fontWeight: "700",
color: semanticColors.HEADER_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,
},
animatedIndicator: {
position: "absolute",
top: 8,
right: 8,
backgroundColor: "rgba(0, 0, 0, 0.7)",
borderRadius: 10,
width: 20,
height: 20,
justifyContent: "center",
alignItems: "center",
zIndex: 1,
},
gifText: {
color: "#FFFFFF",
fontSize: 10,
fontWeight: "bold",
},
});
// Fetch user's avatar on component mount
React.useEffect(() => {
if (currentUser) {
let url;
let animated = false;
// Check if avatar is animated (starts with "a_")
const hasAnimatedAvatar = currentUser.avatar?.startsWith("a_");
// Try to get the user's custom avatar first
if (getUserAvatarURL && currentUser.avatar) {
// Use animated parameter for GIF avatars
url = getUserAvatarURL(currentUser, hasAnimatedAvatar);
animated = hasAnimatedAvatar;
}
// Fall back to default avatar
else if (getDefaultAvatarURL) {
url = getDefaultAvatarURL(currentUser);
}
// Last resort: Construct Discord CDN URL
else if (currentUser.avatar) {
const isGif = currentUser.avatar.startsWith("a_");
url = `https://cdn.discordapp.com/avatars/${currentUser.id}/${currentUser.avatar}.${isGif ? "gif" : "png"}?size=256`;
animated = isGif;
} else {
// Default Discord avatar based on discriminator or id
const defaultAvatarIndex = currentUser.discriminator
? parseInt(currentUser.discriminator) % 5
: (parseInt(currentUser.id) >> 22) % 6;
url = `https://cdn.discordapp.com/embed/avatars/${defaultAvatarIndex}.png`;
}
setAvatarUrl(url);
setIsAnimatedAvatar(animated);
}
}, [currentUser]);
const handleAvatarPress = () => {
if (storage.hiddenSettings?.enabled) {
storage.hiddenSettings.visible = !storage.hiddenSettings.visible;
showToast(
`Hidden settings ${storage.hiddenSettings.visible ? "visible" : "hidden"}`,
getAssetIDByName("SettingsIcon"),
);
return;
}
if (clickTimeout) {
clearTimeout(clickTimeout);
}
const newTimeout = setTimeout(() => {
setClickCounter(0);
}, 1000);
setClickTimeout(newTimeout);
const newCounter = clickCounter + 1;
setClickCounter(newCounter);
if (newCounter < 10) {
return;
}
showToast("Hidden settings unlocked!", getAssetIDByName("CheckmarkIcon"));
storage.hiddenSettings = {
...storage.hiddenSettings,
enabled: true,
visible: true,
};
setClickCounter(0);
};
const handleAvatarLongPress = () => {
const newState = !storage.sidebarEnabled;
storage.sidebarEnabled = newState;
storage.pendingRestart = true;
showToast(
`Sidebar ${newState ? "enabled" : "disabled"}. Restart Discord to apply.`,
getAssetIDByName(newState ? "CheckmarkIcon" : "XIcon"),
);
};
const [showCogwheel, setShowCogwheel] = React.useState(false);
return (
<RN.View style={styles.container}>
<RN.View style={styles.leftSection}>
<RN.Pressable
style={styles.avatarContainer}
onPress={handleAvatarPress}
onPressIn={() => setShowCogwheel(true)}
onPressOut={() => setShowCogwheel(false)}
delayLongPress={500}
onLongPress={handleAvatarLongPress}
>
<RN.View style={styles.avatarWrapper}>
{avatarUrl ? (
<>
<RN.Image
source={{ uri: avatarUrl }}
style={styles.avatar}
resizeMode="cover"
/>
{isAnimatedAvatar && (
<RN.View style={styles.animatedIndicator}>
<RN.Text style={styles.gifText}>GIF</RN.Text>
</RN.View>
)}
</>
) : (
<RN.Image
source={getAssetIDByName("ic_account_circle_24px")}
style={styles.avatarPlaceholder}
/>
)}
</RN.View>
{/* Cogwheel background overlay (appears on press) */}
<RN.View
style={[
styles.cogwheelBackground,
{ opacity: showCogwheel ? 0.8 : 0 },
]}
>
<RN.Image
source={getAssetIDByName("SettingsIcon")}
style={styles.cogwheelIcon}
/>
</RN.View>
</RN.Pressable>
<RN.View style={styles.textContainer}>
<RN.Text style={styles.title}>Commands</RN.Text>
<RN.Text style={styles.subtitle}>
A collection of useful commands for Vendetta like clients
</RN.Text>
</RN.View>
</RN.View>
</RN.View>
);
}

View file

@ -0,0 +1,12 @@
export { default as Header } from "./components/Header";
export { default as BetterTableRowGroup } from "./components/BetterTableRowGroup";
export { default as FactsSettingsPage } from "./pages/FactsSettingsPage";
export { default as ListSettingsPage } from "./pages/ListSettingsPage";
export { default as ImageSettingsPage } from "./pages/ImageSettingsPage";
export { default as GaryAPIPage } from "./pages/GaryAPIPage";
export { default as SpotifySettingsPage } from "./pages/SpotifySettingsPage";
export { default as OtherSettingsPage } from "./pages/OtherSettingsPage";
export { default as AliucordPage } from "./pages/AliucordPage";
export { default as HiddenSettingsPage } from "./pages/HiddenSettingsPage";
export { default as CreditsPage } from "./pages/CreditsPage";
export { default as NekosLifeCategoriesPage } from "./pages/NekosLifeCategoriesPage";

View file

@ -0,0 +1,76 @@
import { storage } from "@vendetta/plugin";
import { useProxy } from "@vendetta/storage";
import { React, ReactNative as RN, stylesheet } from "@vendetta/metro/common";
import { NavigationNative } from "@vendetta/metro/common";
import { semanticColors } from "@vendetta/ui";
import { getAssetIDByName } from "@vendetta/ui/assets";
import { Forms } from "@vendetta/ui/components";
import BetterTableRowGroup from "../components/BetterTableRowGroup";
import NekosLifeCategoriesPage from "./NekosLifeCategoriesPage";
const { FormSwitchRow, FormRow } = Forms;
export default function AliucordPage() {
useProxy(storage);
const [, forceUpdate] = React.useReducer((x) => x + 1, 0);
const navigation = NavigationNative.useNavigation();
return (
<RN.ScrollView
style={{ flex: 1, backgroundColor: semanticColors.BACKGROUND_PRIMARY }}
contentContainerStyle={{
flexGrow: 1,
paddingVertical: 16,
maxWidth: "100%",
}}
>
<BetterTableRowGroup
title="IP Commands"
icon={getAssetIDByName("GlobeEarthIcon")}
>
<FormSwitchRow
label="/ip"
subLabel="Get your current IP address"
leading={<FormRow.Icon source={getAssetIDByName("GlobeEarthIcon")} />}
value={storage.enabledCommands.ip}
onValueChange={(v) => {
storage.enabledCommands.ip = v;
storage.pendingRestart = true;
forceUpdate();
}}
/>
</BetterTableRowGroup>
<BetterTableRowGroup
title="NekosLife Commands"
icon={getAssetIDByName("ImageIcon")}
>
<FormSwitchRow
label="/nekoslife"
subLabel="Get images/gifs from nekos.life"
leading={<FormRow.Icon source={getAssetIDByName("ImageIcon")} />}
value={storage.enabledCommands.nekoslife}
onValueChange={(v) => {
storage.enabledCommands.nekoslife = v;
storage.pendingRestart = true;
forceUpdate();
}}
/>
<FormRow
label="View Categories"
subLabel="See all 16 available categories"
leading={<FormRow.Icon source={getAssetIDByName("BookOpenIcon")} />}
trailing={<FormRow.Arrow />}
onPress={() =>
navigation.push("VendettaCustomPage", {
title: "NekosLife Categories",
render: NekosLifeCategoriesPage,
})
}
/>
</BetterTableRowGroup>
<RN.View style={{ height: 32 }} />
</RN.ScrollView>
);
}

View file

@ -0,0 +1,184 @@
import { React, ReactNative as RN, stylesheet } from "@vendetta/metro/common";
import { semanticColors } from "@vendetta/ui";
import { getAssetIDByName } from "@vendetta/ui/assets";
import BetterTableRowGroup from "../components/BetterTableRowGroup";
export default function CreditsPage() {
const styles = stylesheet.createThemedStyleSheet({
container: {
flex: 1,
backgroundColor: semanticColors.BACKGROUND_PRIMARY,
},
creditItem: {
flexDirection: "row",
alignItems: "center",
paddingVertical: 16,
paddingHorizontal: 20,
backgroundColor: semanticColors.CARD_PRIMARY_BG,
borderRadius: 12,
marginHorizontal: 16,
marginVertical: 6,
},
avatar: {
width: 48,
height: 48,
borderRadius: 24,
marginRight: 16,
backgroundColor: semanticColors.BACKGROUND_SECONDARY,
},
textContainer: {
flex: 1,
},
commandText: {
fontSize: 17,
fontWeight: "600",
color: semanticColors.HEADER_PRIMARY,
marginBottom: 4,
},
authorText: {
fontSize: 15,
color: semanticColors.TEXT_MUTED,
marginBottom: 2,
},
linkText: {
fontSize: 13,
color: semanticColors.TEXT_MUTED,
fontWeight: "500",
},
infoText: {
fontSize: 14,
color: semanticColors.TEXT_MUTED,
marginBottom: 20,
textAlign: "center",
lineHeight: 20,
},
versionText: {
fontSize: 15,
color: semanticColors.TEXT_MUTED,
textAlign: "center",
fontWeight: "600",
lineHeight: 22,
},
});
const credits = [
{
command: "Facts Commands",
author: "jdev082",
avatar: "https://github.com/jdev082.png",
github: "https://github.com/jdev082",
},
{
command: "List Commands",
author: "Kitomanari",
avatar: "https://github.com/Kitosight.png",
github: "https://github.com/Kitosight",
},
{
command: "PetPet",
author: "wolfieeee",
avatar: "https://github.com/WolfPlugs.png",
github: "https://github.com/WolfPlugs",
},
{
command: "KonoChan Commands",
author: "btmc727 & Rico040",
avatar: "https://github.com/OTKUSteyler.png",
github: "https://github.com/OTKUSteyler",
},
{
command: "FirstMessage Command",
author: "sapphire",
avatar: "https://github.com/aeongdesu.png",
github: "https://github.com/aeongdesu",
},
{
command: "Sysinfo Command",
author: "mugman",
avatar: "https://github.com/mugman174.png",
github: "https://github.com/mugman174",
},
{
command: "Spotify Commands",
author: "Kitomanari",
avatar: "https://github.com/Kitosight.png",
github: "https://github.com/Kitosight",
},
{
command: "Gary Command",
author: "Zach Orange",
avatar: "https://github.com/IAmGaryTheCat.png",
github: "https://github.com/IAmGaryTheCat",
},
{
command: "IP & NekosLife Commands",
author: "scruzism",
avatar: "https://github.com/scrazzz.png",
github: "https://github.com/scrazzz",
},
{
command: "FriendInvites",
author: "nikosszzz",
avatar: "https://github.com/nikosszzz.png",
github: "https://github.com/nikosszzz",
},
];
const handleProfilePress = (githubUrl: string) => {
RN.Linking.openURL(githubUrl);
};
return (
<RN.ScrollView
style={styles.container}
contentContainerStyle={{
flexGrow: 1,
paddingVertical: 16,
maxWidth: "100%",
}}
>
<BetterTableRowGroup
title="Plugin Authors"
icon={getAssetIDByName("HeartIcon")}
padding={true}
>
<RN.Text style={styles.infoText}>
Thanks to this developers for creating such a nice plugins!{"\n"}
Tap on any developer to visit their GitHub profile.
</RN.Text>
</BetterTableRowGroup>
{credits.map((credit, index) => (
<RN.Pressable
key={index}
style={styles.creditItem}
onPress={() => handleProfilePress(credit.github)}
android_ripple={{
color: semanticColors.ANDROID_RIPPLE,
radius: 200,
}}
>
<RN.Image source={{ uri: credit.avatar }} style={styles.avatar} />
<RN.View style={styles.textContainer}>
<RN.Text style={styles.commandText}>{credit.command}</RN.Text>
<RN.Text style={styles.authorText}>by {credit.author}</RN.Text>
<RN.Text style={styles.linkText}>
{credit.github.replace("https://github.com/", "@")}
</RN.Text>
</RN.View>
</RN.Pressable>
))}
<BetterTableRowGroup
title="About"
icon={getAssetIDByName("InfoIcon")}
padding={true}
>
<RN.Text style={styles.versionText}>
Commands Plugin Collection{"\n"}
Version 1.3.0
</RN.Text>
</BetterTableRowGroup>
</RN.ScrollView>
);
}

View file

@ -0,0 +1,90 @@
import { storage } from "@vendetta/plugin";
import { useProxy } from "@vendetta/storage";
import { React, ReactNative as RN, stylesheet } from "@vendetta/metro/common";
import { semanticColors } from "@vendetta/ui";
import { getAssetIDByName } from "@vendetta/ui/assets";
import { Forms } from "@vendetta/ui/components";
import BetterTableRowGroup from "../components/BetterTableRowGroup";
const { FormSwitchRow, FormRow } = Forms;
export default function FactsSettingsPage() {
useProxy(storage);
const [, forceUpdate] = React.useReducer((x) => x + 1, 0);
return (
<RN.ScrollView
style={{ flex: 1, backgroundColor: semanticColors.BACKGROUND_PRIMARY }}
contentContainerStyle={{
flexGrow: 1,
paddingVertical: 16,
maxWidth: "100%",
}}
>
<BetterTableRowGroup
title="Fact Display Settings"
icon={getAssetIDByName("SettingsIcon")}
>
<FormSwitchRow
label="Send as Reply"
subLabel="Send facts as a reply to the command message"
leading={
<FormRow.Icon source={getAssetIDByName("ArrowAngleLeftUpIcon")} />
}
value={storage.factSettings.sendAsReply}
onValueChange={(v) => {
storage.factSettings.sendAsReply = v;
}}
/>
<FormSwitchRow
label="Include Source Citation"
subLabel="Include the source of facts when available"
leading={<FormRow.Icon source={getAssetIDByName("LinkIcon")} />}
value={storage.factSettings.includeCitation}
onValueChange={(v) => {
storage.factSettings.includeCitation = v;
}}
/>
</BetterTableRowGroup>
<BetterTableRowGroup
title="Available Fact Commands"
icon={getAssetIDByName("BookmarkIcon")}
>
<FormSwitchRow
label="/catfact"
subLabel="Get random cat facts"
leading={<FormRow.Icon source={getAssetIDByName("BookCheckIcon")} />}
value={storage.enabledCommands.catfact}
onValueChange={(v) => {
storage.enabledCommands.catfact = v;
storage.pendingRestart = true;
forceUpdate();
}}
/>
<FormSwitchRow
label="/dogfact"
subLabel="Get random dog facts"
leading={<FormRow.Icon source={getAssetIDByName("BookCheckIcon")} />}
value={storage.enabledCommands.dogfact}
onValueChange={(v) => {
storage.enabledCommands.dogfact = v;
storage.pendingRestart = true;
forceUpdate();
}}
/>
<FormSwitchRow
label="/useless"
subLabel="Get random useless facts"
leading={<FormRow.Icon source={getAssetIDByName("BookCheckIcon")} />}
value={storage.enabledCommands.useless}
onValueChange={(v) => {
storage.enabledCommands.useless = v;
storage.pendingRestart = true;
forceUpdate();
}}
/>
</BetterTableRowGroup>
</RN.ScrollView>
);
}

View file

@ -0,0 +1,146 @@
import { storage } from "@vendetta/plugin";
import { useProxy } from "@vendetta/storage";
import { React, ReactNative as RN, stylesheet } from "@vendetta/metro/common";
import { semanticColors } from "@vendetta/ui";
import { getAssetIDByName } from "@vendetta/ui/assets";
import { Forms } from "@vendetta/ui/components";
import BetterTableRowGroup from "../components/BetterTableRowGroup";
const { FormSwitchRow, FormRow } = Forms;
export default function GaryAPIPage() {
useProxy(storage);
const [, forceUpdate] = React.useReducer((x) => x + 1, 0);
const styles = stylesheet.createThemedStyleSheet({
container: {
flex: 1,
backgroundColor: semanticColors.BACKGROUND_PRIMARY,
},
infoText: {
fontSize: 14,
color: semanticColors.TEXT_MUTED,
marginBottom: 16,
textAlign: "center",
},
currentText: {
fontSize: 14,
color: semanticColors.TEXT_MUTED,
textAlign: "center",
fontWeight: "600",
},
});
const currentSource = storage.garySettings.imageSource;
return (
<RN.ScrollView
style={styles.container}
contentContainerStyle={{
flexGrow: 1,
paddingVertical: 16,
maxWidth: "100%",
}}
>
<BetterTableRowGroup
title="Gary Command Settings"
icon={getAssetIDByName("SettingsIcon")}
>
<FormSwitchRow
label="/gary"
subLabel="Send random Gary images to channel"
leading={<FormRow.Icon source={getAssetIDByName("AttachmentIcon")} />}
value={storage.enabledCommands.gary}
onValueChange={(v) => {
storage.enabledCommands.gary = v;
storage.pendingRestart = true;
forceUpdate();
}}
/>
</BetterTableRowGroup>
<BetterTableRowGroup
title="Image Source Selection"
icon={getAssetIDByName("DownloadIcon")}
padding={true}
>
<RN.Text style={styles.infoText}>
Choose which API the /gary command should use to fetch images.
</RN.Text>
</BetterTableRowGroup>
<BetterTableRowGroup
title="API Options"
icon={getAssetIDByName("CloudIcon")}
>
<FormSwitchRow
label="Gary API"
subLabel="Original Gary the cat images from api.garythe.cat"
leading={<FormRow.Icon source={getAssetIDByName("ImageIcon")} />}
value={currentSource === "gary"}
onValueChange={(v) => {
if (v) {
storage.garySettings.imageSource = "gary";
forceUpdate();
}
}}
/>
<FormSwitchRow
label="Cat API"
subLabel="Random cat pictures from thecatapi.com"
leading={<FormRow.Icon source={getAssetIDByName("ImageIcon")} />}
value={currentSource === "catapi"}
onValueChange={(v) => {
if (v) {
storage.garySettings.imageSource = "catapi";
forceUpdate();
}
}}
/>
<FormSwitchRow
label="Minker API"
subLabel="Minky images from minky.materii.dev"
leading={<FormRow.Icon source={getAssetIDByName("ImageIcon")} />}
value={currentSource === "minker"}
onValueChange={(v) => {
if (v) {
storage.garySettings.imageSource = "minker";
forceUpdate();
}
}}
/>
<FormSwitchRow
label="Goober API"
subLabel="Goober images from api.garythe.cat/goober"
leading={<FormRow.Icon source={getAssetIDByName("ImageIcon")} />}
value={currentSource === "goober"}
onValueChange={(v) => {
if (v) {
storage.garySettings.imageSource = "goober";
forceUpdate();
}
}}
/>
</BetterTableRowGroup>
<BetterTableRowGroup
title="Current Selection"
icon={getAssetIDByName("CheckmarkIcon")}
padding={true}
>
<RN.Text style={styles.currentText}>
Currently using:{" "}
{currentSource === "gary"
? "Gary API"
: currentSource === "catapi"
? "Cat API"
: currentSource === "minker"
? "Minker API"
: currentSource === "goober"
? "Goober API"
: "Gary API"}
</RN.Text>
</BetterTableRowGroup>
</RN.ScrollView>
);
}

View file

@ -0,0 +1,152 @@
import { storage } from "@vendetta/plugin";
import { useProxy } from "@vendetta/storage";
import { React, ReactNative as RN, stylesheet } from "@vendetta/metro/common";
import { semanticColors } from "@vendetta/ui";
import { getAssetIDByName } from "@vendetta/ui/assets";
import { Forms } from "@vendetta/ui/components";
import { alerts } from "@vendetta/ui";
import { showToast } from "@vendetta/ui/toasts";
import BetterTableRowGroup from "../components/BetterTableRowGroup";
const { FormSwitchRow, FormRow } = Forms;
export default function HiddenSettingsPage() {
useProxy(storage);
const [, forceUpdate] = React.useReducer((x) => x + 1, 0);
const styles = stylesheet.createThemedStyleSheet({
container: {
flex: 1,
backgroundColor: semanticColors.BACKGROUND_PRIMARY,
},
warningText: {
fontSize: 14,
color: semanticColors.TEXT_MUTED,
textAlign: "center",
lineHeight: 20,
fontWeight: "600",
},
infoText: {
fontSize: 14,
color: semanticColors.TEXT_MUTED,
textAlign: "center",
lineHeight: 20,
marginTop: 8,
},
});
return (
<RN.ScrollView
style={styles.container}
contentContainerStyle={{
flexGrow: 1,
paddingVertical: 16,
maxWidth: "100%",
}}
>
<BetterTableRowGroup
title="⚠️ Warning"
icon={getAssetIDByName("WarningIcon")}
padding={true}
>
<RN.Text style={styles.warningText}>
These are hidden commands that may contain mature content or
experimental features.
</RN.Text>
<RN.Text style={styles.infoText}>
Use at your own discretion. Commands in this section are disabled by
default.
</RN.Text>
</BetterTableRowGroup>
<BetterTableRowGroup
title="Hidden Commands"
icon={getAssetIDByName("EyeIcon")}
>
<FormSwitchRow
label="/lovefemboys"
subLabel="Get random femboy images from r/femboys (NSFW content available)"
leading={<FormRow.Icon source={getAssetIDByName("HeartIcon")} />}
value={storage.enabledCommands.lovefemboys}
onValueChange={(v) => {
storage.enabledCommands.lovefemboys = v;
storage.pendingRestart = true;
forceUpdate();
}}
/>
</BetterTableRowGroup>
<BetterTableRowGroup
title="NSFW Bypass Options"
icon={getAssetIDByName("WarningIcon")}
>
<FormSwitchRow
label="KonoChan NSFW Bypass"
subLabel="Allow NSFW KonoChan content in non-NSFW channels (USE WITH CAUTION)"
leading={<FormRow.Icon source={getAssetIDByName("ShieldIcon")} />}
value={storage.hiddenSettings.konochanBypassNsfw}
onValueChange={(v) => {
if (v) {
alerts.showConfirmationAlert({
title: "⚠️ NSFW Bypass Warning",
content:
"Enabling this allows NSFW content from KonoChan to be sent in any channel, including non-NSFW channels. This could violate server rules or Discord ToS. Use responsibly!",
confirmText: "I Understand",
cancelText: "Cancel",
onConfirm: () => {
storage.hiddenSettings.konochanBypassNsfw = true;
forceUpdate();
},
onCancel: () => {},
});
} else {
storage.hiddenSettings.konochanBypassNsfw = false;
forceUpdate();
}
}}
/>
</BetterTableRowGroup>
<BetterTableRowGroup
title="Hidden Settings Control"
icon={getAssetIDByName("SettingsIcon")}
>
<FormSwitchRow
label="Keep Hidden Settings Visible"
subLabel="Keep this section visible even when navigating away"
leading={<FormRow.Icon source={getAssetIDByName("EyeIcon")} />}
value={storage.hiddenSettings.visible}
onValueChange={(v) => {
storage.hiddenSettings.visible = v;
}}
/>
<FormRow
label="Reset Hidden Settings"
subLabel="Hide this section and disable all hidden commands"
leading={<FormRow.Icon source={getAssetIDByName("TrashIcon")} />}
onPress={() => {
alerts.showConfirmationAlert({
title: "Reset Hidden Settings",
content:
"This will hide the hidden settings section and disable all hidden commands. Are you sure?",
confirmText: "Reset",
onConfirm: () => {
storage.hiddenSettings.enabled = false;
storage.hiddenSettings.visible = false;
storage.hiddenSettings.konochanBypassNsfw = false;
storage.enabledCommands.lovefemboys = false;
storage.pendingRestart = true;
showToast(
"Hidden settings reset",
getAssetIDByName("CheckmarkIcon"),
);
},
cancelText: "Cancel",
});
}}
/>
</BetterTableRowGroup>
</RN.ScrollView>
);
}

View file

@ -0,0 +1,72 @@
import { storage } from "@vendetta/plugin";
import { useProxy } from "@vendetta/storage";
import { React, ReactNative as RN, stylesheet } from "@vendetta/metro/common";
import { semanticColors } from "@vendetta/ui";
import { getAssetIDByName } from "@vendetta/ui/assets";
import { Forms } from "@vendetta/ui/components";
import BetterTableRowGroup from "../components/BetterTableRowGroup";
const { FormSwitchRow, FormRow } = Forms;
export default function ImageSettingsPage() {
useProxy(storage);
const [, forceUpdate] = React.useReducer((x) => x + 1, 0);
return (
<RN.ScrollView
style={{ flex: 1, backgroundColor: semanticColors.BACKGROUND_PRIMARY }}
contentContainerStyle={{
flexGrow: 1,
paddingVertical: 16,
maxWidth: "100%",
}}
>
<BetterTableRowGroup
title="Image Commands"
icon={getAssetIDByName("ImageIcon")}
>
<FormSwitchRow
label="/petpet"
subLabel="Create pet-pet GIF of a user"
leading={
<FormRow.Icon source={getAssetIDByName("HandRequestSpeakIcon")} />
}
value={storage.enabledCommands.petpet}
onValueChange={(v) => {
storage.enabledCommands.petpet = v;
storage.pendingRestart = true;
forceUpdate();
}}
/>
</BetterTableRowGroup>
<BetterTableRowGroup
title="KonoChan Commands"
icon={getAssetIDByName("ImageIcon")}
>
<FormSwitchRow
label="/konoself"
subLabel="Get random image from KonoChan (private)"
leading={<FormRow.Icon source={getAssetIDByName("EyeIcon")} />}
value={storage.enabledCommands.konoself}
onValueChange={(v) => {
storage.enabledCommands.konoself = v;
storage.pendingRestart = true;
forceUpdate();
}}
/>
<FormSwitchRow
label="/konosend"
subLabel="Send random image from KonoChan to channel"
leading={<FormRow.Icon source={getAssetIDByName("ImageIcon")} />}
value={storage.enabledCommands.konosend}
onValueChange={(v) => {
storage.enabledCommands.konosend = v;
storage.pendingRestart = true;
forceUpdate();
}}
/>
</BetterTableRowGroup>
</RN.ScrollView>
);
}

View file

@ -0,0 +1,85 @@
import { storage } from "@vendetta/plugin";
import { useProxy } from "@vendetta/storage";
import { React, ReactNative as RN, stylesheet } from "@vendetta/metro/common";
import { semanticColors } from "@vendetta/ui";
import { getAssetIDByName } from "@vendetta/ui/assets";
import { Forms } from "@vendetta/ui/components";
import BetterTableRowGroup from "../components/BetterTableRowGroup";
const { FormSwitchRow, FormRow } = Forms;
export default function ListSettingsPage() {
useProxy(storage);
const [, forceUpdate] = React.useReducer((x) => x + 1, 0);
return (
<RN.ScrollView
style={{ flex: 1, backgroundColor: semanticColors.BACKGROUND_PRIMARY }}
contentContainerStyle={{
flexGrow: 1,
paddingVertical: 16,
maxWidth: "100%",
}}
>
<BetterTableRowGroup
title="List Display Settings"
icon={getAssetIDByName("SettingsIcon")}
>
<FormSwitchRow
label="Always Send Detailed Plugin List"
subLabel="Always use detailed mode when listing plugins"
leading={
<FormRow.Icon source={getAssetIDByName("PuzzlePieceIcon")} />
}
value={storage.listSettings.pluginListAlwaysDetailed}
onValueChange={(v) => {
storage.listSettings.pluginListAlwaysDetailed = v;
}}
/>
<FormSwitchRow
label="Always Send Detailed Theme List"
subLabel="Always use detailed mode when listing themes"
leading={
<FormRow.Icon source={getAssetIDByName("PaintPaletteIcon")} />
}
value={storage.listSettings.themeListAlwaysDetailed}
onValueChange={(v) => {
storage.listSettings.themeListAlwaysDetailed = v;
}}
/>
</BetterTableRowGroup>
<BetterTableRowGroup
title="Available List Commands"
icon={getAssetIDByName("ListViewIcon")}
>
<FormSwitchRow
label="/plugin-list"
subLabel="List all installed plugins"
leading={
<FormRow.Icon source={getAssetIDByName("PuzzlePieceIcon")} />
}
value={storage.enabledCommands.pluginList}
onValueChange={(v) => {
storage.enabledCommands.pluginList = v;
storage.pendingRestart = true;
forceUpdate();
}}
/>
<FormSwitchRow
label="/theme-list"
subLabel="List all installed themes"
leading={
<FormRow.Icon source={getAssetIDByName("PaintPaletteIcon")} />
}
value={storage.enabledCommands.themeList}
onValueChange={(v) => {
storage.enabledCommands.themeList = v;
storage.pendingRestart = true;
forceUpdate();
}}
/>
</BetterTableRowGroup>
</RN.ScrollView>
);
}

View file

@ -0,0 +1,161 @@
import { React, ReactNative as RN, stylesheet } from "@vendetta/metro/common";
import { semanticColors } from "@vendetta/ui";
import { getAssetIDByName } from "@vendetta/ui/assets";
import BetterTableRowGroup from "../components/BetterTableRowGroup";
export default function NekosLifeCategoriesPage() {
const styles = stylesheet.createThemedStyleSheet({
container: {
flex: 1,
backgroundColor: semanticColors.BACKGROUND_PRIMARY,
},
tableHeader: {
flexDirection: "row",
alignItems: "center",
paddingVertical: 12,
paddingHorizontal: 16,
backgroundColor: semanticColors.BACKGROUND_MODIFIER_ACCENT,
borderBottomWidth: 2,
borderBottomColor: semanticColors.BORDER_STRONG,
},
headerText: {
fontSize: 14,
fontWeight: "700",
color: semanticColors.HEADER_PRIMARY,
textTransform: "uppercase",
},
nameHeader: {
flex: 2,
},
codeHeader: {
flex: 2,
textAlign: "center",
},
badgeHeader: {
flex: 1,
textAlign: "center",
},
categoryItem: {
flexDirection: "row",
alignItems: "center",
paddingVertical: 12,
paddingHorizontal: 16,
borderBottomWidth: 1,
borderBottomColor: semanticColors.BORDER_FAINT,
},
categoryName: {
fontSize: 16,
fontWeight: "500",
color: semanticColors.HEADER_PRIMARY,
flex: 2,
},
categoryValue: {
fontSize: 14,
color: semanticColors.TEXT_MUTED,
fontFamily: "monospace",
flex: 2,
textAlign: "center",
},
sfwBadgeContainer: {
flex: 1,
alignItems: "center",
},
sfwBadge: {
backgroundColor: semanticColors.STATUS_POSITIVE,
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 12,
},
badgeText: {
fontSize: 12,
fontWeight: "600",
color: semanticColors.TEXT_NORMAL,
},
infoText: {
fontSize: 14,
color: semanticColors.TEXT_MUTED,
textAlign: "center",
lineHeight: 20,
},
});
// nekos.life categories
const sfwCategories = [
{ name: "Avatar", value: "avatar" },
{ name: "Classic", value: "classic" },
{ name: "Cuddle", value: "cuddle" },
{ name: "Fox Girl", value: "fox_girl" },
{ name: "Gecg", value: "gecg" },
{ name: "Holo", value: "holo" },
{ name: "Kemonomimi", value: "kemonomimi" },
{ name: "Kiss", value: "kiss" },
{ name: "Neko", value: "neko" },
{ name: "Neko GIF", value: "ngif" },
{ name: "Smug", value: "smug" },
{ name: "Spank", value: "spank" },
{ name: "Tickle", value: "tickle" },
{ name: "Waifu", value: "waifu" },
{ name: "Wallpaper", value: "wallpaper" },
{ name: "Woof", value: "woof" },
];
// Sort categories alphabetically
const sortedCategories = sfwCategories.sort((a, b) =>
a.name.localeCompare(b.name),
);
return (
<RN.ScrollView
style={styles.container}
contentContainerStyle={{
flexGrow: 1,
paddingVertical: 16,
maxWidth: "100%",
}}
>
<BetterTableRowGroup
title="📊 Category Statistics"
icon={getAssetIDByName("ChartIcon")}
padding={true}
>
<RN.Text style={styles.infoText}>
Total SFW Categories: {sfwCategories.length}
{"\n\n"}
Use these category names with the /nekoslife command.{"\n"}
All categories are Safe For Work (SFW).
</RN.Text>
</BetterTableRowGroup>
<BetterTableRowGroup
title="📋 SFW Categories"
icon={getAssetIDByName("ListViewIcon")}
>
{/* Table Header */}
<RN.View style={styles.tableHeader}>
<RN.Text style={[styles.headerText, styles.nameHeader]}>
Category Name
</RN.Text>
<RN.Text style={[styles.headerText, styles.codeHeader]}>
Command Code
</RN.Text>
<RN.Text style={[styles.headerText, styles.badgeHeader]}>
Type
</RN.Text>
</RN.View>
{/* Table Rows */}
{sortedCategories.map((category, index) => (
<RN.View key={index} style={styles.categoryItem}>
<RN.Text style={styles.categoryName}>{category.name}</RN.Text>
<RN.Text style={styles.categoryValue}>{category.value}</RN.Text>
<RN.View style={styles.sfwBadgeContainer}>
<RN.View style={styles.sfwBadge}>
<RN.Text style={styles.badgeText}>SFW</RN.Text>
</RN.View>
</RN.View>
</RN.View>
))}
</BetterTableRowGroup>
</RN.ScrollView>
);
}

View file

@ -0,0 +1,95 @@
import { storage } from "@vendetta/plugin";
import { useProxy } from "@vendetta/storage";
import { React, ReactNative as RN, stylesheet } from "@vendetta/metro/common";
import { semanticColors } from "@vendetta/ui";
import { getAssetIDByName } from "@vendetta/ui/assets";
import { Forms } from "@vendetta/ui/components";
import BetterTableRowGroup from "../components/BetterTableRowGroup";
const { FormSwitchRow, FormRow } = Forms;
export default function OtherSettingsPage() {
useProxy(storage);
const [, forceUpdate] = React.useReducer((x) => x + 1, 0);
return (
<RN.ScrollView
style={{ flex: 1, backgroundColor: semanticColors.BACKGROUND_PRIMARY }}
contentContainerStyle={{
flexGrow: 1,
paddingVertical: 16,
maxWidth: "100%",
}}
>
<BetterTableRowGroup
title="Message Commands"
icon={getAssetIDByName("ChatIcon")}
>
<FormSwitchRow
label="/firstmessage"
subLabel="Get the first message in a channel"
leading={<FormRow.Icon source={getAssetIDByName("ChatIcon")} />}
value={storage.enabledCommands.firstmessage}
onValueChange={(v) => {
storage.enabledCommands.firstmessage = v;
storage.pendingRestart = true;
forceUpdate();
}}
/>
</BetterTableRowGroup>
<BetterTableRowGroup
title="System Commands"
icon={getAssetIDByName("SettingsIcon")}
>
<FormSwitchRow
label="/sysinfo"
subLabel="Display system information"
leading={<FormRow.Icon source={getAssetIDByName("SettingsIcon")} />}
value={storage.enabledCommands.sysinfo}
onValueChange={(v) => {
storage.enabledCommands.sysinfo = v;
storage.pendingRestart = true;
forceUpdate();
}}
/>
</BetterTableRowGroup>
<BetterTableRowGroup
title="Friend Invites"
icon={getAssetIDByName("UserAddIcon")}
>
<FormSwitchRow
label="/invite create"
subLabel="Generate a friend invite link"
value={storage.enabledCommands.friendInviteCreate}
onValueChange={(v) => {
storage.enabledCommands.friendInviteCreate = v;
storage.pendingRestart = true;
forceUpdate();
}}
/>
<FormSwitchRow
label="/view invites"
subLabel="View your current friend invites"
value={storage.enabledCommands.friendInviteView}
onValueChange={(v) => {
storage.enabledCommands.friendInviteView = v;
storage.pendingRestart = true;
forceUpdate();
}}
/>
<FormSwitchRow
label="/revoke invites"
subLabel="Revoke all your friend invites"
value={storage.enabledCommands.friendInviteRevoke}
onValueChange={(v) => {
storage.enabledCommands.friendInviteRevoke = v;
storage.pendingRestart = true;
forceUpdate();
}}
/>
</BetterTableRowGroup>
</RN.ScrollView>
);
}

View file

@ -0,0 +1,104 @@
import { storage } from "@vendetta/plugin";
import { useProxy } from "@vendetta/storage";
import { React, ReactNative as RN, stylesheet } from "@vendetta/metro/common";
import { semanticColors } from "@vendetta/ui";
import { getAssetIDByName } from "@vendetta/ui/assets";
import { Forms } from "@vendetta/ui/components";
import BetterTableRowGroup from "../components/BetterTableRowGroup";
const { FormSwitchRow, FormRow } = Forms;
export default function SpotifySettingsPage() {
useProxy(storage);
const [, forceUpdate] = React.useReducer((x) => x + 1, 0);
const styles = stylesheet.createThemedStyleSheet({
infoText: {
fontSize: 14,
color: semanticColors.TEXT_MUTED,
textAlign: "center",
lineHeight: 20,
},
});
return (
<RN.ScrollView
style={{ flex: 1, backgroundColor: semanticColors.BACKGROUND_PRIMARY }}
contentContainerStyle={{
flexGrow: 1,
paddingVertical: 16,
maxWidth: "100%",
}}
>
<BetterTableRowGroup
title="Spotify Commands"
icon={getAssetIDByName("SpotifyNeutralIcon")}
>
<FormSwitchRow
label="/spotify track"
subLabel="Share your current Spotify track"
leading={
<FormRow.Icon source={getAssetIDByName("SpotifyNeutralIcon")} />
}
value={storage.enabledCommands.spotifyTrack}
onValueChange={(v) => {
storage.enabledCommands.spotifyTrack = v;
storage.pendingRestart = true;
forceUpdate();
}}
/>
<FormSwitchRow
label="/spotify album"
subLabel="Share your current track's album"
leading={
<FormRow.Icon source={getAssetIDByName("SpotifyNeutralIcon")} />
}
value={storage.enabledCommands.spotifyAlbum}
onValueChange={(v) => {
storage.enabledCommands.spotifyAlbum = v;
storage.pendingRestart = true;
forceUpdate();
}}
/>
<FormSwitchRow
label="/spotify artists"
subLabel="Share your current track's artists"
leading={
<FormRow.Icon source={getAssetIDByName("SpotifyNeutralIcon")} />
}
value={storage.enabledCommands.spotifyArtists}
onValueChange={(v) => {
storage.enabledCommands.spotifyArtists = v;
storage.pendingRestart = true;
forceUpdate();
}}
/>
<FormSwitchRow
label="/spotify cover"
subLabel="Share your current track's cover"
leading={
<FormRow.Icon source={getAssetIDByName("SpotifyNeutralIcon")} />
}
value={storage.enabledCommands.spotifyCover}
onValueChange={(v) => {
storage.enabledCommands.spotifyCover = v;
storage.pendingRestart = true;
forceUpdate();
}}
/>
</BetterTableRowGroup>
<BetterTableRowGroup
title="About Spotify Commands"
icon={getAssetIDByName("InfoIcon")}
padding={true}
>
<RN.Text style={styles.infoText}>
These commands allow you to share your current Spotify activity in
Discord. Make sure you have Spotify connected to Discord for these
commands to work properly.
</RN.Text>
</BetterTableRowGroup>
</RN.ScrollView>
);
}

View file

@ -0,0 +1,264 @@
import { storage } from "@vendetta/plugin";
import { useProxy } from "@vendetta/storage";
import { React, ReactNative, stylesheet } from "@vendetta/metro/common";
import { NavigationNative } from "@vendetta/metro/common";
import { semanticColors } from "@vendetta/ui";
import { getAssetIDByName } from "@vendetta/ui/assets";
import { Forms } from "@vendetta/ui/components";
import { alerts } from "@vendetta/ui";
import { showToast } from "@vendetta/ui/toasts";
import Header from "./components/Header";
import BetterTableRowGroup from "./components/BetterTableRowGroup";
// Import all pages directly
import FactsSettingsPage from "./pages/FactsSettingsPage";
import ListSettingsPage from "./pages/ListSettingsPage";
import ImageSettingsPage from "./pages/ImageSettingsPage";
import GaryAPIPage from "./pages/GaryAPIPage";
import SpotifySettingsPage from "./pages/SpotifySettingsPage";
import AliucordPage from "./pages/AliucordPage";
import OtherSettingsPage from "./pages/OtherSettingsPage";
import HiddenSettingsPage from "./pages/HiddenSettingsPage";
import CreditsPage from "./pages/CreditsPage";
const { FormRow } = Forms;
// Initialize storage with default values
storage.factSettings ??= {
sendAsReply: true,
includeCitation: false,
};
storage.listSettings ??= {
pluginListAlwaysDetailed: false,
themeListAlwaysDetailed: false,
};
storage.garySettings ??= {
imageSource: "gary",
};
storage.enabledCommands ??= {
catfact: true,
dogfact: true,
useless: true,
petpet: true,
pluginList: true,
themeList: true,
konoself: true,
konosend: true,
firstmessage: true,
sysinfo: true,
spotifyTrack: true,
spotifyAlbum: true,
spotifyArtists: true,
spotifyCover: true,
gary: true,
ip: true,
lovefemboys: false,
nekoslife: false,
friendInviteCreate: true,
friendInviteView: true,
friendInviteRevoke: true,
};
storage.pendingRestart ??= false;
// Hidden settings storage with NSFW bypass option
storage.hiddenSettings ??= {
enabled: false,
visible: false,
konochanBypassNsfw: false,
};
// Sidebar setting (enabled by default)
storage.sidebarEnabled ??= true;
// MAIN SETTINGS COMPONENT
export default function Settings() {
useProxy(storage);
const navigation = NavigationNative.useNavigation();
React.useEffect(() => {
return () => {
if (storage.pendingRestart) {
storage.pendingRestart = false;
alerts.showConfirmationAlert({
title: "Restart Required",
content:
"You have made changes to commands. Please restart Discord to apply these changes.",
confirmText: "Okay",
cancelText: null,
onConfirm: () => {},
});
}
};
}, []);
return (
<ReactNative.View style={{ flex: 1 }}>
<ReactNative.ScrollView
style={{
flex: 1,
backgroundColor: semanticColors.BACKGROUND_PRIMARY,
}}
contentContainerStyle={{
flexGrow: 1,
maxWidth: "100%",
}}
showsVerticalScrollIndicator={false}
>
<Header />
<BetterTableRowGroup
title="Command Categories"
icon={getAssetIDByName("ChannelListIcon")}
>
<FormRow
label="Facts Commands"
subLabel="Cat facts, dog facts, and useless facts"
leading={<FormRow.Icon source={getAssetIDByName("BookmarkIcon")} />}
trailing={<FormRow.Arrow />}
onPress={() =>
navigation.push("VendettaCustomPage", {
title: "Facts Commands",
render: FactsSettingsPage,
})
}
/>
<FormRow
label="List Commands"
subLabel="Plugin lists and theme lists"
leading={<FormRow.Icon source={getAssetIDByName("ListViewIcon")} />}
trailing={<FormRow.Arrow />}
onPress={() =>
navigation.push("VendettaCustomPage", {
title: "List Commands",
render: ListSettingsPage,
})
}
/>
<FormRow
label="Image Commands"
subLabel="PetPet, KonoChan, and image utilities"
leading={<FormRow.Icon source={getAssetIDByName("ImageIcon")} />}
trailing={<FormRow.Arrow />}
onPress={() =>
navigation.push("VendettaCustomPage", {
title: "Image Commands",
render: ImageSettingsPage,
})
}
/>
<FormRow
label="Gary Commands"
subLabel={`Gary images - Current: ${
storage.garySettings.imageSource === "gary"
? "Gary API"
: storage.garySettings.imageSource === "catapi"
? "Cat API"
: storage.garySettings.imageSource === "minker"
? "Minker API"
: storage.garySettings.imageSource === "goober"
? "Goober API"
: "Gary API"
}`}
leading={<FormRow.Icon source={getAssetIDByName("CameraIcon")} />}
trailing={<FormRow.Arrow />}
onPress={() =>
navigation.push("VendettaCustomPage", {
title: "Gary Commands",
render: GaryAPIPage,
})
}
/>
<FormRow
label="Spotify Commands"
subLabel="Share your Spotify activity"
leading={
<FormRow.Icon source={getAssetIDByName("SpotifyNeutralIcon")} />
}
trailing={<FormRow.Arrow />}
onPress={() =>
navigation.push("VendettaCustomPage", {
title: "Spotify Commands",
render: SpotifySettingsPage,
})
}
/>
<FormRow
label="Aliucord Commands"
subLabel="Commands from Aliucord"
leading={
<FormRow.Icon
source={{
uri: "https://avatars.githubusercontent.com/u/78881422?s=200&v=4",
}}
/>
}
trailing={<FormRow.Arrow />}
onPress={() =>
navigation.push("VendettaCustomPage", {
title: "Aliucord",
render: AliucordPage,
})
}
/>
<FormRow
label="Other Commands"
subLabel="Utility and system commands"
leading={<FormRow.Icon source={getAssetIDByName("WrenchIcon")} />}
trailing={<FormRow.Arrow />}
onPress={() =>
navigation.push("VendettaCustomPage", {
title: "Other Commands",
render: OtherSettingsPage,
})
}
/>
</BetterTableRowGroup>
{storage.hiddenSettings?.visible && (
<BetterTableRowGroup
title="Hidden Settings"
icon={getAssetIDByName("EyeIcon")}
>
<FormRow
label="Hidden Commands"
subLabel="Access to experimental and NSFW commands"
leading={<FormRow.Icon source={getAssetIDByName("EyeIcon")} />}
trailing={<FormRow.Arrow />}
onPress={() =>
navigation.push("VendettaCustomPage", {
title: "Hidden Settings",
render: HiddenSettingsPage,
})
}
/>
</BetterTableRowGroup>
)}
<BetterTableRowGroup
title="Other"
icon={getAssetIDByName("InformationIcon")}
>
<FormRow
label="Credits"
subLabel="View original authors of the plugins"
leading={<FormRow.Icon source={getAssetIDByName("HeartIcon")} />}
trailing={<FormRow.Arrow />}
onPress={() =>
navigation.push("VendettaCustomPage", {
title: "Credits",
render: CreditsPage,
})
}
/>
</BetterTableRowGroup>
{/* Add sufficient bottom padding for scrolling */}
<ReactNative.View style={{ height: 32 }} />
</ReactNative.ScrollView>
</ReactNative.View>
);
}

View file

@ -0,0 +1,52 @@
export interface FactSettings {
sendAsReply: boolean;
includeCitation: boolean;
}
export interface ListSettings {
pluginListAlwaysDetailed: boolean;
themeListAlwaysDetailed: boolean;
}
export interface GarySettings {
imageSource: "gary" | "catapi" | "minker" | "goober";
}
export interface EnabledCommands {
catfact: boolean;
dogfact: boolean;
useless: boolean;
petpet: boolean;
pluginList: boolean;
themeList: boolean;
konoself: boolean;
konosend: boolean;
firstmessage: boolean;
sysinfo: boolean;
spotifyTrack: boolean;
spotifyAlbum: boolean;
spotifyArtists: boolean;
spotifyCover: boolean;
gary: boolean;
ip: boolean;
lovefemboys: boolean;
nekoslife: boolean;
friendInviteCreate: boolean;
friendInviteView: boolean;
friendInviteRevoke: boolean;
}
export interface HiddenSettings {
enabled: boolean;
visible: boolean;
konochanBypassNsfw: boolean;
}
export interface StorageType {
factSettings: FactSettings;
listSettings: ListSettings;
garySettings: GarySettings;
enabledCommands: EnabledCommands;
pendingRestart: boolean;
hiddenSettings: HiddenSettings;
}

View file

@ -0,0 +1,235 @@
import { storage } from "@vendetta/plugin";
import { React, NavigationNative } from "@vendetta/metro/common";
import { getAssetIDByName } from "@vendetta/ui/assets";
import { after } from "@vendetta/patcher";
import { Forms } from "@vendetta/ui/components";
import { findInReactTree } from "@vendetta/utils";
import { findByProps } from "@vendetta/metro";
import { logger } from "@vendetta";
import Settings from "./settings/settings";
const { FormSection, FormRow } = Forms;
const { TableRowIcon } = findByProps("TableRowIcon");
const bunny = window.bunny;
declare global {
interface Window {
bunny: any;
}
}
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,
);
function Section({ tabs }) {
const navigation = NavigationNative.useNavigation();
return React.createElement(FormRow, {
label: tabs.title(),
leading: React.createElement(FormRow.Icon, { source: tabs.icon }),
trailing: React.createElement(React.Fragment, {}, [
tabs.trailing ? tabs.trailing() : null,
React.createElement(FormRow.Arrow, { key: "arrow" }),
]),
onPress: () => {
const Component = tabs.page;
navigation.navigate("VendettaCustomPage", {
title: tabs.title(),
render: () => React.createElement(Component),
});
},
});
}
function patchPanelUI(tabs, patches) {
try {
patches.push(
after(
"default",
bunny?.metro?.findByPropsLazy(["renderTitle", "sections"], false),
(_, ret) => {
const UserSettingsOverview = findInReactTree(
ret.props.children,
(n) => n.type?.name === "UserSettingsOverview",
);
if (UserSettingsOverview) {
patches.push(
after(
"render",
UserSettingsOverview.type.prototype,
(_args, res) => {
const sections = findInReactTree(
res.props.children,
(n) => n?.children?.[1]?.type === FormSection,
)?.children;
if (sections) {
const index = sections.findIndex((c) =>
["BILLING_SETTINGS", "PREMIUM_SETTINGS"].includes(
c?.props?.label,
),
);
sections.splice(
-~index || 4,
0,
React.createElement(Section, { key: tabs.key, tabs }),
);
}
},
),
);
}
},
true,
),
);
} catch (error) {
logger.info("Panel UI patch failed graciously 💔", error);
}
}
function patchTabsUI(tabs, patches) {
if (!settingConstants || !tabsNavigationRef) {
console.warn(
"[Commands Plugin] Missing required constants for tabs UI patch",
);
return;
}
const row = {
[tabs.key]: {
type: "pressable",
title: tabs.title,
icon: tabs.icon,
IconComponent:
tabs.icon &&
(() => React.createElement(TableRowIcon, { source: tabs.icon })),
usePredicate: tabs.predicate,
useTrailing: tabs.trailing,
onPress: () => {
const navigation = tabsNavigationRef.getRootNavigationRef();
const Component = tabs.page;
navigation.navigate("VendettaCustomPage", {
title: tabs.title(),
render: () => React.createElement(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, function (args, ret) {
if (!args[0][firstRender]) {
args[0][firstRender] = true;
const [config] = args;
const sections = config.sections;
const section = sections?.find((x: any) =>
["Bunny", "Revenge", "Kettu", "Vencore", "ShiggyCord"].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) => {
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", "Vencore", "ShiggyCord"].some(
(mod) => x.label === mod && x.title === mod,
),
);
if (section?.settings) {
section.settings = [...section.settings, tabs.key];
}
}
}),
);
}
}
function patchSettingsPin(tabs) {
const patches = [];
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();
}
};
}
export default function patchSidebar() {
// Check if sidebar is enabled in storage
if (storage.sidebarEnabled === false) {
console.log("[Commands Plugin] Sidebar disabled in settings");
return () => {};
}
console.log("[Commands Plugin] Patching sidebar...");
try {
const unpatch = patchSettingsPin({
key: "CommandsPlugin",
icon: getAssetIDByName("SettingsIcon"),
title: () => "Commands",
predicate: () => storage.sidebarEnabled !== false,
page: Settings,
});
console.log("[Commands Plugin] Successfully patched sidebar");
return unpatch;
} catch (error) {
console.error("[Commands Plugin] Failed to patch sidebar:", error);
return () => {};
}
}