From 8588ab70d2d2c8541a1c63230ea51c33c2905dc8 Mon Sep 17 00:00:00 2001 From: Vendicated Date: Wed, 11 Mar 2026 03:03:46 +0100 Subject: [PATCH 01/12] Improve TextReplace settings UX --- src/components/ExpandableCard.css | 23 +++++ src/components/ExpandableCard.tsx | 45 +++++++++ src/components/Icons.tsx | 32 +++++- src/plugins/textReplace/index.tsx | 156 ++++++++++++++++++----------- src/plugins/textReplace/styles.css | 11 ++ 5 files changed, 206 insertions(+), 61 deletions(-) create mode 100644 src/components/ExpandableCard.css create mode 100644 src/components/ExpandableCard.tsx create mode 100644 src/plugins/textReplace/styles.css diff --git a/src/components/ExpandableCard.css b/src/components/ExpandableCard.css new file mode 100644 index 000000000..54ad0f712 --- /dev/null +++ b/src/components/ExpandableCard.css @@ -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; +} \ No newline at end of file diff --git a/src/components/ExpandableCard.tsx b/src/components/ExpandableCard.tsx new file mode 100644 index 000000000..31ee11a24 --- /dev/null +++ b/src/components/ExpandableCard.tsx @@ -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 ( + + setExpanded(c => !c)} > + {children} + + + + {expanded + ?
+ +
+ : null + } +
+ ); +} diff --git a/src/components/Icons.tsx b/src/components/Icons.tsx index 6e39f3f49..eb36ede95 100644 --- a/src/components/Icons.tsx +++ b/src/components/Icons.tsx @@ -571,7 +571,7 @@ export function CloudUploadIcon(props: IconProps) { ); } -export const ClockIcon = (props?: any) => { +export function ClockIcon(props: IconProps) { return ( { /> ); -}; +} + +export function DownArrow(props: IconProps) { + return ( + + + + ); +} + +export function RightArrow(props: IconProps) { + return ( + + + + ); +} diff --git a/src/plugins/textReplace/index.tsx b/src/plugins/textReplace/index.tsx index 8122ab395..8e70d85c8 100644 --- a/src/plugins/textReplace/index.tsx +++ b/src/plugins/textReplace/index.tsx @@ -16,22 +16,31 @@ * along with this program. If not, see . */ +import "./styles.css"; + import { definePluginSettings } from "@api/Settings"; +import { Button } from "@components/Button"; +import { ExpandableSection } from "@components/ExpandableCard"; import { Flex } from "@components/Flex"; -import { DeleteIcon } from "@components/Icons"; +import { HeadingSecondary } from "@components/Heading"; +import { Paragraph } from "@components/Paragraph"; +import { Span } from "@components/Span"; +import { TooltipContainer } from "@components/TooltipContainer"; import { Devs } from "@utils/constants"; +import { classNameFactory } from "@utils/css"; import { Logger } from "@utils/Logger"; import definePlugin, { OptionType } from "@utils/types"; -import { Button, Forms, React, TextInput, useState } from "@webpack/common"; +import { React, TextInput, useState } from "@webpack/common"; -const STRING_RULES_KEY = "TextReplace_rulesString"; -const REGEX_RULES_KEY = "TextReplace_rulesRegex"; +const cl = classNameFactory("vc-textReplace-"); type Rule = Record<"find" | "replace" | "onlyIfIncludes", string>; interface TextReplaceProps { title: string; + description: string; rulesArray: Rule[]; + isRegex?: boolean; } const makeEmptyRule: () => Rule = () => ({ @@ -49,15 +58,18 @@ const settings = definePluginSettings({ return ( <> + - ); } @@ -116,21 +128,32 @@ function Input({ initialValue, onChange, placeholder }: { ); } -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 ( + <> + + {label} + + + + ); +} - 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); } @@ -138,49 +161,61 @@ function TextReplace({ title, rulesArray }: TextReplaceProps) { return ( <> - {title} +
+ {title} + {description} +
- { - rulesArray.map((rule, index) => - - - onChange(e, index, "find")} - /> - onChange(e, index, "replace")} - /> - onChange(e, index, "onlyIfIncludes")} - /> + {rulesArray.map((rule, index) => + ( + <> +
+ onChange(e, index, "find")} + /> + onChange(e, index, "replace")} + /> + onChange(e, index, "onlyIfIncludes")} + /> +
+ {isRegex && renderFindError(rule.find)} -
- {isRegexRules && renderFindError(rule.find)} -
- ) - } + + )} + > + + {isEmptyRule(rule) + ? `Empty Rule ${index + 1}` + : `Rule ${index + 1} - ${rule.find}` + } + + + )} +
); @@ -188,12 +223,15 @@ function TextReplace({ title, rulesArray }: TextReplaceProps) { function TextReplaceTesting() { const [value, setValue] = useState(""); + return ( - <> - Test Rules - - - +
+ Rule Tester + + + + +
); } diff --git a/src/plugins/textReplace/styles.css b/src/plugins/textReplace/styles.css new file mode 100644 index 000000000..51a65fe1a --- /dev/null +++ b/src/plugins/textReplace/styles.css @@ -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 +} \ No newline at end of file From f766ea6b4959d5a0bc4515fac5d3613d5bf35d48 Mon Sep 17 00:00:00 2001 From: Vendicated Date: Wed, 11 Mar 2026 03:41:28 +0100 Subject: [PATCH 02/12] TextReplace: fix rule section closing when changing find --- src/plugins/textReplace/index.tsx | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/plugins/textReplace/index.tsx b/src/plugins/textReplace/index.tsx index 8e70d85c8..81cea86e0 100644 --- a/src/plugins/textReplace/index.tsx +++ b/src/plugins/textReplace/index.tsx @@ -34,7 +34,7 @@ import { React, TextInput, useState } from "@webpack/common"; const cl = classNameFactory("vc-textReplace-"); -type Rule = Record<"find" | "replace" | "onlyIfIncludes", string>; +type Rule = Record<"find" | "replace" | "onlyIfIncludes" | "id", string>; interface TextReplaceProps { title: string; @@ -46,7 +46,8 @@ interface TextReplaceProps { const makeEmptyRule: () => Rule = () => ({ find: "", replace: "", - onlyIfIncludes: "" + onlyIfIncludes: "", + id: crypto.randomUUID() }); const makeEmptyRuleArray = () => [makeEmptyRule()]; @@ -123,7 +124,7 @@ function Input({ initialValue, onChange, placeholder }: { value={value} onChange={setValue} spellCheck={false} - onBlur={() => value !== initialValue && onChange(value)} + onBlur={() => value !== initialValue && setTimeout(() => onChange(value), 0)} /> ); } @@ -168,7 +169,7 @@ function TextReplace({ title, description, rulesArray, isRegex = false }: TextRe {rulesArray.map((rule, index) => ( <>
@@ -272,6 +273,11 @@ export default definePlugin({ settings, + start() { + settings.store.regexRules.forEach(rule => rule.id ??= crypto.randomUUID()); + settings.store.stringRules.forEach(rule => rule.id ??= crypto.randomUUID()); + }, + onBeforeMessageSend(channelId, msg) { // Channel used for sharing rules, applying rules here would be messy if (channelId === TEXT_REPLACE_RULES_CHANNEL_ID) return; From 143bb60051b02e50c8536eeb2fc111f573f57dd3 Mon Sep 17 00:00:00 2001 From: Vendicated Date: Wed, 11 Mar 2026 05:27:41 +0100 Subject: [PATCH 03/12] MessageTags: add argument support & considerably improve UX --- src/plugins/messageTags/CreateTagModal.tsx | 134 +++++++++++++ src/plugins/messageTags/SettingsTagList.tsx | 41 ++++ src/plugins/messageTags/index.ts | 201 +++++++------------- src/plugins/messageTags/settings.ts | 42 ++++ src/plugins/messageTags/styles.css | 8 + 5 files changed, 293 insertions(+), 133 deletions(-) create mode 100644 src/plugins/messageTags/CreateTagModal.tsx create mode 100644 src/plugins/messageTags/SettingsTagList.tsx create mode 100644 src/plugins/messageTags/settings.ts create mode 100644 src/plugins/messageTags/styles.css diff --git a/src/plugins/messageTags/CreateTagModal.tsx b/src/plugins/messageTags/CreateTagModal.tsx new file mode 100644 index 000000000..c12ec8f56 --- /dev/null +++ b/src/plugins/messageTags/CreateTagModal.tsx @@ -0,0 +1,134 @@ +/* + * 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, registerTagCommand } from "."; +import { addTag, getTag, Tag } from "./settings"; + +export function openCreateTagModal(initialValue: Tag = { name: "", message: "" }) { + openModal(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 ( + + + Create Tag + + + + + + Create a new tag which will be registered as a slash command. + +
+ Name + +
+ +
+ Response +