322 lines
12 KiB
TypeScript
322 lines
12 KiB
TypeScript
/*
|
|
* Vencord, a Discord client mod
|
|
* Copyright (c) 2025 Vendicated and contributors
|
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
*/
|
|
|
|
import { openNotificationLogModal } from "@api/Notifications/notificationLog";
|
|
import { isPluginEnabled, plugins } from "@api/PluginManager";
|
|
import { Settings, useSettings } from "@api/Settings";
|
|
import { openPluginModal, openSettingsTabModal, PluginsTab, ThemesTab } from "@components/settings";
|
|
import { useAwaiter } from "@utils/react";
|
|
import { wordsFromCamel, wordsToTitle } from "@utils/text";
|
|
import { OptionType, Plugin } from "@utils/types";
|
|
import { Menu, showToast, useMemo, useState } from "@webpack/common";
|
|
import type { ReactNode } from "react";
|
|
|
|
import { settings } from ".";
|
|
|
|
function buildPluginMenu() {
|
|
const { showPluginMenu } = settings.use(["showPluginMenu"]);
|
|
|
|
// has to be here due to hooks
|
|
const pluginEntries = buildPluginMenuEntries();
|
|
|
|
if (!showPluginMenu) return null;
|
|
|
|
return (
|
|
<Menu.MenuItem
|
|
id="plugins"
|
|
label="Plugins"
|
|
action={() => openSettingsTabModal(PluginsTab)}
|
|
>
|
|
{pluginEntries}
|
|
</Menu.MenuItem>
|
|
);
|
|
}
|
|
|
|
export function buildPluginMenuEntries(includeEmpty = false) {
|
|
const pluginSettings = useSettings().plugins;
|
|
|
|
const [search, setSearch] = useState("");
|
|
|
|
const lowerSearch = search.toLowerCase();
|
|
|
|
const sortedPlugins = useMemo(() =>
|
|
Object.values(plugins).sort((a, b) => a.name.localeCompare(b.name)),
|
|
[]
|
|
);
|
|
|
|
const candidates = useMemo(() =>
|
|
sortedPlugins
|
|
.filter(p => {
|
|
if (!isPluginEnabled(p.name)) return false;
|
|
if (p.name.endsWith("API")) return false;
|
|
|
|
const name = p.name.toLowerCase();
|
|
return name.includes(lowerSearch);
|
|
}),
|
|
[lowerSearch]
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<Menu.MenuControlItem
|
|
id="plugins-search"
|
|
control={(props, ref) => (
|
|
<Menu.MenuSearchControl
|
|
{...props}
|
|
query={search}
|
|
onChange={setSearch}
|
|
ref={ref}
|
|
/>
|
|
)}
|
|
/>
|
|
|
|
<Menu.MenuSeparator />
|
|
|
|
{candidates
|
|
.map(p => {
|
|
const options = [] as ReactNode[];
|
|
|
|
let hasAnyOption = false;
|
|
|
|
if (p.options) for (const [key, option] of Object.entries(p.options)) {
|
|
const shouldHide = "hidden" in option && (
|
|
typeof option.hidden === "function"
|
|
? option.hidden.call(p.settings)
|
|
: option.hidden
|
|
);
|
|
if (shouldHide) continue;
|
|
|
|
hasAnyOption = true;
|
|
|
|
const s = pluginSettings[p.name];
|
|
|
|
const baseProps = {
|
|
id: `${p.name}-${key}`,
|
|
key: key,
|
|
label: wordsToTitle(wordsFromCamel(key)),
|
|
disabled: "disabled" in option ? option.disabled?.call(p.settings) : false,
|
|
};
|
|
|
|
switch (option.type) {
|
|
case OptionType.BOOLEAN:
|
|
options.push(
|
|
<Menu.MenuCheckboxItem
|
|
{...baseProps}
|
|
checked={s[key]}
|
|
action={() => {
|
|
s[key] = !s[key];
|
|
if (option.restartNeeded) showToast("Restart to apply the change");
|
|
}}
|
|
/>
|
|
);
|
|
break;
|
|
case OptionType.SELECT:
|
|
options.push(
|
|
<Menu.MenuItem {...baseProps}>
|
|
{option.options.map(opt => (
|
|
<Menu.MenuRadioItem
|
|
group={`${p.name}-${key}`}
|
|
id={`${p.name}-${key}-${opt.value}`}
|
|
key={opt.label}
|
|
label={opt.label}
|
|
checked={s[key] === opt.value}
|
|
action={() => {
|
|
s[key] = opt.value;
|
|
if (option.restartNeeded) showToast("Restart to apply the change");
|
|
}}
|
|
/>
|
|
))}
|
|
</Menu.MenuItem>
|
|
);
|
|
break;
|
|
case OptionType.SLIDER:
|
|
// The menu slider doesn't support these options. Skip to avoid confusion
|
|
if (option.stickToMarkers || option.componentProps) continue;
|
|
|
|
options.push(
|
|
<Menu.MenuControlItem
|
|
{...baseProps}
|
|
control={(props, ref) => (
|
|
<Menu.MenuSliderControl
|
|
ref={ref}
|
|
{...props}
|
|
minValue={option.markers[0]}
|
|
maxValue={option.markers.at(-1)!}
|
|
value={s[key]}
|
|
onChange={v => s[key] = v}
|
|
/>
|
|
)}
|
|
/>
|
|
);
|
|
break;
|
|
}
|
|
}
|
|
|
|
const hasVisibleOptions = options.length > 0;
|
|
const shouldSkip = !hasVisibleOptions && !(includeEmpty && hasAnyOption);
|
|
if (shouldSkip) return null;
|
|
|
|
return (
|
|
<Menu.MenuItem
|
|
id={`${p.name}-menu`}
|
|
key={p.name}
|
|
label={p.name}
|
|
action={() => openPluginModal(p)}
|
|
>
|
|
{hasVisibleOptions && (
|
|
<>
|
|
<Menu.MenuGroup label={p.name}>
|
|
{options}
|
|
</Menu.MenuGroup>
|
|
|
|
<Menu.MenuSeparator />
|
|
|
|
<Menu.MenuItem
|
|
id={`${p.name}-open`}
|
|
label={"Open Settings"}
|
|
action={() => openPluginModal(p)}
|
|
/>
|
|
</>
|
|
)}
|
|
</Menu.MenuItem>
|
|
);
|
|
})
|
|
}
|
|
</>
|
|
);
|
|
}
|
|
|
|
export function buildThemeMenu() {
|
|
return (
|
|
<Menu.MenuItem
|
|
id="themes"
|
|
label="Themes"
|
|
action={() => openSettingsTabModal(ThemesTab)}
|
|
>
|
|
{buildThemeMenuEntries()}
|
|
</Menu.MenuItem>
|
|
);
|
|
}
|
|
|
|
export function buildThemeMenuEntries() {
|
|
const { useQuickCss, enabledThemes } = useSettings(["useQuickCss", "enabledThemes"]);
|
|
const [themes] = useAwaiter(VencordNative.themes.getThemesList);
|
|
|
|
return (
|
|
<>
|
|
<Menu.MenuCheckboxItem
|
|
id="toggle-quickcss"
|
|
checked={useQuickCss}
|
|
label={"Enable QuickCSS"}
|
|
action={() => {
|
|
Settings.useQuickCss = !useQuickCss;
|
|
}}
|
|
/>
|
|
<Menu.MenuItem
|
|
id="edit-quickcss"
|
|
label="Edit QuickCSS"
|
|
action={() => VencordNative.quickCss.openEditor()}
|
|
/>
|
|
<Menu.MenuItem
|
|
id="manage-themes"
|
|
label="Manage Themes"
|
|
action={() => openSettingsTabModal(ThemesTab)}
|
|
/>
|
|
{!!themes?.length && (
|
|
<Menu.MenuGroup>
|
|
{themes.map(theme => (
|
|
<Menu.MenuCheckboxItem
|
|
id={`theme-${theme.fileName}`}
|
|
key={theme.fileName}
|
|
label={theme.name}
|
|
checked={enabledThemes.includes(theme.fileName)}
|
|
action={() => {
|
|
if (enabledThemes.includes(theme.fileName)) {
|
|
Settings.enabledThemes = enabledThemes.filter(t => t !== theme.fileName);
|
|
} else {
|
|
Settings.enabledThemes = [...enabledThemes, theme.fileName];
|
|
}
|
|
}}
|
|
/>
|
|
))}
|
|
</Menu.MenuGroup>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function buildCustomPluginEntries() {
|
|
const pluginEntries = [] as { plugin: Plugin, node: ReactNode; }[];
|
|
|
|
for (const plugin of Object.values(plugins)) {
|
|
if (plugin.toolboxActions && isPluginEnabled(plugin.name)) {
|
|
const entries = typeof plugin.toolboxActions === "function"
|
|
? plugin.toolboxActions()
|
|
: Object.entries(plugin.toolboxActions).map(([text, action]) => {
|
|
const key = `${plugin.name}-${text}`;
|
|
|
|
return (
|
|
<Menu.MenuItem
|
|
id={key}
|
|
key={key}
|
|
label={text}
|
|
action={action}
|
|
/>
|
|
);
|
|
});
|
|
|
|
if (!entries || Array.isArray(entries) && entries.length === 0) continue;
|
|
|
|
pluginEntries.push({
|
|
plugin,
|
|
node:
|
|
<Menu.MenuGroup label={plugin.name} key={`${plugin.name}-group`}>
|
|
{entries}
|
|
</Menu.MenuGroup>
|
|
});
|
|
}
|
|
}
|
|
|
|
// If there aren't too many entries, just put them all in the main menu.
|
|
// Otherwise, add submenus for each plugin
|
|
// FIXME: the Slider component has broken styles that overlap with higher context menus
|
|
// https://discord.com/channels/1015060230222131221/1015063227299811479/1440489344631705693
|
|
if (pluginEntries.length <= 5)
|
|
return pluginEntries.map(e => e.node);
|
|
|
|
const submenuEntries = pluginEntries.map(({ node, plugin }) => (
|
|
<Menu.MenuItem
|
|
id={`${plugin.name}-menu`}
|
|
key={`${plugin.name}-menu`}
|
|
label={plugin.name}
|
|
action={() => openPluginModal(plugin)}
|
|
>
|
|
{node}
|
|
</Menu.MenuItem>
|
|
));
|
|
|
|
return <Menu.MenuGroup>{submenuEntries}</Menu.MenuGroup>;
|
|
}
|
|
|
|
export function renderPopout(onClose: () => void) {
|
|
return (
|
|
<Menu.Menu
|
|
navId="vc-toolbox"
|
|
onClose={onClose}
|
|
>
|
|
<Menu.MenuItem
|
|
id="notifications"
|
|
label="Open Notification Log"
|
|
action={openNotificationLogModal}
|
|
/>
|
|
|
|
{buildThemeMenu()}
|
|
{buildPluginMenu()}
|
|
|
|
{buildCustomPluginEntries()}
|
|
</Menu.Menu >
|
|
);
|
|
}
|