Merge remote-tracking branch 'upstream/dev' into dev
This commit is contained in:
commit
9ea0a66b36
13 changed files with 636 additions and 304 deletions
23
src/components/ExpandableCard.css
Normal file
23
src/components/ExpandableCard.css
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
.vc-expandable-card {
|
||||
color: var(--text-default);
|
||||
}
|
||||
|
||||
.vc-expandable-card-header {
|
||||
display: flex;
|
||||
cursor: pointer;
|
||||
padding: .75em 1em;
|
||||
margin: 4px;
|
||||
border-radius: var(--radius-xs);
|
||||
|
||||
.vc-expandable-card[data-expanded="true"] & {
|
||||
background: var(--card-secondary-bg);
|
||||
}
|
||||
}
|
||||
|
||||
.vc-expandable-card-icon {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.vc-expandable-card-content {
|
||||
padding: 0.5em 1em;
|
||||
}
|
||||
45
src/components/ExpandableCard.tsx
Normal file
45
src/components/ExpandableCard.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import "./ExpandableCard.css";
|
||||
|
||||
import { classes } from "@utils/misc";
|
||||
import { Clickable, useState } from "@webpack/common";
|
||||
import { PropsWithChildren } from "react";
|
||||
|
||||
import { Card } from "./Card";
|
||||
import { DownArrow, RightArrow } from "./Icons";
|
||||
|
||||
export type ExpandableSectionProps = PropsWithChildren<{
|
||||
renderContent: () => React.ReactNode;
|
||||
className?: string;
|
||||
initialExpanded?: boolean;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* A card component that can expand and collapse to show/hide content. The header (props.children) is always visible, and the content (props.renderContent) is only visible when expanded.
|
||||
*/
|
||||
export function ExpandableSection({ children, renderContent: Content, className, initialExpanded = false }: ExpandableSectionProps) {
|
||||
const [expanded, setExpanded] = useState(initialExpanded);
|
||||
|
||||
const Icon = expanded ? DownArrow : RightArrow;
|
||||
|
||||
return (
|
||||
<Card data-expanded={expanded} className={classes("vc-expandable-card", className)}>
|
||||
<Clickable className="vc-expandable-card-header" onClick={() => setExpanded(c => !c)} >
|
||||
{children}
|
||||
<Icon className="vc-expandable-card-icon" />
|
||||
</Clickable>
|
||||
|
||||
{expanded
|
||||
? <div className="vc-expandable-card-content">
|
||||
<Content />
|
||||
</div>
|
||||
: null
|
||||
}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -714,7 +714,7 @@ export function CloudUploadIcon(props: IconProps) {
|
|||
);
|
||||
}
|
||||
|
||||
export const ClockIcon = (props?: any) => {
|
||||
export function ClockIcon(props: IconProps) {
|
||||
return (
|
||||
<Icon
|
||||
{...props}
|
||||
|
|
@ -728,7 +728,35 @@ export const ClockIcon = (props?: any) => {
|
|||
/>
|
||||
</Icon>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export function DownArrow(props: IconProps) {
|
||||
return (
|
||||
<Icon
|
||||
{...props}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
fill={props.fill || "currentColor"}
|
||||
d="M5.3 9.3a1 1 0 0 1 1.4 0l5.3 5.29 5.3-5.3a1 1 0 1 1 1.4 1.42l-6 6a1 1 0 0 1-1.4 0l-6-6a1 1 0 0 1 0-1.42Z"
|
||||
/>
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function RightArrow(props: IconProps) {
|
||||
return (
|
||||
<Icon
|
||||
{...props}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
fill={props.fill || "currentColor"}
|
||||
d="M9.3 5.3a1 1 0 0 0 0 1.4l5.29 5.3-5.3 5.3a1 1 0 1 0 1.42 1.4l6-6a1 1 0 0 0 0-1.4l-6-6a1 1 0 0 0-1.42 0Z"
|
||||
/>
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export const QrCodeIcon = (props?: any) => {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ export default definePlugin({
|
|||
|
||||
patches: [
|
||||
{
|
||||
find: '"role-icon-children"',
|
||||
find: "#{intl::GUILD_COMMUNICATION_DISABLED_ICON_TOOLTIP_BODY}",
|
||||
replacement: {
|
||||
match: /#{intl::GUILD_COMMUNICATION_DISABLED_BOTTOM_SHEET_TITLE}.+?renderPopout:.+?(?=\])/,
|
||||
replace: "$&,Vencord.Api.MessageDecorations.__addDecorationsToMessage(arguments[0])"
|
||||
|
|
|
|||
133
src/plugins/customCommands/CreateTagModal.tsx
Normal file
133
src/plugins/customCommands/CreateTagModal.tsx
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { BaseText } from "@components/BaseText";
|
||||
import { Button } from "@components/Button";
|
||||
import { Card } from "@components/Card";
|
||||
import { InlineCode } from "@components/CodeBlock";
|
||||
import { ExpandableSection } from "@components/ExpandableCard";
|
||||
import { Flex } from "@components/Flex";
|
||||
import { HeadingSecondary } from "@components/Heading";
|
||||
import { InfoIcon } from "@components/Icons";
|
||||
import { Margins } from "@components/margins";
|
||||
import { Paragraph } from "@components/Paragraph";
|
||||
import { ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal";
|
||||
import { TextArea, TextInput, useState } from "@webpack/common";
|
||||
|
||||
import { parseTagArguments } from ".";
|
||||
import { addTag, getTag, Tag } from "./settings";
|
||||
|
||||
export function openCreateTagModal(initialValue: Tag = { name: "", message: "" }) {
|
||||
openModal(modalProps => (
|
||||
<Modal initialValue={initialValue} modalProps={modalProps} />
|
||||
));
|
||||
}
|
||||
|
||||
const EXAMPLE_RESPONSE = "Hello {{user}}! I am feeling {{mood = great}}.";
|
||||
|
||||
function Modal({ initialValue, modalProps }: { initialValue: Tag; modalProps: ModalProps; }) {
|
||||
const [name, setName] = useState(initialValue.name);
|
||||
const [message, setMessage] = useState(initialValue.message.replaceAll("\\n", "\n"));
|
||||
|
||||
const detectedArguments = parseTagArguments(message);
|
||||
const hasReservedEphemeral = detectedArguments.some(arg => arg.name === "ephemeral");
|
||||
const nameAlreadyExists = name !== initialValue.name && getTag(name);
|
||||
|
||||
return (
|
||||
<ModalRoot {...modalProps} size={ModalSize.MEDIUM}>
|
||||
<ModalHeader>
|
||||
<BaseText size="lg" weight="semibold" style={{ flexGrow: 1 }}>Create Tag</BaseText>
|
||||
<ModalCloseButton onClick={modalProps.onClose} />
|
||||
</ModalHeader>
|
||||
|
||||
<ModalContent>
|
||||
<Flex flexDirection="column" gap={12}>
|
||||
<Paragraph>Create a new tag which will be registered as a slash command.</Paragraph>
|
||||
|
||||
<section className={Margins.top8}>
|
||||
<HeadingSecondary>Name</HeadingSecondary>
|
||||
<TextInput value={name} onChange={setName} placeholder="greet" />
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<HeadingSecondary>Response</HeadingSecondary>
|
||||
<TextArea value={message} onChange={setMessage} placeholder={EXAMPLE_RESPONSE} />
|
||||
</section>
|
||||
|
||||
{detectedArguments.length > 0 && (
|
||||
<section>
|
||||
<HeadingSecondary>Detected Arguments</HeadingSecondary>
|
||||
<Paragraph>
|
||||
<ul>
|
||||
{detectedArguments.map(arg => (
|
||||
<li key={arg.name}>
|
||||
— <b>{arg.name}</b>{arg.defaultValue ? ` (default: ${arg.defaultValue})` : ""}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Paragraph>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<ExpandableSection
|
||||
renderContent={() => (
|
||||
<Flex flexDirection="column" gap={12}>
|
||||
<Paragraph>
|
||||
Your response can include variables wrapped in double curly braces which will become command arguments, for example <InlineCode>{"Hello {{user}}"}</InlineCode>.
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
You can specify arguments with default values by using an equals sign, for example <InlineCode>{"Hello {{user = pal}}"}</InlineCode>.
|
||||
</Paragraph>
|
||||
|
||||
<section>
|
||||
<Paragraph><b>Example Command response:</b> <InlineCode>{EXAMPLE_RESPONSE}</InlineCode></Paragraph>
|
||||
<Paragraph><b>Example usage:</b> <InlineCode>{"/greet user:@Clyde"}</InlineCode></Paragraph>
|
||||
<Paragraph><b>Example output:</b> <InlineCode>{"Hello @Clyde! I am feeling great."}</InlineCode></Paragraph>
|
||||
</section>
|
||||
</Flex>
|
||||
)}
|
||||
>
|
||||
<Flex alignItems="center" gap={8}>
|
||||
<InfoIcon color="var(--text-muted)" height={16} width={16} />
|
||||
View Arguments guide
|
||||
</Flex>
|
||||
</ExpandableSection>
|
||||
{hasReservedEphemeral &&
|
||||
<Card variant="danger" className={Margins.top8} defaultPadding>
|
||||
<Paragraph>The argument name "ephemeral" is reserved and cannot be used.</Paragraph>
|
||||
</Card>
|
||||
}
|
||||
{nameAlreadyExists &&
|
||||
<Card variant="warning" className={Margins.top8} defaultPadding>
|
||||
<Paragraph>A tag with the name <InlineCode>{name}</InlineCode> already exists and will be overwritten.</Paragraph>
|
||||
</Card>
|
||||
}
|
||||
</Flex>
|
||||
</ModalContent>
|
||||
|
||||
<ModalFooter>
|
||||
<Flex>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={modalProps.onClose}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
const tag = { name, message };
|
||||
addTag(tag);
|
||||
modalProps.onClose();
|
||||
}}
|
||||
disabled={!name || !message || hasReservedEphemeral}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</Flex>
|
||||
</ModalFooter>
|
||||
</ModalRoot>
|
||||
);
|
||||
}
|
||||
41
src/plugins/customCommands/SettingsTagList.tsx
Normal file
41
src/plugins/customCommands/SettingsTagList.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { BaseText } from "@components/BaseText";
|
||||
import { Button } from "@components/Button";
|
||||
import { Card } from "@components/Card";
|
||||
import { Flex } from "@components/Flex";
|
||||
import { DeleteIcon, PencilIcon } from "@components/Icons";
|
||||
import { Margins } from "@components/margins";
|
||||
import { Paragraph } from "@components/Paragraph";
|
||||
|
||||
import { openCreateTagModal } from "./CreateTagModal";
|
||||
import { removeTag, settings } from "./settings";
|
||||
|
||||
export function SettingsTagList() {
|
||||
const { tagsList } = settings.use(["tagsList"]);
|
||||
|
||||
return (
|
||||
<section className={Margins.top8}>
|
||||
<BaseText size="md" weight="semibold">Registered Tags</BaseText>
|
||||
<Flex flexDirection="column" gap="0.5em" className={Margins.top8}>
|
||||
{Object.values(tagsList).map(tag => (
|
||||
<Card key={tag.name} className="vc-customCommands-card">
|
||||
<Paragraph size="md" weight="medium">{tag.name}</Paragraph>
|
||||
|
||||
<Button variant="secondary" size="iconOnly" onClick={() => openCreateTagModal(tag)}>
|
||||
<PencilIcon aria-label="Edit Tag" width={20} height={20} />
|
||||
</Button>
|
||||
<Button variant="dangerSecondary" size="iconOnly" onClick={() => removeTag(tag.name)}>
|
||||
<DeleteIcon aria-label="Delete Tag" width={20} height={20} />
|
||||
</Button>
|
||||
</Card>
|
||||
))}
|
||||
<Button onClick={() => openCreateTagModal()}>Create Tag</Button>
|
||||
</Flex>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
174
src/plugins/customCommands/index.ts
Normal file
174
src/plugins/customCommands/index.ts
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
/*
|
||||
* Vencord, a modification for Discord's desktop app
|
||||
* Copyright (c) 2022 Vendicated and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import "./styles.css";
|
||||
|
||||
import { ApplicationCommandInputType, ApplicationCommandOptionType, findOption, registerCommand, sendBotMessage } from "@api/Commands";
|
||||
import { migratePluginSettings } from "@api/Settings";
|
||||
import { Devs } from "@utils/constants";
|
||||
import { sendMessage } from "@utils/discord";
|
||||
import definePlugin from "@utils/types";
|
||||
|
||||
import { openCreateTagModal } from "./CreateTagModal";
|
||||
import { getTag, getTags, removeTag, settings, Tag } from "./settings";
|
||||
|
||||
const CustomCommandsMarker = Symbol("CustomCommands");
|
||||
const ArgumentRegex = /{{(.+?)}}/g;
|
||||
|
||||
export function parseTagArguments(message: string) {
|
||||
const args = [] as { name: string, defaultValue: string | null; }[];
|
||||
|
||||
for (const [, value] of message.matchAll(ArgumentRegex)) {
|
||||
const [name, defaultValue] = value.split("=").map(s => s.trim());
|
||||
|
||||
if (!name) continue;
|
||||
if (args.some(arg => arg.name === name)) continue;
|
||||
|
||||
args.push({ name: name.toLowerCase(), defaultValue: defaultValue ?? null });
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
export function registerTagCommand(tag: Tag) {
|
||||
const tagArguments = parseTagArguments(tag.message);
|
||||
|
||||
registerCommand({
|
||||
name: tag.name,
|
||||
description: tag.name,
|
||||
inputType: ApplicationCommandInputType.BUILT_IN,
|
||||
options: [
|
||||
...tagArguments.map(arg => ({
|
||||
name: arg.name,
|
||||
description: arg.name,
|
||||
type: ApplicationCommandOptionType.STRING,
|
||||
required: arg.defaultValue === null
|
||||
})),
|
||||
{
|
||||
name: "ephemeral",
|
||||
description: "Whether the response should only be visible to you",
|
||||
type: ApplicationCommandOptionType.BOOLEAN,
|
||||
required: false
|
||||
}
|
||||
],
|
||||
|
||||
execute: async (args, ctx) => {
|
||||
const ephemeral = findOption(args, "ephemeral", false);
|
||||
|
||||
const response = tag.message
|
||||
.replace(ArgumentRegex, (fullMatch, value: string) => {
|
||||
const [argName, defaultValue] = value.split("=").map(s => s.trim());
|
||||
return findOption(args, argName, null) ?? defaultValue ?? fullMatch;
|
||||
})
|
||||
.replaceAll("\\n", "\n");
|
||||
|
||||
const doSend = ephemeral ? sendBotMessage : sendMessage;
|
||||
doSend(ctx.channel.id, { content: response });
|
||||
},
|
||||
[CustomCommandsMarker]: true,
|
||||
}, "CustomCommands");
|
||||
}
|
||||
|
||||
|
||||
migratePluginSettings("CustomCommands", "MessageTags");
|
||||
export default definePlugin({
|
||||
name: "CustomCommands",
|
||||
description: "Allows you to create custom slash commands / tags",
|
||||
tags: ["MessageTags"],
|
||||
authors: [Devs.Ven, Devs.Luna,],
|
||||
settings,
|
||||
|
||||
async start() {
|
||||
const tags = getTags();
|
||||
for (const tagName in tags) {
|
||||
registerTagCommand(tags[tagName]);
|
||||
}
|
||||
},
|
||||
|
||||
commands: [
|
||||
{
|
||||
name: "tags",
|
||||
description: "Manage all custom commands",
|
||||
inputType: ApplicationCommandInputType.BUILT_IN,
|
||||
options: [
|
||||
{
|
||||
name: "create",
|
||||
description: "Create a new tag",
|
||||
type: ApplicationCommandOptionType.SUB_COMMAND,
|
||||
},
|
||||
{
|
||||
name: "list",
|
||||
description: "List all your tags",
|
||||
type: ApplicationCommandOptionType.SUB_COMMAND,
|
||||
options: []
|
||||
},
|
||||
{
|
||||
name: "delete",
|
||||
description: "Remove a tag by name",
|
||||
type: ApplicationCommandOptionType.SUB_COMMAND,
|
||||
options: [
|
||||
{
|
||||
name: "tag-name",
|
||||
description: "The name of the tag",
|
||||
type: ApplicationCommandOptionType.STRING,
|
||||
required: true
|
||||
}
|
||||
]
|
||||
},
|
||||
],
|
||||
|
||||
async execute(args, ctx) {
|
||||
switch (args[0].name) {
|
||||
case "create": {
|
||||
openCreateTagModal();
|
||||
break;
|
||||
}
|
||||
|
||||
case "delete": {
|
||||
const name: string = findOption(args[0].options, "tag-name", "");
|
||||
|
||||
if (!getTag(name))
|
||||
return sendBotMessage(ctx.channel.id, {
|
||||
content: `A Tag with the name **${name}** does not exist!`
|
||||
});
|
||||
|
||||
removeTag(name);
|
||||
|
||||
sendBotMessage(ctx.channel.id, {
|
||||
content: `Successfully deleted the tag **${name}**!`
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case "list": {
|
||||
const content = Object.values(getTags())
|
||||
.map(tag => `\`${tag.name}\`: ${tag.message.slice(0, 72).replaceAll("\\n", " ")}${tag.message.length > 72 ? "..." : ""}`)
|
||||
.join("\n");
|
||||
|
||||
sendBotMessage(ctx.channel.id, {
|
||||
content: content || "Woops! There are no tags yet, use `/tags create` to create one!",
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
48
src/plugins/customCommands/settings.ts
Normal file
48
src/plugins/customCommands/settings.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { unregisterCommand } from "@api/Commands";
|
||||
import { definePluginSettings } from "@api/Settings";
|
||||
import { OptionType } from "@utils/types";
|
||||
|
||||
import { registerTagCommand } from ".";
|
||||
import { SettingsTagList } from "./SettingsTagList";
|
||||
|
||||
export const settings = definePluginSettings({
|
||||
tagsList: {
|
||||
type: OptionType.CUSTOM,
|
||||
default: {} as Record<string, Tag>,
|
||||
},
|
||||
tagComponent: {
|
||||
type: OptionType.COMPONENT,
|
||||
component: SettingsTagList
|
||||
}
|
||||
});
|
||||
|
||||
export interface Tag {
|
||||
name: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function getTags() {
|
||||
return settings.store.tagsList;
|
||||
}
|
||||
|
||||
export function getTag(name: string) {
|
||||
return settings.store.tagsList[name];
|
||||
}
|
||||
|
||||
export function addTag(tag: Tag) {
|
||||
unregisterCommand(tag.name);
|
||||
|
||||
settings.store.tagsList[tag.name] = tag;
|
||||
registerTagCommand(tag);
|
||||
}
|
||||
|
||||
export function removeTag(name: string) {
|
||||
delete settings.store.tagsList[name];
|
||||
unregisterCommand(name);
|
||||
}
|
||||
8
src/plugins/customCommands/styles.css
Normal file
8
src/plugins/customCommands/styles.css
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
.vc-customCommands-card {
|
||||
padding: 0.5em;
|
||||
padding-left: 1em;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr min-content min-content;
|
||||
align-items: center;
|
||||
gap: 0.25em;
|
||||
}
|
||||
|
|
@ -97,7 +97,7 @@ export default definePlugin({
|
|||
]
|
||||
},
|
||||
...[
|
||||
'"role-icon-children"', // Messages
|
||||
"#{intl::GUILD_COMMUNICATION_DISABLED_ICON_TOOLTIP_BODY}", // Messages
|
||||
"#{intl::COLLECTIBLES_NAMEPLATE_PREVIEW_A11Y}", // Nameplate preview
|
||||
"#{intl::COLLECTIBLES_PROFILE_PREVIEW_A11Y}", // Avatar preview
|
||||
].map(find => ({
|
||||
|
|
|
|||
|
|
@ -1,239 +0,0 @@
|
|||
/*
|
||||
* Vencord, a modification for Discord's desktop app
|
||||
* Copyright (c) 2022 Vendicated and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { ApplicationCommandInputType, ApplicationCommandOptionType, findOption, registerCommand, sendBotMessage, unregisterCommand } from "@api/Commands";
|
||||
import { definePluginSettings } from "@api/Settings";
|
||||
import { Devs } from "@utils/constants";
|
||||
import definePlugin, { OptionType } from "@utils/types";
|
||||
|
||||
const EMOTE = "<:luna:1035316192220553236>";
|
||||
const DATA_KEY = "MessageTags_TAGS";
|
||||
const MessageTagsMarker = Symbol("MessageTags");
|
||||
|
||||
interface Tag {
|
||||
name: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
function getTags() {
|
||||
return settings.store.tagsList;
|
||||
}
|
||||
|
||||
function getTag(name: string) {
|
||||
return settings.store.tagsList[name] ?? null;
|
||||
}
|
||||
|
||||
function addTag(tag: Tag) {
|
||||
settings.store.tagsList[tag.name] = tag;
|
||||
}
|
||||
|
||||
function removeTag(name: string) {
|
||||
delete settings.store.tagsList[name];
|
||||
}
|
||||
|
||||
function createTagCommand(tag: Tag) {
|
||||
registerCommand({
|
||||
name: tag.name,
|
||||
description: tag.name,
|
||||
inputType: ApplicationCommandInputType.BUILT_IN_TEXT,
|
||||
execute: async (_, ctx) => {
|
||||
if (!getTag(tag.name)) {
|
||||
sendBotMessage(ctx.channel.id, {
|
||||
content: `${EMOTE} The tag **${tag.name}** does not exist anymore! Please reload ur Discord to fix :)`
|
||||
});
|
||||
return { content: `/${tag.name}` };
|
||||
}
|
||||
|
||||
if (settings.store.clyde) sendBotMessage(ctx.channel.id, {
|
||||
content: `${EMOTE} The tag **${tag.name}** has been sent!`
|
||||
});
|
||||
return { content: tag.message.replaceAll("\\n", "\n") };
|
||||
},
|
||||
[MessageTagsMarker]: true,
|
||||
}, "CustomTags");
|
||||
}
|
||||
|
||||
const settings = definePluginSettings({
|
||||
clyde: {
|
||||
name: "Clyde message on send",
|
||||
description: "If enabled, clyde will send you an ephemeral message when a tag was used.",
|
||||
type: OptionType.BOOLEAN,
|
||||
default: true
|
||||
},
|
||||
tagsList: {
|
||||
type: OptionType.CUSTOM,
|
||||
default: {} as Record<string, Tag>,
|
||||
description: ""
|
||||
}
|
||||
});
|
||||
|
||||
export default definePlugin({
|
||||
name: "MessageTags",
|
||||
description: "Allows you to save messages and to use them with a simple command.",
|
||||
authors: [Devs.Luna],
|
||||
settings,
|
||||
|
||||
async start() {
|
||||
const tags = getTags();
|
||||
for (const tagName in tags) {
|
||||
createTagCommand(tags[tagName]);
|
||||
}
|
||||
},
|
||||
|
||||
commands: [
|
||||
{
|
||||
name: "tags",
|
||||
description: "Manage all the tags for yourself",
|
||||
inputType: ApplicationCommandInputType.BUILT_IN,
|
||||
options: [
|
||||
{
|
||||
name: "create",
|
||||
description: "Create a new tag",
|
||||
type: ApplicationCommandOptionType.SUB_COMMAND,
|
||||
options: [
|
||||
{
|
||||
name: "tag-name",
|
||||
description: "The name of the tag to trigger the response",
|
||||
type: ApplicationCommandOptionType.STRING,
|
||||
required: true
|
||||
},
|
||||
{
|
||||
name: "message",
|
||||
description: "The message that you will send when using this tag",
|
||||
type: ApplicationCommandOptionType.STRING,
|
||||
required: true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "list",
|
||||
description: "List all tags from yourself",
|
||||
type: ApplicationCommandOptionType.SUB_COMMAND,
|
||||
options: []
|
||||
},
|
||||
{
|
||||
name: "delete",
|
||||
description: "Remove a tag from your yourself",
|
||||
type: ApplicationCommandOptionType.SUB_COMMAND,
|
||||
options: [
|
||||
{
|
||||
name: "tag-name",
|
||||
description: "The name of the tag to trigger the response",
|
||||
type: ApplicationCommandOptionType.STRING,
|
||||
required: true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "preview",
|
||||
description: "Preview a tag without sending it publicly",
|
||||
type: ApplicationCommandOptionType.SUB_COMMAND,
|
||||
options: [
|
||||
{
|
||||
name: "tag-name",
|
||||
description: "The name of the tag to trigger the response",
|
||||
type: ApplicationCommandOptionType.STRING,
|
||||
required: true
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
async execute(args, ctx) {
|
||||
|
||||
switch (args[0].name) {
|
||||
case "create": {
|
||||
const name: string = findOption(args[0].options, "tag-name", "");
|
||||
const message: string = findOption(args[0].options, "message", "");
|
||||
|
||||
if (getTag(name))
|
||||
return sendBotMessage(ctx.channel.id, {
|
||||
content: `${EMOTE} A Tag with the name **${name}** already exists!`
|
||||
});
|
||||
|
||||
const tag = {
|
||||
name: name,
|
||||
message: message
|
||||
};
|
||||
|
||||
createTagCommand(tag);
|
||||
addTag(tag);
|
||||
|
||||
sendBotMessage(ctx.channel.id, {
|
||||
content: `${EMOTE} Successfully created the tag **${name}**!`
|
||||
});
|
||||
break; // end 'create'
|
||||
}
|
||||
case "delete": {
|
||||
const name: string = findOption(args[0].options, "tag-name", "");
|
||||
|
||||
if (!getTag(name))
|
||||
return sendBotMessage(ctx.channel.id, {
|
||||
content: `${EMOTE} A Tag with the name **${name}** does not exist!`
|
||||
});
|
||||
|
||||
unregisterCommand(name);
|
||||
removeTag(name);
|
||||
|
||||
sendBotMessage(ctx.channel.id, {
|
||||
content: `${EMOTE} Successfully deleted the tag **${name}**!`
|
||||
});
|
||||
break; // end 'delete'
|
||||
}
|
||||
case "list": {
|
||||
sendBotMessage(ctx.channel.id, {
|
||||
embeds: [
|
||||
{
|
||||
title: "All Tags:",
|
||||
description: Object.values(getTags())
|
||||
.map(tag => `\`${tag.name}\`: ${tag.message.slice(0, 72).replaceAll("\\n", " ")}${tag.message.length > 72 ? "..." : ""}`)
|
||||
.join("\n") || `${EMOTE} Woops! There are no tags yet, use \`/tags create\` to create one!`,
|
||||
// @ts-expect-error
|
||||
color: 0xd77f7f,
|
||||
type: "rich",
|
||||
}
|
||||
]
|
||||
});
|
||||
break; // end 'list'
|
||||
}
|
||||
case "preview": {
|
||||
const name: string = findOption(args[0].options, "tag-name", "");
|
||||
const tag = getTag(name);
|
||||
|
||||
if (!tag)
|
||||
return sendBotMessage(ctx.channel.id, {
|
||||
content: `${EMOTE} A Tag with the name **${name}** does not exist!`
|
||||
});
|
||||
|
||||
sendBotMessage(ctx.channel.id, {
|
||||
content: tag.message.replaceAll("\\n", "\n")
|
||||
});
|
||||
break; // end 'preview'
|
||||
}
|
||||
|
||||
default: {
|
||||
sendBotMessage(ctx.channel.id, {
|
||||
content: "Invalid sub-command"
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
|
@ -16,30 +16,44 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import "./styles.css";
|
||||
|
||||
import { definePluginSettings } from "@api/Settings";
|
||||
import { Button } from "@components/Button";
|
||||
import { Divider } from "@components/Divider";
|
||||
import { ExpandableSection } from "@components/ExpandableCard";
|
||||
import { Flex } from "@components/Flex";
|
||||
import { HeadingTertiary } from "@components/Heading";
|
||||
import { HeadingSecondary, HeadingTertiary } from "@components/Heading";
|
||||
import { DeleteIcon } from "@components/Icons";
|
||||
import { Paragraph } from "@components/Paragraph";
|
||||
import { Span } from "@components/Span";
|
||||
import { TooltipContainer } from "@components/TooltipContainer";
|
||||
import { Devs, EquicordDevs } from "@utils/constants";
|
||||
import { classNameFactory } from "@utils/index";
|
||||
import { Logger } from "@utils/Logger";
|
||||
import definePlugin, { OptionType } from "@utils/types";
|
||||
import { Message } from "@vencord/discord-types";
|
||||
import { Button, React, Select, TextInput, UserStore, useState } from "@webpack/common";
|
||||
import { React, Select, TextInput, UserStore, useState } from "@webpack/common";
|
||||
|
||||
type Rule = Record<"find" | "replace" | "onlyIfIncludes" | "scope", string>;
|
||||
const cl = classNameFactory("vc-textReplace-");
|
||||
|
||||
type Rule = Record<"find" | "replace" | "onlyIfIncludes" | "scope" | "id", string>;
|
||||
|
||||
interface TextReplaceProps {
|
||||
title: string;
|
||||
description: string;
|
||||
rulesArray: Rule[];
|
||||
isRegex?: boolean;
|
||||
}
|
||||
|
||||
const makeEmptyRule: () => Rule = () => ({
|
||||
find: "",
|
||||
replace: "",
|
||||
onlyIfIncludes: "",
|
||||
scope: "myMessages"
|
||||
scope: "myMessages",
|
||||
id: crypto.randomUUID()
|
||||
});
|
||||
|
||||
const makeEmptyRuleArray = () => [makeEmptyRule()];
|
||||
|
||||
const settings = definePluginSettings({
|
||||
|
|
@ -50,15 +64,18 @@ const settings = definePluginSettings({
|
|||
|
||||
return (
|
||||
<>
|
||||
<TextReplaceTesting />
|
||||
<TextReplace
|
||||
title="Using String"
|
||||
title="Simple Replacements"
|
||||
description="Simple find and replace rules. For example, find 'brb' and replace it with 'be right back'"
|
||||
rulesArray={stringRules}
|
||||
/>
|
||||
<TextReplace
|
||||
title="Using Regex"
|
||||
title="Regex Replacements"
|
||||
description="More powerful replacements using Regular Expressions. This section is for advanced users. If you don't understand it, just ignore it"
|
||||
rulesArray={regexRules}
|
||||
isRegex
|
||||
/>
|
||||
<TextReplaceTesting />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -114,26 +131,37 @@ function Input({ initialValue, onChange, placeholder }: {
|
|||
value={value}
|
||||
onChange={setValue}
|
||||
spellCheck={false}
|
||||
onBlur={() => value !== initialValue && onChange(value)}
|
||||
onBlur={() => value !== initialValue && setTimeout(() => onChange(value), 0)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TextReplace({ title, rulesArray }: TextReplaceProps) {
|
||||
const isRegexRules = title === "Using Regex";
|
||||
function TextRow({ label, description, value, onChange }: { label: string; description: string; value: string; onChange(value: string): void; }) {
|
||||
return (
|
||||
<>
|
||||
<TooltipContainer text={description}>
|
||||
<Span weight="medium" size="md">{label}</Span>
|
||||
</TooltipContainer>
|
||||
<Input
|
||||
placeholder={description}
|
||||
initialValue={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
async function onClickRemove(index: number) {
|
||||
if (index === rulesArray.length - 1) return;
|
||||
const isEmptyRule = (rule: Rule) => !rule.find;
|
||||
|
||||
function TextReplace({ title, description, rulesArray, isRegex = false }: TextReplaceProps) {
|
||||
function onClickRemove(index: number) {
|
||||
rulesArray.splice(index, 1);
|
||||
}
|
||||
|
||||
async function onChange(e: string, index: number, key: string) {
|
||||
if (index === rulesArray.length - 1) {
|
||||
rulesArray.push(makeEmptyRule());
|
||||
}
|
||||
|
||||
function onChange(e: string, index: number, key: string) {
|
||||
rulesArray[index][key] = e;
|
||||
|
||||
// If a rule is empty after editing and is not the last rule, remove it
|
||||
if (rulesArray[index].find === "" && rulesArray[index].replace === "" && rulesArray[index].onlyIfIncludes === "" && index !== rulesArray.length - 1) {
|
||||
rulesArray.splice(index, 1);
|
||||
}
|
||||
|
|
@ -147,50 +175,71 @@ function TextReplace({ title, rulesArray }: TextReplaceProps) {
|
|||
|
||||
return (
|
||||
<>
|
||||
<HeadingTertiary>{title}</HeadingTertiary>
|
||||
<div>
|
||||
<HeadingSecondary>{title}</HeadingSecondary>
|
||||
<Paragraph>{description}</Paragraph>
|
||||
</div>
|
||||
<Flex flexDirection="column" style={{ gap: "0.5em", paddingBottom: "1.25em" }}>
|
||||
{
|
||||
rulesArray.map((rule, index) =>
|
||||
<React.Fragment key={`${rule.find}-${index}`}>
|
||||
<Flex gap="0.5em" flexDirection="row" style={{ flexGrow: 1 }}>
|
||||
<Input
|
||||
placeholder="Find"
|
||||
initialValue={rule.find}
|
||||
onChange={e => onChange(e, index, "find")}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Replace"
|
||||
initialValue={rule.replace}
|
||||
onChange={e => onChange(e, index, "replace")}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Only if includes"
|
||||
initialValue={rule.onlyIfIncludes}
|
||||
onChange={e => onChange(e, index, "onlyIfIncludes")}
|
||||
/>
|
||||
</Flex>
|
||||
{(index !== rulesArray.length - 1) && <Flex flexDirection="row" style={{ gap: "0.5em" }}>
|
||||
<div style={{ flex: 0.9 }}>
|
||||
<Select
|
||||
options={scopeOptions}
|
||||
isSelected={e => e === rule.scope}
|
||||
select={e => onChange(e, index, "scope")}
|
||||
serialize={e => e}
|
||||
{rulesArray.map((rule, index) =>
|
||||
<ExpandableSection
|
||||
key={rule.id}
|
||||
renderContent={() => (
|
||||
<>
|
||||
<div className={cl("input-grid")}>
|
||||
<TextRow
|
||||
label="Find"
|
||||
description={isRegex ? "The regex pattern" : "The text to replace"}
|
||||
value={rule.find}
|
||||
onChange={e => onChange(e, index, "find")}
|
||||
/>
|
||||
<TextRow
|
||||
label="Replace"
|
||||
description="The text to replace the found text with"
|
||||
value={rule.replace}
|
||||
onChange={e => onChange(e, index, "replace")}
|
||||
/>
|
||||
<TextRow
|
||||
label="Only if includes"
|
||||
description="This rule will only be applied if the message includes this text. This is optional"
|
||||
value={rule.onlyIfIncludes}
|
||||
onChange={e => onChange(e, index, "onlyIfIncludes")}
|
||||
/>
|
||||
</div>
|
||||
<Flex flexDirection="row" style={{ gap: "0.5em" }}>
|
||||
<div style={{ flex: 0.9 }}>
|
||||
<Select
|
||||
options={scopeOptions}
|
||||
isSelected={e => e === rule.scope}
|
||||
select={e => onChange(e, index, "scope")}
|
||||
serialize={e => e}
|
||||
/>
|
||||
</div>
|
||||
</Flex>
|
||||
{isRegex && renderFindError(rule.find)}
|
||||
<Button
|
||||
size={Button.Sizes.MIN}
|
||||
className={cl("delete-button")}
|
||||
variant="dangerPrimary"
|
||||
onClick={() => onClickRemove(index)}
|
||||
style={{ flex: 0.10, backgroundColor: "var(--input-background)", border: "1px solid var(--input-border)", color: "var(--status-danger)" }}
|
||||
>
|
||||
<DeleteIcon style={{ verticalAlign: "middle" }} />
|
||||
Delete Rule
|
||||
</Button>
|
||||
</Flex>}
|
||||
{(index !== rulesArray.length - 1) && <Divider style={{ width: "unset", margin: "0.5em 0" }}></Divider>}
|
||||
{isRegexRules && renderFindError(rule.find)}
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<Paragraph weight="medium" size="md">
|
||||
{isEmptyRule(rule)
|
||||
? `Empty Rule ${index + 1}`
|
||||
: `Rule ${index + 1} - ${rule.find}`
|
||||
}
|
||||
</Paragraph>
|
||||
</ExpandableSection>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => rulesArray.push(makeEmptyRule())}
|
||||
disabled={rulesArray.length > 0 && isEmptyRule(rulesArray[rulesArray.length - 1])}
|
||||
>
|
||||
Add Rule
|
||||
</Button>
|
||||
</Flex>
|
||||
</>
|
||||
);
|
||||
|
|
@ -198,12 +247,15 @@ function TextReplace({ title, rulesArray }: TextReplaceProps) {
|
|||
|
||||
function TextReplaceTesting() {
|
||||
const [value, setValue] = useState("");
|
||||
|
||||
return (
|
||||
<>
|
||||
<HeadingTertiary>Test Rules</HeadingTertiary>
|
||||
<TextInput placeholder="Type a message" onChange={setValue} />
|
||||
<TextInput placeholder="Message with rules applied" editable={false} value={applyRules(value, "allMessages")} />
|
||||
</>
|
||||
<div>
|
||||
<HeadingSecondary>Rule Tester</HeadingSecondary>
|
||||
<Flex flexDirection="column" gap={6}>
|
||||
<TextInput placeholder="Type a message to test rules on" onChange={setValue} />
|
||||
<TextInput placeholder="Message with rules applied" editable={false} value={applyRules(value, "allMessages")} style={{ opacity: 0.7 }} />
|
||||
</Flex>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -274,8 +326,16 @@ export default definePlugin({
|
|||
|
||||
start() {
|
||||
const { stringRules, regexRules } = settings.store;
|
||||
stringRules.forEach(rule => { if (!rule.scope) rule.scope = "myMessages"; });
|
||||
regexRules.forEach(rule => { if (!rule.scope) rule.scope = "myMessages"; });
|
||||
|
||||
stringRules.forEach(rule => {
|
||||
rule.scope ??= "myMessages";
|
||||
rule.id ??= crypto.randomUUID();
|
||||
});
|
||||
|
||||
regexRules.forEach(rule => {
|
||||
rule.scope ??= "myMessages";
|
||||
rule.id ??= crypto.randomUUID();
|
||||
});
|
||||
},
|
||||
|
||||
onBeforeMessageSend(channelId, msg) {
|
||||
|
|
|
|||
11
src/plugins/textReplace/styles.css
Normal file
11
src/plugins/textReplace/styles.css
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
.vc-textReplace-input-grid {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
gap: 0.25em 1em;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.vc-textReplace-delete-button {
|
||||
width: 100%;
|
||||
margin-block: 0.75em 0.5em
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue