Feature: translate incoming messages into your preferred language automatically

This commit is contained in:
Ben Gotow 2019-07-31 01:19:03 -05:00
parent e2bdf45b68
commit 767bd66361
25 changed files with 697 additions and 118 deletions

View file

@ -1,11 +0,0 @@
## Translate
A package for N1 that translates draft text into other languages using the Yandex Translation API.
<img src="https://raw.githubusercontent.com/nylas/nylas-mail/master/internal_packages/composer-translate/examples-screencap-translate.png"/>
#### Enable this plugin
1. Download and run N1
2. Navigate to Preferences > Plugins and click "Enable" beside the plugin.

View file

@ -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;
}
}

View file

@ -47,6 +47,10 @@
padding: @padding-small-horizontal;
color: @text-color;
.event-location {
user-select: text;
}
.event-date {
display: inline-block;
width: 100%;

View file

@ -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%);
}
}

View file

@ -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 {};
}

View file

@ -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<{
<div className="local-data">
<h6>{localized('Local Data')}</h6>
<div className="btn" onClick={this._onResetEmailCache}>
<div className="btn" onClick={this._onResetEmailCache} style={{ marginLeft: 0 }}>
{localized('Reset Cache')}
</div>
<div className="btn" onClick={this._onResetAccountsAndSettings}>

View file

@ -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 }> {

View file

@ -0,0 +1,11 @@
## Translate
A package for Mailspring that translates draft text into other languages using the Yandex Translation API.
<img src="https://raw.githubusercontent.com/nylas/nylas-mail/master/internal_packages/translation/examples-screencap-translate.png"/>
#### Enable this plugin
1. Download and run Mailspring
2. Navigate to Preferences > Plugins and click "Enable" beside the plugin.

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

View file

Before

Width:  |  Height:  |  Size: 889 B

After

Width:  |  Height:  |  Size: 889 B

View file

@ -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 (
<Menu
className="translate-language-picker"
items={Object.keys(YandexLanguages)}
items={Object.keys(TranslatePopupOptions)}
itemKey={item => item}
itemContent={item => item}
headerComponents={headerComponents}
@ -141,7 +100,7 @@ class TranslateButton extends React.Component<{ draft: Message; session: DraftEd
>
<RetinaImg
mode={RetinaImg.Mode.ContentIsMask}
url="mailspring://composer-translate/assets/icon-composer-translate@2x.png"
url="mailspring://translation/assets/icon-composer-translate@2x.png"
/>
&nbsp;
<RetinaImg name="icon-composer-dropdown.png" mode={RetinaImg.Mode.ContentIsMask} />
@ -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);
}

View file

@ -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);
}

View file

@ -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 (
<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>
);
}
}

View file

@ -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<string | false> {
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);
}

View file

@ -1,5 +1,5 @@
{
"name": "composer-translate",
"name": "translation",
"version": "0.2.0",
"main": "./lib/main",

View file

@ -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%);
}
}
}

View file

@ -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",

View file

@ -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

View file

@ -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;

View file

@ -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

View file

@ -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 });

View file

@ -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;

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB