summaryrefslogtreecommitdiffhomepage
path: root/gui
diff options
context:
space:
mode:
authorAndrej Mihajlov <and@mullvad.net>2019-09-25 11:49:56 +0200
committerAndrej Mihajlov <and@mullvad.net>2019-09-25 11:49:56 +0200
commitc92f52de79106bc2f82dabc9944107af64ada15c (patch)
tree91d8d228b7277c27779abda8adedc9ef04dfbfc2 /gui
parent5a7859670c4a22cb5fecd39c7c7c410f8bef4ff1 (diff)
parent4fdd4eb91f84440a59cf23cf5d3b06ea6c9dbe8c (diff)
downloadmullvadvpn-c92f52de79106bc2f82dabc9944107af64ada15c.tar.xz
mullvadvpn-c92f52de79106bc2f82dabc9944107af64ada15c.zip
Merge branch 'gui-language'
Diffstat (limited to 'gui')
-rw-r--r--gui/assets/images/icon-language.svg1
-rw-r--r--gui/src/main/gui-settings.ts48
-rw-r--r--gui/src/main/index.ts49
-rw-r--r--gui/src/renderer/app.tsx131
-rw-r--r--gui/src/renderer/components/AdvancedSettings.tsx68
-rw-r--r--gui/src/renderer/components/Cell.tsx24
-rw-r--r--gui/src/renderer/components/CityRow.tsx11
-rw-r--r--gui/src/renderer/components/CountryRow.tsx11
-rw-r--r--gui/src/renderer/components/LocationList.tsx13
-rw-r--r--gui/src/renderer/components/Login.tsx2
-rw-r--r--gui/src/renderer/components/RelayRow.tsx9
-rw-r--r--gui/src/renderer/components/RelayStatusIndicator.tsx8
-rw-r--r--gui/src/renderer/components/SelectLanguage.tsx119
-rw-r--r--gui/src/renderer/components/Selector.tsx82
-rw-r--r--gui/src/renderer/components/Settings.tsx57
-rw-r--r--gui/src/renderer/components/SettingsStyles.tsx23
-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.tsx6
-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.ts34
-rw-r--r--gui/src/renderer/redux/userinterface/reducers.ts10
-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.ts17
-rw-r--r--gui/src/shared/ipc-event-channel.ts19
29 files changed, 579 insertions, 229 deletions
diff --git a/gui/assets/images/icon-language.svg b/gui/assets/images/icon-language.svg
new file mode 100644
index 0000000000..8d3c8ab1a1
--- /dev/null
+++ b/gui/assets/images/icon-language.svg
@@ -0,0 +1 @@
+<svg height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><path d="m11.988281 3.085938-9.28125-2.828126v17.4375l9.28125-2.59375z" fill="#040606"/><path d="m11.765625 3.078125 9.640625-2.828125v17.4375l-9.640625-2.59375z" fill="#fff"/><path d="m.300781 20.75 11.464844-3.300781v-14.375l-11.464844 3.300781z" fill="#fff"/><g fill="#040606"><path d="m16.992188 20.964844 1.625 2.308594.855468-2.144532z"/><path d="m4.199219 8.03125c-.0625-.054688.078125.421875.273437.589844.347656.300781.617188.339844.761719.34375.316406.011718.710937-.066406.945313-.152344.222656-.082031.621093-.257812.769531-.511719.03125-.054687.117187-.144531.0625-.371093-.039063-.171876-.167969-.234376-.324219-.222657-.15625.007813-.628906.117188-.855469.175781-.230469.0625-.699219.183594-.90625.222657-.203125.039062-.65625-.015625-.726562-.074219"/><path d="m9.984375 13.6875c-.089844-.027344-1.960937-.695312-2.226563-.804688-.214843-.089843-.746093-.285156-.996093-.371093.703125-.9375 1.148437-1.644531 1.207031-1.753907.109375-.199218.855469-1.457031.875-1.535156.015625-.078125.035156-.367187.019531-.4375-.015625-.070312-.289062.066406-.660156.171875-.371094.109375-1.078125.507813-1.351563.554688-.273437.050781-1.148437.335937-1.597656.464843-.449218.128907-1.296875.351563-1.644531.433594-.347656.082032-.652344.085938-.847656.136719 0 0 .023437.238281.078125.308594.050781.070312.234375.246093.449218.292969.214844.046874.570313.027343.730469-.003907.164063-.03125.445313-.148437.480469-.199219.039062-.054687-.019531-.214843.046875-.265624.0625-.046876.917969-.21875 1.238281-.304688.320313-.085938 1.554688-.453125 1.722656-.433594-.054687.152344-1.046874 1.847656-1.363281 2.355469-.320312.503906-2.175781 2.730469-2.574219 3.121094-.296874.300781-1.023437 1.0625-1.277343 1.234375.0625.015625.515625-.019532.597656-.0625.507813-.269532 1.359375-1.183594 1.636719-1.464844.8125-.824219 1.527344-1.691406 2.097656-2.433594.109375.039063 1.003906.667969 1.238281.808594.230469.140625 1.152344.582031 1.351563.65625.199218.074219.964844.378906 1 .277344.03125-.105469-.140625-.714844-.230469-.746094"/><path d="m5.664062 22.09375c.179688.09375.347657.171875.539063.25.375.160156.804687.332031 1.210937.460938.558594.183593 1.117188.328124 1.671876.4375.308593.0625.648437.113281.976562.15625.03125 0 .917969.09375 1.09375.09375h.898438c.347656-.023438.675781-.042969 1.023437-.085938.28125-.035156.585937-.078125.886719-.136719.21875-.042969.449218-.085937.667968-.144531.207032-.050781.445313-.121094.675782-.191406.148437-.042969.308594-.101563.46875-.152344.128906-.050781.289062-.113281.4375-.164062.179687-.070313.386718-.164063.585937-.25.160157-.066407.339844-.152344.507813-.238282.128906-.0625.429687-.257812.585937-.257812.179688 0 .300781.136718.300781.257812 0 .246094-.390624.324219-.566406.4375-.191406.109375-.417968.195313-.617187.292969-.398438.179687-.808594.332031-1.195313.460937-.507812.164063-1.066406.320313-1.5625.421876-.191406.035156-.378906.078124-.566406.101562-.101562.019531-1.136719.15625-1.425781.15625h-1.3125c-.347657-.027344-.71875-.058594-1.066407-.101562-.308593-.042969-.636718-.097657-.945312-.15625-.238281-.042969-.496094-.101563-.726562-.164063-.398438-.09375-.785157-.214844-1.164063-.34375-.6875-.222656-1.402344-.515625-2.078125-.902344-.121094-.066406-.128906-.136719-.128906-.214843 0-.128907.109375-.246094.285156-.246094.160156 0 .480469.195312.539062.222656" fill-rule="evenodd"/><path d="m12.0625 3.039062v14.433594c-.007812.042969-.027344.085938-.070312.128906-.019532.023438-.058594.058594-.085938.066407-.25.085937-11.457031 3.335937-11.605469 3.335937-.121093 0-.2304685-.070312-.2890622-.183594 0-.007812-.0117188-.015624-.0117188-.03125v-14.441406c.0195312-.042968.03125-.101562.0703125-.136718.0820315-.09375.2187495-.113282.3085935-.136719.167969-.050781 11.207032-3.25 11.367188-3.25.097656 0 .316406.0625.316406.214843zm-.605469 14.21875-10.847656 3.117188v-13.8125l10.847656-3.117188z" fill-rule="evenodd"/><path d="m21.707031.273438v17.378906c-.007812.199218-.167969.285156-.316406.285156-.132813 0-1.066406-.277344-1.226563-.320312-1.253906-.335938-2.515624-.667969-3.761718-1.003907-.277344-.078125-.566406-.15625-.835938-.234375-.238281-.058594-.496094-.125-.734375-.195312-1.066406-.285156-2.152343-.566406-3.214843-.875-.042969-.011719-.140626-.128906-.140626-.15625v-12.136719c.019532-.042969.039063-.09375.089844-.128906.078125-.078125 3.492188-1.058594 4.835938-1.445313.359375-.109375 4.847656-1.441406 4.988281-1.441406.175781 0 .316406.113281.316406.273438zm-.605469 17.050781-9.027343-2.421875v-11.636719l9.027343-2.648437z" fill-rule="evenodd"/><path d="m24 20.964844-12.0625-3.316406.050781-14.441407 12.011719 3.296875z"/></g><path d="m17.207031 7.328125 1.554688.40625 2.832031 8.804687-1.597656-.417968-.570313-1.808594-3.300781-.859375-.707031 1.472656-1.597657-.417969zm.710938 2.332031-1.183594 2.46875 2.175781.570313z" fill="#fff" fill-rule="evenodd"/></svg>
diff --git a/gui/src/main/gui-settings.ts b/gui/src/main/gui-settings.ts
index 895781099e..c860bfe490 100644
--- a/gui/src/main/gui-settings.ts
+++ b/gui/src/main/gui-settings.ts
@@ -2,14 +2,39 @@ 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, SYSTEM_PREFERRED_LOCALE_KEY } from '../shared/gui-settings-state';
-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_PREFERRED_LOCALE_KEY,
+ autoConnect: true,
+ enableSystemNotifications: true,
+ monochromaticIcon: false,
+ startMinimized: false,
+};
export default class GuiSettings {
get state(): IGuiSettingsState {
return this.stateValue;
}
+ set preferredLocale(newValue: string) {
+ this.changeStateAndNotify({ ...this.stateValue, preferredLocale: newValue });
+ }
+
+ get preferredLocale(): string {
+ return this.stateValue.preferredLocale;
+ }
+
set enableSystemNotifications(newValue: boolean) {
this.changeStateAndNotify({ ...this.stateValue, enableSystemNotifications: newValue });
}
@@ -44,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..5b5c58d108 100644
--- a/gui/src/main/index.ts
+++ b/gui/src/main/index.ts
@@ -23,6 +23,7 @@ import {
TunnelState,
} from '../shared/daemon-rpc-types';
import { loadTranslations, messages } from '../shared/gettext';
+import { SYSTEM_PREFERRED_LOCALE_KEY } from '../shared/gui-settings-state';
import { IpcMainEventChannel } from '../shared/ipc-event-channel';
import {
backupLogFile,
@@ -297,24 +298,17 @@ class ApplicationMain {
}
}
- private getLocaleWithOverride(): string {
- const localeOverride = process.env.MULLVAD_LOCALE;
- if (localeOverride) {
- const trimmedLocaleOverride = localeOverride.trim();
- if (trimmedLocaleOverride.length > 0) {
- return trimmedLocaleOverride;
- }
+ private detectLocale(): string {
+ const preferredLocale = this.guiSettings.preferredLocale;
+ if (preferredLocale === SYSTEM_PREFERRED_LOCALE_KEY) {
+ return app.getLocale();
+ } 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 +925,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 +984,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 +1197,22 @@ class ApplicationMain {
return Promise.resolve();
}
+ private updateCurrentLocale() {
+ this.locale = this.detectLocale();
+
+ 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..2613aacb88 100644
--- a/gui/src/renderer/app.tsx
+++ b/gui/src/renderer/app.tsx
@@ -23,7 +23,7 @@ import versionActions from './redux/version/actions';
import { IAppUpgradeInfo, ICurrentAppVersionInfo } from '../main';
import { cities, countries, loadTranslations, messages, relayLocations } from '../shared/gettext';
-import { IGuiSettingsState } from '../shared/gui-settings-state';
+import { IGuiSettingsState, SYSTEM_PREFERRED_LOCALE_KEY } from '../shared/gui-settings-state';
import { IpcRendererEventChannel, IRelayListPair } from '../shared/ipc-event-channel';
import { getRendererLogFile, setupLogging } from '../shared/logging';
@@ -43,6 +43,28 @@ import {
TunnelState,
} from '../shared/daemon-rpc-types';
+interface IPreferredLocaleDescriptor {
+ name: string;
+ code: string;
+}
+
+const SUPPORTED_LOCALE_LIST = [
+ { 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,39 @@ export default class AppRenderer {
actions.settings.setWireguardKeygenEvent(keygenEvent);
}
+ public getPreferredLocaleList(): IPreferredLocaleDescriptor[] {
+ return [
+ {
+ // TRANSLATORS: The option that represents the active operating system language in the
+ // TRANSLATORS: user interface language selection list.
+ name: messages.gettext('System default'),
+ code: SYSTEM_PREFERRED_LOCALE_KEY,
+ },
+ ...SUPPORTED_LOCALE_LIST,
+ ];
+ }
+
+ public setPreferredLocale(preferredLocale: string) {
+ IpcRendererEventChannel.guiSettings.setPreferredLocale(preferredLocale);
+ }
+
+ private getPreferredLocaleDisplayName(localeCode: string): string {
+ const preferredLocale = this.getPreferredLocaleList().find((item) => item.code === localeCode);
+
+ return preferredLocale ? preferredLocale.name : '';
+ }
+
+ 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 +604,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 +654,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) {
@@ -612,6 +678,9 @@ export default class AppRenderer {
private setGuiSettings(guiSettings: IGuiSettingsState) {
this.guiSettings = guiSettings;
this.reduxActions.settings.updateGuiSettings(guiSettings);
+ this.reduxActions.userInterface.updatePreferredLocaleName(
+ this.getPreferredLocaleDisplayName(guiSettings.preferredLocale),
+ );
}
private setAccountExpiry(expiry?: string) {
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/Cell.tsx b/gui/src/renderer/components/Cell.tsx
index 397b7513a0..1693f85c2b 100644
--- a/gui/src/renderer/components/Cell.tsx
+++ b/gui/src/renderer/components/Cell.tsx
@@ -23,6 +23,9 @@ const styles = {
hover: Styles.createButtonStyle({
backgroundColor: colors.blue80,
}),
+ selected: Styles.createViewStyle({
+ backgroundColor: colors.green,
+ }),
},
cellContainer: Styles.createViewStyle({
backgroundColor: colors.blue,
@@ -122,8 +125,9 @@ const styles = {
interface ICellButtonProps {
children?: React.ReactNode;
disabled?: boolean;
- cellHoverStyle?: Types.StyleRuleSetRecursive<Types.ButtonStyleRuleSet>;
+ selected?: boolean;
style?: Types.StyleRuleSetRecursive<Types.ButtonStyleRuleSet>;
+ hoverStyle?: Types.StyleRuleSetRecursive<Types.ButtonStyleRuleSet>;
onPress?: () => void;
}
@@ -135,14 +139,20 @@ const CellSectionContext = React.createContext<boolean>(false);
const CellHoverContext = React.createContext<boolean>(false);
export class CellButton extends Component<ICellButtonProps, IState> {
- public state = { hovered: false };
+ public state: IState = { hovered: false };
public onHoverStart = () => (!this.props.disabled ? this.setState({ hovered: true }) : null);
public onHoverEnd = () => (!this.props.disabled ? this.setState({ hovered: false }) : null);
public render() {
- const { children, style, cellHoverStyle, ...otherProps } = this.props;
- const hoverStyle = cellHoverStyle || styles.cellButton.hover;
+ const { children, style, hoverStyle, ...otherProps } = this.props;
+
+ const stateStyle = this.props.selected
+ ? styles.cellButton.selected
+ : this.state.hovered
+ ? hoverStyle || styles.cellButton.hover
+ : undefined;
+
return (
<CellSectionContext.Consumer>
{(containedInSection) => (
@@ -151,7 +161,7 @@ export class CellButton extends Component<ICellButtonProps, IState> {
styles.cellButton.base,
containedInSection ? styles.cellButton.section : undefined,
style,
- this.state.hovered ? hoverStyle : undefined,
+ stateStyle,
]}
onHoverStart={this.onHoverStart}
onHoverEnd={this.onHoverEnd}
@@ -371,6 +381,10 @@ export const Icon = function CellIcon(props: ImageView['props']) {
);
};
+export const UntintedIcon = function CellIcon(props: ImageView['props']) {
+ return <ImageView {...props} style={[styles.icon, props.style]} />;
+};
+
export const Footer = function CellFooter({ children }: IContainerProps) {
return (
<View style={styles.footer.container}>
diff --git a/gui/src/renderer/components/CityRow.tsx b/gui/src/renderer/components/CityRow.tsx
index 331c6b9fdd..c8041abdbd 100644
--- a/gui/src/renderer/components/CityRow.tsx
+++ b/gui/src/renderer/components/CityRow.tsx
@@ -27,9 +27,6 @@ const styles = {
paddingLeft: 32,
backgroundColor: colors.blue40,
}),
- selected: Styles.createButtonStyle({
- backgroundColor: colors.green,
- }),
};
export default class CityRow extends Component<IProps> {
@@ -75,11 +72,11 @@ export default class CityRow extends Component<IProps> {
<Cell.CellButton
onPress={this.handlePress}
disabled={!this.props.hasActiveRelays}
- cellHoverStyle={this.props.selected ? styles.selected : undefined}
- style={[styles.base, this.props.selected ? styles.selected : undefined]}>
+ selected={this.props.selected}
+ style={styles.base}>
<RelayStatusIndicator
- isActive={this.props.hasActiveRelays}
- isSelected={this.props.selected}
+ active={this.props.hasActiveRelays}
+ selected={this.props.selected}
/>
<Cell.Label>{this.props.name}</Cell.Label>
diff --git a/gui/src/renderer/components/CountryRow.tsx b/gui/src/renderer/components/CountryRow.tsx
index 54e1003736..46f40694c9 100644
--- a/gui/src/renderer/components/CountryRow.tsx
+++ b/gui/src/renderer/components/CountryRow.tsx
@@ -1,6 +1,5 @@
import * as React from 'react';
import { Component, Styles, Types, View } from 'reactxp';
-import { colors } from '../../config.json';
import { compareRelayLocation, RelayLocation } from '../../shared/daemon-rpc-types';
import Accordion from './Accordion';
import * as Cell from './Cell';
@@ -30,9 +29,6 @@ const styles = {
paddingRight: 0,
paddingLeft: 16,
}),
- selected: Styles.createViewStyle({
- backgroundColor: colors.green,
- }),
};
export default class CountryRow extends Component<IProps> {
@@ -82,13 +78,12 @@ export default class CountryRow extends Component<IProps> {
return (
<View style={styles.container}>
<Cell.CellButton
- cellHoverStyle={this.props.selected ? styles.selected : undefined}
- style={[styles.base, this.props.selected ? styles.selected : undefined]}
+ style={styles.base}
onPress={this.handlePress}
disabled={!this.props.hasActiveRelays}>
<RelayStatusIndicator
- isActive={this.props.hasActiveRelays}
- isSelected={this.props.selected}
+ active={this.props.hasActiveRelays}
+ selected={this.props.selected}
/>
<Cell.Label>{this.props.name}</Cell.Label>
{hasChildren ? (
diff --git a/gui/src/renderer/components/LocationList.tsx b/gui/src/renderer/components/LocationList.tsx
index fb1e7ae8f9..a86ae25897 100644
--- a/gui/src/renderer/components/LocationList.tsx
+++ b/gui/src/renderer/components/LocationList.tsx
@@ -1,5 +1,5 @@
import * as React from 'react';
-import { Component, Styles, View } from 'reactxp';
+import { Component, View } from 'reactxp';
import { colors } from '../../config.json';
import {
compareRelayLocation,
@@ -13,12 +13,6 @@ import CityRow from './CityRow';
import CountryRow from './CountryRow';
import RelayRow from './RelayRow';
-const styles = {
- selectedCell: Styles.createViewStyle({
- backgroundColor: colors.green,
- }),
-};
-
export enum LocationSelectionType {
relay = 'relay',
special = 'special',
@@ -226,10 +220,7 @@ interface ISpecialLocationProps<T> {
export class SpecialLocation<T> extends Component<ISpecialLocationProps<T>> {
public render() {
return (
- <Cell.CellButton
- style={this.props.isSelected ? styles.selectedCell : undefined}
- cellHoverStyle={this.props.isSelected ? styles.selectedCell : undefined}
- onPress={this.onSelect}>
+ <Cell.CellButton selected={this.props.isSelected} onPress={this.onSelect}>
<Cell.Icon
source={this.props.isSelected ? 'icon-tick' : this.props.icon}
tintColor={colors.white}
diff --git a/gui/src/renderer/components/Login.tsx b/gui/src/renderer/components/Login.tsx
index 4773e40dbc..9e2ff82433 100644
--- a/gui/src/renderer/components/Login.tsx
+++ b/gui/src/renderer/components/Login.tsx
@@ -449,7 +449,7 @@ class AccountDropdownItem extends Component<IAccountDropdownItemProps> {
<View style={styles.account_dropdown__spacer} />
<Cell.CellButton
style={styles.account_dropdown__item}
- cellHoverStyle={styles.account_dropdown__item_hover}>
+ hoverStyle={styles.account_dropdown__item_hover}>
<Cell.Label
textStyle={styles.account_dropdown__label}
containerStyle={styles.account_dropdown__label_container}
diff --git a/gui/src/renderer/components/RelayRow.tsx b/gui/src/renderer/components/RelayRow.tsx
index 35cb1d2be3..cd87610944 100644
--- a/gui/src/renderer/components/RelayRow.tsx
+++ b/gui/src/renderer/components/RelayRow.tsx
@@ -19,9 +19,6 @@ const styles = {
paddingLeft: 48,
backgroundColor: colors.blue20,
}),
- selected: Styles.createViewStyle({
- backgroundColor: colors.green,
- }),
};
export default class RelayRow extends Component<IProps> {
@@ -42,10 +39,10 @@ export default class RelayRow extends Component<IProps> {
return (
<Cell.CellButton
onPress={this.handlePress}
- cellHoverStyle={this.props.selected ? styles.selected : undefined}
+ selected={this.props.selected}
disabled={!this.props.active}
- style={[styles.base, this.props.selected ? styles.selected : undefined]}>
- <RelayStatusIndicator isActive={this.props.active} isSelected={this.props.selected} />
+ style={styles.base}>
+ <RelayStatusIndicator active={this.props.active} selected={this.props.selected} />
<Cell.Label>{this.props.hostname}</Cell.Label>
</Cell.CellButton>
diff --git a/gui/src/renderer/components/RelayStatusIndicator.tsx b/gui/src/renderer/components/RelayStatusIndicator.tsx
index 594dbec141..26c7f616a1 100644
--- a/gui/src/renderer/components/RelayStatusIndicator.tsx
+++ b/gui/src/renderer/components/RelayStatusIndicator.tsx
@@ -20,16 +20,16 @@ const styles = {
};
interface IProps {
- isActive: boolean;
- isSelected: boolean;
+ active: boolean;
+ selected: boolean;
}
export default class RelayStatusIndicator extends Component<IProps> {
public render() {
- return this.props.isSelected ? (
+ return this.props.selected ? (
<Cell.Icon tintColor={colors.white} source="icon-tick" height={24} width={24} />
) : (
- <View style={[styles.relayStatus, this.props.isActive ? styles.active : styles.inactive]} />
+ <View style={[styles.relayStatus, this.props.active ? styles.active : styles.inactive]} />
);
}
}
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..1bbc00ba9a
--- /dev/null
+++ b/gui/src/renderer/components/Selector.tsx
@@ -0,0 +1,82 @@
+import * as React from 'react';
+import { Component, Styles, Types, View } 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,
+ }),
+ invisibleIcon: Styles.createViewStyle({
+ opacity: 0,
+ }),
+};
+
+export default class Selector<T> extends Component<ISelectorProps<T>> {
+ public render() {
+ const items = 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>
+ );
+ });
+
+ if (this.props.title) {
+ return <Cell.Section style={[styles.section, this.props.style]}>{items}</Cell.Section>;
+ } else {
+ return <View style={[styles.section, this.props.style]}>{items}</View>;
+ }
+ }
+}
+
+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 onPress={this.onPress} selected={this.props.selected}>
+ <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..129550f3a0 100644
--- a/gui/src/renderer/components/Settings.tsx
+++ b/gui/src/renderer/components/Settings.tsx
@@ -5,7 +5,6 @@ import { messages } from '../../shared/gettext';
import AccountExpiry from '../lib/account-expiry';
import * as AppButton from './AppButton';
import * as Cell from './Cell';
-import ImageView from './ImageView';
import { Container, Layout } from './Layout';
import {
CloseBarItem,
@@ -21,6 +20,7 @@ import styles from './SettingsStyles';
import { LoginState } from '../redux/account/reducers';
export interface IProps {
+ preferredLocaleDisplayName: string;
loginState: LoginState;
accountExpiry?: string;
expiryLocale: string;
@@ -30,6 +30,7 @@ export interface IProps {
isOffline: boolean;
onQuit: () => void;
onClose: () => void;
+ onViewSelectLanguage: () => void;
onViewAccount: () => void;
onViewSupport: () => void;
onViewPreferences: () => void;
@@ -39,12 +40,14 @@ export interface IProps {
export default class Settings extends Component<IProps> {
public render() {
+ const showLargeTitle = this.props.loginState !== 'ok';
+
return (
<Layout>
<Container>
<View style={styles.settings}>
<NavigationContainer>
- <NavigationBar>
+ <NavigationBar alwaysDisplayBarTitle={!showLargeTitle}>
<NavigationItems>
<CloseBarItem action={this.props.onClose} />
<TitleBarItem>
@@ -54,12 +57,14 @@ export default class Settings extends Component<IProps> {
</NavigationItems>
</NavigationBar>
- <View style={styles.settings__container}>
- <NavigationScrollbars style={styles.settings__scrollview}>
- <View style={styles.settings__content}>
- <SettingsHeader>
- <HeaderTitle>{messages.pgettext('settings-view', 'Settings')}</HeaderTitle>
- </SettingsHeader>
+ <View style={styles.container}>
+ <NavigationScrollbars style={styles.scrollview}>
+ <View style={styles.content}>
+ {showLargeTitle && (
+ <SettingsHeader>
+ <HeaderTitle>{messages.pgettext('settings-view', 'Settings')}</HeaderTitle>
+ </SettingsHeader>
+ )}
<View>
{this.renderTopButtons()}
{this.renderMiddleButtons()}
@@ -76,9 +81,12 @@ export default class Settings extends Component<IProps> {
);
}
+ private openDownloadLink = () => this.props.onExternalLink(links.download);
+ private openFaqLink = () => this.props.onExternalLink(links.faq);
+
private renderQuitButton() {
return (
- <View style={styles.settings__footer}>
+ <View style={styles.quitButtonFooter}>
<AppButton.RedButton onPress={this.props.onQuit}>
{messages.pgettext('settings-view', 'Quit app')}
</AppButton.RedButton>
@@ -105,8 +113,7 @@ export default class Settings extends Component<IProps> {
<View>
<Cell.CellButton onPress={this.props.onViewAccount}>
<Cell.Label>{messages.pgettext('settings-view', 'Account')}</Cell.Label>
- <Cell.SubText
- style={isOutOfTime ? styles.settings__account_paid_until_label__error : undefined}>
+ <Cell.SubText style={isOutOfTime ? styles.accountPaidUntilErrorLabel : undefined}>
{isOutOfTime ? outOfTimeMessage : formattedExpiry}
</Cell.SubText>
<Cell.Icon height={12} width={7} source="icon-chevron" />
@@ -122,7 +129,7 @@ export default class Settings extends Component<IProps> {
<Cell.Label>{messages.pgettext('settings-view', 'Advanced')}</Cell.Label>
<Cell.Icon height={12} width={7} source="icon-chevron" />
</Cell.CellButton>
- <View style={styles.settings__cell_spacer} />
+ <View style={styles.cellSpacer} />
</View>
);
}
@@ -145,20 +152,14 @@ export default class Settings extends Component<IProps> {
? inconsistentVersionMessage
: updateAvailableMessage;
- icon = (
- <ImageView
- source="icon-alert"
- tintColor={colors.red}
- style={styles.settings__version_warning}
- />
- );
+ icon = <Cell.UntintedIcon source="icon-alert" tintColor={colors.red} />;
footer = (
- <View style={styles.settings__cell_footer}>
- <Text style={styles.settings__cell_footer_label}>{message}</Text>
+ <View style={styles.cellFooter}>
+ <Text style={styles.cellFooterLabel}>{message}</Text>
</View>
);
} else {
- footer = <View style={styles.settings__cell_spacer} />;
+ footer = <View style={styles.cellSpacer} />;
}
return (
@@ -166,7 +167,7 @@ export default class Settings extends Component<IProps> {
<Cell.CellButton disabled={this.props.isOffline} onPress={this.openDownloadLink}>
{icon}
<Cell.Label>{messages.pgettext('settings-view', 'App version')}</Cell.Label>
- <Cell.SubText style={styles.settings__appversion}>{this.props.appVersion}</Cell.SubText>
+ <Cell.SubText style={styles.appVersionLabel}>{this.props.appVersion}</Cell.SubText>
<Cell.Icon height={16} width={16} source="icon-extLink" />
</Cell.CellButton>
{footer}
@@ -174,9 +175,6 @@ export default class Settings extends Component<IProps> {
);
}
- private openDownloadLink = () => this.props.onExternalLink(links.download);
- private openFaqLink = () => this.props.onExternalLink(links.faq);
-
private renderBottomButtons() {
return (
<View>
@@ -189,6 +187,13 @@ export default class Settings extends Component<IProps> {
<Cell.Label>{messages.pgettext('settings-view', 'FAQs & Guides')}</Cell.Label>
<Cell.Icon height={16} width={16} source="icon-extLink" />
</Cell.CellButton>
+
+ <Cell.CellButton onPress={this.props.onViewSelectLanguage}>
+ <Cell.UntintedIcon width={24} height={24} source="icon-language" />
+ <Cell.Label>{messages.pgettext('settings-view', 'Language')}</Cell.Label>
+ <Cell.SubText>{this.props.preferredLocaleDisplayName}</Cell.SubText>
+ <Cell.Icon height={12} width={7} source="icon-chevron" />
+ </Cell.CellButton>
</View>
);
}
diff --git a/gui/src/renderer/components/SettingsStyles.tsx b/gui/src/renderer/components/SettingsStyles.tsx
index 5c9a66fd97..51a215d0fc 100644
--- a/gui/src/renderer/components/SettingsStyles.tsx
+++ b/gui/src/renderer/components/SettingsStyles.tsx
@@ -6,43 +6,40 @@ export default {
backgroundColor: colors.darkBlue,
flex: 1,
}),
- settings__container: Styles.createViewStyle({
+ container: Styles.createViewStyle({
flexDirection: 'column',
flex: 1,
}),
- settings__content: Styles.createViewStyle({
+ content: Styles.createViewStyle({
flexDirection: 'column',
flex: 1,
justifyContent: 'space-between',
overflow: 'visible',
}),
// plain CSS style
- settings__scrollview: {
+ scrollview: {
flex: 1,
},
- settings__cell_spacer: Styles.createViewStyle({
+ cellSpacer: Styles.createViewStyle({
height: 24,
flex: 0,
}),
- settings__cell_footer: Styles.createViewStyle({
+ cellFooter: Styles.createViewStyle({
paddingTop: 8,
paddingRight: 24,
paddingBottom: 24,
paddingLeft: 24,
}),
- settings__footer: Styles.createViewStyle({
+ quitButtonFooter: Styles.createViewStyle({
paddingTop: 24,
- paddingBottom: 24,
+ paddingBottom: 19,
paddingLeft: 24,
paddingRight: 24,
}),
- settings__version_warning: Styles.createViewStyle({
- marginLeft: 8,
- }),
- settings__account_paid_until_label__error: Styles.createTextStyle({
+ accountPaidUntilErrorLabel: Styles.createTextStyle({
color: colors.red,
}),
- settings__cell_footer_label: Styles.createTextStyle({
+ cellFooterLabel: Styles.createTextStyle({
fontFamily: 'Open Sans',
fontSize: 13,
fontWeight: '600',
@@ -50,7 +47,7 @@ export default {
letterSpacing: -0.2,
color: colors.white60,
}),
- settings__appversion: Styles.createTextStyle({
+ appVersionLabel: Styles.createTextStyle({
flex: 0,
}),
};
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..927a2cd40f 100644
--- a/gui/src/renderer/containers/SettingsPage.tsx
+++ b/gui/src/renderer/containers/SettingsPage.tsx
@@ -7,10 +7,11 @@ 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) => ({
+ preferredLocaleDisplayName: state.userInterface.preferredLocaleName,
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 +22,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..d9895625a3 100644
--- a/gui/src/renderer/redux/userinterface/actions.ts
+++ b/gui/src/renderer/redux/userinterface/actions.ts
@@ -1,5 +1,15 @@
import { LocationScope } from './reducers';
+export interface IUpdateLocaleAction {
+ type: 'UPDATE_LOCALE';
+ locale: string;
+}
+
+export interface IUpdatePreferredLocaleNameAction {
+ type: 'UPDATE_PREFERRED_LOCALE_NAME';
+ name: string;
+}
+
export interface IUpdateWindowArrowPositionAction {
type: 'UPDATE_WINDOW_ARROW_POSITION';
arrowPosition: number;
@@ -15,10 +25,26 @@ export interface ISetLocationScopeAction {
}
export type UserInterfaceAction =
+ | IUpdateLocaleAction
+ | IUpdatePreferredLocaleNameAction
| IUpdateWindowArrowPositionAction
| IUpdateConnectionInfoOpenAction
| ISetLocationScopeAction;
+function updateLocale(locale: string): IUpdateLocaleAction {
+ return {
+ type: 'UPDATE_LOCALE',
+ locale,
+ };
+}
+
+function updatePreferredLocaleName(name: string): IUpdatePreferredLocaleNameAction {
+ return {
+ type: 'UPDATE_PREFERRED_LOCALE_NAME',
+ name,
+ };
+}
+
function updateWindowArrowPosition(arrowPosition: number): IUpdateWindowArrowPositionAction {
return {
type: 'UPDATE_WINDOW_ARROW_POSITION',
@@ -39,4 +65,10 @@ function setLocationScope(scope: LocationScope): ISetLocationScopeAction {
};
}
-export default { updateWindowArrowPosition, toggleConnectionPanel, setLocationScope };
+export default {
+ updateLocale,
+ updatePreferredLocaleName,
+ updateWindowArrowPosition,
+ toggleConnectionPanel,
+ setLocationScope,
+};
diff --git a/gui/src/renderer/redux/userinterface/reducers.ts b/gui/src/renderer/redux/userinterface/reducers.ts
index 729d5194b6..6afaadde86 100644
--- a/gui/src/renderer/redux/userinterface/reducers.ts
+++ b/gui/src/renderer/redux/userinterface/reducers.ts
@@ -6,12 +6,16 @@ export enum LocationScope {
}
export interface IUserInterfaceReduxState {
+ locale: string;
+ preferredLocaleName: string;
arrowPosition?: number;
connectionPanelVisible: boolean;
locationScope: LocationScope;
}
const initialState: IUserInterfaceReduxState = {
+ locale: 'en',
+ preferredLocaleName: 'English',
connectionPanelVisible: false,
locationScope: LocationScope.relay,
};
@@ -21,6 +25,12 @@ export default function(
action: ReduxAction,
): IUserInterfaceReduxState {
switch (action.type) {
+ case 'UPDATE_LOCALE':
+ return { ...state, locale: action.locale };
+
+ case 'UPDATE_PREFERRED_LOCALE_NAME':
+ return { ...state, preferredLocaleName: action.name };
+
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 fdda92d830..043892f833 100644
--- a/gui/src/shared/gui-settings-state.ts
+++ b/gui/src/shared/gui-settings-state.ts
@@ -1,6 +1,23 @@
+// This is a special value which is when contained within IGuiSettingsState.preferredLocale
+// indicates that app should use the active operating system locale to determine the UI language.
+export const SYSTEM_PREFERRED_LOCALE_KEY = 'system';
+
export interface IGuiSettingsState {
+ // A user interface locale.
+ // Use 'system' to opt-in for active locale set in the operating system
+ // (see SYSTEM_PREFERRED_LOCALE_KEY)
+ preferredLocale: string;
+
+ // Enable or disable system notifications on tunnel state etc.
enableSystemNotifications: boolean;
+
+ // Tells the app to activate auto-connect feature in the mullvad-daemon, but only if the app is
+ // set to auto-start with the system.
autoConnect: boolean;
+
+ // Tells the app to use monochromatic set of icons for tray.
monochromaticIcon: boolean;
+
+ // Tells the app to hide the main window on start.
startMinimized: 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 = {