diff --git a/src/plugins/translate/TranslateIcon.tsx b/src/plugins/translate/TranslateIcon.tsx
index b22c488eb..fa1d9abf6 100644
--- a/src/plugins/translate/TranslateIcon.tsx
+++ b/src/plugins/translate/TranslateIcon.tsx
@@ -17,10 +17,9 @@
*/
import { ChatBarButton } from "@api/ChatButtons";
-import { Margins } from "@utils/margins";
import { classes } from "@utils/misc";
import { openModal } from "@utils/modal";
-import { Alerts, Forms } from "@webpack/common";
+import { Alerts, Forms, Tooltip, useEffect, useState } from "@webpack/common";
import { settings } from "./settings";
import { TranslateModal } from "./TranslateModal";
@@ -39,9 +38,17 @@ export function TranslateIcon({ height = 24, width = 24, className }: { height?:
);
}
+export let setShouldShowTranslateEnabledTooltip: undefined | ((show: boolean) => void);
+
export const TranslateChatBarIcon: ChatBarButton = ({ isMainChat }) => {
const { autoTranslate, showChatBarButton } = settings.use(["autoTranslate", "showChatBarButton"]);
+ const [shouldShowTranslateEnabledTooltip, setter] = useState(false);
+ useEffect(() => {
+ setShouldShowTranslateEnabledTooltip = setter;
+ return () => setShouldShowTranslateEnabledTooltip = undefined;
+ }, []);
+
if (!isMainChat || !showChatBarButton) return null;
const toggle = () => {
@@ -52,21 +59,20 @@ export const TranslateChatBarIcon: ChatBarButton = ({ isMainChat }) => {
title: "Vencord Auto-Translate Enabled",
body: <>
- You just enabled auto translate (by right clicking the Translate icon). Any message you send will automatically be translated before being sent.
-
-
- If this was an accident, disable it again, or it will change your message content before sending.
+ You just enabled Auto Translate! Any message will automatically be translated before being sent.
>,
- cancelText: "Disable Auto-Translate",
- confirmText: "Got it",
+ confirmText: "Disable Auto-Translate",
+ cancelText: "Got it",
secondaryConfirmText: "Don't show again",
onConfirmSecondary: () => settings.store.showAutoTranslateAlert = false,
- onCancel: () => settings.store.autoTranslate = false
+ onConfirm: () => settings.store.autoTranslate = false,
+ // troll
+ confirmColor: "vc-notification-log-danger-btn",
});
};
- return (
+ const button = (
{
@@ -76,7 +82,7 @@ export const TranslateChatBarIcon: ChatBarButton = ({ isMainChat }) => {
));
}}
- onContextMenu={() => toggle()}
+ onContextMenu={toggle}
buttonProps={{
"aria-haspopup": "dialog"
}}
@@ -84,4 +90,13 @@ export const TranslateChatBarIcon: ChatBarButton = ({ isMainChat }) => {
);
+
+ if (shouldShowTranslateEnabledTooltip && settings.store.showAutoTranslateTooltip)
+ return (
+
+ {() => button}
+
+ );
+
+ return button;
};
diff --git a/src/plugins/translate/TranslateModal.tsx b/src/plugins/translate/TranslateModal.tsx
index 7628a31e0..7a32d1b75 100644
--- a/src/plugins/translate/TranslateModal.tsx
+++ b/src/plugins/translate/TranslateModal.tsx
@@ -20,9 +20,8 @@ import { Margins } from "@utils/margins";
import { ModalCloseButton, ModalContent, ModalHeader, ModalProps, ModalRoot } from "@utils/modal";
import { Forms, SearchableSelect, Switch, useMemo } from "@webpack/common";
-import { Languages } from "./languages";
import { settings } from "./settings";
-import { cl } from "./utils";
+import { cl, getLanguages } from "./utils";
const LanguageSettingKeys = ["receivedInput", "receivedOutput", "sentInput", "sentOutput"] as const;
@@ -31,7 +30,7 @@ function LanguageSelect({ settingsKey, includeAuto }: { settingsKey: typeof Lang
const options = useMemo(
() => {
- const options = Object.entries(Languages).map(([value, label]) => ({ value, label }));
+ const options = Object.entries(getLanguages()).map(([value, label]) => ({ value, label }));
if (!includeAuto)
options.shift();
diff --git a/src/plugins/translate/TranslationAccessory.tsx b/src/plugins/translate/TranslationAccessory.tsx
index 72b35940d..8e8f4c174 100644
--- a/src/plugins/translate/TranslationAccessory.tsx
+++ b/src/plugins/translate/TranslationAccessory.tsx
@@ -19,7 +19,6 @@
import { Parser, useEffect, useState } from "@webpack/common";
import { Message } from "discord-types/general";
-import { Languages } from "./languages";
import { TranslateIcon } from "./TranslateIcon";
import { cl, TranslationValue } from "./utils";
@@ -59,7 +58,7 @@ export function TranslationAccessory({ message }: { message: Message; }) {
{Parser.parse(translation.text)}
{" "}
- (translated from {Languages[translation.src] ?? translation.src} - setTranslation(undefined)} />)
+ (translated from {translation.sourceLanguage} - setTranslation(undefined)} />)
);
}
diff --git a/src/plugins/translate/index.tsx b/src/plugins/translate/index.tsx
index f602d1255..de61cef9c 100644
--- a/src/plugins/translate/index.tsx
+++ b/src/plugins/translate/index.tsx
@@ -28,7 +28,7 @@ import definePlugin from "@utils/types";
import { ChannelStore, Menu } from "@webpack/common";
import { settings } from "./settings";
-import { TranslateChatBarIcon, TranslateIcon } from "./TranslateIcon";
+import { setShouldShowTranslateEnabledTooltip, TranslateChatBarIcon, TranslateIcon } from "./TranslateIcon";
import { handleTranslate, TranslationAccessory } from "./TranslationAccessory";
import { translate } from "./utils";
@@ -53,8 +53,8 @@ const messageCtxPatch: NavContextMenuPatchCallback = (children, { message }) =>
export default definePlugin({
name: "Translate",
- description: "Translate messages with Google Translate",
- authors: [Devs.Ven],
+ description: "Translate messages with Google Translate or DeepL",
+ authors: [Devs.Ven, Devs.AshtonMemer],
dependencies: ["MessageAccessoriesAPI", "MessagePopoverAPI", "MessageEventsAPI", "ChatInputButtonAPI"],
settings,
contextMenus: {
@@ -83,11 +83,18 @@ export default definePlugin({
};
});
+ let tooltipTimeout: any;
this.preSend = addPreSendListener(async (_, message) => {
if (!settings.store.autoTranslate) return;
if (!message.content) return;
- message.content = (await translate("sent", message.content)).text;
+ setShouldShowTranslateEnabledTooltip?.(true);
+ clearTimeout(tooltipTimeout);
+ tooltipTimeout = setTimeout(() => setShouldShowTranslateEnabledTooltip?.(false), 2000);
+
+ const trans = await translate("sent", message.content);
+ message.content = trans.text;
+
});
},
diff --git a/src/plugins/translate/languages.ts b/src/plugins/translate/languages.ts
index 4bf370b5c..3e2e7c711 100644
--- a/src/plugins/translate/languages.ts
+++ b/src/plugins/translate/languages.ts
@@ -31,9 +31,10 @@ copy(Object.fromEntries(
))
*/
-export type Language = keyof typeof Languages;
+export type GoogleLanguage = keyof typeof GoogleLanguages;
+export type DeeplLanguage = keyof typeof DeeplLanguages;
-export const Languages = {
+export const GoogleLanguages = {
"auto": "Detect language",
"af": "Afrikaans",
"sq": "Albanian",
@@ -169,3 +170,57 @@ export const Languages = {
"yo": "Yoruba",
"zu": "Zulu"
} as const;
+
+export const DeeplLanguages = {
+ "": "Detect language",
+ "ar": "Arabic",
+ "bg": "Bulgarian",
+ "zh-hans": "Chinese (Simplified)",
+ "zh-hant": "Chinese (Traditional)",
+ "cs": "Czech",
+ "da": "Danish",
+ "nl": "Dutch",
+ "en-us": "English (American)",
+ "en-gb": "English (British)",
+ "et": "Estonian",
+ "fi": "Finnish",
+ "fr": "French",
+ "de": "German",
+ "el": "Greek",
+ "hu": "Hungarian",
+ "id": "Indonesian",
+ "it": "Italian",
+ "ja": "Japanese",
+ "ko": "Korean",
+ "lv": "Latvian",
+ "lt": "Lithuanian",
+ "nb": "Norwegian",
+ "pl": "Polish",
+ "pt-br": "Portuguese (Brazilian)",
+ "pt-pt": "Portuguese (European)",
+ "ro": "Romanian",
+ "ru": "Russian",
+ "sk": "Slovak",
+ "sl": "Slovenian",
+ "es": "Spanish",
+ "sv": "Swedish",
+ "tr": "Turkish",
+ "uk": "Ukrainian"
+} as const;
+
+export function deeplLanguageToGoogleLanguage(language: string) {
+ switch (language) {
+ case "": return "auto";
+ case "nb": return "no";
+ case "zh-hans": return "zh-CN";
+ case "zh-hant": return "zh-TW";
+ case "en-us":
+ case "en-gb":
+ return "en";
+ case "pt-br":
+ case "pt-pt":
+ return "pt";
+ default:
+ return language;
+ }
+}
diff --git a/src/plugins/translate/native.ts b/src/plugins/translate/native.ts
new file mode 100644
index 000000000..3415e95e9
--- /dev/null
+++ b/src/plugins/translate/native.ts
@@ -0,0 +1,29 @@
+/*
+ * Vencord, a Discord client mod
+ * Copyright (c) 2024 Vendicated and contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+import { IpcMainInvokeEvent } from "electron";
+
+export async function makeDeeplTranslateRequest(_: IpcMainInvokeEvent, pro: boolean, apiKey: string, payload: string) {
+ const url = pro
+ ? "https://api.deepl.com/v2/translate"
+ : "https://api-free.deepl.com/v2/translate";
+
+ try {
+ const res = await fetch(url, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "Authorization": `DeepL-Auth-Key ${apiKey}`
+ },
+ body: payload
+ });
+
+ const data = await res.text();
+ return { status: res.status, data };
+ } catch (e) {
+ return { status: -1, data: String(e) };
+ }
+}
diff --git a/src/plugins/translate/settings.ts b/src/plugins/translate/settings.ts
index 65d845353..916c70bd2 100644
--- a/src/plugins/translate/settings.ts
+++ b/src/plugins/translate/settings.ts
@@ -22,38 +22,76 @@ import { OptionType } from "@utils/types";
export const settings = definePluginSettings({
receivedInput: {
type: OptionType.STRING,
- description: "Input language for received messages",
+ description: "Language that received messages should be translated from",
default: "auto",
hidden: true
},
receivedOutput: {
type: OptionType.STRING,
- description: "Output language for received messages",
+ description: "Language that received messages should be translated to",
default: "en",
hidden: true
},
sentInput: {
type: OptionType.STRING,
- description: "Input language for sent messages",
+ description: "Language that your own messages should be translated from",
default: "auto",
hidden: true
},
sentOutput: {
type: OptionType.STRING,
- description: "Output language for sent messages",
+ description: "Language that your own messages should be translated to",
default: "en",
hidden: true
},
+
+ showChatBarButton: {
+ type: OptionType.BOOLEAN,
+ description: "Show translate button in chat bar",
+ default: true
+ },
+ service: {
+ type: OptionType.SELECT,
+ description: IS_WEB ? "Translation service (Not supported on Web!)" : "Translation service",
+ disabled: () => IS_WEB,
+ options: [
+ { label: "Google Translate", value: "google", default: true },
+ { label: "DeepL Free", value: "deepl" },
+ { label: "DeepL Pro", value: "deepl-pro" }
+ ] as const,
+ onChange: resetLanguageDefaults
+ },
+ deeplApiKey: {
+ type: OptionType.STRING,
+ description: "DeepL API key",
+ default: "",
+ placeholder: "Get your API key from https://deepl.com/your-account",
+ disabled: () => IS_WEB
+ },
autoTranslate: {
type: OptionType.BOOLEAN,
description: "Automatically translate your messages before sending. You can also shift/right click the translate button to toggle this",
default: false
},
- showChatBarButton: {
+ showAutoTranslateTooltip: {
type: OptionType.BOOLEAN,
- description: "Show translate button in chat bar",
+ description: "Show a tooltip on the ChatBar button whenever a message is automatically translated",
default: true
- }
+ },
}).withPrivateSettings<{
showAutoTranslateAlert: boolean;
}>();
+
+export function resetLanguageDefaults() {
+ if (IS_WEB || settings.store.service === "google") {
+ settings.store.receivedInput = "auto";
+ settings.store.receivedOutput = "en";
+ settings.store.sentInput = "auto";
+ settings.store.sentOutput = "en";
+ } else {
+ settings.store.receivedInput = "";
+ settings.store.receivedOutput = "en-us";
+ settings.store.sentInput = "";
+ settings.store.sentOutput = "en-us";
+ }
+}
diff --git a/src/plugins/translate/utils.ts b/src/plugins/translate/utils.ts
index 493fb2ca2..aff64e8a6 100644
--- a/src/plugins/translate/utils.ts
+++ b/src/plugins/translate/utils.ts
@@ -17,12 +17,18 @@
*/
import { classNameFactory } from "@api/Styles";
+import { onlyOnce } from "@utils/onlyOnce";
+import { PluginNative } from "@utils/types";
+import { showToast, Toasts } from "@webpack/common";
-import { settings } from "./settings";
+import { DeeplLanguages, deeplLanguageToGoogleLanguage, GoogleLanguages } from "./languages";
+import { resetLanguageDefaults, settings } from "./settings";
export const cl = classNameFactory("vc-trans-");
-interface TranslationData {
+const Native = VencordNative.pluginHelpers.Translate as PluginNative;
+
+interface GoogleData {
src: string;
sentences: {
// 🏳️⚧️
@@ -30,15 +36,47 @@ interface TranslationData {
}[];
}
+interface DeeplData {
+ translations: {
+ detected_source_language: string;
+ text: string;
+ }[];
+}
+
export interface TranslationValue {
- src: string;
+ sourceLanguage: string;
text: string;
}
-export async function translate(kind: "received" | "sent", text: string): Promise {
- const sourceLang = settings.store[kind + "Input"];
- const targetLang = settings.store[kind + "Output"];
+export const getLanguages = () => IS_WEB || settings.store.service === "google"
+ ? GoogleLanguages
+ : DeeplLanguages;
+export async function translate(kind: "received" | "sent", text: string): Promise {
+ const translate = IS_WEB || settings.store.service === "google"
+ ? googleTranslate
+ : deeplTranslate;
+
+ try {
+ return await translate(
+ text,
+ settings.store[`${kind}Input`],
+ settings.store[`${kind}Output`]
+ );
+ } catch (e) {
+ const userMessage = typeof e === "string"
+ ? e
+ : "Something went wrong. If this issue persists, please check the console or ask for help in the support server.";
+
+ showToast(userMessage, Toasts.Type.FAILURE);
+
+ throw e instanceof Error
+ ? e
+ : new Error(userMessage);
+ }
+}
+
+async function googleTranslate(text: string, sourceLang: string, targetLang: string): Promise {
const url = "https://translate.googleapis.com/translate_a/single?" + new URLSearchParams({
// see https://stackoverflow.com/a/29537590 for more params
// holy shidd nvidia
@@ -63,13 +101,69 @@ export async function translate(kind: "received" | "sent", text: string): Promis
+ `\n${res.status} ${res.statusText}`
);
- const { src, sentences }: TranslationData = await res.json();
+ const { src, sentences }: GoogleData = await res.json();
return {
- src,
+ sourceLanguage: GoogleLanguages[src] ?? src,
text: sentences.
map(s => s?.trans).
filter(Boolean).
join("")
};
}
+
+function fallbackToGoogle(text: string, sourceLang: string, targetLang: string): Promise {
+ return googleTranslate(
+ text,
+ deeplLanguageToGoogleLanguage(sourceLang),
+ deeplLanguageToGoogleLanguage(targetLang)
+ );
+}
+
+const showDeeplApiQuotaToast = onlyOnce(
+ () => showToast("Deepl API quota exceeded. Falling back to Google Translate", Toasts.Type.FAILURE)
+);
+
+async function deeplTranslate(text: string, sourceLang: string, targetLang: string): Promise {
+ if (!settings.store.deeplApiKey) {
+ showToast("DeepL API key is not set. Resetting to Google", Toasts.Type.FAILURE);
+
+ settings.store.service = "google";
+ resetLanguageDefaults();
+
+ return fallbackToGoogle(text, sourceLang, targetLang);
+ }
+
+ // CORS jumpscare
+ const { status, data } = await Native.makeDeeplTranslateRequest(
+ settings.store.service === "deepl-pro",
+ settings.store.deeplApiKey,
+ JSON.stringify({
+ text: [text],
+ target_lang: targetLang,
+ source_lang: sourceLang.split("-")[0]
+ })
+ );
+
+ switch (status) {
+ case 200:
+ break;
+ case -1:
+ throw "Failed to connect to DeepL API: " + data;
+ case 403:
+ throw "Invalid DeepL API key or version";
+ case 456:
+ showDeeplApiQuotaToast();
+ return fallbackToGoogle(text, sourceLang, targetLang);
+ default:
+ throw new Error(`Failed to translate "${text}" (${sourceLang} -> ${targetLang})\n${status} ${data}`);
+ }
+
+ const { translations }: DeeplData = JSON.parse(data);
+ const src = translations[0].detected_source_language;
+
+ return {
+ sourceLanguage: DeeplLanguages[src] ?? src,
+ text: translations[0].text
+ };
+}
diff --git a/src/utils/constants.ts b/src/utils/constants.ts
index f46618e21..35525cd17 100644
--- a/src/utils/constants.ts
+++ b/src/utils/constants.ts
@@ -538,6 +538,10 @@ export const Devs = /* #__PURE__*/ Object.freeze({
name: "Joona",
id: 297410829589020673n
},
+ AshtonMemer: {
+ name: "AshtonMemer",
+ id: 373657230530052099n
+ },
surgedevs: {
name: "Chloe",
id: 1084592643784331324n