diff --git a/plugins/rose-utils/src/ui/Settings.tsx b/plugins/rose-utils/src/ui/Settings.tsx
index af12582..d19e1c4 100644
--- a/plugins/rose-utils/src/ui/Settings.tsx
+++ b/plugins/rose-utils/src/ui/Settings.tsx
@@ -1,4 +1,4 @@
-import { React, clipboard } from "@vendetta/metro/common";
+import { React, ReactNative, clipboard } from "@vendetta/metro/common";
import { Forms } from "@vendetta/ui/components";
import { showToast } from "@vendetta/ui/toasts";
import SettingsScaffold from "@shared/ui/SettingsScaffold";
@@ -6,12 +6,33 @@ import NoteBox from "@shared/ui/NoteBox";
import modules from "../modules";
import { ModuleCategory, type AnyModule, type ModuleSetting } from "../lib/Module";
-const { FormSection, FormSwitchRow, FormRow } = Forms;
+const { View, Text, TouchableOpacity } = ReactNative;
+const { FormSection, FormSwitchRow, FormRow, FormInput } = Forms;
function resolveSubLabel(setting: ModuleSetting, value: any): string | undefined {
return typeof setting.subLabel === "function" ? setting.subLabel(value) : setting.subLabel;
}
+function moduleMatchesQuery(module: AnyModule, query: string): boolean {
+ if (!query) return true;
+ const needle = query.toLowerCase();
+
+ if (module.label.toLowerCase().includes(needle)) return true;
+ if (module.meta.sublabel.toLowerCase().includes(needle)) return true;
+
+ return Object.values(module.settings).some((setting) => {
+ if (setting.label.toLowerCase().includes(needle)) return true;
+ const subLabel = typeof setting.subLabel === "string" ? setting.subLabel : undefined;
+ return subLabel?.toLowerCase().includes(needle) ?? false;
+ });
+}
+
+function setModulesEnabled(mods: AnyModule[], enabled: boolean) {
+ for (const module of mods) {
+ if (module.storage.enabled !== enabled) module.toggle();
+ }
+}
+
function ModuleSettingRow({ module, settingKey }: { module: AnyModule; settingKey: string }) {
const setting = module.settings[settingKey];
if (setting.predicate && !setting.predicate.call(module)) return null;
@@ -91,9 +112,68 @@ function ModuleSection({ module }: { module: AnyModule }) {
);
}
+function CategoryHeader({
+ category,
+ mods,
+ collapsed,
+ onToggleCollapse,
+}: {
+ category: ModuleCategory;
+ mods: AnyModule[];
+ collapsed: boolean;
+ onToggleCollapse: () => void;
+}) {
+ const enabledCount = mods.filter((m) => m.storage.enabled).length;
+
+ return (
+
+
+
+ {collapsed ? "▸" : "▾"} {category.toUpperCase()} · {enabledCount}/{mods.length}
+
+
+
+ setModulesEnabled(mods, true)}>
+ All
+
+ setModulesEnabled(mods, false)} style={{ marginLeft: 16 }}>
+ None
+
+
+
+ );
+}
+
const CATEGORY_ORDER: ModuleCategory[] = [ModuleCategory.Useful, ModuleCategory.Fixes, ModuleCategory.Fun];
export default function Settings() {
+ // Every module's own toggle already re-renders its own ModuleSection via module.useRefresh()
+ // inside ModuleSection - but the category headers here (enabled counts) live in this parent
+ // component, which isn't otherwise subscribed to anything. Subscribing every module here too,
+ // in this same fixed order every render, keeps those counts in sync with both individual
+ // toggles and the bulk All/None actions below.
+ for (const module of modules) module.useRefresh();
+
+ const [query, setQuery] = React.useState("");
+ const [collapsed, setCollapsed] = React.useState>(new Set());
+
+ const searching = query.trim().length > 0;
+ const visibleByCategory = CATEGORY_ORDER.map((category) => ({
+ category,
+ mods: modules.filter((m) => m.meta.category === category && moduleMatchesQuery(m, query.trim())),
+ })).filter(({ mods }) => mods.length > 0);
+
+ const allVisible = visibleByCategory.flatMap(({ mods }) => mods);
+
return (
@@ -102,11 +182,59 @@ export default function Settings() {
Discord versions. If a module shows an error below, tap it to copy the details for
a bug report.
- {CATEGORY_ORDER.flatMap((category) =>
- modules
- .filter((m) => m.meta.category === category)
- .map((module) => ),
+
+
+
+
+
+
+ setModulesEnabled(allVisible, true)}>
+
+ Enable {searching ? "matching" : "all"}
+
+
+ setModulesEnabled(allVisible, false)} style={{ marginLeft: 16 }}>
+
+ Disable {searching ? "matching" : "all"}
+
+
+
+
+ {allVisible.length === 0 && (
+ No modules or settings match "{query.trim()}".
)}
+
+ {visibleByCategory.map(({ category, mods }) => {
+ // Collapse state is ignored while searching, so a match inside a collapsed
+ // category doesn't just disappear.
+ const isCollapsed = !searching && collapsed.has(category);
+
+ return (
+
+ {
+ setCollapsed((prev) => {
+ const next = new Set(prev);
+ if (next.has(category)) next.delete(category);
+ else next.add(category);
+ return next;
+ });
+ }}
+ />
+ {!isCollapsed && mods.map((module) => )}
+
+ );
+ })}
);
}