Plugin

TextReplace

Replace text in your messages. Share rules in the FiveCord community.

Chat Customisation Utility
index.tsx
Download

Source

src/plugins/textReplace/index.tsx
1/*
2 * FiveCord — a Discord client mod
3 * Copyright (c) 2025 FiveCord
4 * SPDX-License-Identifier: GPL-3.0-or-later
5 */
6
7import "./styles.css";
8
9import { definePluginSettings } from "@api/Settings";
10import { Button } from "@components/Button";
11import { ExpandableSection } from "@components/ExpandableCard";
12import { Flex } from "@components/Flex";
13import { HeadingSecondary } from "@components/Heading";
14import { Paragraph } from "@components/Paragraph";
15import { Span } from "@components/Span";
16import { TooltipContainer } from "@components/TooltipContainer";
17import { Devs } from "@utils/constants";
18import { classNameFactory } from "@utils/css";
19import { Logger } from "@utils/Logger";
20import definePlugin, { OptionType } from "@utils/types";
21import { React, TextInput, useState } from "@webpack/common";
22
23const cl = classNameFactory("vc-textReplace-");
24
25type Rule = Record<"find" | "replace" | "onlyIfIncludes" | "id", string>;
26
27interface TextReplaceProps {
28 title: string;
29 description: string;
30 rulesArray: Rule[];
31 isRegex?: boolean;
32}
33
34const makeEmptyRule: () => Rule = () => ({
35 find: "",
36 replace: "",
37 onlyIfIncludes: "",
38 id: crypto.randomUUID()
39});
40const makeEmptyRuleArray = () => [makeEmptyRule()];
41
42const settings = definePluginSettings({
43 replace: {
44 type: OptionType.COMPONENT,
45 component: () => {
46 const { stringRules, regexRules } = settings.use(["stringRules", "regexRules"]);
47
48 return (
49 <>
50 <TextReplaceTesting />
51 <TextReplace
52 title="Simple Replacements"
53 description="Simple find and replace rules. For example, find &#039;brb&#039; and replace it with &#039;be right back&#039;"
54 rulesArray={stringRules}
55 />
56 <TextReplace
57 title="Regex Replacements"
58 description="More powerful replacements using Regular Expressions. This section is for advanced users. If you don&#039;t understand it, just ignore it"
59 rulesArray={regexRules}
60 isRegex
61 />
62 </>
63 );
64 }
65 },
66 stringRules: {
67 type: OptionType.CUSTOM,
68 default: makeEmptyRuleArray(),
69 },
70 regexRules: {
71 type: OptionType.CUSTOM,
72 default: makeEmptyRuleArray(),
73 }
74});
75
76function stringToRegex(str: string) {
77 const match = str.match(/^(\/)?(.+?)(?:\/([gimsuyv]*))?$/); class="ts-cmt">// Regex to match regex
78 return match
79 ? new RegExp(
80 match[2], class="ts-cmt">// Pattern
81 match[3]
82 ?.split("") class="ts-cmt">// Remove duplicate flags
83 .filter((char, pos, flagArr) => flagArr.indexOf(char) === pos)
84 .join("")
85 ?? "g"
86 )
87 : new RegExp(str); class="ts-cmt">// Not a regex, return string
88}
89
90function renderFindError(find: string) {
91 try {
92 stringToRegex(find);
93 return null;
94 } catch (e) {
95 return (
96 <span style={{ color: "var(--text-feedback-critical)" }}>
97 {String(e)}
98 </span>
99 );
100 }
101}
102
103function Input({ initialValue, onChange, placeholder }: {
104 placeholder: string;
105 initialValue: string;
106 onChange(value: string): void;
107}) {
108 const [value, setValue] = useState(initialValue);
109 return (
110 <TextInput
111 placeholder={placeholder}
112 value={value}
113 onChange={setValue}
114 spellCheck={false}
115 onBlur={() => value !== initialValue && setTimeout(() => onChange(value), 0)}
116 />
117 );
118}
119
120function TextRow({ label, description, value, onChange }: { label: string; description: string; value: string; onChange(value: string): void; }) {
121 return (
122 <>
123 <TooltipContainer text={description}>
124 <Span weight="medium" size="md">{label}</Span>
125 </TooltipContainer>
126 <Input
127 placeholder={description}
128 initialValue={value}
129 onChange={onChange}
130 />
131 </>
132 );
133}
134
135const isEmptyRule = (rule: Rule) => !rule.find;
136
137function TextReplace({ title, description, rulesArray, isRegex = false }: TextReplaceProps) {
138 function onClickRemove(index: number) {
139 rulesArray.splice(index, 1);
140 }
141
142 function onChange(e: string, index: number, key: string) {
143 rulesArray[index][key] = e;
144
145 // If a rule is empty after editing and is not the last rule, remove it
146 if (rulesArray[index].find === "" && rulesArray[index].replace === "" && rulesArray[index].onlyIfIncludes === "" && index !== rulesArray.length - 1) {
147 rulesArray.splice(index, 1);
148 }
149 }
150
151 return (
152 <>
153 <div>
154 <HeadingSecondary>{title}</HeadingSecondary>
155 <Paragraph>{description}</Paragraph>
156 </div>
157 <Flex flexDirection="column" style={{ gap: "0.5em" }}>
158 {rulesArray.map((rule, index) =>
159 <ExpandableSection
160 key={rule.id}
161 renderContent={() => (
162 <>
163 <div className={cl("input-grid")}>
164 <TextRow
165 label="Find"
166 description={isRegex ? "The regex pattern" : "The text to replace"}
167 value={rule.find}
168 onChange={e => onChange(e, index, "find")}
169 />
170 <TextRow
171 label="Replace"
172 description="The text to replace the found text with"
173 value={rule.replace}
174 onChange={e => onChange(e, index, "replace")}
175 />
176 <TextRow
177 label="Only if includes"
178 description="This rule will only be applied if the message includes this text. This is optional"
179 value={rule.onlyIfIncludes}
180 onChange={e => onChange(e, index, "onlyIfIncludes")}
181 />
182 </div>
183 {isRegex && renderFindError(rule.find)}
184 <Button
185 className={cl("delete-button")}
186 variant="dangerPrimary"
187 onClick={() => onClickRemove(index)}
188 >
189 Delete Rule
190 </Button>
191 </>
192 )}
193 >
194 <Paragraph weight="medium" size="md">
195 {isEmptyRule(rule)
196 ? `Empty Rule ${index + 1}`
197 : `Rule ${index + 1} - ${rule.find}`
198 }
199 </Paragraph>
200 </ExpandableSection>
201 )}
202 <Button
203 onClick={() => rulesArray.push(makeEmptyRule())}
204 disabled={rulesArray.length > 0 && isEmptyRule(rulesArray[rulesArray.length - 1])}
205 >
206 Add Rule
207 </Button>
208 </Flex>
209 </>
210 );
211}
212
213function TextReplaceTesting() {
214 const [value, setValue] = useState("");
215
216 return (
217 <div>
218 <HeadingSecondary>Rule Tester</HeadingSecondary>
219 <Flex flexDirection="column" gap={6}>
220 <TextInput placeholder="Type a message to test rules on" onChange={setValue} />
221 <TextInput placeholder="Message with rules applied" editable={false} value={applyRules(value)} style={{ opacity: 0.7 }} />
222 </Flex>
223 </div>
224 );
225}
226
227function applyRules(content: string): string {
228 if (content.length === 0) {
229 return content;
230 }
231
232 for (const rule of settings.store.stringRules) {
233 if (!rule.find) continue;
234 if (rule.onlyIfIncludes && !content.includes(rule.onlyIfIncludes)) continue;
235
236 content = ` ${content} `.replaceAll(rule.find, rule.replace.replaceAll("\\n", "\n")).replace(/^\s|\s$/g, "");
237 }
238
239 for (const rule of settings.store.regexRules) {
240 if (!rule.find) continue;
241 if (rule.onlyIfIncludes && !content.includes(rule.onlyIfIncludes)) continue;
242
243 try {
244 const regex = stringToRegex(rule.find);
245 content = content.replace(regex, rule.replace.replaceAll("\\n", "\n"));
246 } catch (e) {
247 new Logger("TextReplace").error(`Invalid regex: ${rule.find}`);
248 }
249 }
250
251 content = content.trim();
252 return content;
253}
254
255const TEXT_REPLACE_RULES_CHANNEL_ID = "1102784112584040479";
256
257export default definePlugin({
258 name: "TextReplace",
259 description: "Replace text in your messages. Share rules in the FiveCord community.",
260 tags: ["Chat", "Customisation", "Utility"],
261 authors: [Devs.AutumnVN, Devs.TheKodeToad],
262
263 settings,
264
265 start() {
266 settings.store.regexRules.forEach(rule => rule.id ??= crypto.randomUUID());
267 settings.store.stringRules.forEach(rule => rule.id ??= crypto.randomUUID());
268 },
269
270 onBeforeMessageSend(channelId, msg) {
271 // Channel used for sharing rules, applying rules here would be messy
272 if (channelId === TEXT_REPLACE_RULES_CHANNEL_ID) return;
273 msg.content = applyRules(msg.content);
274 }
275});
276