531 lines
26 KiB
TypeScript
531 lines
26 KiB
TypeScript
/*
|
||
* Vencord, a Discord client mod
|
||
* Copyright (c) 2025 Vendicated and contributors
|
||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||
*/
|
||
|
||
import { Switch } from "@components/Switch";
|
||
import { Button, Forms, React, Select, TextInput } from "@webpack/common";
|
||
|
||
import { openCommandPicker } from "./CommandPicker";
|
||
import {
|
||
type CustomCommandDefinition,
|
||
getCategoryPath,
|
||
getCommandById,
|
||
getCustomCommandsSnapshot,
|
||
getSettingsCommandMetaById,
|
||
getSettingsCommandMetaByRoute,
|
||
listAllTags,
|
||
listCategories,
|
||
normalizeTag,
|
||
saveCustomCommands,
|
||
subscribeCustomCommands,
|
||
subscribeRegistry
|
||
} from "./registry";
|
||
|
||
const ACTION_OPTIONS = [
|
||
{ label: "Run Command", value: "command" },
|
||
{ label: "Open Settings Page", value: "settings" },
|
||
{ label: "Open URL", value: "url" },
|
||
{ label: "Run Macro", value: "macro" }
|
||
] as const;
|
||
|
||
type ActionType = typeof ACTION_OPTIONS[number]["value"];
|
||
|
||
const ACTION_HELP_TEXT: Record<ActionType, string> = {
|
||
command: "Alias another palette command by ID. Use the picker if you’re unsure of the identifier.",
|
||
settings: "Jump directly to a Discord settings page. Choose a page from the picker.",
|
||
url: "Open a link. Use https:// links for best compatibility.",
|
||
macro: "Run a sequence of commands in order. Add steps via the picker."
|
||
};
|
||
|
||
type CategoryOption = { label: string; value: string; description?: string; };
|
||
|
||
function buildCategoryOptions(): CategoryOption[] {
|
||
const categories = listCategories();
|
||
const options: CategoryOption[] = categories.map(category => {
|
||
const path = getCategoryPath(category.id);
|
||
const label = path.length ? path.map(item => item.label).join(" › ") : category.label;
|
||
return {
|
||
label: `${label} (${category.id})`,
|
||
value: category.id,
|
||
description: category.description
|
||
} satisfies CategoryOption;
|
||
});
|
||
return [
|
||
{
|
||
label: "Auto (use default placement)",
|
||
value: ""
|
||
},
|
||
...options
|
||
];
|
||
}
|
||
|
||
function createDefaultCommand(): CustomCommandDefinition {
|
||
const baseId = `custom-${Math.random().toString(36).slice(2, 8)}`;
|
||
return {
|
||
id: baseId,
|
||
label: "New Command",
|
||
keywords: [],
|
||
tags: [],
|
||
categoryId: undefined,
|
||
description: "",
|
||
danger: false,
|
||
action: {
|
||
type: "command",
|
||
commandId: ""
|
||
}
|
||
};
|
||
}
|
||
|
||
function normalizeTagList(values: string[]): string[] {
|
||
const seen = new Set<string>();
|
||
const result: string[] = [];
|
||
for (const raw of values) {
|
||
const trimmed = raw.trim();
|
||
if (!trimmed) continue;
|
||
const slug = normalizeTag(trimmed);
|
||
if (!slug || seen.has(slug)) continue;
|
||
seen.add(slug);
|
||
result.push(trimmed);
|
||
}
|
||
return result;
|
||
}
|
||
|
||
function duplicateWithUpdates(command: CustomCommandDefinition, updates: Partial<CustomCommandDefinition>): CustomCommandDefinition {
|
||
const nextTags = updates.tags !== undefined ? normalizeTagList(updates.tags) : command.tags;
|
||
return {
|
||
...command,
|
||
...updates,
|
||
keywords: updates.keywords ?? command.keywords,
|
||
tags: nextTags,
|
||
action: updates.action ?? command.action
|
||
};
|
||
}
|
||
|
||
type TemplateConfig = {
|
||
label: string;
|
||
description: string;
|
||
apply(command: CustomCommandDefinition): CustomCommandDefinition;
|
||
};
|
||
|
||
const TEMPLATE_CONFIGS: TemplateConfig[] = [
|
||
{
|
||
label: "Alias",
|
||
description: "Mirror an existing command",
|
||
apply: command => duplicateWithUpdates(command, {
|
||
action: { type: "command", commandId: "" }
|
||
})
|
||
},
|
||
{
|
||
label: "Settings",
|
||
description: "Open Discord settings",
|
||
apply: command => duplicateWithUpdates(command, {
|
||
action: { type: "settings", route: "My Account" }
|
||
})
|
||
},
|
||
{
|
||
label: "Link",
|
||
description: "Open an external URL",
|
||
apply: command => duplicateWithUpdates(command, {
|
||
action: { type: "url", url: "https://", openExternal: true }
|
||
})
|
||
},
|
||
{
|
||
label: "Macro",
|
||
description: "Run multiple commands",
|
||
apply: command => duplicateWithUpdates(command, {
|
||
action: { type: "macro", steps: [] }
|
||
})
|
||
}
|
||
];
|
||
|
||
function parseListInput(value: string): string[] {
|
||
const parts = value
|
||
.split(",")
|
||
.map(item => item.trim());
|
||
return normalizeTagList(parts);
|
||
}
|
||
|
||
function renderActionFields(command: CustomCommandDefinition, onChange: (action: CustomCommandDefinition["action"]) => void) {
|
||
const { action } = command;
|
||
|
||
switch (action.type) {
|
||
case "command":
|
||
const trimmedId = action.commandId.trim();
|
||
let commandError: string | undefined;
|
||
if (!trimmedId) {
|
||
commandError = "Enter a command ID or choose one below.";
|
||
} else if (!getCommandById(trimmedId)) {
|
||
commandError = "No command matches this ID.";
|
||
}
|
||
return (
|
||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||
<TextInput
|
||
label="Target Command ID"
|
||
value={action.commandId}
|
||
placeholder="Existing command id"
|
||
error={commandError}
|
||
onChange={value => onChange({ type: "command", commandId: value })}
|
||
/>
|
||
<Button
|
||
size={Button.Sizes.SMALL}
|
||
onClick={() => openCommandPicker({
|
||
initialQuery: action.commandId,
|
||
onSelect: command => onChange({ type: "command", commandId: command.id })
|
||
})}
|
||
>
|
||
Choose Command…
|
||
</Button>
|
||
</div>
|
||
);
|
||
case "settings": {
|
||
const selectedSettings = action.route ? getSettingsCommandMetaByRoute(action.route) : undefined;
|
||
const currentSummary = selectedSettings
|
||
? `${selectedSettings.label} (${selectedSettings.route})`
|
||
: action.route || "No page selected";
|
||
|
||
return (
|
||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||
<Forms.FormText style={{ color: "var(--text-muted, #a5a6ab)" }}>
|
||
Current page: {currentSummary}
|
||
</Forms.FormText>
|
||
<Button
|
||
size={Button.Sizes.SMALL}
|
||
onClick={() => openCommandPicker({
|
||
initialSelectedIds: selectedSettings ? [selectedSettings.id] : undefined,
|
||
filter: entry => entry.categoryId === "discord-settings",
|
||
onSelect: command => {
|
||
const meta = getSettingsCommandMetaById(command.id);
|
||
onChange({ type: "settings", route: meta?.route ?? command.label });
|
||
}
|
||
})}
|
||
>
|
||
Choose Page…
|
||
</Button>
|
||
</div>
|
||
);
|
||
}
|
||
case "url": {
|
||
let urlError: string | undefined;
|
||
const trimmedUrl = action.url.trim();
|
||
if (trimmedUrl && !trimmedUrl.startsWith("http://") && !trimmedUrl.startsWith("https://")) {
|
||
urlError = "Use http:// or https:// links.";
|
||
} else if (trimmedUrl) {
|
||
try {
|
||
new URL(trimmedUrl);
|
||
} catch {
|
||
urlError = "Enter a valid URL.";
|
||
}
|
||
}
|
||
return (
|
||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||
<TextInput
|
||
label="URL"
|
||
value={action.url}
|
||
placeholder="https://example.com"
|
||
error={urlError}
|
||
onChange={value => onChange({ type: "url", url: value, openExternal: action.openExternal })}
|
||
/>
|
||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||
<Switch
|
||
checked={Boolean(action.openExternal)}
|
||
onChange={value => onChange({ type: "url", url: action.url, openExternal: value })}
|
||
/>
|
||
<Forms.FormText>Open externally</Forms.FormText>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
case "macro":
|
||
const invalidSteps = action.steps.filter(step => !getCommandById(step));
|
||
return (
|
||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||
<TextInput
|
||
label="Command Sequence"
|
||
value={action.steps.join(", ")}
|
||
placeholder="command-a, command-b"
|
||
onChange={value => onChange({ type: "macro", steps: parseListInput(value) })}
|
||
/>
|
||
<Button
|
||
size={Button.Sizes.SMALL}
|
||
onClick={() => openCommandPicker({
|
||
allowMultiple: true,
|
||
initialSelectedIds: action.steps,
|
||
onComplete: (commands, ids) => {
|
||
const nextIds = ids ?? commands.map(command => command.id);
|
||
onChange({ type: "macro", steps: nextIds });
|
||
}
|
||
})}
|
||
>
|
||
Add Step…
|
||
</Button>
|
||
{action.steps.length > 0 && (
|
||
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
|
||
{action.steps.map((step, index) => (
|
||
<Button
|
||
key={`${step}-${index}`}
|
||
size={Button.Sizes.MIN}
|
||
onClick={() => {
|
||
const next = [...action.steps];
|
||
next.splice(index, 1);
|
||
onChange({ type: "macro", steps: next });
|
||
}}
|
||
look={Button.Looks.LINK}
|
||
>
|
||
{step} ✕
|
||
</Button>
|
||
))}
|
||
</div>
|
||
)}
|
||
{invalidSteps.length > 0 && (
|
||
<Forms.FormText style={{ color: "var(--status-danger-text, #f04747)" }}>
|
||
Unknown command IDs: {invalidSteps.join(", ")}
|
||
</Forms.FormText>
|
||
)}
|
||
</div>
|
||
);
|
||
default:
|
||
return null;
|
||
}
|
||
}
|
||
|
||
export function CommandPaletteSettingsPanel() {
|
||
const [commands, setCommands] = React.useState<CustomCommandDefinition[]>(() => getCustomCommandsSnapshot());
|
||
const [tagMeta, setTagMeta] = React.useState(() => listAllTags());
|
||
const [categoryOptions, setCategoryOptions] = React.useState(() => buildCategoryOptions());
|
||
const [advancedOpen, setAdvancedOpen] = React.useState<Record<string, boolean>>({});
|
||
const [collapsed, setCollapsed] = React.useState<Record<string, boolean>>({});
|
||
|
||
React.useEffect(() => {
|
||
return subscribeCustomCommands(next => setCommands(next));
|
||
}, []);
|
||
|
||
React.useEffect(() => {
|
||
setTagMeta(listAllTags());
|
||
setCategoryOptions(buildCategoryOptions());
|
||
return subscribeRegistry(() => {
|
||
setTagMeta(listAllTags());
|
||
setCategoryOptions(buildCategoryOptions());
|
||
});
|
||
}, []);
|
||
|
||
const updateCommands = React.useCallback((next: CustomCommandDefinition[]) => {
|
||
setCommands(next);
|
||
void saveCustomCommands(next);
|
||
}, []);
|
||
|
||
const handleAdd = () => {
|
||
updateCommands([...commands, createDefaultCommand()]);
|
||
};
|
||
|
||
const handleRemove = (id: string) => {
|
||
updateCommands(commands.filter(command => command.id !== id));
|
||
};
|
||
|
||
const handleUpdate = (id: string, updater: (command: CustomCommandDefinition) => CustomCommandDefinition) => {
|
||
updateCommands(commands.map(command => (command.id === id ? updater(command) : command)));
|
||
};
|
||
|
||
React.useEffect(() => {
|
||
setAdvancedOpen(prev => {
|
||
const next = { ...prev };
|
||
for (const key of Object.keys(next)) {
|
||
if (!commands.some(command => command.id === key)) {
|
||
delete next[key];
|
||
}
|
||
}
|
||
return next;
|
||
});
|
||
setCollapsed(prev => {
|
||
const next = { ...prev };
|
||
for (const key of Object.keys(next)) {
|
||
if (!commands.some(command => command.id === key)) {
|
||
delete next[key];
|
||
}
|
||
}
|
||
return next;
|
||
});
|
||
}, [commands]);
|
||
|
||
return (
|
||
<section>
|
||
<Forms.FormTitle tag="h4" style={{ color: "var(--text-normal, #dcddde)" }}>Custom Commands</Forms.FormTitle>
|
||
<Forms.FormText className="vc-cp-settings-help">
|
||
{/* Hank, don't abbreviate cyberpunk hank */}
|
||
1) Name the command · 2) Add optional description/keywords/tags/category · 3) Choose an action and fill its details (IDs should match existing palette commands for aliases and macros).
|
||
</Forms.FormText>
|
||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||
{commands.map(command => {
|
||
const manualAdvanced = advancedOpen[command.id];
|
||
const hasAdvancedContent = Boolean(command.description?.trim()?.length || (command.keywords?.length ?? 0) || (command.tags?.length ?? 0) || command.categoryId || command.danger);
|
||
const showAdvanced = manualAdvanced ?? hasAdvancedContent;
|
||
const categoryId = command.categoryId ?? "";
|
||
const categoryPath = categoryId ? getCategoryPath(categoryId) : [];
|
||
const categoryLabel = categoryId
|
||
? (categoryPath.length ? categoryPath.map(item => item.label).join(" › ") : categoryId)
|
||
: "Auto (default)";
|
||
const isCollapsed = collapsed[command.id] ?? false;
|
||
|
||
return (
|
||
<div key={command.id} style={{ padding: 14, borderRadius: 8, background: "var(--background-primary)", border: "1px solid var(--background-base-low)", display: "flex", flexDirection: "column", gap: 12 }}>
|
||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||
<Forms.FormTitle tag="h5" style={{ color: "var(--text-normal, #dcddde)" }}>{command.label || "Untitled Command"}</Forms.FormTitle>
|
||
<Button
|
||
size={Button.Sizes.MIN}
|
||
look={Button.Looks.LINK}
|
||
onClick={() => setCollapsed(prev => ({ ...prev, [command.id]: !isCollapsed }))}
|
||
>
|
||
{isCollapsed ? "Expand" : "Collapse"}
|
||
</Button>
|
||
</div>
|
||
|
||
{!isCollapsed && (
|
||
<>
|
||
<TextInput
|
||
label="Label"
|
||
value={command.label}
|
||
placeholder="Display name"
|
||
onChange={value => handleUpdate(command.id, prev => duplicateWithUpdates(prev, { label: value }))}
|
||
/>
|
||
|
||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, alignItems: "center" }}>
|
||
<Select
|
||
options={ACTION_OPTIONS}
|
||
isSelected={value => value === command.action.type}
|
||
select={value => {
|
||
const nextType = value as ActionType;
|
||
switch (nextType) {
|
||
case "command":
|
||
handleUpdate(command.id, prev => TEMPLATE_CONFIGS[0].apply(prev));
|
||
break;
|
||
case "settings":
|
||
handleUpdate(command.id, prev => TEMPLATE_CONFIGS[1].apply(prev));
|
||
break;
|
||
case "url":
|
||
handleUpdate(command.id, prev => TEMPLATE_CONFIGS[2].apply(prev));
|
||
break;
|
||
case "macro":
|
||
handleUpdate(command.id, prev => TEMPLATE_CONFIGS[3].apply(prev));
|
||
break;
|
||
default:
|
||
break;
|
||
}
|
||
}}
|
||
serialize={option => option.value}
|
||
/>
|
||
</div>
|
||
<Forms.FormText style={{ color: "var(--text-muted, #a5a6ab)", fontSize: 12 }}>
|
||
{ACTION_HELP_TEXT[command.action.type]}
|
||
</Forms.FormText>
|
||
</div>
|
||
|
||
{renderActionFields(command, action => handleUpdate(command.id, prev => duplicateWithUpdates(prev, { action })))}
|
||
|
||
<Button
|
||
size={Button.Sizes.SMALL}
|
||
look={Button.Looks.LINK}
|
||
onClick={() => setAdvancedOpen(prev => ({ ...prev, [command.id]: !(showAdvanced ?? false) }))}
|
||
>
|
||
{showAdvanced ? "Hide advanced options" : "Show advanced options"}
|
||
</Button>
|
||
|
||
{showAdvanced && (
|
||
<div style={{ display: "flex", flexDirection: "column", gap: 12, background: "var(--background-secondary-alt)", borderRadius: 8, padding: 12 }}>
|
||
<TextInput
|
||
label="Description"
|
||
value={command.description ?? ""}
|
||
placeholder="Optional subtitle"
|
||
onChange={value => handleUpdate(command.id, prev => duplicateWithUpdates(prev, { description: value }))}
|
||
/>
|
||
<TextInput
|
||
label="Keywords"
|
||
value={(command.keywords ?? []).join(", ")}
|
||
placeholder="Comma-separated keywords"
|
||
onChange={value => handleUpdate(command.id, prev => duplicateWithUpdates(prev, { keywords: parseListInput(value) }))}
|
||
/>
|
||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||
<TextInput
|
||
label="Tags"
|
||
value={(command.tags ?? []).join(", ")}
|
||
placeholder="Comma-separated tags"
|
||
onChange={value => handleUpdate(command.id, prev => duplicateWithUpdates(prev, { tags: parseListInput(value) }))}
|
||
/>
|
||
{tagMeta.length > 0 && (
|
||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||
<Forms.FormText style={{ color: "var(--text-muted, #a5a6ab)", fontSize: 12 }}>
|
||
Suggestions
|
||
</Forms.FormText>
|
||
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
|
||
{tagMeta
|
||
.filter(meta => !(command.tags ?? []).some(tag => normalizeTag(tag) === meta.id))
|
||
.slice(0, 12)
|
||
.map(meta => (
|
||
<Button
|
||
key={meta.id}
|
||
size={Button.Sizes.MIN}
|
||
look={Button.Looks.LINK}
|
||
onClick={() => handleUpdate(command.id, prev => duplicateWithUpdates(prev, {
|
||
tags: normalizeTagList([...(prev.tags ?? []), meta.label])
|
||
}))}
|
||
>
|
||
{meta.label}
|
||
</Button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||
<Select
|
||
options={categoryOptions}
|
||
isSelected={value => value === categoryId}
|
||
select={value => handleUpdate(command.id, prev => duplicateWithUpdates(prev, { categoryId: value || undefined }))}
|
||
serialize={option => option.value}
|
||
/>
|
||
<Forms.FormText style={{ color: "var(--text-muted, #a5a6ab)", fontSize: 12 }}>
|
||
Choose where this command appears in the palette.
|
||
</Forms.FormText>
|
||
{categoryId && (
|
||
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
|
||
<span style={{ display: "inline-flex", alignItems: "center", gap: 6, borderRadius: 999, padding: "2px 10px", fontSize: 12, background: "var(--background-base-low)", color: "var(--text-normal, #dcddde)" }}>
|
||
{categoryLabel}
|
||
<span style={{ color: "var(--text-muted, #a5a6ab)" }}>· {categoryId}</span>
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||
<Switch
|
||
checked={Boolean(command.danger)}
|
||
onChange={value => handleUpdate(command.id, prev => duplicateWithUpdates(prev, { danger: value }))}
|
||
/>
|
||
<Forms.FormText>Show as dangerous</Forms.FormText>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
<Button
|
||
color={Button.Colors.RED}
|
||
size={Button.Sizes.SMALL}
|
||
onClick={() => handleRemove(command.id)}
|
||
>
|
||
Remove Command
|
||
</Button>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
<Button
|
||
color={Button.Colors.BRAND}
|
||
style={{ marginTop: 16 }}
|
||
onClick={handleAdd}
|
||
>
|
||
Add Command
|
||
</Button>
|
||
</section>
|
||
);
|
||
}
|