diff options
| author | Andrej Mihajlov <and@mullvad.net> | 2019-09-23 12:29:26 +0200 |
|---|---|---|
| committer | Andrej Mihajlov <and@mullvad.net> | 2019-09-25 10:50:58 +0200 |
| commit | 54aca7f5c0f54fb42975d8c5e288627308eef5c5 (patch) | |
| tree | 1a38aaca469f4a10b91f072dc60b3368226e96d8 | |
| parent | ff1ffa5b8b17141fe4f9bc7686db273757e2c45c (diff) | |
| download | mullvadvpn-54aca7f5c0f54fb42975d8c5e288627308eef5c5.tar.xz mullvadvpn-54aca7f5c0f54fb42975d8c5e288627308eef5c5.zip | |
Add language selector
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 = { |
