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) => )} + + ); + })} ); }