Plugin
TextReplace
Replace text in your messages. Share rules in the FiveCord community.
1
/*2
* FiveCord — a Discord client mod3
* Copyright (c) 2025 FiveCord4
* SPDX-License-Identifier: GPL-3.0-or-later5
*/6
7
import "./styles.css";8
9
import { definePluginSettings } from "@api/Settings";10
import { Button } from "@components/Button";11
import { ExpandableSection } from "@components/ExpandableCard";12
import { Flex } from "@components/Flex";13
import { HeadingSecondary } from "@components/Heading";14
import { Paragraph } from "@components/Paragraph";15
import { Span } from "@components/Span";16
import { TooltipContainer } from "@components/TooltipContainer";17
import { Devs } from "@utils/constants";18
import { classNameFactory } from "@utils/css";19
import { Logger } from "@utils/Logger";20
import definePlugin, { OptionType } from "@utils/types";21
import { React, TextInput, useState } from "@webpack/common";22
23
const cl = classNameFactory("vc-textReplace-");24
25
type Rule = Record<"find" | "replace" | "onlyIfIncludes" | "id", string>;26
27
interface TextReplaceProps {28
title: string;29
description: string;30
rulesArray: Rule[];31
isRegex?: boolean;32
}33
34
const makeEmptyRule: () => Rule = () => ({35
find: "",36
replace: "",37
onlyIfIncludes: "",38
id: crypto.randomUUID()39
});40
const makeEmptyRuleArray = () => [makeEmptyRule()];41
42
const 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
<TextReplace52
title="Simple Replacements"53
description="Simple find and replace rules. For example, find 039;brb039; and replace it with 039;be right back039;"54
rulesArray={stringRules}55
/>56
<TextReplace57
title="Regex Replacements"58
description="More powerful replacements using Regular Expressions. This section is for advanced users. If you don039;t understand it, just ignore it"59
rulesArray={regexRules}60
isRegex61
/>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
76
function stringToRegex(str: string) {77
const match = str.match(/^(\/)?(.+?)(?:\/([gimsuyv]*))?$/); class="ts-cmt">// Regex to match regex78
return match79
? new RegExp(80
match[2], class="ts-cmt">// Pattern81
match[3]82
?.split("") class="ts-cmt">// Remove duplicate flags83
.filter((char, pos, flagArr) => flagArr.indexOf(char) === pos)84
.join("")85
?? "g"86
)87
: new RegExp(str); class="ts-cmt">// Not a regex, return string88
}89
90
function 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
103
function 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
<TextInput111
placeholder={placeholder}112
value={value}113
onChange={setValue}114
spellCheck={false}115
onBlur={() => value !== initialValue && setTimeout(() => onChange(value), 0)}116
/>117
);118
}119
120
function 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
<Input127
placeholder={description}128
initialValue={value}129
onChange={onChange}130
/>131
</>132
);133
}134
135
const isEmptyRule = (rule: Rule) => !rule.find;136
137
function 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 it146
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
<ExpandableSection160
key={rule.id}161
renderContent={() => (162
<>163
<div className={cl("input-grid")}>164
<TextRow165
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
<TextRow171
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
<TextRow177
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
<Button185
className={cl("delete-button")}186
variant="dangerPrimary"187
onClick={() => onClickRemove(index)}188
>189
Delete Rule190
</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
<Button203
onClick={() => rulesArray.push(makeEmptyRule())}204
disabled={rulesArray.length > 0 && isEmptyRule(rulesArray[rulesArray.length - 1])}205
>206
Add Rule207
</Button>208
</Flex>209
</>210
);211
}212
213
function 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
227
function 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
255
const TEXT_REPLACE_RULES_CHANNEL_ID = "1102784112584040479";256
257
export 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 messy272
if (channelId === TEXT_REPLACE_RULES_CHANNEL_ID) return;273
msg.content = applyRules(msg.content);274
}275
});276