mirror of
https://github.com/Foundry376/Mailspring.git
synced 2025-01-18 05:58:11 +08:00
348 lines
11 KiB
TypeScript
348 lines
11 KiB
TypeScript
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 (
|
|
<div className="translate-message-header">
|
|
<div className="message with-actions">
|
|
<div className="message-centered">
|
|
{localized(
|
|
'Mailspring has translated this message into %@.',
|
|
AllLanguages[result.toLang]
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="actions">
|
|
<div className="action" tabIndex={-1} onClick={this._onToggleTranslate}>
|
|
<span>{localized('Show Original')}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!this.state.detected) {
|
|
return <span />;
|
|
}
|
|
|
|
const fromLanguage = AllLanguages[this.state.detected];
|
|
const toLanguage = AllLanguages[getCurrentLocale().split('-')[0]];
|
|
const prefs = getPrefs();
|
|
|
|
const spinner = (
|
|
<RetinaImg
|
|
name="inline-loading-spinner.gif"
|
|
mode={RetinaImg.Mode.ContentDark}
|
|
style={{ width: 14, height: 14, mixBlendMode: 'multiply' }}
|
|
/>
|
|
);
|
|
|
|
if (this.state.translating === 'auto') {
|
|
return (
|
|
<div className="translate-message-header">
|
|
<div className="message">
|
|
<div className="message-centered">
|
|
{localized('Translating from %1$@ to %2$@.', fromLanguage, toLanguage)}
|
|
</div>
|
|
<div style={{ flex: 1 }} />
|
|
<RetinaImg
|
|
name="inline-loading-spinner.gif"
|
|
mode={RetinaImg.Mode.ContentDark}
|
|
style={{ width: 14, height: 14, mixBlendMode: 'multiply' }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<div className="translate-message-header">
|
|
<div className="message with-actions">
|
|
<div className="message-centered">
|
|
{localized('Translate from %1$@ to %2$@?', fromLanguage, toLanguage)}
|
|
<div className="note">
|
|
{localized('Privacy note: text below will be sent to an online translation service.')}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="actions">
|
|
<div className="action" tabIndex={-1} onClick={() => this._onTranslate('manual')}>
|
|
{this.state.translating === 'manual' ? spinner : <span>{localized('Translate')}</span>}
|
|
</div>
|
|
<ButtonDropdown
|
|
bordered={false}
|
|
attachment="right"
|
|
closeOnMenuClick={true}
|
|
primaryItem={<span>{localized('Options')}</span>}
|
|
className="action"
|
|
menu={
|
|
<Menu
|
|
items={[
|
|
prefs.automatic.includes(this.state.detected)
|
|
? {
|
|
key: 'always',
|
|
label: localized('Stop translating %@', fromLanguage),
|
|
select: this._onDisableAlwaysForLanguage,
|
|
}
|
|
: {
|
|
key: 'always',
|
|
label: localized('Always translate %@', fromLanguage) + ` (Pro)`,
|
|
select: this._onAlwaysForLanguage,
|
|
},
|
|
{
|
|
key: 'never',
|
|
label: localized('Never translate %@', fromLanguage),
|
|
select: this._onNeverForLanguage,
|
|
},
|
|
{ key: 'divider' },
|
|
{
|
|
key: 'reset',
|
|
label: localized('Reset translation settings'),
|
|
select: this._onReset,
|
|
},
|
|
]}
|
|
itemKey={item => item.key}
|
|
itemContent={item =>
|
|
item.label ? item.label : <Menu.Item key={item.key} divider={true} />
|
|
}
|
|
onSelect={item => item.select()}
|
|
/>
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
}
|