diff --git a/src/plugins/translate/TranslateIcon.tsx b/src/plugins/translate/TranslateIcon.tsx index b22c488e..fa1d9abf 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 7628a31e..7a32d1b7 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 72b35940..8e8f4c17 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 f602d125..de61cef9 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 4bf370b5..3e2e7c71 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 00000000..3415e95e --- /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 65d84535..916c70bd 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 493fb2ca..aff64e8a 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 f46618e2..35525cd1 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