import React from 'react'; import ReactDOM from 'react-dom'; import cld from '@paulcbetts/cld'; import { remote } from 'electron'; import { localized, Message, MessageViewExtension, getCurrentLocale, MessageBodyProcessor, IdentityStore, FeatureUsageStore, } from 'mailspring-exports'; import { translateMessageBody, AllLanguages, TranslationsUsedLexicon } from './service'; import { Menu, ButtonDropdown, RetinaImg } from 'mailspring-component-kit'; interface TranslateMessageHeaderProps { message: Message; } interface TranslateMessageHeaderState { detected: string | null; translating: 'manual' | 'auto' | false; } let RecentlyTranslatedBodies: { id: string; translated: string; enabled: boolean; fromLang: string; toLang: string; }[] = []; function getPrefs() { return { disabled: AppEnv.config.get('core.translation.disabled') || [], automatic: AppEnv.config.get('core.translation.automatic') || [], }; } function setPrefs(opts: { disabled: string[]; automatic: string[] }) { AppEnv.config.set('core.translation.disabled', opts.disabled); AppEnv.config.set('core.translation.automatic', opts.automatic); } export class TranslateMessageExtension extends MessageViewExtension { static formatMessageBody = ({ message }) => { const result = RecentlyTranslatedBodies.find(o => o.id === message.id); if (result && result.enabled) message.body = result.translated; }; } export class TranslateMessageHeader extends React.Component< TranslateMessageHeaderProps, TranslateMessageHeaderState > { static displayName = 'TranslateMessageHeader'; _mounted: boolean = false; _detectionStarted: boolean = false; state: TranslateMessageHeaderState = { detected: null, translating: false, }; componentDidMount() { this._mounted = true; this._detectLanguageIfReady(); } componentDidUpdate() { this._detectLanguageIfReady(); } componentWillUnmount() { this._mounted = false; } _detectLanguageIfReady = async () => { if (this._detectionStarted) return; // load the previous translation result if this message is already translated const result = RecentlyTranslatedBodies.find(o => o.id === this.props.message.id); if (result) { this._detectionStarted = true; this.setState({ detected: result.fromLang }); return; } // add a delay to avoid this work if the user is rapidly flipping through messages await Promise.delay(1000); if (this._detectionStarted || !this._mounted) return; // we need to trim the quoted text, convert the HTML to plain text to analyze, etc. // the second step is costly and we can just wait for the message to mount and read // the innerText which is much more efficient. const el = ReactDOM.findDOMNode(this) as Element; const messageEl = el && el.closest('.message-item-area'); const iframeEl = messageEl && messageEl.querySelector('iframe'); if (!iframeEl || !this.props.message.body) return; let text = iframeEl.contentDocument.body.innerText; if (text.length > 1000) text = text.slice(0, 1000); if (!text) return; this._detectionStarted = true; cld.detect(text, (err, result) => { if (err || !result || !result.languages.length) { console.warn(`Could not detect message language: ${err.toString()}`); return; } const detected = result.languages[0].code; const current = getCurrentLocale().split('-')[0]; // no-op if the current and detected language are the same if (current === detected) return; // no-op if we don't know what either of the language codes are if (!AllLanguages[current] || !AllLanguages[detected]) return; const prefs = getPrefs(); if (prefs.disabled.includes(detected)) return; this.setState({ detected }); if (prefs.automatic.includes(detected)) { this._onTranslate('auto'); } }); }; _onTranslate = async (mode: 'auto' | 'manual') => { const { message } = this.props; const result = RecentlyTranslatedBodies.find(o => o.id === message.id); if (result) { if (!result.enabled) this._onToggleTranslate(); return; } if (!IdentityStore.hasProFeatures()) { try { await FeatureUsageStore.markUsedOrUpgrade('translation', TranslationsUsedLexicon); } catch (err) { // user does not have access to this feature return; } } this.setState({ translating: mode }); const targetLanguage = getCurrentLocale().split('-')[0]; const translated = await translateMessageBody(message.body, targetLanguage); if (this._mounted) { this.setState({ translating: false }); } if (!translated) return; if (RecentlyTranslatedBodies.length > 50) { RecentlyTranslatedBodies.shift(); } RecentlyTranslatedBodies.push({ id: message.id, translated, enabled: true, fromLang: this.state.detected, toLang: targetLanguage, }); MessageBodyProcessor.updateCacheForMessage(message); }; _onToggleTranslate = () => { const result = RecentlyTranslatedBodies.find(o => o.id === this.props.message.id); result.enabled = !result.enabled; MessageBodyProcessor.updateCacheForMessage(this.props.message); }; _onDisableAlwaysForLanguage = () => { const prefs = getPrefs(); prefs.automatic = prefs.automatic.filter(p => p !== this.state.detected); setPrefs(prefs); this.forceUpdate(); }; _onAlwaysForLanguage = () => { if (!IdentityStore.hasProFeatures()) { FeatureUsageStore.displayUpgradeModal('translation', { headerText: localized('Translate automatically with Mailspring Pro'), rechargeText: `${localized( "Unfortunately, translation services bill per character and we can't offer this feature for free." )} ${localized('Upgrade to Pro today!')}`, iconUrl: 'mailspring://translation/assets/ic-translation-modal@2x.png', }); return; } const prefs = getPrefs(); prefs.disabled = prefs.disabled.filter(p => p !== this.state.detected); prefs.automatic = prefs.automatic.concat([this.state.detected]); setPrefs(prefs); this.forceUpdate(); this._onTranslate('manual'); }; _onNeverForLanguage = () => { if (!this.state.detected) return; const response = remote.dialog.showMessageBox(remote.getCurrentWindow(), { type: 'warning', buttons: [localized('Yes'), localized('Cancel')], message: localized('Are you sure?'), detail: localized( 'Mailspring will no longer offer to translate messages written in %@.', AllLanguages[this.state.detected] ), }); if (response === 0) { const prefs = getPrefs(); prefs.disabled = prefs.disabled.concat([this.state.detected]); prefs.automatic = prefs.automatic.filter(p => p !== this.state.detected); setPrefs(prefs); this.setState({ detected: null }); } }; _onReset = () => { setPrefs({ automatic: [], disabled: [] }); this.forceUpdate(); }; render() { const result = RecentlyTranslatedBodies.find(o => o.id === this.props.message.id); if (result && result.enabled) { return (
{localized( 'Mailspring has translated this message into %@.', AllLanguages[result.toLang] )}
{localized('Show Original')}
); } if (!this.state.detected) { return ; } const fromLanguage = AllLanguages[this.state.detected]; const toLanguage = AllLanguages[getCurrentLocale().split('-')[0]]; const prefs = getPrefs(); const spinner = ( ); if (this.state.translating === 'auto') { return (
{localized('Translating from %1$@ to %2$@.', fromLanguage, toLanguage)}
); } return (
{localized('Translate from %1$@ to %2$@?', fromLanguage, toLanguage)}
{localized('Privacy note: text below will be sent to an online translation service.')}
this._onTranslate('manual')}> {this.state.translating === 'manual' ? spinner : {localized('Translate')}}
{localized('Options')}} className="action" menu={ item.key} itemContent={item => item.label ? item.label : } onSelect={item => item.select()} /> } />
); } }