diff options
| author | Andrej Mihajlov <and@mullvad.net> | 2019-09-25 11:49:56 +0200 |
|---|---|---|
| committer | Andrej Mihajlov <and@mullvad.net> | 2019-09-25 11:49:56 +0200 |
| commit | c92f52de79106bc2f82dabc9944107af64ada15c (patch) | |
| tree | 91d8d228b7277c27779abda8adedc9ef04dfbfc2 | |
| parent | 5a7859670c4a22cb5fecd39c7c7c410f8bef4ff1 (diff) | |
| parent | 4fdd4eb91f84440a59cf23cf5d3b06ea6c9dbe8c (diff) | |
| download | mullvadvpn-c92f52de79106bc2f82dabc9944107af64ada15c.tar.xz mullvadvpn-c92f52de79106bc2f82dabc9944107af64ada15c.zip | |
Merge branch 'gui-language'
31 files changed, 584 insertions, 234 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 1dfbe53077..d1032a04cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,11 @@ Line wrap the file at 100 chars. Th ## [Unreleased] +### Added +- Add ability to change the desktop GUI language from within Settings. + +### Removed +- Remove support for `MULLVAD_LOCALE` environment variable. ## [2019.8] - 2019-09-23 @@ -293,11 +293,6 @@ to do that before starting the GUI. 1. `MULLVAD_PATH` - Allows changing the path to the folder with the `problem-report` tool when running in development mode. Defaults to: `<repo>/target/debug/`. -1. `MULLVAD_LOCALE` - Allows changing the UI locale, for example: - ``` - MULLVAD_LOCALE=en-US ./mullvad-vpn - ``` - ## Building the Android app 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 = { |
