summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAndrej Mihajlov <and@mullvad.net>2019-09-23 12:29:26 +0200
committerAndrej Mihajlov <and@mullvad.net>2019-09-25 10:50:58 +0200
commit54aca7f5c0f54fb42975d8c5e288627308eef5c5 (patch)
tree1a38aaca469f4a10b91f072dc60b3368226e96d8
parentff1ffa5b8b17141fe4f9bc7686db273757e2c45c (diff)
downloadmullvadvpn-54aca7f5c0f54fb42975d8c5e288627308eef5c5.tar.xz
mullvadvpn-54aca7f5c0f54fb42975d8c5e288627308eef5c5.zip
Add language selector
-rw-r--r--gui/src/main/gui-settings.ts46
-rw-r--r--gui/src/main/index.ts47
-rw-r--r--gui/src/renderer/app.tsx118
-rw-r--r--gui/src/renderer/components/AdvancedSettings.tsx68
-rw-r--r--gui/src/renderer/components/SelectLanguage.tsx119
-rw-r--r--gui/src/renderer/components/Selector.tsx89
-rw-r--r--gui/src/renderer/components/Settings.tsx6
-rw-r--r--gui/src/renderer/containers/AccountPage.tsx4
-rw-r--r--gui/src/renderer/containers/ConnectPage.tsx4
-rw-r--r--gui/src/renderer/containers/SelectLanguagePage.tsx31
-rw-r--r--gui/src/renderer/containers/SettingsPage.tsx5
-rw-r--r--gui/src/renderer/containers/WireguardKeysPage.tsx4
-rw-r--r--gui/src/renderer/redux/settings/reducers.ts1
-rw-r--r--gui/src/renderer/redux/userinterface/actions.ts15
-rw-r--r--gui/src/renderer/redux/userinterface/reducers.ts5
-rw-r--r--gui/src/renderer/routes.tsx3
-rw-r--r--gui/src/renderer/transitions.ts1
-rw-r--r--gui/src/shared/gettext.ts18
-rw-r--r--gui/src/shared/gui-settings-state.ts2
-rw-r--r--gui/src/shared/ipc-event-channel.ts19
20 files changed, 461 insertions, 144 deletions
diff --git a/gui/src/main/gui-settings.ts b/gui/src/main/gui-settings.ts
index 2a0cad2841..47f4e1bf83 100644
--- a/gui/src/main/gui-settings.ts
+++ b/gui/src/main/gui-settings.ts
@@ -2,20 +2,37 @@ import { app } from 'electron';
import log from 'electron-log';
import * as fs from 'fs';
import * as path from 'path';
-
+import { validate } from 'validated/object';
+import { boolean, partialObject, string } from 'validated/schema';
import { IGuiSettingsState } from '../shared/gui-settings-state';
+const settingsSchema = partialObject({
+ preferredLocale: string,
+ autoConnect: boolean,
+ enableSystemNotifications: boolean,
+ monochromaticIcon: boolean,
+ startMinimized: boolean,
+});
+
+const defaultSettings: IGuiSettingsState = {
+ preferredLocale: 'system',
+ autoConnect: true,
+ enableSystemNotifications: true,
+ monochromaticIcon: false,
+ startMinimized: false,
+};
+
export default class GuiSettings {
get state(): IGuiSettingsState {
return this.stateValue;
}
- set preferredLocale(newValue: string | undefined) {
+ set preferredLocale(newValue: string) {
this.changeStateAndNotify({ ...this.stateValue, preferredLocale: newValue });
}
- get preferredLocale(): string | undefined {
- return this.preferredLocale;
+ get preferredLocale(): string {
+ return this.stateValue.preferredLocale;
}
set enableSystemNotifications(newValue: boolean) {
@@ -52,27 +69,18 @@ export default class GuiSettings {
public onChange?: (newState: IGuiSettingsState, oldState: IGuiSettingsState) => void;
- private stateValue: IGuiSettingsState = {
- autoConnect: true,
- enableSystemNotifications: true,
- monochromaticIcon: false,
- startMinimized: false,
- };
+ private stateValue: IGuiSettingsState = { ...defaultSettings };
public load() {
try {
const settingsFile = this.filePath();
const contents = fs.readFileSync(settingsFile, 'utf8');
- const settings = JSON.parse(contents);
+ const rawJson = JSON.parse(contents);
- this.stateValue.autoConnect =
- typeof settings.autoConnect === 'boolean' ? settings.autoConnect : true;
- this.stateValue.enableSystemNotifications =
- typeof settings.enableSystemNotifications === 'boolean'
- ? settings.enableSystemNotifications
- : true;
- this.stateValue.monochromaticIcon = settings.monochromaticIcon || false;
- this.stateValue.startMinimized = settings.startMinimized || false;
+ this.stateValue = {
+ ...defaultSettings,
+ ...(validate(settingsSchema, rawJson) as Partial<IGuiSettingsState>),
+ };
} catch (error) {
log.error(`Failed to read GUI settings file: ${error}`);
}
diff --git a/gui/src/main/index.ts b/gui/src/main/index.ts
index 19517352bf..d8e64ee123 100644
--- a/gui/src/main/index.ts
+++ b/gui/src/main/index.ts
@@ -299,22 +299,28 @@ class ApplicationMain {
private getLocaleWithOverride(): string {
const localeOverride = process.env.MULLVAD_LOCALE;
+ const systemLocale = app.getLocale();
+
if (localeOverride) {
const trimmedLocaleOverride = localeOverride.trim();
if (trimmedLocaleOverride.length > 0) {
return trimmedLocaleOverride;
+ } else {
+ return systemLocale;
+ }
+ } else {
+ const preferredLocale = this.guiSettings.preferredLocale;
+ log.info('preferredLocale = ', preferredLocale);
+ if (preferredLocale === 'system') {
+ return systemLocale;
+ } else {
+ return preferredLocale;
}
}
-
- return app.getLocale();
}
private onReady = async () => {
- this.locale = this.getLocaleWithOverride();
-
- log.info(`Detected locale: ${this.locale}`);
-
- loadTranslations(this.locale, messages);
+ this.updateCurrentLocale();
this.daemonRpc.addConnectionObserver(
new ConnectionObserver(this.onDaemonConnected, this.onDaemonDisconnected),
@@ -931,8 +937,10 @@ class ApplicationMain {
tunnelState: this.tunnelState,
settings: this.settings,
location: this.location,
- relays: this.processRelaysForPresentation(this.relays, this.settings.relaySettings),
- bridges: this.processBridgesForPresentation(this.relays, this.settings.bridgeState),
+ relayListPair: {
+ relays: this.processRelaysForPresentation(this.relays, this.settings.relaySettings),
+ bridges: this.processBridgesForPresentation(this.relays, this.settings.bridgeState),
+ },
currentVersion: this.currentVersion,
upgradeVersion: this.upgradeVersion,
guiSettings: this.guiSettings.state,
@@ -988,6 +996,11 @@ class ApplicationMain {
this.guiSettings.monochromaticIcon = monochromaticIcon;
});
+ IpcMainEventChannel.guiSettings.handleSetPreferredLocale((locale: string) => {
+ this.guiSettings.preferredLocale = locale;
+ this.didChangeLocale();
+ });
+
IpcMainEventChannel.account.handleLogin((token: AccountToken) => this.login(token));
IpcMainEventChannel.account.handleLogout(() => this.logout());
@@ -1196,6 +1209,22 @@ class ApplicationMain {
return Promise.resolve();
}
+ private updateCurrentLocale() {
+ this.locale = this.getLocaleWithOverride();
+
+ log.info(`Detected locale: ${this.locale}`);
+
+ loadTranslations(this.locale, messages);
+ }
+
+ private didChangeLocale() {
+ this.updateCurrentLocale();
+
+ if (this.windowController) {
+ IpcMainEventChannel.locale.notify(this.windowController.webContents, this.locale);
+ }
+ }
+
private async installDevTools() {
const installer = require('electron-devtools-installer');
const extensions = ['REACT_DEVELOPER_TOOLS', 'REDUX_DEVTOOLS'];
diff --git a/gui/src/renderer/app.tsx b/gui/src/renderer/app.tsx
index 39e6f56fda..c816701731 100644
--- a/gui/src/renderer/app.tsx
+++ b/gui/src/renderer/app.tsx
@@ -43,6 +43,28 @@ import {
TunnelState,
} from '../shared/daemon-rpc-types';
+interface IPreferredLocaleDescriptor {
+ name: string;
+ code: string;
+}
+
+const supportedLocaleList = [
+ { name: 'Deutsche', code: 'de' },
+ { name: 'English', code: 'en' },
+ { name: 'Español', code: 'es' },
+ { name: 'Français', code: 'fr' },
+ { name: 'Italiano', code: 'it' },
+ { name: '日本語', code: 'ja' },
+ { name: 'Nederlands', code: 'nl' },
+ { name: 'Norsk', code: 'no' },
+ { name: 'Português', code: 'pt' },
+ { name: 'Русский', code: 'ru' },
+ { name: 'Svenska', code: 'sv' },
+ { name: 'Türkçe', code: 'tr' },
+ { name: '简体中文', code: 'zh-CN' },
+ { name: '繁體中文', code: 'zh-TW' },
+];
+
export default class AppRenderer {
private memoryHistory = createMemoryHistory();
private reduxStore = configureStore(this.memoryHistory);
@@ -61,10 +83,12 @@ export default class AppRenderer {
),
};
- private locale: string;
- private tunnelState: TunnelState;
- private settings: ISettings;
- private guiSettings: IGuiSettingsState;
+ private locale = 'en';
+ private location?: ILocation;
+ private relayListPair!: IRelayListPair;
+ private tunnelState!: TunnelState;
+ private settings!: ISettings;
+ private guiSettings!: IGuiSettingsState;
private connectedToDaemon = false;
private autoConnected = false;
private doingLogin = false;
@@ -73,6 +97,20 @@ export default class AppRenderer {
constructor() {
setupLogging(getRendererLogFile());
+ IpcRendererEventChannel.locale.listen((locale) => {
+ // load translations for the new locale
+ this.loadTranslations(locale);
+
+ // set current locale
+ this.setLocale(locale);
+
+ // refresh the relay list pair with the new translations
+ this.propagateRelayListPairToRedux();
+
+ // refresh the location with the new translations
+ this.propagateLocationToRedux();
+ });
+
IpcRendererEventChannel.windowShape.listen((windowShapeParams) => {
if (typeof windowShapeParams.arrowPosition === 'number') {
this.reduxActions.userInterface.updateWindowArrowPosition(windowShapeParams.arrowPosition);
@@ -113,8 +151,7 @@ export default class AppRenderer {
});
IpcRendererEventChannel.relays.listen((relayListPair: IRelayListPair) => {
- this.setRelays(relayListPair.relays);
- this.setBridges(relayListPair.bridges);
+ this.setRelayListPair(relayListPair);
});
IpcRendererEventChannel.currentVersion.listen((currentVersion: ICurrentAppVersionInfo) => {
@@ -144,15 +181,9 @@ export default class AppRenderer {
// Request the initial state from the main process
const initialState = IpcRendererEventChannel.state.get();
- this.locale = initialState.locale;
- this.tunnelState = initialState.tunnelState;
- this.settings = initialState.settings;
- this.guiSettings = initialState.guiSettings;
-
// Load translations
- for (const catalogue of [messages, countries, cities, relayLocations]) {
- loadTranslations(this.locale, catalogue);
- }
+ this.loadTranslations(initialState.locale);
+ this.setLocale(initialState.locale);
this.setAccountExpiry(initialState.accountData && initialState.accountData.expiry);
this.setAccountHistory(initialState.accountHistory);
@@ -164,8 +195,7 @@ export default class AppRenderer {
this.setLocation(initialState.location);
}
- this.setRelays(initialState.relays);
- this.setBridges(initialState.bridges);
+ this.setRelayListPair(initialState.relayListPair);
this.setCurrentVersion(initialState.currentVersion);
this.setUpgradeVersion(initialState.upgradeVersion);
this.setGuiSettings(initialState.guiSettings);
@@ -185,12 +215,7 @@ export default class AppRenderer {
<Provider store={this.reduxStore}>
<ConnectedRouter history={this.memoryHistory}>
<ErrorBoundary>
- <AppRoutes
- sharedProps={{
- app: this,
- locale: this.locale,
- }}
- />
+ <AppRoutes sharedProps={{ app: this }} />
</ErrorBoundary>
</ConnectedRouter>
</Provider>
@@ -338,6 +363,31 @@ export default class AppRenderer {
actions.settings.setWireguardKeygenEvent(keygenEvent);
}
+ public getPreferredLocaleList(): IPreferredLocaleDescriptor[] {
+ return [
+ {
+ name: messages.pgettext('application-languages', 'System default'),
+ code: 'system',
+ },
+ ...supportedLocaleList,
+ ];
+ }
+
+ public setPreferredLocale(preferredLocale: string) {
+ IpcRendererEventChannel.guiSettings.setPreferredLocale(preferredLocale);
+ }
+
+ private loadTranslations(locale: string) {
+ for (const catalogue of [messages, countries, cities, relayLocations]) {
+ loadTranslations(locale, catalogue);
+ }
+ }
+
+ private setLocale(locale: string) {
+ this.locale = locale;
+ this.reduxActions.userInterface.updateLocale(locale);
+ }
+
private setRelaySettings(relaySettings: RelaySettings) {
const actions = this.reduxActions;
@@ -546,7 +596,14 @@ export default class AppRenderer {
}
private setLocation(location: ILocation) {
- this.reduxActions.connection.newLocation(this.translateLocation(location));
+ this.location = location;
+ this.propagateLocationToRedux();
+ }
+
+ private propagateLocationToRedux() {
+ if (this.location) {
+ this.reduxActions.connection.newLocation(this.translateLocation(this.location));
+ }
}
private translateLocation(inputLocation: ILocation): ILocation {
@@ -589,16 +646,17 @@ export default class AppRenderer {
.sort((countryA, countryB) => countryA.name.localeCompare(countryB.name, this.locale));
}
- private setRelays(relayList: IRelayList) {
- const locations = this.convertRelayListToLocationList(relayList);
-
- this.reduxActions.settings.updateRelayLocations(locations);
+ private setRelayListPair(relayListPair: IRelayListPair) {
+ this.relayListPair = relayListPair;
+ this.propagateRelayListPairToRedux();
}
- private setBridges(relayList: IRelayList) {
- const locations = this.convertRelayListToLocationList(relayList);
+ private propagateRelayListPairToRedux() {
+ const relays = this.convertRelayListToLocationList(this.relayListPair.relays);
+ const bridges = this.convertRelayListToLocationList(this.relayListPair.relays);
- this.reduxActions.settings.updateBridgeLocations(locations);
+ this.reduxActions.settings.updateRelayLocations(relays);
+ this.reduxActions.settings.updateBridgeLocations(bridges);
}
private setCurrentVersion(versionInfo: ICurrentAppVersionInfo) {
diff --git a/gui/src/renderer/components/AdvancedSettings.tsx b/gui/src/renderer/components/AdvancedSettings.tsx
index c9a985789b..53c0feb4bd 100644
--- a/gui/src/renderer/components/AdvancedSettings.tsx
+++ b/gui/src/renderer/components/AdvancedSettings.tsx
@@ -1,7 +1,6 @@
import * as React from 'react';
import { Component, View } from 'reactxp';
import { sprintf } from 'sprintf-js';
-import { colors } from '../../config.json';
import { BridgeState, RelayProtocol, TunnelProtocol } from '../../shared/daemon-rpc-types';
import { messages } from '../../shared/gettext';
import styles from './AdvancedSettingsStyles';
@@ -15,6 +14,7 @@ import {
NavigationScrollbars,
TitleBarItem,
} from './NavigationBar';
+import Selector, { ISelectorItem } from './Selector';
import SettingsHeader, { HeaderTitle } from './SettingsHeader';
const MIN_MSSFIX_VALUE = 1000;
@@ -404,69 +404,3 @@ export default class AdvancedSettings extends Component<IProps, IState> {
return mssfix === undefined || (mssfix >= MIN_MSSFIX_VALUE && mssfix <= MAX_MSSFIX_VALUE);
}
}
-
-interface ISelectorItem<T> {
- label: string;
- value: T;
-}
-
-interface ISelectorProps<T> {
- title: string;
- values: Array<ISelectorItem<T>>;
- value: T;
- onSelect: (value: T) => void;
-}
-
-class Selector<T> extends Component<ISelectorProps<T>> {
- public render() {
- return (
- <Cell.Section style={styles.advanced_settings__selector_section}>
- <Cell.SectionTitle>{this.props.title}</Cell.SectionTitle>
- {this.props.values.map((item, i) => (
- <SelectorCell
- key={i}
- value={item.value}
- selected={item.value === this.props.value}
- onSelect={this.props.onSelect}>
- {item.label}
- </SelectorCell>
- ))}
- </Cell.Section>
- );
- }
-}
-
-interface ISelectorCell<T> {
- value: T;
- selected: boolean;
- onSelect: (value: T) => void;
- children?: React.ReactText;
-}
-
-class SelectorCell<T> extends Component<ISelectorCell<T>> {
- public render() {
- return (
- <Cell.CellButton
- style={this.props.selected ? styles.advanced_settings__cell_selected_hover : undefined}
- cellHoverStyle={
- this.props.selected ? styles.advanced_settings__cell_selected_hover : undefined
- }
- onPress={this.onPress}>
- <Cell.Icon
- style={this.props.selected ? undefined : styles.advanced_settings__cell_icon_invisible}
- source="icon-tick"
- width={24}
- height={24}
- tintColor={colors.white}
- />
- <Cell.Label>{this.props.children}</Cell.Label>
- </Cell.CellButton>
- );
- }
-
- private onPress = () => {
- if (!this.props.selected) {
- this.props.onSelect(this.props.value);
- }
- };
-}
diff --git a/gui/src/renderer/components/SelectLanguage.tsx b/gui/src/renderer/components/SelectLanguage.tsx
new file mode 100644
index 0000000000..c5c2228d00
--- /dev/null
+++ b/gui/src/renderer/components/SelectLanguage.tsx
@@ -0,0 +1,119 @@
+import * as React from 'react';
+import ReactDOM from 'react-dom';
+import { Component, Styles, View } from 'reactxp';
+import { colors } from '../../config.json';
+import { messages } from '../../shared/gettext';
+import CustomScrollbars from './CustomScrollbars';
+import { Container, Layout } from './Layout';
+import {
+ BackBarItem,
+ NavigationBar,
+ NavigationContainer,
+ NavigationItems,
+ NavigationScrollbars,
+ TitleBarItem,
+} from './NavigationBar';
+import Selector, { ISelectorItem, SelectorCell } from './Selector';
+import SettingsHeader, { HeaderTitle } from './SettingsHeader';
+
+interface IProps {
+ preferredLocale: string;
+ preferredLocalesList: Array<{ name: string; code: string }>;
+ setPreferredLocale: (locale: string) => void;
+ onClose: () => void;
+}
+
+interface IState {
+ source: Array<ISelectorItem<string>>;
+}
+
+const styles = {
+ page: Styles.createViewStyle({
+ backgroundColor: colors.darkBlue,
+ flex: 1,
+ }),
+ container: Styles.createViewStyle({
+ flex: 1,
+ }),
+ selector: Styles.createViewStyle({
+ marginBottom: 0,
+ }),
+ // plain CSS style
+ scrollview: {
+ flex: 1,
+ },
+};
+
+export default class SelectLanguage extends Component<IProps, IState> {
+ private scrollView = React.createRef<CustomScrollbars>();
+ private selectedCellRef = React.createRef<SelectorCell<string>>();
+
+ constructor(props: IProps) {
+ super(props);
+
+ this.state = {
+ source: [
+ ...this.props.preferredLocalesList.map((item) => ({ label: item.name, value: item.code })),
+ ],
+ };
+ }
+
+ public componentDidMount() {
+ this.scrollToSelectedCell();
+ }
+
+ public render() {
+ return (
+ <Layout>
+ <Container>
+ <View style={styles.page}>
+ <NavigationContainer>
+ <NavigationBar>
+ <NavigationItems>
+ <BackBarItem action={this.props.onClose}>
+ {// TRANSLATORS: Back button in navigation bar
+ messages.pgettext('select-language-nav', 'Settings')}
+ </BackBarItem>
+ <TitleBarItem>
+ {// TRANSLATORS: Title label in navigation bar
+ messages.pgettext('select-language-nav', 'Select language')}
+ </TitleBarItem>
+ </NavigationItems>
+ </NavigationBar>
+
+ <View style={styles.container}>
+ <NavigationScrollbars style={styles.scrollview}>
+ <SettingsHeader>
+ <HeaderTitle>
+ {messages.pgettext('select-language-nav', 'Select language')}
+ </HeaderTitle>
+ </SettingsHeader>
+ <Selector
+ style={styles.selector}
+ title=""
+ values={this.state.source}
+ value={this.props.preferredLocale}
+ onSelect={this.props.setPreferredLocale}
+ selectedCellRef={this.selectedCellRef}
+ />
+ </NavigationScrollbars>
+ </View>
+ </NavigationContainer>
+ </View>
+ </Container>
+ </Layout>
+ );
+ }
+
+ private scrollToSelectedCell() {
+ const ref = this.selectedCellRef.current;
+ const scrollView = this.scrollView.current;
+
+ if (scrollView && ref) {
+ const cellDOMNode = ReactDOM.findDOMNode(ref);
+ if (cellDOMNode instanceof HTMLElement) {
+ scrollView.scrollToElement(cellDOMNode, 'middle');
+ }
+ }
+ }
+}
diff --git a/gui/src/renderer/components/Selector.tsx b/gui/src/renderer/components/Selector.tsx
new file mode 100644
index 0000000000..b57f49aa85
--- /dev/null
+++ b/gui/src/renderer/components/Selector.tsx
@@ -0,0 +1,89 @@
+import * as React from 'react';
+import { Component, Styles, Types } from 'reactxp';
+import { colors } from '../../config.json';
+import * as Cell from './Cell';
+
+export interface ISelectorItem<T> {
+ label: string;
+ value: T;
+}
+
+interface ISelectorProps<T> {
+ style?: Types.ViewStyleRuleSet;
+ title?: string;
+ values: Array<ISelectorItem<T>>;
+ value: T;
+ onSelect: (value: T) => void;
+ selectedCellRef?: React.Ref<SelectorCell<T>>;
+}
+
+const styles = {
+ section: Styles.createViewStyle({
+ marginBottom: 24,
+ }),
+ cell: {
+ selectedHover: Styles.createButtonStyle({
+ backgroundColor: colors.green,
+ }),
+ },
+ invisibleIcon: Styles.createViewStyle({
+ opacity: 0,
+ }),
+};
+
+export default class Selector<T> extends Component<ISelectorProps<T>> {
+ public render() {
+ return (
+ <Cell.Section style={[styles.section, this.props.style]}>
+ {this.props.title && <Cell.SectionTitle>{this.props.title}</Cell.SectionTitle>}
+ {this.props.values.map((item, i) => {
+ const selected = item.value === this.props.value;
+
+ return (
+ <SelectorCell
+ key={i}
+ value={item.value}
+ selected={selected}
+ ref={selected ? this.props.selectedCellRef : undefined}
+ onSelect={this.props.onSelect}>
+ {item.label}
+ </SelectorCell>
+ );
+ })}
+ </Cell.Section>
+ );
+ }
+}
+
+interface ISelectorCellProps<T> {
+ value: T;
+ selected: boolean;
+ onSelect: (value: T) => void;
+ children?: React.ReactText;
+}
+
+export class SelectorCell<T> extends Component<ISelectorCellProps<T>> {
+ public render() {
+ return (
+ <Cell.CellButton
+ style={this.props.selected ? styles.cell.selectedHover : undefined}
+ cellHoverStyle={this.props.selected ? styles.cell.selectedHover : undefined}
+ onPress={this.onPress}>
+ <Cell.Icon
+ style={this.props.selected ? undefined : styles.invisibleIcon}
+ source="icon-tick"
+ width={24}
+ height={24}
+ tintColor={colors.white}
+ />
+ <Cell.Label>{this.props.children}</Cell.Label>
+ </Cell.CellButton>
+ );
+ }
+
+ private onPress = () => {
+ if (!this.props.selected) {
+ this.props.onSelect(this.props.value);
+ }
+ };
+}
diff --git a/gui/src/renderer/components/Settings.tsx b/gui/src/renderer/components/Settings.tsx
index 6c04d9a8e5..c48d57641a 100644
--- a/gui/src/renderer/components/Settings.tsx
+++ b/gui/src/renderer/components/Settings.tsx
@@ -30,6 +30,7 @@ export interface IProps {
isOffline: boolean;
onQuit: () => void;
onClose: () => void;
+ onViewSelectLanguage: () => void;
onViewAccount: () => void;
onViewSupport: () => void;
onViewPreferences: () => void;
@@ -61,6 +62,11 @@ export default class Settings extends Component<IProps> {
<HeaderTitle>{messages.pgettext('settings-view', 'Settings')}</HeaderTitle>
</SettingsHeader>
<View>
+ <Cell.CellButton onPress={this.props.onViewSelectLanguage}>
+ <Cell.Label>{messages.pgettext('settings-view', 'Language')}</Cell.Label>
+ <Cell.Icon height={12} width={7} source="icon-chevron" />
+ </Cell.CellButton>
+
{this.renderTopButtons()}
{this.renderMiddleButtons()}
{this.renderBottomButtons()}
diff --git a/gui/src/renderer/containers/AccountPage.tsx b/gui/src/renderer/containers/AccountPage.tsx
index 9edd159e1c..ee8588a7c3 100644
--- a/gui/src/renderer/containers/AccountPage.tsx
+++ b/gui/src/renderer/containers/AccountPage.tsx
@@ -8,10 +8,10 @@ import Account from '../components/Account';
import { IReduxState, ReduxDispatch } from '../redux/store';
import { ISharedRouteProps } from '../routes';
-const mapStateToProps = (state: IReduxState, props: ISharedRouteProps) => ({
+const mapStateToProps = (state: IReduxState, _props: ISharedRouteProps) => ({
accountToken: state.account.accountToken,
accountExpiry: state.account.expiry,
- expiryLocale: props.locale,
+ expiryLocale: state.userInterface.locale,
isOffline: state.connection.isBlocked,
});
const mapDispatchToProps = (dispatch: ReduxDispatch, props: ISharedRouteProps) => {
diff --git a/gui/src/renderer/containers/ConnectPage.tsx b/gui/src/renderer/containers/ConnectPage.tsx
index 66974ffed8..ffc7b76299 100644
--- a/gui/src/renderer/containers/ConnectPage.tsx
+++ b/gui/src/renderer/containers/ConnectPage.tsx
@@ -64,10 +64,10 @@ function getRelayName(
}
}
-const mapStateToProps = (state: IReduxState, props: ISharedRouteProps) => {
+const mapStateToProps = (state: IReduxState, _props: ISharedRouteProps) => {
return {
accountExpiry: state.account.expiry
- ? new AccountExpiry(state.account.expiry, props.locale)
+ ? new AccountExpiry(state.account.expiry, state.userInterface.locale)
: undefined,
selectedRelayName: getRelayName(state.settings.relaySettings, state.settings.relayLocations),
connection: state.connection,
diff --git a/gui/src/renderer/containers/SelectLanguagePage.tsx b/gui/src/renderer/containers/SelectLanguagePage.tsx
new file mode 100644
index 0000000000..248e63e367
--- /dev/null
+++ b/gui/src/renderer/containers/SelectLanguagePage.tsx
@@ -0,0 +1,31 @@
+import { goBack } from 'connected-react-router';
+import { connect } from 'react-redux';
+import { bindActionCreators } from 'redux';
+import SelectLanguage from '../components/SelectLanguage';
+
+import { IReduxState, ReduxDispatch } from '../redux/store';
+import { ISharedRouteProps } from '../routes';
+
+const mapStateToProps = (state: IReduxState) => ({
+ preferredLocale: state.settings.guiSettings.preferredLocale,
+});
+
+const mapDispatchToProps = (dispatch: ReduxDispatch, props: ISharedRouteProps) => {
+ const history = bindActionCreators({ goBack }, dispatch);
+
+ return {
+ preferredLocalesList: props.app.getPreferredLocaleList(),
+ setPreferredLocale(locale: string) {
+ props.app.setPreferredLocale(locale);
+ history.goBack();
+ },
+ onClose() {
+ history.goBack();
+ },
+ };
+};
+
+export default connect(
+ mapStateToProps,
+ mapDispatchToProps,
+)(SelectLanguage);
diff --git a/gui/src/renderer/containers/SettingsPage.tsx b/gui/src/renderer/containers/SettingsPage.tsx
index eb036920c1..ca83b996c1 100644
--- a/gui/src/renderer/containers/SettingsPage.tsx
+++ b/gui/src/renderer/containers/SettingsPage.tsx
@@ -7,10 +7,10 @@ import Settings from '../components/Settings';
import { IReduxState, ReduxDispatch } from '../redux/store';
import { ISharedRouteProps } from '../routes';
-const mapStateToProps = (state: IReduxState, props: ISharedRouteProps) => ({
+const mapStateToProps = (state: IReduxState, _props: ISharedRouteProps) => ({
loginState: state.account.status,
accountExpiry: state.account.expiry,
- expiryLocale: props.locale,
+ expiryLocale: state.userInterface.locale,
appVersion: state.version.current,
consistentVersion: state.version.consistent,
upToDateVersion: !state.version.currentIsOutdated,
@@ -21,6 +21,7 @@ const mapDispatchToProps = (dispatch: ReduxDispatch, _props: ISharedRouteProps)
return {
onQuit: () => remote.app.quit(),
onClose: () => history.goBack(),
+ onViewSelectLanguage: () => history.push('/settings/language'),
onViewAccount: () => history.push('/settings/account'),
onViewSupport: () => history.push('/settings/support'),
onViewPreferences: () => history.push('/settings/preferences'),
diff --git a/gui/src/renderer/containers/WireguardKeysPage.tsx b/gui/src/renderer/containers/WireguardKeysPage.tsx
index 813526ff29..8c88a042cc 100644
--- a/gui/src/renderer/containers/WireguardKeysPage.tsx
+++ b/gui/src/renderer/containers/WireguardKeysPage.tsx
@@ -8,10 +8,10 @@ import { IWgKey } from '../redux/settings/reducers';
import { IReduxState, ReduxDispatch } from '../redux/store';
import { ISharedRouteProps } from '../routes';
-const mapStateToProps = (state: IReduxState, props: ISharedRouteProps) => ({
+const mapStateToProps = (state: IReduxState, _props: ISharedRouteProps) => ({
keyState: state.settings.wireguardKeyState,
isOffline: state.connection.isBlocked,
- locale: props.locale,
+ locale: state.userInterface.locale,
});
const mapDispatchToProps = (dispatch: ReduxDispatch, props: ISharedRouteProps) => {
const history = bindActionCreators({ push, goBack }, dispatch);
diff --git a/gui/src/renderer/redux/settings/reducers.ts b/gui/src/renderer/redux/settings/reducers.ts
index fed04b38e4..585eefbbb2 100644
--- a/gui/src/renderer/redux/settings/reducers.ts
+++ b/gui/src/renderer/redux/settings/reducers.ts
@@ -135,6 +135,7 @@ export interface ISettingsReduxState {
const initialState: ISettingsReduxState = {
autoStart: false,
guiSettings: {
+ preferredLocale: 'system',
enableSystemNotifications: true,
autoConnect: true,
monochromaticIcon: false,
diff --git a/gui/src/renderer/redux/userinterface/actions.ts b/gui/src/renderer/redux/userinterface/actions.ts
index f9e2fff444..c83ef47bff 100644
--- a/gui/src/renderer/redux/userinterface/actions.ts
+++ b/gui/src/renderer/redux/userinterface/actions.ts
@@ -1,5 +1,10 @@
import { LocationScope } from './reducers';
+export interface IUpdateLocaleAction {
+ type: 'UPDATE_LOCALE';
+ locale: string;
+}
+
export interface IUpdateWindowArrowPositionAction {
type: 'UPDATE_WINDOW_ARROW_POSITION';
arrowPosition: number;
@@ -15,10 +20,18 @@ export interface ISetLocationScopeAction {
}
export type UserInterfaceAction =
+ | IUpdateLocaleAction
| IUpdateWindowArrowPositionAction
| IUpdateConnectionInfoOpenAction
| ISetLocationScopeAction;
+function updateLocale(locale: string): IUpdateLocaleAction {
+ return {
+ type: 'UPDATE_LOCALE',
+ locale,
+ };
+}
+
function updateWindowArrowPosition(arrowPosition: number): IUpdateWindowArrowPositionAction {
return {
type: 'UPDATE_WINDOW_ARROW_POSITION',
@@ -39,4 +52,4 @@ function setLocationScope(scope: LocationScope): ISetLocationScopeAction {
};
}
-export default { updateWindowArrowPosition, toggleConnectionPanel, setLocationScope };
+export default { updateLocale, updateWindowArrowPosition, toggleConnectionPanel, setLocationScope };
diff --git a/gui/src/renderer/redux/userinterface/reducers.ts b/gui/src/renderer/redux/userinterface/reducers.ts
index 729d5194b6..7941d6001b 100644
--- a/gui/src/renderer/redux/userinterface/reducers.ts
+++ b/gui/src/renderer/redux/userinterface/reducers.ts
@@ -6,12 +6,14 @@ export enum LocationScope {
}
export interface IUserInterfaceReduxState {
+ locale: string;
arrowPosition?: number;
connectionPanelVisible: boolean;
locationScope: LocationScope;
}
const initialState: IUserInterfaceReduxState = {
+ locale: 'en',
connectionPanelVisible: false,
locationScope: LocationScope.relay,
};
@@ -21,6 +23,9 @@ export default function(
action: ReduxAction,
): IUserInterfaceReduxState {
switch (action.type) {
+ case 'UPDATE_LOCALE':
+ return { ...state, locale: action.locale };
+
case 'UPDATE_WINDOW_ARROW_POSITION':
return { ...state, arrowPosition: action.arrowPosition };
diff --git a/gui/src/renderer/routes.tsx b/gui/src/renderer/routes.tsx
index 814eeae31e..2f715c472a 100644
--- a/gui/src/renderer/routes.tsx
+++ b/gui/src/renderer/routes.tsx
@@ -9,6 +9,7 @@ import LaunchPage from './containers/LaunchPage';
import LoginPage from './containers/LoginPage';
import PlatformWindowContainer from './containers/PlatformWindowContainer';
import PreferencesPage from './containers/PreferencesPage';
+import SelectLanguagePage from './containers/SelectLanguagePage';
import SelectLocationPage from './containers/SelectLocationPage';
import SettingsPage from './containers/SettingsPage';
import SupportPage from './containers/SupportPage';
@@ -17,7 +18,6 @@ import { getTransitionProps } from './transitions';
export interface ISharedRouteProps {
app: App;
- locale: string;
}
type CustomRouteProps = {
@@ -84,6 +84,7 @@ class AppRoutes extends React.Component<IAppRoutesProps, IAppRoutesState> {
<CustomRoute exact={true} path="/login" component={LoginPage} />
<CustomRoute exact={true} path="/connect" component={ConnectPage} />
<CustomRoute exact={true} path="/settings" component={SettingsPage} />
+ <CustomRoute exact={true} path="/settings/language" component={SelectLanguagePage} />
<CustomRoute exact={true} path="/settings/account" component={AccountPage} />
<CustomRoute exact={true} path="/settings/preferences" component={PreferencesPage} />
<CustomRoute
diff --git a/gui/src/renderer/transitions.ts b/gui/src/renderer/transitions.ts
index 21c9884fb0..23ada748aa 100644
--- a/gui/src/renderer/transitions.ts
+++ b/gui/src/renderer/transitions.ts
@@ -40,6 +40,7 @@ const transitions: ITransitionMap = {
* (null) is used to indicate any route.
*/
const transitionRules = [
+ r('/settings', '/settings/language', transitions.push),
r('/settings', '/settings/account', transitions.push),
r('/settings', '/settings/preferences', transitions.push),
r('/settings', '/settings/advanced', transitions.push),
diff --git a/gui/src/shared/gettext.ts b/gui/src/shared/gettext.ts
index d7cd0f8b23..1f6af17aed 100644
--- a/gui/src/shared/gettext.ts
+++ b/gui/src/shared/gettext.ts
@@ -21,16 +21,21 @@ export function loadTranslations(currentLocale: string, catalogue: Gettext) {
preferredLocales.push(language);
}
- for (const locale of preferredLocales) {
- // NOTE: domain is not publicly exposed
- const domain = (catalogue as any).domain;
+ // NOTE: domain is not publicly exposed
+ const domain = (catalogue as any).domain;
+ for (const locale of preferredLocales) {
if (parseTranslation(locale, domain, catalogue)) {
- log.info(`Loaded translations for ${locale}`);
+ log.info(`Loaded translations ${locale}/${domain}`);
catalogue.setLocale(locale);
return;
}
}
+
+ // Reset the locale to source language if we couldn't load the catalogue for the requested locale
+ // Add empty translations to suppress some of the warnings produces by node-gettext
+ catalogue.addTranslations(SOURCE_LANGUAGE, domain, {});
+ catalogue.setLocale(SOURCE_LANGUAGE);
}
function parseTranslation(locale: string, domain: string, catalogue: Gettext): boolean {
@@ -66,7 +71,10 @@ function setErrorHandler(catalogue: Gettext) {
// Filter out the "no translation was found" errors for the source language.
// The catalogue's locale is set to an empty string when using the source translation.
- if (catalogueLocale === '' && error.indexOf('No translation was found') !== -1) {
+ if (
+ (catalogueLocale === '' || catalogueLocale === SOURCE_LANGUAGE) &&
+ error.indexOf('No translation was found') !== -1
+ ) {
return;
}
diff --git a/gui/src/shared/gui-settings-state.ts b/gui/src/shared/gui-settings-state.ts
index 42bb05a062..b897d0e7b4 100644
--- a/gui/src/shared/gui-settings-state.ts
+++ b/gui/src/shared/gui-settings-state.ts
@@ -1,5 +1,5 @@
export interface IGuiSettingsState {
- preferredLocale?: string;
+ preferredLocale: string;
enableSystemNotifications: boolean;
autoConnect: boolean;
monochromaticIcon: boolean;
diff --git a/gui/src/shared/ipc-event-channel.ts b/gui/src/shared/ipc-event-channel.ts
index 2800ab50ea..8a1a31c2d4 100644
--- a/gui/src/shared/ipc-event-channel.ts
+++ b/gui/src/shared/ipc-event-channel.ts
@@ -29,8 +29,7 @@ export interface IAppStateSnapshot {
tunnelState: TunnelState;
settings: ISettings;
location?: ILocation;
- relays: IRelayList;
- bridges: IRelayList;
+ relayListPair: IRelayListPair;
currentVersion: ICurrentAppVersionInfo;
upgradeVersion: IAppUpgradeInfo;
guiSettings: IGuiSettingsState;
@@ -89,6 +88,7 @@ interface IGuiSettingsMethods extends IReceiver<IGuiSettingsState> {
setAutoConnect(autoConnect: boolean): void;
setStartMinimized(startMinimized: boolean): void;
setMonochromaticIcon(monochromaticIcon: boolean): void;
+ setPreferredLocale(locale: string): void;
}
interface IGuiSettingsHandlers extends ISender<IGuiSettingsState> {
@@ -96,6 +96,7 @@ interface IGuiSettingsHandlers extends ISender<IGuiSettingsState> {
handleAutoConnect(fn: (autoConnect: boolean) => void): void;
handleStartMinimized(fn: (startMinimized: boolean) => void): void;
handleMonochromaticIcon(fn: (monochromaticIcon: boolean) => void): void;
+ handleSetPreferredLocale(fn: (locale: string) => void): void;
}
interface IAccountHandlers extends ISender<IAccountData | undefined> {
@@ -138,6 +139,7 @@ interface IWireguardKeyHandlers extends ISender<IWireguardPublicKey | undefined>
/// Events names
+const LOCALE_CHANGED = 'locale-changed';
const WINDOW_SHAPE_CHANGED = 'window-shape-changed';
const DAEMON_CONNECTED = 'daemon-connected';
@@ -166,6 +168,7 @@ const SET_ENABLE_SYSTEM_NOTIFICATIONS = 'set-enable-system-notifications';
const SET_AUTO_CONNECT = 'set-auto-connect';
const SET_MONOCHROMATIC_ICON = 'set-monochromatic-icon';
const SET_START_MINIMIZED = 'set-start-minimized';
+const SET_PREFERRED_LOCALE = 'set-preferred-locale';
const GET_APP_STATE = 'get-app-state';
@@ -197,6 +200,10 @@ export class IpcRendererEventChannel {
},
};
+ public static locale: IReceiver<string> = {
+ listen: listen(LOCALE_CHANGED),
+ };
+
public static windowShape: IReceiver<IWindowShapeParameters> = {
listen: listen(WINDOW_SHAPE_CHANGED),
};
@@ -248,6 +255,7 @@ export class IpcRendererEventChannel {
setAutoConnect: set(SET_AUTO_CONNECT),
setMonochromaticIcon: set(SET_MONOCHROMATIC_ICON),
setStartMinimized: set(SET_START_MINIMIZED),
+ setPreferredLocale: set(SET_PREFERRED_LOCALE),
};
public static autoStart: IAutoStartMethods = {
@@ -283,8 +291,12 @@ export class IpcMainEventChannel {
},
};
+ public static locale: ISender<string> = {
+ notify: sender(LOCALE_CHANGED),
+ };
+
public static windowShape: ISender<IWindowShapeParameters> = {
- notify: sender<IWindowShapeParameters>(WINDOW_SHAPE_CHANGED),
+ notify: sender(WINDOW_SHAPE_CHANGED),
};
public static daemonConnected: ISenderVoid = {
@@ -334,6 +346,7 @@ export class IpcMainEventChannel {
handleAutoConnect: handler(SET_AUTO_CONNECT),
handleMonochromaticIcon: handler(SET_MONOCHROMATIC_ICON),
handleStartMinimized: handler(SET_START_MINIMIZED),
+ handleSetPreferredLocale: handler(SET_PREFERRED_LOCALE),
};
public static autoStart: IAutoStartHandlers = {