diff --git a/app/internal_packages/composer-translate/README.md b/app/internal_packages/composer-translate/README.md deleted file mode 100644 index 6bda407e9..000000000 --- a/app/internal_packages/composer-translate/README.md +++ /dev/null @@ -1,11 +0,0 @@ -## Translate - -A package for N1 that translates draft text into other languages using the Yandex Translation API. - - - -#### Enable this plugin - -1. Download and run N1 - -2. Navigate to Preferences > Plugins and click "Enable" beside the plugin. diff --git a/app/internal_packages/composer-translate/styles/translate.less b/app/internal_packages/composer-translate/styles/translate.less deleted file mode 100644 index d78d5fb07..000000000 --- a/app/internal_packages/composer-translate/styles/translate.less +++ /dev/null @@ -1,13 +0,0 @@ -@import 'ui-variables'; -@import 'ui-mixins'; - -.translate-language-picker { - .footer-container { - display: none; - } - .content-container { - height: 185px; - width: 170px; - overflow: scroll; - } -} diff --git a/app/internal_packages/events/styles/events.less b/app/internal_packages/events/styles/events.less index bd38c4188..098ba07ee 100644 --- a/app/internal_packages/events/styles/events.less +++ b/app/internal_packages/events/styles/events.less @@ -47,6 +47,10 @@ padding: @padding-small-horizontal; color: @text-color; + .event-location { + user-select: text; + } + .event-date { display: inline-block; width: 100%; diff --git a/app/internal_packages/message-autoload-images/styles/message-autoload-images.less b/app/internal_packages/message-autoload-images/styles/message-autoload-images.less index 5bd7037d1..c5461f1a9 100644 --- a/app/internal_packages/message-autoload-images/styles/message-autoload-images.less +++ b/app/internal_packages/message-autoload-images/styles/message-autoload-images.less @@ -2,7 +2,8 @@ .autoload-images-header { background-color: mix(@background-primary, #ffcc11, 80%); - border: 1px solid darken(mix(@background-primary, #ffcc11, 50%), 25%); + border: 1px solid mix(@black, mix(@background-primary, #ffcc11, 50%), 25%); + border-radius: @border-radius-base; color: mix(@text-color-subtle, #ffcc11, 40%); margin: @padding-base-vertical 0; padding: @padding-base-vertical @padding-base-horizontal; @@ -14,6 +15,6 @@ color: fade(mix(@text-color-subtle, #ffcc11, 80%), 70%); } .option:hover { - color: mix(@text-color-subtle, #ffcc11, 80%); + color: mix(@black, mix(@text-color-subtle, #ffcc11, 80%), 25%); } } diff --git a/app/internal_packages/participant-profile/lib/participant-profile-data-source.ts b/app/internal_packages/participant-profile/lib/participant-profile-data-source.ts index 8f26cc4cd..f646f3834 100644 --- a/app/internal_packages/participant-profile/lib/participant-profile-data-source.ts +++ b/app/internal_packages/participant-profile/lib/participant-profile-data-source.ts @@ -37,7 +37,7 @@ class ParticipantProfileDataSource { path: `/api/info-for-email-v2/${email}?phrase=${encodeURIComponent(name)}`, }); } catch (err) { - // we don't care about errors returned by this clearbit proxy + // we don't care about errors return {}; } diff --git a/app/internal_packages/preferences/lib/tabs/preferences-general.tsx b/app/internal_packages/preferences/lib/tabs/preferences-general.tsx index 7a100d055..1917ce221 100644 --- a/app/internal_packages/preferences/lib/tabs/preferences-general.tsx +++ b/app/internal_packages/preferences/lib/tabs/preferences-general.tsx @@ -8,7 +8,7 @@ import WorkspaceSection from './workspace-section'; import SendingSection from './sending-section'; import LanguageSection from './language-section'; import { ConfigLike, ConfigSchemaLike } from '../types'; -import {remote} from "electron"; +import { remote } from 'electron'; class PreferencesGeneral extends React.Component<{ config: ConfigLike; @@ -100,7 +100,7 @@ class PreferencesGeneral extends React.Component<{
{localized('Local Data')}
-
+
{localized('Reset Cache')}
diff --git a/app/internal_packages/preferences/lib/tabs/preferences-identity.tsx b/app/internal_packages/preferences/lib/tabs/preferences-identity.tsx index 0777b1c44..4df7c6a42 100644 --- a/app/internal_packages/preferences/lib/tabs/preferences-identity.tsx +++ b/app/internal_packages/preferences/lib/tabs/preferences-identity.tsx @@ -126,6 +126,14 @@ const ProTourFeatures = [ `Use the Activity tab to get a birds-eye view of your mailbox: open and click rates, subject line effectiveness, and more.` ), }, + { + link: 'https://foundry376.zendesk.com/hc/en-us/articles/360031102452', + icon: `pro-feature-translation.png`, + title: localized(`Automatic Translation`), + text: localized( + `Instantly translate messages you receive into your preferred reading language.` + ), + }, ]; class PreferencesIdentity extends React.Component<{}, { identity: IIdentity }> { diff --git a/app/internal_packages/translation/README.md b/app/internal_packages/translation/README.md new file mode 100644 index 000000000..050bb5cb6 --- /dev/null +++ b/app/internal_packages/translation/README.md @@ -0,0 +1,11 @@ +## Translate + +A package for Mailspring that translates draft text into other languages using the Yandex Translation API. + + + +#### Enable this plugin + +1. Download and run Mailspring + +2. Navigate to Preferences > Plugins and click "Enable" beside the plugin. diff --git a/app/internal_packages/translation/assets/ic-translation-modal@2x.png b/app/internal_packages/translation/assets/ic-translation-modal@2x.png new file mode 100644 index 000000000..edc6a2324 Binary files /dev/null and b/app/internal_packages/translation/assets/ic-translation-modal@2x.png differ diff --git a/app/internal_packages/composer-translate/assets/icon-composer-translate@1x.png b/app/internal_packages/translation/assets/icon-composer-translate@1x.png similarity index 100% rename from app/internal_packages/composer-translate/assets/icon-composer-translate@1x.png rename to app/internal_packages/translation/assets/icon-composer-translate@1x.png diff --git a/app/internal_packages/composer-translate/assets/icon-composer-translate@2x.png b/app/internal_packages/translation/assets/icon-composer-translate@2x.png similarity index 100% rename from app/internal_packages/composer-translate/assets/icon-composer-translate@2x.png rename to app/internal_packages/translation/assets/icon-composer-translate@2x.png diff --git a/app/internal_packages/composer-translate/icon.png b/app/internal_packages/translation/icon.png similarity index 100% rename from app/internal_packages/composer-translate/icon.png rename to app/internal_packages/translation/icon.png diff --git a/app/internal_packages/composer-translate/lib/main.tsx b/app/internal_packages/translation/lib/composer-button.tsx similarity index 50% rename from app/internal_packages/composer-translate/lib/main.tsx rename to app/internal_packages/translation/lib/composer-button.tsx index b49132037..9fa074674 100644 --- a/app/internal_packages/composer-translate/lib/main.tsx +++ b/app/internal_packages/translation/lib/composer-button.tsx @@ -1,44 +1,23 @@ -/* eslint global-require: "off" */ - -// // Translation Plugin -// Last Revised: Feb. 29, 2016 by Ben Gotow - -// TranslateButton is a simple React component that allows you to select -// a language from a popup menu and translates draft text into that language. - import React from 'react'; import ReactDOM from 'react-dom'; import { PropTypes, - ComponentRegistry, - QuotedHTMLTransformer, localized, Actions, Message, DraftEditingSession, + FeatureUsageStore, } from 'mailspring-exports'; import { Menu, RetinaImg } from 'mailspring-component-kit'; +import { TranslatePopupOptions, translateMessageBody, TranslationsUsedLexicon } from './service'; -const YandexTranslationURL = 'https://translate.yandex.net/api/v1.5/tr.json/translate'; -const YandexTranslationKey = - 'trnsl.1.1.20150415T044616Z.24814c314120d022.0a339e2bc2d2337461a98d5ec9863fc46e42735e'; -const YandexLanguages = { - English: 'en', - Spanish: 'es', - Russian: 'ru', - Chinese: 'zh', - French: 'fr', - German: 'de', - Italian: 'it', - Japanese: 'ja', - Portuguese: 'pt', - Korean: 'ko', -}; - -class TranslateButton extends React.Component<{ draft: Message; session: DraftEditingSession }> { +export class TranslateComposerButton extends React.Component<{ + draft: Message; + session: DraftEditingSession; +}> { // Adding a `displayName` makes debugging React easier - static displayName = 'TranslateButton'; + static displayName = 'TranslateComposerButton'; // Since our button is being injected into the Composer Footer, // we receive the local id of the current draft as a `prop` (a read-only @@ -54,47 +33,27 @@ class TranslateButton extends React.Component<{ draft: Message; session: DraftEd return nextProps.session !== this.props.session; } - _onError(error) { + _onTranslate = async langName => { Actions.closePopover(); - const dialog = require('electron').remote.dialog; - dialog.showErrorBox(localized('Language Conversion Failed'), error.toString()); - } - _onTranslate = async lang => { - Actions.closePopover(); + try { + await FeatureUsageStore.markUsedOrUpgrade('translation', TranslationsUsedLexicon); + } catch (err) { + // user does not have access to this feature + return; + } // Obtain the session for the current draft. The draft session provides us // the draft object and also manages saving changes to the local cache and // Nilas API as multiple parts of the application touch the draft. - const draftHtml = this.props.draft.body; - const text = QuotedHTMLTransformer.removeQuotedHTML(draftHtml); + const langCode = TranslatePopupOptions[langName]; + const translated = await translateMessageBody(this.props.draft.body, langCode); - const queryParams = new URLSearchParams(); - queryParams.set('key', YandexTranslationKey); - queryParams.set('lang', YandexLanguages[lang]); - queryParams.set('text', text); - queryParams.set('format', 'html'); - - try { - const resp = await fetch(YandexTranslationURL, { method: 'POST', body: queryParams }); - if (!resp.ok) { - throw new Error(localized('Sorry, we were unable to complete the translation request.')); - } - const json = await resp.json(); - let translated = json.text.join(''); - - // The new text of the draft is our translated response, plus any quoted text - // that we didn't process. - translated = QuotedHTMLTransformer.appendQuotedHTML(translated, draftHtml); - - // To update the draft, we add the new body to it's session. The session object - // automatically marshalls changes to the database and ensures that others accessing - // the same draft are notified of changes. - this.props.session.changes.add({ body: translated }); - this.props.session.changes.commit(); - } catch (error) { - this._onError(error); - } + // To update the draft, we add the new body to it's session. The session object + // automatically marshalls changes to the database and ensures that others accessing + // the same draft are notified of changes. + this.props.session.changes.add({ body: translated }); + this.props.session.changes.commit(); }; _onClickTranslateButton = () => { @@ -108,7 +67,7 @@ class TranslateButton extends React.Component<{ draft: Message; session: DraftEd return ( item} itemContent={item => item} headerComponents={headerComponents} @@ -141,7 +100,7 @@ class TranslateButton extends React.Component<{ draft: Message; session: DraftEd >   @@ -149,25 +108,3 @@ class TranslateButton extends React.Component<{ draft: Message; session: DraftEd ); } } - -/* -All packages must export a basic object that has at least the following 3 -methods: - -1. `activate` - Actions to take once the package gets turned on. -Pre-enabled packages get activated on Mailspring bootup. They can also be -activated manually by a user. - -2. `deactivate` - Actions to take when a package gets turned off. This can -happen when a user manually disables a package. -*/ - -export function activate() { - ComponentRegistry.register(TranslateButton, { - role: 'Composer:ActionButton', - }); -} - -export function deactivate() { - ComponentRegistry.unregister(TranslateButton); -} diff --git a/app/internal_packages/translation/lib/main.tsx b/app/internal_packages/translation/lib/main.tsx new file mode 100644 index 000000000..13ae00507 --- /dev/null +++ b/app/internal_packages/translation/lib/main.tsx @@ -0,0 +1,39 @@ +/* eslint global-require: "off" */ + +// // Translation Plugin +// Last Revised: Feb. 29, 2016 by Ben Gotow + +// TranslateButton is a simple React component that allows you to select +// a language from a popup menu and translates draft text into that language. + +import { ComponentRegistry, ExtensionRegistry } from 'mailspring-exports'; +import { TranslateComposerButton } from './composer-button'; +import { TranslateMessageHeader, TranslateMessageExtension } from './message-header'; +/* +All packages must export a basic object that has at least the following 3 +methods: + +1. `activate` - Actions to take once the package gets turned on. +Pre-enabled packages get activated on Mailspring bootup. They can also be +activated manually by a user. + +2. `deactivate` - Actions to take when a package gets turned off. This can +happen when a user manually disables a package. +*/ + +export function activate() { + ExtensionRegistry.MessageView.register(TranslateMessageExtension); + + ComponentRegistry.register(TranslateComposerButton, { + role: 'Composer:ActionButton', + }); + ComponentRegistry.register(TranslateMessageHeader, { + role: 'message:BodyHeader', + }); +} + +export function deactivate() { + ExtensionRegistry.MessageView.unregister(TranslateMessageExtension); + ComponentRegistry.unregister(TranslateComposerButton); + ComponentRegistry.unregister(TranslateMessageHeader); +} diff --git a/app/internal_packages/translation/lib/message-header.tsx b/app/internal_packages/translation/lib/message-header.tsx new file mode 100644 index 000000000..d032f05e8 --- /dev/null +++ b/app/internal_packages/translation/lib/message-header.tsx @@ -0,0 +1,348 @@ +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()} + /> + } + /> +
+
+ ); + } +} diff --git a/app/internal_packages/translation/lib/service.ts b/app/internal_packages/translation/lib/service.ts new file mode 100644 index 000000000..6b1f60085 --- /dev/null +++ b/app/internal_packages/translation/lib/service.ts @@ -0,0 +1,151 @@ +import { + QuotedHTMLTransformer, + localized, + Actions, + MailspringAPIRequest, +} from 'mailspring-exports'; + +export const TranslatePopupOptions = { + English: 'en', + Spanish: 'es', + Russian: 'ru', + Chinese: 'zh', + Arabic: 'ar', + French: 'fr', + German: 'de', + Italian: 'it', + Japanese: 'ja', + Portuguese: 'pt', + Hindi: 'hi', + Korean: 'ko', +}; + +export const AllLanguages = { + az: 'Azerbaijan', + ml: 'Malayalam', + sq: 'Albanian', + mt: 'Maltese', + am: 'Amharic', + mk: 'Macedonian', + en: 'English', + mi: 'Maori', + ar: 'Arabic', + mr: 'Marathi', + hy: 'Armenian', + mhr: 'Mari', + af: 'Afrikaans', + mn: 'Mongolian', + eu: 'Basque', + de: 'German', + ba: 'Bashkir', + ne: 'Nepali', + be: 'Belarusian', + no: 'Norwegian', + bn: 'Bengali', + pa: 'Punjabi', + my: 'Burmese', + pap: 'Papiamento', + bg: 'Bulgarian', + fa: 'Persian', + bs: 'Bosnian', + pl: 'Polish', + cy: 'Welsh', + pt: 'Portuguese', + hu: 'Hungarian', + ro: 'Romanian', + vi: 'Vietnamese', + ru: 'Russian', + ht: 'Haitian (Creole)', + ceb: 'Cebuano', + gl: 'Galician', + sr: 'Serbian', + nl: 'Dutch', + si: 'Sinhala', + mrj: 'Hill Mari', + sk: 'Slovakian', + el: 'Greek', + sl: 'Slovenian', + ka: 'Georgian', + sw: 'Swahili', + gu: 'Gujarati', + su: 'Sundanese', + da: 'Danish', + tg: 'Tajik', + he: 'Hebrew', + th: 'Thai', + yi: 'Yiddish', + tl: 'Tagalog', + id: 'Indonesian', + ta: 'Tamil', + ga: 'Irish', + tt: 'Tatar', + it: 'Italian', + te: 'Telugu', + is: 'Icelandic', + tr: 'Turkish', + es: 'Spanish', + udm: 'Udmurt', + kk: 'Kazakh', + uz: 'Uzbek', + kn: 'Kannada', + uk: 'Ukrainian', + ca: 'Catalan', + ur: 'Urdu', + ky: 'Kyrgyz', + fi: 'Finnish', + zh: 'Chinese', + fr: 'French', + ko: 'Korean', + hi: 'Hindi', + xh: 'Xhosa', + hr: 'Croatian', + km: 'Khmer', + cs: 'Czech', + lo: 'Laotian', + sv: 'Swedish', + la: 'Latin', + gd: 'Scottish', + lv: 'Latvian', + et: 'Estonian', + lt: 'Lithuanian', + eo: 'Esperanto', + lb: 'Luxembourgish', + jv: 'Javanese', + mg: 'Malagasy', + ja: 'Japanese', + ms: 'Malay', +}; + +export const TranslationsUsedLexicon = { + headerText: localized('All Translations Used'), + rechargeText: `${localized( + 'You can translate up to %1$@ emails each %2$@ with Mailspring Basic.' + )} ${localized('Upgrade to Pro today!')}`, + iconUrl: 'mailspring://translation/assets/ic-translation-modal@2x.png', +}; + +export async function translateMessageBody( + html: string, + targetLang?: string +): Promise { + const text = QuotedHTMLTransformer.removeQuotedHTML(html); + + let response = null; + try { + response = await MailspringAPIRequest.makeRequest({ + server: 'identity', + method: 'POST', + path: `/api/translate`, + json: true, + body: { lang: targetLang, text, format: 'html' }, + timeout: 5000, + }); + } catch (error) { + Actions.closePopover(); + const dialog = require('electron').remote.dialog; + dialog.showErrorBox(localized('Language Conversion Failed'), error.toString()); + return false; + } + + return QuotedHTMLTransformer.appendQuotedHTML(response.result, html); +} diff --git a/app/internal_packages/composer-translate/package.json b/app/internal_packages/translation/package.json similarity index 93% rename from app/internal_packages/composer-translate/package.json rename to app/internal_packages/translation/package.json index b16316639..bc6258b6a 100755 --- a/app/internal_packages/composer-translate/package.json +++ b/app/internal_packages/translation/package.json @@ -1,5 +1,5 @@ { - "name": "composer-translate", + "name": "translation", "version": "0.2.0", "main": "./lib/main", diff --git a/app/internal_packages/translation/styles/translate.less b/app/internal_packages/translation/styles/translate.less new file mode 100644 index 000000000..ff6e4d9ae --- /dev/null +++ b/app/internal_packages/translation/styles/translate.less @@ -0,0 +1,94 @@ +@import 'ui-variables'; +@import 'ui-mixins'; + +@feature-tint: #0095ff; + +.translate-language-picker { + .footer-container { + display: none; + } + .content-container { + height: 185px; + width: 170px; + overflow: scroll; + } +} + +.translate-message-header { + display: flex; + align-items: stretch; + background-color: mix(@background-primary, @feature-tint, 90%); + color: mix(@text-color-subtle, @feature-tint, 40%); + margin: 10px 0; + + .message { + flex: 1; + border: 1px solid darken(mix(@background-primary, @feature-tint, 50%), 25%); + border-radius: @border-radius-base; + + &.with-actions { + border-right: 0; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } + + padding: 2px 8px; + display: flex; + align-items: center; + .message-centered { + .note { + font-size: 0.85rem; + opacity: 0.7; + } + } + } + + .actions { + display: flex; + flex-direction: column; + + .action { + padding: 0 8px; + min-height: 27px; + background: mix(@background-primary, @feature-tint, 80%); + border: 1px solid darken(mix(@background-primary, @feature-tint, 50%), 25%); + border-bottom: 0; + display: flex; + align-items: center; + min-width: 90px; + flex: 1; + &:active { + background: mix(@background-primary, @feature-tint, 70%); + } + } + + .action.button-dropdown { + display: inline-block; + padding: 0; + .only-item { + border: none; + padding: 0; + margin: 0; + box-shadow: none; + background: transparent; + color: inherit; + display: flex; + align-items: center; + justify-content: space-between; + height: 100%; + padding: 0 8px; + + img.content-mask { + background: mix(@text-color-subtle, @feature-tint, 40%); + } + } + } + .action:first-child { + border-top-right-radius: @border-radius-base; + } + .action:last-child { + border-bottom-right-radius: @border-radius-base; + border-bottom: 1px solid darken(mix(@background-primary, @feature-tint, 50%), 25%); + } + } +} diff --git a/app/package.json b/app/package.json index ecf0cba86..f553e1d1d 100644 --- a/app/package.json +++ b/app/package.json @@ -11,6 +11,7 @@ "license": "GPL-3.0", "main": "./src/browser/main.js", "dependencies": { + "@paulcbetts/cld": "2.4.6", "@bengotow/slate-edit-list": "github:bengotow/slate-edit-list#b868e108", "better-sqlite3": "bengotow/better-sqlite3#dcd5b6e73c9a5329fd72c85be3316131fcfb83ab", "chromium-net-errors": "1.0.3", diff --git a/app/src/flux/stores/feature-usage-store.tsx b/app/src/flux/stores/feature-usage-store.tsx index ea73af537..08d52cb6f 100644 --- a/app/src/flux/stores/feature-usage-store.tsx +++ b/app/src/flux/stores/feature-usage-store.tsx @@ -9,7 +9,7 @@ import { localized } from '../../intl'; class NoProAccessError extends Error {} -const UsageRecordedServerSide = ['contact-profiles']; +const UsageRecordedServerSide = ['contact-profiles', 'translation']; /** * FeatureUsageStore is backed by the IdentityStore diff --git a/app/src/global/mailspring-exports.d.ts b/app/src/global/mailspring-exports.d.ts index 19e08e003..dfec20107 100644 --- a/app/src/global/mailspring-exports.d.ts +++ b/app/src/global/mailspring-exports.d.ts @@ -1,6 +1,8 @@ export type localized = typeof import('../intl').localized; export const localized: localized; + export type getCurrentLocale = typeof import('../intl').getCurrentLocale; + export const getCurrentLocale: getCurrentLocale; export type localizedReactFragment = typeof import('../intl').localizedReactFragment; export const localizedReactFragment: localizedReactFragment; export type getAvailableLanguages = typeof import('../intl').getAvailableLanguages; diff --git a/app/src/global/mailspring-exports.js b/app/src/global/mailspring-exports.js index e238e3a70..b8ad03d20 100644 --- a/app/src/global/mailspring-exports.js +++ b/app/src/global/mailspring-exports.js @@ -50,6 +50,7 @@ const lazyLoadAndRegisterTask = (klassName, path) => { lazyLoadWithGetter(`localized`, () => require('../intl').localized); lazyLoadWithGetter(`localizedReactFragment`, () => require('../intl').localizedReactFragment); lazyLoadWithGetter(`getAvailableLanguages`, () => require('../intl').getAvailableLanguages); +lazyLoadWithGetter(`getCurrentLocale`, () => require('../intl').getCurrentLocale); lazyLoadWithGetter(`isRTL`, () => require('../intl').isRTL); // Actions diff --git a/app/src/intl.ts b/app/src/intl.ts index 8afefe1c4..3ca4e2a23 100644 --- a/app/src/intl.ts +++ b/app/src/intl.ts @@ -245,6 +245,10 @@ export function localizedReactFragment(en, ...subs) { // For Preferences UI: +export function getCurrentLocale() { + return locale; +} + export function getAvailableLanguages() { const localeToItem = f => ({ key: f, name: LANG_NAMES[f] || f }); diff --git a/app/static/components/feature-used-up-modal.less b/app/static/components/feature-used-up-modal.less index 11b1f3ee2..c404333d5 100644 --- a/app/static/components/feature-used-up-modal.less +++ b/app/static/components/feature-used-up-modal.less @@ -12,12 +12,14 @@ padding-bottom: 30px; color: @white; border-radius: 5px 5px 0 0; - background: linear-gradient(to top, #777ff0, lighten(#777ff0, 10%)); + background: linear-gradient(to top, #777ff0, lighten(#777ff0, 10%)); } .header-text { color: @white; margin-top: 24px; margin-bottom: 11px; + margin-left: 8px; + margin-right: 8px; } .recharge-text { margin: 0 30px; diff --git a/app/static/images/preferences/identity/pro-feature-translation@2x.png b/app/static/images/preferences/identity/pro-feature-translation@2x.png new file mode 100644 index 000000000..bd4d64e71 Binary files /dev/null and b/app/static/images/preferences/identity/pro-feature-translation@2x.png differ