Equicord/src/plugins/textReplace/index.tsx

288 lines
11 KiB
TypeScript
Raw Normal View History

/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 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/>.
*/
2023-05-06 01:36:00 +02:00
import { definePluginSettings } from "@api/Settings";
import { Divider } from "@components/Divider";
import { Flex } from "@components/Flex";
2025-10-10 21:11:31 -04:00
import { HeadingTertiary } from "@components/Heading";
2023-09-26 20:47:12 +01:00
import { DeleteIcon } from "@components/Icons";
import { Devs, EquicordDevs } from "@utils/constants";
2023-05-06 01:36:00 +02:00
import { Logger } from "@utils/Logger";
feat(i18n): Add translation keys and implement localization for 80+ p… (#772) * feat(i18n): Add translation keys and implement localization for 80+ plugins - Add comprehensive translation keys to i18n/en-US/vencord.json - Update all plugins to use t() function from @utils/translation - Replace hardcoded strings with translation function calls - Support plural forms with _one/_other suffixes - Use Translate component for strings with embedded JSX Plugins translated include: - Core: reviewDB, translate, voiceMessages, messageLogger - UI: showHiddenChannels, customRPC, clientTheme, permissionsViewer - Utility: betterSessions, betterSettings, memberCount, betterFolders - Social: relationshipNotifier, messageLatency, whoReacted - Media: imageZoom, viewIcons, viewRaw, decor, fakeNitro - And 60+ more plugins All changes pass TypeScript compilation. * Update i18n/en-US/vencord.json Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * Update i18n/en-US/vencord.json Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * Update i18n/en-US/vencord.json Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * Update i18n/en-US/vencord.json Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * Update i18n/en-US/vencord.json Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * Update i18n/en-US/vencord.json Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * feat(i18n): Add equicord.json for equicordplugins localization - Create i18n/en-US/equicord.json with messageLoggerEnhanced translation keys - Create empty equicord.json files for all other locales (Crowdin will handle translations) - Build system automatically picks up all JSON files in i18n/{locale}/ * Update i18n/en-US/vencord.json Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * feat(i18n): Add translations to 200 equicordplugins - Translate all user-facing strings in equicordplugins to use t() function - Add translation keys to i18n/en-US/equicord.json - Plugin names remain static strings for build system compatibility - Settings descriptions, tooltips, context menus, and UI strings now translatable * FileUpload: add sharex file support (#771) * FileUpload: add sharex file support Quick note, the settings seem to split pacal case with spaces for some reason, so `ShareX` renders as `Share X`. This is stupid and im upset and I dont think theres anything I can do about it * FileUpload: use Headers object to construct HTTP headers * Update vencord.json --------- Co-authored-by: Mishal <91066601+mishalhossin@users.noreply.github.com> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: Key <keircn@proton.me> Co-authored-by: thororen1234 <78185467+thororen1234@users.noreply.github.com>
2026-02-15 20:39:58 +06:00
import { t } from "@utils/translation";
import definePlugin, { OptionType } from "@utils/types";
import { Message } from "@vencord/discord-types";
2025-10-10 21:11:31 -04:00
import { Button, React, Select, TextInput, UserStore, useState } from "@webpack/common";
type Rule = Record<"find" | "replace" | "onlyIfIncludes" | "scope", string>;
interface TextReplaceProps {
title: string;
rulesArray: Rule[];
}
const makeEmptyRule: () => Rule = () => ({
find: "",
replace: "",
onlyIfIncludes: "",
scope: "myMessages"
});
const makeEmptyRuleArray = () => [makeEmptyRule()];
const settings = definePluginSettings({
replace: {
type: OptionType.COMPONENT,
component: () => {
const { stringRules, regexRules } = settings.use(["stringRules", "regexRules"]);
return (
<>
<TextReplace
2026-03-03 12:14:28 -05:00
title={t("vencord.textReplace.usingString")}
rulesArray={stringRules}
/>
<TextReplace
2026-03-03 12:14:28 -05:00
title={t("vencord.textReplace.usingRegex")}
rulesArray={regexRules}
/>
<TextReplaceTesting />
</>
);
}
},
stringRules: {
type: OptionType.CUSTOM,
default: makeEmptyRuleArray(),
2026-03-06 20:59:33 -05:00
description: t("vencord.textReplace.stringRules")
},
regexRules: {
type: OptionType.CUSTOM,
default: makeEmptyRuleArray(),
2026-03-06 20:59:33 -05:00
description: t("vencord.textReplace.regexRules")
}
});
function stringToRegex(str: string) {
2024-06-28 16:04:51 +02:00
const match = str.match(/^(\/)?(.+?)(?:\/([gimsuyv]*))?$/); // Regex to match regex
return match
? new RegExp(
match[2], // Pattern
match[3]
?.split("") // Remove duplicate flags
.filter((char, pos, flagArr) => flagArr.indexOf(char) === pos)
.join("")
?? "g"
)
: new RegExp(str); // Not a regex, return string
}
function renderFindError(find: string) {
try {
stringToRegex(find);
return null;
} catch (e) {
return (
2025-12-06 17:49:06 -05:00
<span style={{ color: "var(--text-feedback-critical)" }}>
{String(e)}
</span>
);
}
}
function Input({ initialValue, onChange, placeholder }: {
placeholder: string;
initialValue: string;
onChange(value: string): void;
}) {
const [value, setValue] = useState(initialValue);
return (
<TextInput
placeholder={placeholder}
value={value}
onChange={setValue}
spellCheck={false}
onBlur={() => value !== initialValue && onChange(value)}
/>
);
}
function TextReplace({ title, rulesArray }: TextReplaceProps) {
2026-03-03 12:14:28 -05:00
const isRegexRules = title === t("vencord.textReplace.usingRegex");
async function onClickRemove(index: number) {
if (index === rulesArray.length - 1) return;
rulesArray.splice(index, 1);
}
async function onChange(e: string, index: number, key: string) {
if (index === rulesArray.length - 1) {
rulesArray.push(makeEmptyRule());
}
rulesArray[index][key] = e;
if (rulesArray[index].find === "" && rulesArray[index].replace === "" && rulesArray[index].onlyIfIncludes === "" && index !== rulesArray.length - 1) {
rulesArray.splice(index, 1);
}
}
const scopeOptions = [
2026-03-03 12:14:28 -05:00
{ label: t("vencord.textReplace.scope.myMessages"), value: "myMessages" },
{ label: t("vencord.textReplace.scope.othersMessages"), value: "othersMessages" },
{ label: t("vencord.textReplace.scope.allMessages"), value: "allMessages" }
];
return (
<>
2025-10-10 21:11:31 -04:00
<HeadingTertiary>{title}</HeadingTertiary>
<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
2026-03-03 12:14:28 -05:00
placeholder={t("vencord.textReplace.find")}
initialValue={rule.find}
onChange={e => onChange(e, index, "find")}
/>
<Input
2026-03-03 12:14:28 -05:00
placeholder={t("vencord.textReplace.replace")}
initialValue={rule.replace}
onChange={e => onChange(e, index, "replace")}
/>
<Input
2026-03-03 12:14:28 -05:00
placeholder={t("vencord.textReplace.onlyIfIncludes")}
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}
/>
</div>
<Button
size={Button.Sizes.MIN}
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" }} />
</Button>
</Flex>}
{(index !== rulesArray.length - 1) && <Divider style={{ width: "unset", margin: "0.5em 0" }}></Divider>}
{isRegexRules && renderFindError(rule.find)}
</React.Fragment>
)
}
</Flex>
</>
);
}
function TextReplaceTesting() {
const [value, setValue] = useState("");
return (
<>
2026-03-03 12:14:28 -05:00
<HeadingTertiary>{t("vencord.textReplace.testRules")}</HeadingTertiary>
<TextInput placeholder={t("vencord.textReplace.typeMessage")} onChange={setValue} />
<TextInput placeholder={t("vencord.textReplace.messageWithRules")} editable={false} value={applyRules(value, "allMessages")} />
</>
);
}
function applyRules(content: string, scope: "myMessages" | "othersMessages" | "allMessages"): string {
if (content.length === 0) {
return content;
}
for (const rule of settings.store.stringRules) {
if (!rule.find) continue;
if (rule.onlyIfIncludes && !content.includes(rule.onlyIfIncludes)) continue;
if (rule.scope !== "allMessages" && rule.scope !== scope && scope !== "allMessages") continue;
content = ` ${content} `.replaceAll(rule.find, rule.replace.replaceAll("\\n", "\n")).replace(/^\s|\s$/g, "");
}
for (const rule of settings.store.regexRules) {
if (!rule.find) continue;
if (rule.onlyIfIncludes && !content.includes(rule.onlyIfIncludes)) continue;
if (rule.scope !== "allMessages" && rule.scope !== scope && scope !== "allMessages") continue;
try {
const regex = stringToRegex(rule.find);
content = content.replace(regex, rule.replace.replaceAll("\\n", "\n"));
} catch (e) {
new Logger("TextReplace").error(`Invalid regex: ${rule.find}`);
}
}
content = content.trim();
return content;
}
function modifyIncomingMessage(message: Message) {
const currentUser = UserStore.getCurrentUser();
const messageAuthor = message.author;
if (!message.content || !currentUser?.id || !messageAuthor?.id || messageAuthor.id === currentUser.id) {
return message.content;
}
return applyRules(message.content, "othersMessages");
}
const TEXT_REPLACE_RULES_EXEMPT_CHANNEL_IDS = [
"1102784112584040479", // Vencord's Text Replace Rules Channel
"1419347113745059961", // Equicord's Requests Channel
];
export default definePlugin({
name: "TextReplace",
2026-03-06 20:59:33 -05:00
description: t("vencord.textReplace.description"),
authors: [Devs.AutumnVN, Devs.TheKodeToad, EquicordDevs.Etorix],
2025-10-13 01:20:46 -04:00
isModified: true,
settings,
modifyIncomingMessage,
patches: [
{
find: "!1,hideSimpleEmbedContent",
replacement: {
2026-02-05 01:46:29 -05:00
match: /(let{toAST:.{0,125}?)\(\i\?\?\i\).content/,
replace: "const textReplaceContent=$self.modifyIncomingMessage(arguments[2]?.contentMessage??arguments[1]);$1textReplaceContent"
}
},
],
start() {
const { stringRules, regexRules } = settings.store;
stringRules.forEach(rule => { if (!rule.scope) rule.scope = "myMessages"; });
regexRules.forEach(rule => { if (!rule.scope) rule.scope = "myMessages"; });
},
onBeforeMessageSend(channelId, msg) {
// Replacing text in channels used for sharing/requesting rules may be messy.
if (TEXT_REPLACE_RULES_EXEMPT_CHANNEL_IDS.includes(channelId)) return;
msg.content = applyRules(msg.content, "myMessages");
}
});