summaryrefslogtreecommitdiffhomepage
path: root/app/components
diff options
context:
space:
mode:
Diffstat (limited to 'app/components')
-rw-r--r--app/components/Accordion.js146
-rw-r--r--app/components/Account.css98
-rw-r--r--app/components/Account.js78
-rw-r--r--app/components/AccountInput.js317
-rw-r--r--app/components/AdvancedSettings.css55
-rw-r--r--app/components/AdvancedSettings.js136
-rw-r--r--app/components/Connect.css138
-rw-r--r--app/components/Connect.js373
-rw-r--r--app/components/CustomScrollbars.css22
-rw-r--r--app/components/CustomScrollbars.js133
-rw-r--r--app/components/HeaderBar.js61
-rw-r--r--app/components/HeaderBarStyles.js69
-rw-r--r--app/components/Img.android.js9
-rw-r--r--app/components/Img.js13
-rw-r--r--app/components/Layout.css15
-rw-r--r--app/components/Layout.js46
-rw-r--r--app/components/Login.css211
-rw-r--r--app/components/Login.js339
-rw-r--r--app/components/Map.js51
-rw-r--r--app/components/SelectLocation.css146
-rw-r--r--app/components/SelectLocation.js225
-rw-r--r--app/components/Settings.css125
-rw-r--r--app/components/Settings.js120
-rw-r--r--app/components/Support.css169
-rw-r--r--app/components/Support.js254
-rw-r--r--app/components/Switch.css44
-rw-r--r--app/components/Switch.js142
-rw-r--r--app/components/WindowChrome.css13
-rw-r--r--app/components/WindowChrome.js16
29 files changed, 3564 insertions, 0 deletions
diff --git a/app/components/Accordion.js b/app/components/Accordion.js
new file mode 100644
index 0000000000..456a95fe05
--- /dev/null
+++ b/app/components/Accordion.js
@@ -0,0 +1,146 @@
+// @flow
+
+import React, { Component } from 'react';
+
+export type AccordionProps = {
+ height?: number | string,
+ transitionStyle?: string,
+ children?: Array<React.Element<*>> | React.Element<*> // see https://github.com/facebook/flow/issues/1964
+};
+
+export type AccordionState = {
+ computedHeight: ?number | ?string,
+};
+
+export default class Accordion extends Component {
+ props: AccordionProps;
+ static defaultProps: $Shape<AccordionProps> = {
+ height: 'auto',
+ transitionStyle: 'height 0.25s ease-in-out'
+ };
+
+ state: AccordionState = {
+ computedHeight: null,
+ };
+
+ _containerElement: ?HTMLElement;
+ _contentElement: ?HTMLElement;
+
+ componentDidMount() {
+ const containerElement = this._containerElement;
+ if(!containerElement) {
+ throw new Error('containerElement cannot be null');
+ }
+
+ // update initial state
+ if(this.props.height !== Accordion.defaultProps.height) {
+ this._updateHeight();
+ }
+
+ containerElement.addEventListener('transitionend', this._onTransitionEnd);
+ }
+
+ componentWillUnmount() {
+ const containerElement = this._containerElement;
+ if(!containerElement) {
+ throw new Error('containerElement cannot be null');
+ }
+ containerElement.removeEventListener('transitionend', this._onTransitionEnd);
+ }
+
+ componentDidUpdate(prevProps: AccordionProps, _prevState: AccordionState) {
+ if(prevProps.height !== this.props.height) {
+ (async () => {
+ const { transitionStyle } = this.props;
+
+ // make sure to warm up CSS transition before updating height
+ // do not warm up transitions if they are not expected to run
+ if(transitionStyle && transitionStyle.toLowerCase() !== 'none') {
+ await this._warmupTransition();
+ this._updateHeight();
+ } else {
+ this._updateHeight();
+ this._onTransitionEnd();
+ }
+
+ })();
+ }
+ }
+
+ render() {
+ const { height: _height, children, transitionStyle, ...otherProps } = this.props;
+ let style = {
+ transition: transitionStyle,
+ };
+
+ if(typeof(this.state.computedHeight) === 'number') {
+ style = {
+ ...style,
+ overflow: 'hidden',
+ height: this.state.computedHeight.toString() + 'px',
+ };
+ }
+
+ return (
+ <div { ...otherProps } style={ style } ref={ this._onContainerRef }>
+ <div ref={ this._onContentRef }>
+ { children }
+ </div>
+ </div>
+ );
+ }
+
+ // Sets initial height and delays transition until next runloop
+ // to make sure CSS transitions properly kick in.
+ // This method resolves immediately if the height is already set.
+ _warmupTransition() {
+ const contentElement = this._contentElement;
+ if(!contentElement) {
+ throw new Error('contentElement cannot be null');
+ }
+ return new Promise((resolve, _) => {
+ // CSS transition always needs the initial height
+ // to perform the animation
+ if(this.state.computedHeight === null) {
+ this.setState({
+ computedHeight: contentElement.clientHeight
+ }, () => {
+ // important to skip a run loop
+ // for CSS transition to kick in
+ setTimeout(resolve, 0);
+ });
+ } else {
+ resolve();
+ }
+ });
+ }
+
+ _updateHeight() {
+ const contentElement = this._contentElement;
+ if(!contentElement) {
+ throw new Error('contentElement cannot be null');
+ }
+ this.setState({
+ computedHeight: this.props.height === 'auto' ?
+ contentElement.clientHeight :
+ this.props.height
+ });
+ }
+
+ _onTransitionEnd = () => {
+ // reset height after transition to let element layout naturally
+ if(this.props.height === 'auto') {
+ this.setState({
+ computedHeight: null,
+ });
+ }
+ }
+
+ _onContainerRef = (element) => {
+ this._containerElement = element;
+ }
+
+ _onContentRef = (element) => {
+ this._contentElement = element;
+ }
+} \ No newline at end of file
diff --git a/app/components/Account.css b/app/components/Account.css
new file mode 100644
index 0000000000..2f450e3828
--- /dev/null
+++ b/app/components/Account.css
@@ -0,0 +1,98 @@
+.account {
+ background: #192E45;
+ height: 100%;
+}
+
+.account__container {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+}
+
+.account__header {
+ flex: 0 0 auto;
+ padding: 40px 24px 24px;
+ position: relative; /* anchor for close button */
+}
+
+.account__close {
+ position: absolute;
+ display: flex;
+ align-items: center;
+ border: 0;
+ padding: 0;
+ margin: 0;
+ top: 24px;
+ left: 12px;
+ z-index: 1; /* part of .account__container covers the button */
+}
+
+.account__close-icon {
+ opacity: 0.6;
+ margin-right: 8px;
+}
+
+.account__close-title {
+ font-family: "Open Sans";
+ font-size: 13px;
+ font-weight: 600;
+ color: rgba(255, 255, 255, 0.6);
+}
+
+.account__title {
+ font-family: DINPro;
+ font-size: 32px;
+ font-weight: 900;
+ line-height: 40px;
+ color: #FFFFFF;
+}
+
+.account__content {
+ flex: 1 1 auto;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+}
+
+.account__main {
+ margin-bottom: 24px;
+}
+
+.account__footer {
+ padding: 24px;
+}
+
+.account__row {
+ padding: 0 24px;
+}
+
+.account__row + .account__row {
+ margin-top: 24px;
+}
+
+.account__row-label {
+ font-family: "Open Sans";
+ font-size: 13px;
+ font-weight: 600;
+ color: rgba(255, 255, 255, 0.8);
+ margin-bottom: 8px;
+}
+
+.account__row-value {
+ font-family: "Open Sans";
+ font-size: 16px;
+ font-weight: 800;
+ color: rgba(255, 255, 255, 0.8);
+}
+
+.account__row-value--error {
+ color: #d0021b;
+}
+
+.account__footer .button + .button {
+ margin-top: 24px;
+}
+
+.account__id {
+ user-select: text;
+}
diff --git a/app/components/Account.js b/app/components/Account.js
new file mode 100644
index 0000000000..215014e543
--- /dev/null
+++ b/app/components/Account.js
@@ -0,0 +1,78 @@
+// @flow
+import moment from 'moment';
+import React, { Component } from 'react';
+import { If, Then, Else } from 'react-if';
+import { Layout, Container, Header } from './Layout';
+import { formatAccount } from '../lib/formatters';
+import ExternalLinkSVG from '../assets/images/icon-extLink.svg';
+
+import type { AccountReduxState } from '../redux/account/reducers';
+
+export type AccountProps = {
+ account: AccountReduxState;
+ onLogout: () => void;
+ onClose: () => void;
+ onBuyMore: () => void;
+};
+
+export default class Account extends Component {
+ props: AccountProps;
+
+ render(): React.Element<*> {
+ const expiry = moment(this.props.account.expiry);
+ const formattedAccountToken = formatAccount(this.props.account.accountToken || '');
+ const formattedExpiry = expiry.format('hA, D MMMM YYYY').toUpperCase();
+ const isOutOfTime = expiry.isSameOrBefore(moment());
+
+ return (
+ <Layout>
+ <Header hidden={ true } style={ 'defaultDark' } />
+ <Container>
+ <div className="account">
+ <div className="account__close" onClick={ this.props.onClose }>
+ <img className="account__close-icon" src="./assets/images/icon-back.svg" />
+ <span className="account__close-title">Settings</span>
+ </div>
+ <div className="account__container">
+
+ <div className="account__header">
+ <h2 className="account__title">Account</h2>
+ </div>
+
+ <div className="account__content">
+ <div className="account__main">
+
+ <div className="account__row">
+ <div className="account__row-label">Account ID</div>
+ <div className="account__row-value account__id">{ formattedAccountToken }</div>
+ </div>
+
+ <div className="account__row">
+ <div className="account__row-label">Paid until</div>
+ <If condition={ isOutOfTime }>
+ <Then>
+ <div className="account__out-of-time account__row-value account__row-value--error">OUT OF TIME</div>
+ </Then>
+ <Else>
+ <div className="account__row-value">{ formattedExpiry }</div>
+ </Else>
+ </If>
+ </div>
+
+ <div className="account__footer">
+ <button className="account__buymore button button--positive" onClick={ this.props.onBuyMore }>
+ <span className="button-label">Buy more time</span>
+ <ExternalLinkSVG className="button-icon button-icon--16" />
+ </button>
+ <button className="account__logout button button--negative" onClick={ this.props.onLogout }>Logout</button>
+ </div>
+
+ </div>
+ </div>
+ </div>
+ </div>
+ </Container>
+ </Layout>
+ );
+ }
+}
diff --git a/app/components/AccountInput.js b/app/components/AccountInput.js
new file mode 100644
index 0000000000..4ff99e13fd
--- /dev/null
+++ b/app/components/AccountInput.js
@@ -0,0 +1,317 @@
+// @flow
+import React, { Component } from 'react';
+import { formatAccount } from '../lib/formatters';
+
+// @TODO: move it into types.js
+
+// ESLint issue: https://github.com/babel/babel-eslint/issues/445
+declare class ClipboardData { // eslint-disable-line no-unused-vars
+ setData(type: string, data: string): void;
+ getData(type: string): string;
+}
+
+declare class ClipboardEvent extends Event {
+ clipboardData: ClipboardData;
+}
+
+export type AccountInputProps = {
+ value: string;
+ onEnter: ?(() => void);
+ onChange: ?((newValue: string) => void);
+};
+
+export type AccountInputState = {
+ value: string;
+ selectionRange: SelectionRange;
+};
+
+export type SelectionRange = [number, number];
+
+export default class AccountInput extends Component {
+ props: AccountInputProps;
+
+ static defaultProps: AccountInputProps = {
+ value: '',
+ onEnter: null,
+ onChange: null
+ };
+
+ state: AccountInputState = {
+ value: '',
+ selectionRange: [0, 0]
+ };
+
+ _ref: ?HTMLInputElement;
+ _ignoreSelect = false;
+
+ constructor(props: AccountInputProps) {
+ super(props);
+
+ // selection range holds selection converted from DOM selection range to
+ // internal unformatted representation of account number
+ const val = this.sanitize(props.value);
+
+ this.state = {
+ value: val,
+ selectionRange: [val.length, val.length]
+ };
+ }
+
+ componentWillReceiveProps(nextProps: AccountInputProps) {
+ const nextVal = this.sanitize(nextProps.value);
+ if(nextVal !== this.state.value) {
+ const len = nextVal.length;
+ this.setState({ value: nextVal, selectionRange: [len, len] });
+ }
+ }
+
+ shouldComponentUpdate(nextProps: AccountInputProps, nextState: AccountInputState) {
+ return (this.props.value !== nextProps.value ||
+ this.props.onEnter !== nextProps.onEnter ||
+ this.props.onChange !== nextProps.onChange ||
+ this.state.value !== nextState.value ||
+ this.state.selectionRange[0] !== nextState.selectionRange[0] ||
+ this.state.selectionRange[1] !== nextState.selectionRange[1]);
+ }
+
+ render() {
+ const displayString = formatAccount(this.state.value || '');
+ const { value, onChange, onEnter, ...otherProps } = this.props; // eslint-disable-line no-unused-vars
+ return (
+ <input { ...otherProps }
+ type="text"
+ value={ displayString }
+ onChange={ () => {} }
+ onSelect={ this.onSelect }
+ onKeyUp={ this.onKeyUp }
+ onKeyDown={ this.onKeyDown }
+ onPaste={ this.onPaste }
+ onCut={ this.onCut }
+ ref={ this.onRef } />
+ );
+ }
+
+ // Private
+
+ /**
+ * Modify original string inserting substring using selection range
+ */
+ sanitize(val: ?string): string {
+ return (val || '').replace(/[^0-9]/g, '');
+ }
+
+ /**
+ * Modify original string inserting substring using selection range
+ *
+ * @private
+ * @param {String} val original string
+ * @param {String} insert insertion string
+ * @param {Array} selRange selection range ([x,y])
+ * @returns {Object}
+ */
+ insert(val: string, insert: string, selRange: SelectionRange): AccountInputState {
+ const head = val.slice(0, selRange[0]);
+ const tail = val.slice(selRange[1], val.length);
+ const newVal = head + insert + tail;
+ const selectionOffset = head.length + insert.length;
+
+ return { value: newVal, selectionRange: [selectionOffset, selectionOffset] };
+ }
+
+
+ /**
+ * Modify string by removing single character or range of characters based on selection range.
+ *
+ * @private
+ * @param {String} val original string
+ * @param {Array} selRange selection range ([x,y])
+ * @returns {Object}
+ *
+ * @memberOf AccountInput
+ */
+ remove(val: string, selRange: SelectionRange): AccountInputState {
+ let newVal, selectionOffset;
+
+ if(selRange[0] === selRange[1]) {
+ const oneOff = Math.max(0, selRange[0] - 1);
+ const head = val.slice(0, oneOff);
+ const tail = val.slice(selRange[0], val.length);
+ newVal = head + tail;
+ selectionOffset = head.length;
+ } else {
+ const head = val.slice(0, selRange[0]);
+ const tail = val.slice(selRange[1], val.length);
+ newVal = head + tail;
+ selectionOffset = head.length;
+ }
+
+ return { value: newVal, selectionRange: [selectionOffset, selectionOffset] };
+ }
+
+
+ /**
+ * Convert DOM selection range to internal selection range
+ *
+ * @private
+ * @param {String} val original string
+ * @param {Array} domRange selection range from DOM
+ * @returns {Object}
+ *
+ * @memberOf AccountInput
+ */
+ toInternalSelectionRange(val: string, domRange: SelectionRange): SelectionRange {
+ const countSpaces = (val) => {
+ return (val.match(/\s/g) || []).length;
+ };
+
+ const fmt = formatAccount(val || '');
+ let start = domRange[0];
+ let end = domRange[1];
+ const before = countSpaces(fmt.slice(0, start));
+ const within = countSpaces(fmt.slice(start, end));
+
+ start -= before;
+ end -= (before + within);
+
+ return [ start, end ];
+ }
+
+
+ /**
+ * Convert internal selection range to DOM selection range
+ *
+ * @private
+ * @param {String} val original string
+ * @param {Array} selRange selection range
+ * @returns {Object}
+ *
+ * @memberOf AccountInput
+ */
+ toDomSelection(val: string, selRange: SelectionRange): SelectionRange {
+ const countSpaces = (val, untilIndex) => {
+ if(val.length > 12) { return 0; }
+ return Math.floor(untilIndex / 4); // groups of 4 digits
+ };
+
+ let start = selRange[0];
+ let end = selRange[1];
+ const startSpaces = countSpaces(val, start);
+ const endSpaces = countSpaces(val, end);
+
+ start += startSpaces;
+ end += startSpaces + (endSpaces - startSpaces);
+
+ return [ start, end ];
+ }
+
+ // Events
+
+ onKeyDown = (e: KeyboardEvent) => {
+ const { value, selectionRange } = this.state;
+
+ if(e.which === 8) { // backspace
+ const result = this.remove(value, selectionRange);
+ e.preventDefault();
+
+ this._ignoreSelect = true;
+
+ this.setState(result, () => {
+ if(this.props.onChange) {
+ this.props.onChange(result.value);
+ }
+ });
+ } else if(/^[0-9]$/.test(e.key)) { // digits or cmd+v
+ const result = this.insert(value, e.key, selectionRange);
+ e.preventDefault();
+
+ this._ignoreSelect = true;
+
+ this.setState(result, () => {
+ if(this.props.onChange) {
+ this.props.onChange(result.value);
+ }
+ });
+ }
+ }
+
+ onKeyUp = (e: KeyboardEvent) => {
+ this._ignoreSelect = false;
+
+ if(e.which === 13 && this.props.onEnter) {
+ this.props.onEnter();
+ }
+ }
+
+ onSelect = (e: Event) => {
+ const ref = e.target;
+ if(!(ref instanceof HTMLInputElement)) {
+ throw new Error('ref must be an instance of HTMLInputElement');
+ }
+
+ if(this._ignoreSelect) {
+ return;
+ }
+
+ const start = ref.selectionStart;
+ const end = ref.selectionEnd;
+ const selRange = this.toInternalSelectionRange(this.sanitize(ref.value), [start, end]);
+ this.setState({ selectionRange: selRange });
+ }
+
+ onPaste = (e: ClipboardEvent) => {
+ const { value, selectionRange } = this.state;
+ const pastedData = e.clipboardData.getData('text');
+ const filteredData = this.sanitize(pastedData);
+ const result = this.insert(value, filteredData, selectionRange);
+ e.preventDefault();
+ this.setState(result, () => {
+ if(this.props.onChange) {
+ this.props.onChange(result.value);
+ }
+ });
+ }
+
+ onCut = (e: ClipboardEvent) => {
+ const target = e.target;
+ if(!(target instanceof HTMLInputElement)) {
+ throw new Error('ref must be an instance of HTMLInputElement');
+ }
+
+ const { value, selectionRange } = this.state;
+
+ e.preventDefault();
+
+ // range is not empty?
+ if(selectionRange[0] !== selectionRange[1]) {
+ const result = this.remove(value, selectionRange);
+ const domSelectionRange = this.toDomSelection(value, selectionRange);
+ const slice = target.value.slice(domSelectionRange[0], domSelectionRange[1]);
+
+ e.clipboardData.setData('text', slice);
+
+ this.setState(result, () => {
+ if(this.props.onChange) {
+ this.props.onChange(result.value);
+ }
+ });
+ }
+ }
+
+ onRef = (ref: HTMLInputElement) => {
+ this._ref = ref;
+ if(!ref) { return; }
+
+ const { value, selectionRange } = this.state;
+ const domRange = this.toDomSelection(value, selectionRange);
+
+ ref.selectionStart = domRange[0];
+ ref.selectionEnd = domRange[1];
+ }
+
+ focus() {
+ if(this._ref) {
+ this._ref.focus();
+ }
+ }
+
+} \ No newline at end of file
diff --git a/app/components/AdvancedSettings.css b/app/components/AdvancedSettings.css
new file mode 100644
index 0000000000..8f6c352bd7
--- /dev/null
+++ b/app/components/AdvancedSettings.css
@@ -0,0 +1,55 @@
+.advanced-settings__section-title {
+ background-color:rgb(41, 71, 115);
+ padding: 15px 24px;
+ font-family: DINPro;
+ font-size: 20px;
+ font-weight: 900;
+ line-height: 26px;
+ color: #fff;
+}
+
+.advanced-settings__cell {
+ background-color:rgb(41, 71, 115);
+ padding: 15px 24px;
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+}
+
+.advanced-settings__cell--dimmed {
+ background-color: rgb(36, 57, 84);
+}
+
+.advanced-settings__cell--dimmed:hover {
+ background-color: rgba(41, 71, 115, 0.9);
+}
+
+.advanced-settings__cell--selected,
+.advanced-settings__cell--selected:hover {
+ background-color: #44AD4D;
+}
+
+.advanced-settings__cell--active:hover {
+ background-color: rgba(41, 71, 115, 0.9);
+}
+
+.advanced-settings__section-title + .advanced-settings__cell,
+.advanced-settings__cell + .advanced-settings__cell {
+ margin-top: 1px;
+}
+
+.advanced-settings__cell-label {
+ font-family: DINPro;
+ font-size: 20px;
+ font-weight: 900;
+ line-height: 26px;
+ color: #FFFFFF;
+ flex: 1 0 auto;
+}
+
+.advanced-settings__cell-icon {
+ width: 24px;
+ flex: 0 0 auto;
+ margin-right: 8px;
+ color: #fff;
+} \ No newline at end of file
diff --git a/app/components/AdvancedSettings.js b/app/components/AdvancedSettings.js
new file mode 100644
index 0000000000..56c5844346
--- /dev/null
+++ b/app/components/AdvancedSettings.js
@@ -0,0 +1,136 @@
+// @flow
+
+import React from 'react';
+import { Layout, Container, Header } from './Layout';
+import CustomScrollbars from './CustomScrollbars';
+
+import TickSVG from '../assets/images/icon-tick.svg';
+
+export class AdvancedSettings extends React.Component {
+
+ props: {
+ protocol: string,
+ port: string | number,
+ onUpdate: (protocol: string, port: string | number) => void,
+ onClose: () => void,
+ };
+
+ render() {
+ let portSelector = null;
+ let protocol = this.props.protocol.toUpperCase();
+
+ if (protocol === 'AUTOMATIC') {
+ protocol = 'Automatic';
+ } else {
+ portSelector = this._createPortSelector();
+ }
+
+ return <BaseLayout onClose={ this.props.onClose }>
+
+ <Selector
+ title={ 'Network protocols' }
+ values={ ['Automatic', 'UDP', 'TCP'] }
+ value={ protocol }
+ onSelect={ protocol => {
+ this.props.onUpdate(protocol, 'Automatic');
+ }}/>
+
+ <div className="settings__cell-spacer"></div>
+
+ { portSelector }
+
+ </BaseLayout>;
+ }
+
+ _createPortSelector() {
+ const protocol = this.props.protocol.toUpperCase();
+ const ports = protocol === 'TCP'
+ ? ['Automatic', 80, 443]
+ : ['Automatic', 1194, 1195, 1196, 1197, 1300, 1301, 1302];
+
+ return <Selector
+ title={ protocol + ' port' }
+ values={ ports }
+ value={ this.props.port }
+ onSelect={ port => {
+ this.props.onUpdate(protocol, port);
+ }} />;
+ }
+}
+
+
+class Selector extends React.Component {
+
+ props: {
+ title: string,
+ values: Array<*>,
+ value: *,
+ onSelect: (*) => void,
+ }
+
+ render() {
+ return <div>
+ <div className="advanced-settings__section-title">
+ { this.props.title }
+ </div>
+
+ { this.props.values.map(value => this._renderCell(value)) }
+ </div>;
+ }
+
+ _renderCell(value) {
+ const selected = value === this.props.value;
+ if (selected) {
+ return this._renderSelectedCell(value);
+ } else {
+ return this._renderUnselectedCell(value);
+ }
+ }
+
+ _renderSelectedCell(value) {
+ return <div
+ key={ value }
+ className="advanced-settings__cell advanced-settings__cell--selected"
+ onClick={ () => this.props.onSelect(value) } >
+ <div className="advanced-settings__cell-icon"><TickSVG /></div>
+ <div className="advanced-settings__cell-label">{ value }</div>
+ </div>;
+ }
+
+ _renderUnselectedCell(value) {
+ return <div
+ key={ value }
+ className="advanced-settings__cell advanced-settings__cell--dimmed"
+ onClick={ () => this.props.onSelect(value) }>
+ <div className="advanced-settings__cell-icon"></div>
+ <div className="advanced-settings__cell-label">{ value }</div>
+ </div>;
+ }
+}
+
+function BaseLayout(props) {
+ return <Layout>
+ <Header hidden={ true } style={ 'defaultDark' } />
+ <Container>
+ <div className="settings">
+ <div className="support__close" onClick={ props.onClose }>
+ <img className="support__close-icon" src="./assets/images/icon-back.svg" />
+ <span className="support__close-title">Settings</span>
+ </div>
+ <div className="settings__container">
+ <div className="settings__header">
+ <h2 className="settings__title">Advanced</h2>
+ </div>
+ <CustomScrollbars autoHide={ true }>
+ <div className="settings__content">
+ <div className="settings__main">
+ { props.children }
+ </div>
+ </div>
+ </CustomScrollbars>
+ </div>
+ </div>
+ </Container>
+ </Layout>;
+}
+
diff --git a/app/components/Connect.css b/app/components/Connect.css
new file mode 100644
index 0000000000..41c3c61894
--- /dev/null
+++ b/app/components/Connect.css
@@ -0,0 +1,138 @@
+.connect {
+ height: 100%;
+ position: relative;
+}
+
+.connect__map {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ z-index: 0;
+}
+
+.connect__container {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ position: relative; /* need this for z-index to work to cover map */
+ z-index: 1;
+}
+
+.connect__footer {
+ display: flex;
+ flex-direction: column;
+ padding: 42px 24px 24px;
+}
+
+.connect__row + .connect__row{
+ margin-top: 16px;
+}
+
+.connect__server-label {
+ flex: 1 1 auto;
+ text-align: center;
+}
+
+.connect__server-chevron {
+ flex: 0 0 auto;
+ width: 7px;
+ margin-left: -7px; /* let .connect__server-label extend to occupy the entire space */
+}
+
+.connect__footer-button {
+ display: block;
+ width:100%;
+ border: 0;
+ padding: 7px 12px 9px;
+ border-radius: 4px;
+ font-family: DINPro;
+ font-size: 20px;
+ font-weight: 900;
+ text-align: center;
+ line-height: 26px;
+ color: #FFFFFF;
+}
+
+.connect__status {
+ padding: 0 24px;
+ margin-top: 94px;
+ margin-bottom: auto;
+}
+
+.connect__error-title {
+ font-family: DINPro;
+ font-size: 32px;
+ font-weight: 900;
+ line-height: 1.25;
+ color: #fff;
+ margin-bottom: 8px;
+}
+
+.connect__error-message {
+ font-family: "Open Sans";
+ font-size: 13px;
+ font-weight: 600;
+ line-height: normal;
+ color: #fff;
+ margin-bottom: 24px;
+}
+
+.connect__status-security {
+ font-family: "Open Sans";
+ font-size: 16px;
+ font-weight: 800;
+ line-height: 22px;
+ margin-bottom: 4px;
+ color: #FFFFFF;
+ text-transform: uppercase;
+}
+
+.connect__status-security--unsecured {
+ color: #D0021B;
+}
+
+.connect__status-security--secure {
+ color: #44AD4D;
+}
+
+.connect__status-location {
+ font-family: DINPro;
+ font-size: 38px;
+ font-weight: 900;
+ line-height: 1.16em;
+ max-height: calc(1.16em * 2);
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ letter-spacing: -0.9px;
+ color: #FFFFFF;
+ margin-bottom: 4px;
+}
+
+.connect__status-location-icon {
+ display: inline-block;
+ margin-right: 8px;
+}
+
+.connect__status-ipaddress {
+ font-family: "Open Sans";
+ font-size: 16px;
+ font-weight: 800;
+ line-height: normal;
+ color: #FFFFFF;
+}
+
+.connect__status-ipaddress--invisible {
+ visibility: hidden;
+}
+
+.connect__status-icon {
+ text-align: center;
+ margin-bottom: 32px;
+}
+
+.connect__status-icon--hidden {
+ visibility: hidden;
+}
diff --git a/app/components/Connect.js b/app/components/Connect.js
new file mode 100644
index 0000000000..93ac19a4eb
--- /dev/null
+++ b/app/components/Connect.js
@@ -0,0 +1,373 @@
+// @flow
+
+import moment from 'moment';
+import React, { Component } from 'react';
+import { If, Then } from 'react-if';
+import { Layout, Container, Header } from './Layout';
+import { BackendError } from '../lib/backend';
+
+import ExternalLinkSVG from '../assets/images/icon-extLink.svg';
+import ChevronRightSVG from '../assets/images/icon-chevron.svg';
+
+import type { HeaderBarStyle } from './HeaderBar';
+import type { ConnectionReduxState } from '../redux/connection/reducers';
+import type { SettingsReduxState } from '../redux/settings/reducers';
+import type { RelayLocation } from '../lib/ipc-facade';
+
+export type ConnectProps = {
+ accountExpiry: string,
+ connection: ConnectionReduxState,
+ settings: SettingsReduxState,
+ onSettings: () => void,
+ onSelectLocation: () => void,
+ onConnect: () => void,
+ onCopyIP: () => void,
+ onDisconnect: () => void,
+ onExternalLink: (type: string) => void,
+};
+
+
+export default class Connect extends Component {
+ props: ConnectProps;
+ state = {
+ isFirstPass: true,
+ showCopyIPMessage: false
+ };
+
+ _copyTimer: ?number;
+
+ componentDidMount() {
+ this.setState({ isFirstPass: false });
+ }
+
+ componentWillUnmount() {
+ if(this._copyTimer) {
+ clearTimeout(this._copyTimer);
+ this._copyTimer = null;
+ }
+
+ this.setState({
+ isFirstPass: true,
+ showCopyIPMessage: false
+ });
+ }
+
+ render(): React.Element<*> {
+ const error = this.displayError();
+ const child = error ? this.renderError(error) : this.renderMap();
+
+ return (
+ <Layout>
+ <Header style={ this.headerStyle() } showSettings={ true } onSettings={ this.props.onSettings } />
+ <Container>
+ { child }
+ </Container>
+ </Layout>
+ );
+ }
+
+ renderError(error: BackendError): React.Element<*> {
+ return (
+ <div className="connect">
+ <div className="connect__status">
+ <div className="connect__status-icon">
+ <img src="./assets/images/icon-fail.svg" alt="" />
+ </div>
+ <div className="connect__error-title">
+ { error.title }
+ </div>
+ <div className="connect__error-message">
+ { error.message }
+ </div>
+ <If condition={ error.type === 'NO_CREDIT' }>
+ <Then>
+ <div>
+ <button className="button button--positive" onClick={ this.onExternalLink.bind(this, 'purchase') }>
+ <span className="button-label">Buy more time</span>
+ <ExternalLinkSVG className="button-icon button-icon--16" />
+ </button>
+ </div>
+ </Then>
+ </If>
+ </div>
+ </div>
+ );
+ }
+
+ _findRelayName(relay: RelayLocation): ?string {
+ const countries = this.props.settings.relayLocations;
+ const countryPredicate = (countryCode) => (country) => country.code === countryCode;
+
+ if(relay.country) {
+ const country = countries.find(countryPredicate(relay.country));
+ if(country) {
+ return country.name;
+ }
+ } else if(relay.city) {
+ const [countryCode, cityCode] = relay.city;
+ const country = countries.find(countryPredicate(countryCode));
+ if(country) {
+ const city = country.cities.find((city) => city.code === cityCode);
+ if(city) {
+ return city.name;
+ }
+ }
+ }
+ return null;
+ }
+
+ _getLocationName(): string {
+ const { relaySettings } = this.props.settings;
+ if(relaySettings.normal) {
+ const location = relaySettings.normal.location;
+ if(location === 'any') {
+ return 'Automatic';
+ } else {
+ return this._findRelayName(location) || 'Unknown';
+ }
+ } else if(relaySettings.custom_tunnel_endpoint) {
+ return 'Custom';
+ } else {
+ throw new Error('Unsupported relay settings.');
+ }
+ }
+
+ renderMap(): React.Element<*> {
+ let [ isConnecting, isConnected, isDisconnected ] = [false, false, false];
+ switch(this.props.connection.status) {
+ case 'connecting': isConnecting = true; break;
+ case 'connected': isConnected = true; break;
+ case 'disconnected': isDisconnected = true; break;
+ }
+
+ // We decided to not include the map in the first beta release to customers
+ // but it MUST be included in the following releases. Therefore we choose
+ // to just comment it out
+ const map = undefined;
+ /*
+ const altitude = (isConnecting ? 300 : 100) * 1000;
+ const { location } = this.props.connection;
+ const map = <Map animate={ !this.state.isFirstPass }
+ location={ location || [0, 0] }
+ altitude= { altitude }
+ markerImagePath= { isConnected
+ ? './assets/images/location-marker-secure.svg'
+ : './assets/images/location-marker-unsecure.svg' } />
+ */
+
+ let ipComponent = undefined;
+ if (isConnected || isDisconnected) {
+ if (this.state.showCopyIPMessage) {
+ ipComponent = (<span>{ 'IP copied to clipboard!' }</span>);
+ } else {
+ // TODO: remove empty IP placeholder when implemented in backend.
+ if(isDisconnected) {
+ ipComponent = (<span>{ '\u2003' }</span>);
+ } else {
+ ipComponent = (<span>{ this.props.connection.clientIp }</span>);
+ }
+ }
+ }
+ return (
+ <div className="connect">
+ <div className="connect__map">
+ { map }
+ </div>
+ <div className="connect__container">
+
+ <div className="connect__status">
+ { /* show spinner when connecting */ }
+ <div className={ this.spinnerClass() }>
+ <img src="./assets/images/icon-spinner.svg" alt="" />
+ </div>
+
+ <div className={ this.networkSecurityClass() }>{ this.networkSecurityMessage() }</div>
+
+ { /*
+ **********************************
+ Begin: Location block
+ **********************************
+ */ }
+
+ { /* location when disconnected.
+ TODO: merge with the isConnecting block below when implemented in backend.
+ */ }
+ <If condition={ isDisconnected }>
+ <Then>
+ <div className="connect__status-location">
+ <span>{ '\u2002' }</span>
+ </div>
+ </Then>
+ </If>
+
+ { /* location when connecting */ }
+ <If condition={ isConnecting }>
+ <Then>
+ <div className="connect__status-location">
+ <span>{ this.props.connection.country }</span>
+ </div>
+ </Then>
+ </If>
+
+ { /* location when connected */ }
+ <If condition={ isConnected }>
+ <Then>
+ <div className="connect__status-location">
+ { this.props.connection.city }<br/>{ this.props.connection.country }
+ </div>
+ </Then>
+ </If>
+
+ { /*
+ **********************************
+ End: Location block
+ **********************************
+ */ }
+
+ <div className={ this.ipAddressClass() } onClick={ this.onIPAddressClick.bind(this) }>
+ { ipComponent }
+ </div>
+ </div>
+
+
+ { /*
+ **********************************
+ Begin: Footer block
+ **********************************
+ */ }
+
+ { /* footer when disconnected */ }
+ <If condition={ isDisconnected }>
+ <Then>
+ <div className="connect__footer">
+ <div className="connect__row">
+ <button className="connect__server button button--neutral button--blur" onClick={ this.props.onSelectLocation }>
+ <div className="connect__server-label">{ this._getLocationName() }</div>
+ <div className="connect__server-chevron"><ChevronRightSVG /></div>
+ </button>
+ </div>
+
+ <div className="connect__row">
+ <button className="button button--positive" onClick={ this.props.onConnect }>Secure my connection</button>
+ </div>
+ </div>
+ </Then>
+ </If>
+
+ { /* footer when connecting */ }
+ <If condition={ isConnecting }>
+ <Then>
+ <div className="connect__footer">
+ <div className="connect__row">
+ <button className="button button--neutral button--blur" onClick={ this.props.onSelectLocation }>Switch location</button>
+ </div>
+
+ <div className="connect__row">
+ <button className="button button--negative-light button--blur" onClick={ this.props.onDisconnect }>Cancel</button>
+ </div>
+ </div>
+ </Then>
+ </If>
+
+ { /* footer when connected */ }
+ <If condition={ isConnected }>
+ <Then>
+ <div className="connect__footer">
+ <div className="connect__row">
+ <button className="button button--neutral button--blur" onClick={ this.props.onSelectLocation }>Switch location</button>
+ </div>
+
+ <div className="connect__row">
+ <button className="button button--negative-light button--blur" onClick={ this.props.onDisconnect }>Disconnect</button>
+ </div>
+ </div>
+ </Then>
+ </If>
+
+ { /*
+ **********************************
+ End: Footer block
+ **********************************
+ */ }
+
+ </div>
+ </div>
+ );
+ }
+
+ // Handlers
+
+ onExternalLink(type: string) {
+ this.props.onExternalLink(type);
+ }
+
+ onIPAddressClick() {
+ this._copyTimer && clearTimeout(this._copyTimer);
+ this._copyTimer = setTimeout(() => this.setState({ showCopyIPMessage: false }), 3000);
+ this.setState({ showCopyIPMessage: true });
+ this.props.onCopyIP();
+ }
+
+ // Private
+
+ headerStyle(): HeaderBarStyle {
+ switch(this.props.connection.status) {
+ case 'connecting':
+ case 'disconnected':
+ return 'error';
+ case 'connected':
+ return 'success';
+ }
+ throw new Error('Invalid ConnectionState');
+ }
+
+ networkSecurityClass(): string {
+ let classes = ['connect__status-security'];
+ if(this.props.connection.status === 'connected') {
+ classes.push('connect__status-security--secure');
+ } else if(this.props.connection.status === 'disconnected') {
+ classes.push('connect__status-security--unsecured');
+ }
+
+ return classes.join(' ');
+ }
+
+ networkSecurityMessage(): string {
+ switch(this.props.connection.status) {
+ case 'connected': return 'Secure connection';
+ case 'connecting': return 'Creating secure connection';
+ default: return 'Unsecured connection';
+ }
+ }
+
+ spinnerClass(): string {
+ var classes = ['connect__status-icon'];
+ if(this.props.connection.status !== 'connecting') {
+ classes.push('connect__status-icon--hidden');
+ }
+ return classes.join(' ');
+ }
+
+ ipAddressClass(): string {
+ var classes = ['connect__status-ipaddress'];
+ if(this.props.connection.status === 'connecting') {
+ classes.push('connect__status-ipaddress--invisible');
+ }
+ return classes.join(' ');
+ }
+
+ displayError(): ?BackendError {
+ // Offline?
+ if(!this.props.connection.isOnline) {
+ return new BackendError('NO_INTERNET');
+ }
+
+ // No credit?
+ const expiry = this.props.accountExpiry;
+ if(expiry && moment(expiry).isSameOrBefore(moment())) {
+ return new BackendError('NO_CREDIT');
+ }
+
+ return null;
+ }
+}
diff --git a/app/components/CustomScrollbars.css b/app/components/CustomScrollbars.css
new file mode 100644
index 0000000000..074c081209
--- /dev/null
+++ b/app/components/CustomScrollbars.css
@@ -0,0 +1,22 @@
+.custom-scrollbars {
+ display: flex;
+ flex-direction: column;
+ position: relative;
+}
+
+.custom-scrollbars__scrollable {
+ width: 100%;
+ height: 100%;
+}
+
+.custom-scrollbars__scrollable::-webkit-scrollbar {
+ display: none;
+}
+
+.custom-scrollbars__thumb {
+ background-color: rgba(255, 255, 255, 0.2);
+ border-radius: 4px;
+ width: 8px;
+ transition: height 0.25s ease-in-out, opacity 0.25s ease-in-out;
+ pointer-events: none;
+} \ No newline at end of file
diff --git a/app/components/CustomScrollbars.js b/app/components/CustomScrollbars.js
new file mode 100644
index 0000000000..6d0ce96daa
--- /dev/null
+++ b/app/components/CustomScrollbars.js
@@ -0,0 +1,133 @@
+// @flow
+import React, { Component } from 'react';
+
+type ScrollbarUpdateContext = {
+ size: boolean,
+ position: boolean,
+};
+
+export default class CustomScrollbars extends Component {
+ props: {
+ thumbInset: { x: number, y: number },
+ children: ?React.Element<*>,
+ };
+
+ static defaultProps = {
+ thumbInset: { x: 2, y: 2 },
+ };
+
+ _scrollableElement: ?HTMLElement;
+ _thumbElement: ?HTMLElement;
+
+ componentDidMount() {
+ this._updateScrollbarsHelper({
+ position: true,
+ size: true
+ });
+ }
+
+ componentDidUpdate() {
+ this._updateScrollbarsHelper({
+ position: true,
+ size: true
+ });
+ }
+
+ render() {
+ return (
+ <div className="custom-scrollbars">
+ <div className="custom-scrollbars__thumb"
+ style={{ position: 'absolute', top: 0, right: 0 }}
+ ref={ this._onThumbRef }></div>
+ <div className="custom-scrollbars__scrollable"
+ style={{ overflow: 'auto' }}
+ onScroll={ this._onScroll }
+ ref={ this._onScrollableRef }>
+ { this.props.children }
+ </div>
+ </div>
+ );
+ }
+
+
+ _onScrollableRef = (ref) => {
+ this._scrollableElement = ref;
+ }
+
+ _onThumbRef = (ref) => {
+ this._thumbElement = ref;
+ }
+
+ _onScroll = () => {
+ this._updateScrollbarsHelper({ position: true });
+ }
+
+ _computeThumbPosition(scrollable: HTMLElement, thumb: HTMLElement) {
+ // the content height of the scroll view
+ const scrollHeight = scrollable.scrollHeight;
+
+ // the visible height of the scroll view
+ const visibleHeight = scrollable.offsetHeight;
+
+ // scroll offset
+ const scrollTop = scrollable.scrollTop;
+
+ // lowest point of scrollTop
+ const maxScrollTop = scrollHeight - visibleHeight;
+
+ // calculate scroll position within 0..1 range
+ const scrollPosition = scrollHeight > 0 ? scrollTop / maxScrollTop : 0;
+
+ const thumbHeight = thumb.clientHeight;
+
+ // calculate the thumb boundary to make sure that the visual appearance of
+ // a thumb at lowest point matches the bottom of scrollable view
+ const thumbBoundary = visibleHeight - thumbHeight - (this.props.thumbInset.y * 2);
+
+ // calculate thumb position based on scroll progress and thumb boundary
+ // adding vertical inset to adjust the thumb's appearance
+ const thumbPosition = (thumbBoundary * scrollPosition) + this.props.thumbInset.y;
+
+ return {
+ x: -this.props.thumbInset.x,
+ y: thumbPosition,
+ };
+ }
+
+ _computeThumbHeight(scrollable: HTMLElement) {
+ const scrollHeight = scrollable.scrollHeight;
+ const visibleHeight = scrollable.offsetHeight;
+
+ const thumbHeight = (visibleHeight / scrollHeight) * visibleHeight;
+
+ // ensure that the scroll thumb doesn't shrink to nano size
+ return Math.max(thumbHeight, 8);
+ }
+
+ _updateScrollbarsHelper(updateFlags: $Shape<ScrollbarUpdateContext>) {
+ const scrollable = this._scrollableElement;
+ const thumb = this._thumbElement;
+ if(scrollable && thumb) {
+ this._updateScrollbars(scrollable, thumb, updateFlags);
+ }
+ }
+
+ _updateScrollbars(scrollable: HTMLElement, thumb: HTMLElement, context: $Shape<ScrollbarUpdateContext>) {
+ if(context.size) {
+ const thumbHeight = this._computeThumbHeight(scrollable);
+ thumb.style.setProperty('height', thumbHeight + 'px');
+
+ // hide thumb when there is nothing to scroll
+ if(thumbHeight < scrollable.offsetHeight) {
+ thumb.style.setProperty('opacity', '1');
+ } else {
+ thumb.style.setProperty('opacity', '0');
+ }
+ }
+
+ if(context.position) {
+ const { x, y } = this._computeThumbPosition(scrollable, thumb);
+ thumb.style.setProperty('transform', `translate(${x}px, ${y}px)`);
+ }
+ }
+}
diff --git a/app/components/HeaderBar.js b/app/components/HeaderBar.js
new file mode 100644
index 0000000000..c2e02cc35c
--- /dev/null
+++ b/app/components/HeaderBar.js
@@ -0,0 +1,61 @@
+// @flow
+import React from 'react';
+import {
+ Component,
+ Text,
+ Button,
+ View
+} from 'reactxp';
+
+import Img from './Img';
+
+import styles from './HeaderBarStyles';
+
+export type HeaderBarStyle = 'default' | 'defaultDark' | 'error' | 'success';
+export type HeaderBarProps = {
+ style: HeaderBarStyle;
+ hidden: boolean;
+ showSettings: boolean;
+ onSettings: ?(() => void);
+};
+
+export default class HeaderBar extends Component {
+ props: HeaderBarProps;
+ static defaultProps: $Shape<HeaderBarProps> = {
+ style: 'default',
+ hidden: false,
+ showSettings: false,
+ onSettings: null
+ };
+
+ render() {
+ let containerClass = [
+ styles['headerbar'],
+ styles['headerbar__' + process.platform],
+ styles['headerbar__style_' + this.props.style]
+ ];
+
+ if(this.props.hidden) {
+ containerClass.push(styles['headerbar__hidden']);
+ }
+
+ return (
+ <View style={ containerClass }>
+ {!this.props.hidden ?
+ <View style={styles.headerbar__container} testName="headerbar__container">
+ <Img style={ styles.headerbar__logo } source='logo-icon'/>
+ <Text style={styles.headerbar__title}>MULLVAD VPN</Text>
+ </View>
+ : null}
+
+ {this.props.showSettings ?
+ <View style={styles.headerbar__settings}>
+ <Button onPress={ this.props.onSettings } testName="headerbar__settings">
+ <Img style={ styles.headerbar__settings } source='icon-settings'/>
+ </Button>
+ </View>
+ : null}
+ </View>
+ );
+ }
+}
diff --git a/app/components/HeaderBarStyles.js b/app/components/HeaderBarStyles.js
new file mode 100644
index 0000000000..5e80f4f635
--- /dev/null
+++ b/app/components/HeaderBarStyles.js
@@ -0,0 +1,69 @@
+// @flow
+import { Styles } from 'reactxp';
+
+const styles = {
+ headerbar:
+ Styles.createViewStyle({
+ paddingTop: 12,
+ paddingBottom: 12,
+ paddingLeft: 12,
+ paddingRight: 12,
+ backgroundColor: '#294D73',
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ }),
+ headerbar__hidden:
+ Styles.createViewStyle({
+ paddingTop: 24,
+ paddingBottom: 0,
+ paddingLeft: 0,
+ paddingRight: 0,
+ }),
+ headerbar__darwin: Styles.createViewStyle({
+ paddingTop: 24,
+ }),
+ headerbar__style_defaultDark:
+ Styles.createViewStyle({
+ backgroundColor: '#192E45',
+ }),
+ headerbar__style_error:
+ Styles.createViewStyle({
+ backgroundColor: '#D0021B',
+ }),
+ headerbar__style_success:
+ Styles.createViewStyle({
+ backgroundColor: '#44AD4D',
+ }),
+ headerbar__container:
+ Styles.createViewStyle({
+ display: 'flex',
+ flexDirection: 'row',
+ alignItems: 'center',
+ }),
+ headerbar__title:
+ Styles.createTextStyle({
+ fontFamily: 'DINPro',
+ fontSize: 24,
+ fontWeight: '900',
+ lineHeight: 30,
+ letterSpacing: -0.5,
+ color: 'rgba(255,255,255,0.6)',
+ marginLeft: 8,
+ }),
+ headerbar__logo:
+ Styles.createViewStyle({
+ height: 50,
+ width: 50,
+ }),
+ headerbar__settings:
+ Styles.createViewStyle({
+ width: 24,
+ height: 24,
+ backgroundColor: 'transparent',
+ marginLeft: -6, //Because of button.css, when removed remove this
+ marginTop: -1, //Because of button.css, when removed remove this
+ })
+};
+
+module.exports = styles;
diff --git a/app/components/Img.android.js b/app/components/Img.android.js
new file mode 100644
index 0000000000..303fe4e5cb
--- /dev/null
+++ b/app/components/Img.android.js
@@ -0,0 +1,9 @@
+// @flow
+import React from 'react';
+import { Image, Component } from 'reactxp';
+
+export default class Img extends Component {
+ render(){
+ return (<Image style={ this.props.style } source={ this.props.source }/>);
+ }
+}
diff --git a/app/components/Img.js b/app/components/Img.js
new file mode 100644
index 0000000000..0c687ff654
--- /dev/null
+++ b/app/components/Img.js
@@ -0,0 +1,13 @@
+// @flow
+import React from 'react';
+import { View, Component } from 'reactxp';
+
+export default class Img extends Component {
+ render() {
+ const url = './assets/images/' + this.props.source + '.svg';
+
+ const style = this.props.style;
+
+ return (<View style={ style }> <img src={ url } /> </View>);
+ }
+}
diff --git a/app/components/Layout.css b/app/components/Layout.css
new file mode 100644
index 0000000000..14acf93769
--- /dev/null
+++ b/app/components/Layout.css
@@ -0,0 +1,15 @@
+.layout {
+ display: flex;
+ flex-direction: column;
+ height: 100vh;
+}
+
+.layout__header {
+ flex: 0 0 auto;
+}
+
+.layout__container {
+ flex: 1 1 100%;
+ background: #294D73;
+ overflow: hidden; /* needed for flex boxes with overflow: auto to work */
+} \ No newline at end of file
diff --git a/app/components/Layout.js b/app/components/Layout.js
new file mode 100644
index 0000000000..5c0e1f5bcb
--- /dev/null
+++ b/app/components/Layout.js
@@ -0,0 +1,46 @@
+// @flow
+import React, { Component } from 'react';
+import HeaderBar from './HeaderBar';
+
+import type { HeaderBarProps } from './HeaderBar';
+
+export class Header extends Component {
+ props: HeaderBarProps;
+ static defaultProps = HeaderBar.defaultProps;
+
+ render(): React.Element<*> {
+ return (
+ <div className="layout__header">
+ <HeaderBar { ...this.props } />
+ </div>
+ );
+ }
+}
+
+export class Container extends Component {
+ props: {
+ children: React.Element<*>
+ }
+
+ render(): React.Element<*> {
+ return (
+ <div className="layout__container">
+ { this.props.children }
+ </div>
+ );
+ }
+}
+
+export class Layout extends Component {
+ props: {
+ children: Array<React.Element<*>> | React.Element<*>
+ }
+
+ render(): React.Element<*> {
+ return (
+ <div className="layout">
+ { this.props.children }
+ </div>
+ );
+ }
+}
diff --git a/app/components/Login.css b/app/components/Login.css
new file mode 100644
index 0000000000..9311212ec9
--- /dev/null
+++ b/app/components/Login.css
@@ -0,0 +1,211 @@
+.login {
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+}
+
+.login-footer {
+ background-color: #192E45;
+ padding: 18px 24px 24px;
+ flex: 0 0 auto;
+ transition: transform 0.25s ease-in-out;
+}
+
+.login-footer--invisible {
+ transform: translateY(100%);
+}
+
+.login-form__status-icon {
+ flex: 0 0 auto; /* never collapse or grow */
+ text-align: center;
+ margin-bottom: 44px;
+
+ /* use fixed size to make space and avoid jitter when changing <img> visibility */
+ height: 60px;
+}
+
+.login-footer__prompt {
+ font-family: "Open Sans";
+ font-size: 13px;
+ line-height: 18px;
+ font-weight: 600;
+ color: rgba(255,255,255,0.8);
+ margin-bottom: 8px;
+}
+
+.login-form {
+ display: flex;
+ flex-direction: column;
+ padding: 0 24px;
+ margin: 83px 0 auto;
+}
+
+.login-form__title {
+ font-family: DINPro;
+ font-size: 32px;
+ font-weight: 900;
+ line-height: 1.25em;
+ color: #FFFFFF;
+ margin-bottom: 7px;
+}
+
+.login-form__subtitle {
+ font-family: "Open Sans";
+ font-size: 13px;
+ font-weight: 600;
+ line-height: normal;
+ color: rgba(255,255,255,0.8);
+ margin-bottom: 8px;
+}
+
+.login-form__account-input-container {
+ /*
+ Provides an anchor for input-container
+ to be properly positioned when it gains absolute
+ position to overlap the footer view.
+ */
+ position: relative;
+
+ /* push border to the outer side of the layout grid */
+ margin-left: -2px;
+ margin-right: -2px;
+}
+
+.login-form__account-input-group {
+ border: 2px solid transparent;
+ border-radius: 8px;
+ transition: border 0.25s ease-in-out;
+ overflow: hidden;
+}
+
+.login-form__account-input-group--active {
+ position: absolute;
+ border-color: rgba(25,46,69,0.4);
+}
+
+.login-form__account-input-group--inactive {
+ opacity: 0.6;
+}
+
+.login-form__account-input-group--error {
+ border-color: rgba(208,2,27,0.4);
+}
+
+.login-form__account-input-group--error .login-form__account-input-textfield {
+ color: #D0021B;
+}
+
+.login-form__account-input-backdrop {
+ background-color: #FFFFFF;
+ display: flex;
+ flex-direction: row;
+ transition: background-color 0.25s ease-in-out;
+}
+
+.login-form__account-input-textfield::-webkit-input-placeholder {
+ color: rgba(41,77,115,0.4);
+}
+
+.login-form__account-input-textfield {
+ width: 100%;
+ border: 0;
+ padding: 10px 12px 12px 12px;
+ font-family: DINPro;
+ font-size: 20px;
+ font-weight: 900;
+ line-height: 26px;
+ color: #294D73;
+ background-color: transparent;
+ flex: 1 1 auto;
+ transition: 0.3s color ease-in-out;
+}
+
+.login-form__account-input-textfield--inactive {
+ background-color: rgba(255,255,255,0.6);
+}
+
+.login-form__account-input-button {
+ flex: 0 0 auto;
+ border: 0;
+ width: 48px;
+ background-color: transparent;
+ transition-duration: 0.3s;
+ transition-property: background-color, opacity;
+ transition-timing-function: ease-in-out;
+}
+
+.login-form__account-input-button-icon {
+ display: inline-block;
+ vertical-align: middle;
+}
+
+.login-form__account-input-button-icon path {
+ fill: rgba(41,77,115,0.2);
+ transition: fill 0.3s ease-in-out;
+}
+
+.login-form__account-input-button--active {
+ background-color: #44ad4d;
+}
+
+.login-form__account-input-button--active .login-form__account-input-button-icon path {
+ fill: #fff;
+}
+
+.login-form__account-input-button--active:hover {
+ background-color: rgba(68, 173, 76, 0.9);
+}
+
+.login-form__account-input-button--invisible {
+ visibility: hidden;
+ opacity: 0;
+}
+
+.login-form__account-dropdown-container {
+ overflow: hidden;
+ transition: height 0.25s ease-in-out;
+}
+
+.login-form__account-dropdown__item {
+ display: flex;
+ flex-direction: row;
+ border-top: 1px solid rgba(25,46,69,0.4);
+ background-color: rgba(255,255,255,0.6);
+ background-clip: padding-box;
+ transition: background-color 0.25s ease-in-out;
+}
+
+.login-form__account-dropdown__item:hover {
+ background-color: rgba(255,255,255,0.65);
+}
+
+.login-form__account-dropdown__label {
+ flex: 1 1 auto;
+ font-family: DINPro;
+ font-size: 20px;
+ font-weight: 900;
+ line-height: 26px;
+ color: #294D73;
+ border: 0;
+ background: transparent;
+ padding: 10px 12px 12px 12px;
+ text-align: left;
+}
+
+.login-form__account-dropdown__remove {
+ background: transparent;
+ border: 0;
+ padding: 10px 12px 12px 12px;
+
+ /* center SVG within button */
+ display: flex;
+ justify-content: center;
+}
+
+.login-form__account-dropdown__remove path {
+ transition: fill-opacity 0.25s ease-in-out;
+}
+
+.login-form__account-dropdown__remove:hover path {
+ fill-opacity: 1;
+} \ No newline at end of file
diff --git a/app/components/Login.js b/app/components/Login.js
new file mode 100644
index 0000000000..c133954eed
--- /dev/null
+++ b/app/components/Login.js
@@ -0,0 +1,339 @@
+// @flow
+import React, { Component } from 'react';
+import { Layout, Container, Header } from './Layout';
+import AccountInput from './AccountInput';
+import { formatAccount } from '../lib/formatters';
+import ExternalLinkSVG from '../assets/images/icon-extLink.svg';
+import LoginArrowSVG from '../assets/images/icon-arrow.svg';
+import RemoveAccountSVG from '../assets/images/icon-close-sml.svg';
+
+import type { AccountReduxState } from '../redux/account/reducers';
+import type { AccountToken } from '../lib/ipc-facade';
+
+export type LoginPropTypes = {
+ account: AccountReduxState,
+ onLogin: (accountToken: AccountToken) => void,
+ onSettings: ?(() => void),
+ onFirstChangeAfterFailure: () => void,
+ onExternalLink: (type: string) => void,
+ onAccountTokenChange: (accountToken: AccountToken) => void,
+ onRemoveAccountTokenFromHistory: (accountToken: AccountToken) => void,
+};
+
+export default class Login extends Component {
+ props: LoginPropTypes;
+ state = {
+ notifyOnFirstChangeAfterFailure: false,
+ isActive: false,
+ dropdownHeight: 0
+ };
+
+ constructor(props: LoginPropTypes) {
+ super(props);
+ if(props.account.status === 'failed') {
+ this.state.notifyOnFirstChangeAfterFailure = true;
+ }
+ }
+
+ componentDidMount() {
+ this._updateDropdownHeight();
+ }
+
+ componentDidUpdate() {
+ this._updateDropdownHeight();
+ }
+
+ componentWillReceiveProps(nextProps: LoginPropTypes) {
+ const prev = this.props.account || {};
+ const next = nextProps.account || {};
+
+ if(prev.status !== next.status && next.status === 'failed') {
+ this.setState({ notifyOnFirstChangeAfterFailure: true });
+ }
+ }
+
+ render() {
+ const footerClass = this._shouldShowFooter() ? '' : 'login-footer--invisible';
+ return (
+ <Layout>
+ <Header showSettings={ true } onSettings={ this.props.onSettings } />
+ <Container>
+ <div className="login">
+ <div className="login-form">
+ { this._getStatusIcon() }
+
+ <div className="login-form__title">{ this._formTitle() }</div>
+
+ {this._shouldShowLoginForm() && <div className='login-form__fields'>
+ { this._createLoginForm() }
+ </div>}
+ </div>
+
+ <div className={ 'login-footer ' + footerClass }>
+ { this._createFooter() }
+ </div>
+ </div>
+ </Container>
+ </Layout>
+ );
+ }
+
+ _onCreateAccount = () => this.props.onExternalLink('createAccount');
+ _onFocus = () => this.setState({ isActive: true });
+ _onBlur = (e) => {
+ const relatedTarget = e.relatedTarget;
+
+ // restore focus if click happened within dropdown
+ if(relatedTarget && this._isWithinDropdown(relatedTarget)) {
+ e.target.focus();
+ return;
+ }
+
+ this.setState({ isActive: false });
+ }
+
+ _onLogin = () => {
+ const accountToken = this.props.account.accountToken;
+ if(accountToken && accountToken.length > 0) {
+ this.props.onLogin(accountToken);
+ }
+ }
+
+ _onInputChange = (value: string) => {
+ // notify delegate on first change after login failure
+ if(this.state.notifyOnFirstChangeAfterFailure) {
+ this.setState({ notifyOnFirstChangeAfterFailure: false });
+ this.props.onFirstChangeAfterFailure();
+ }
+ this.props.onAccountTokenChange(value);
+ }
+
+ _formTitle() {
+ switch(this.props.account.status) {
+ case 'logging in':
+ return 'Logging in...';
+ case 'failed':
+ return 'Login failed';
+ case 'ok':
+ return 'Login successful';
+ default:
+ return 'Login';
+ }
+ }
+
+ _formSubtitle() {
+ const { status, error } = this.props.account;
+ switch(status) {
+ case 'failed':
+ return (error && error.message) || 'Unknown error';
+ case 'logging in':
+ return 'Checking account number';
+ default:
+ return 'Enter your account number';
+ }
+ }
+
+ _getStatusIcon() {
+ const statusIconPath = this._getStatusIconPath();
+ return <div className="login-form__status-icon">
+ { statusIconPath ?
+ <img src={ statusIconPath } alt="" /> :
+ null }
+ </div>;
+ }
+
+ _getStatusIconPath(): ?string {
+ switch(this.props.account.status) {
+ case 'logging in':
+ return './assets/images/icon-spinner.svg';
+ case 'failed':
+ return './assets/images/icon-fail.svg';
+ case 'ok':
+ return './assets/images/icon-success.svg';
+ default:
+ return undefined;
+ }
+ }
+
+ _accountInputGroupClass(): string {
+ const classes = ['login-form__account-input-group'];
+ if(this.state.isActive) {
+ classes.push('login-form__account-input-group--active');
+ }
+
+ switch(this.props.account.status) {
+ case 'logging in':
+ classes.push('login-form__account-input-group--inactive');
+ break;
+ case 'failed':
+ classes.push('login-form__account-input-group--error');
+ break;
+ }
+
+ return classes.join(' ');
+ }
+
+ _accountInputButtonClass(): string {
+ const { accountToken, status } = this.props.account;
+ const classes = ['login-form__account-input-button'];
+
+ if(accountToken && accountToken.length > 0) {
+ classes.push('login-form__account-input-button--active');
+ }
+
+ if(status === 'logging in') {
+ classes.push('login-form__account-input-button--invisible');
+ }
+
+ return classes.join(' ');
+ }
+
+ _shouldEnableAccountInput() {
+ // enable account input always except when "logging in"
+ return this.props.account.status !== 'logging in';
+ }
+
+ _shouldShowAccountHistory() {
+ return this._shouldEnableAccountInput() &&
+ this.state.isActive &&
+ this.props.account.accountHistory.length > 0;
+ }
+
+ _shouldShowLoginForm() {
+ return this.props.account.status !== 'ok';
+ }
+
+ _shouldShowFooter() {
+ const { status } = this.props.account;
+ return (status === 'none' || status === 'failed') && !this._shouldShowAccountHistory();
+ }
+
+ // helper function to calculate and save dropdown element's height
+ // this is a no-op of the height didn't change since last update
+ _updateDropdownHeight() {
+ const element = this._accountDropdownElement;
+ if(element && this.state.dropdownHeight !== element.clientHeight) {
+ this.setState({
+ dropdownHeight: element.clientHeight
+ });
+ }
+ }
+
+ // returns true if DOM node is within dropdown hierarchy
+ _isWithinDropdown(relatedTarget) {
+ const dropdownElement = this._accountDropdownElement;
+ return dropdownElement && dropdownElement.contains(relatedTarget);
+ }
+
+ // container element used for measuring the height of the accounts dropdown
+ _accountDropdownElement: ?HTMLElement;
+ _onAccountDropdownContainerRef = ref => this._accountDropdownElement = ref;
+
+ _onSelectAccountFromHistory = (accountToken) => {
+ this.props.onAccountTokenChange(accountToken);
+ this.props.onLogin(accountToken);
+ }
+
+ _createLoginForm() {
+ const { accountHistory, accountToken } = this.props.account;
+ const dropdownStyles = {
+ height: this._shouldShowAccountHistory() ? this.state.dropdownHeight : 0
+ };
+
+ // auto-focus on account input when failed to log in
+ // do not refactor this into instance method,
+ // it has to be new function each time to be called on each render
+ const autoFocusOnFailure = (input) => {
+ if(this.props.account.status === 'failed' && input) {
+ input.focus();
+ }
+ };
+
+ return <div>
+ <div className="login-form__subtitle">{ this._formSubtitle() }</div>
+ <div className="login-form__account-input-container">
+ <div className={ this._accountInputGroupClass() }>
+ <div className="login-form__account-input-backdrop">
+ <AccountInput className="login-form__account-input-textfield"
+ type="text"
+ placeholder="e.g 0000 0000 0000"
+ onFocus={ this._onFocus }
+ onBlur={ this._onBlur }
+ onChange={ this._onInputChange }
+ onEnter={ this._onLogin }
+ value={ accountToken || '' }
+ disabled={ !this._shouldEnableAccountInput() }
+ autoFocus={ true }
+ ref={ autoFocusOnFailure } />
+ <button className={ this._accountInputButtonClass() } onClick={ this._onLogin }>
+ <LoginArrowSVG className="login-form__account-input-button-icon" />
+ </button>
+ </div>
+ <div style={ dropdownStyles } className="login-form__account-dropdown-container">
+ <div ref={ this._onAccountDropdownContainerRef }>
+ { <AccountDropdown
+ items={ accountHistory.slice().reverse() }
+ onSelect={ this._onSelectAccountFromHistory }
+ onRemove={ this.props.onRemoveAccountTokenFromHistory } /> }
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>;
+ }
+
+ _createFooter() {
+ return <div>
+ <div className="login-footer__prompt">{ 'Don\'t have an account number?' }</div>
+ <button className="button button--primary" onClick={ this._onCreateAccount }>
+ <span className="button-label">Create account</span>
+ <ExternalLinkSVG className="button-icon button-icon--16" />
+ </button>
+ </div>;
+ }
+}
+
+class AccountDropdown extends Component {
+ props: {
+ items: Array<AccountToken>,
+ onSelect: ((value: AccountToken) => void),
+ onRemove: ((value: AccountToken) => void)
+ };
+
+ render() {
+ const uniqueItems = [...new Set(this.props.items)];
+ return (
+ <div className="login-form__account-dropdown">
+ { uniqueItems.map(token => (
+ <AccountDropdownItem key={ token }
+ value={ token }
+ label={ formatAccount(token) }
+ onSelect={ this.props.onSelect }
+ onRemove={ this.props.onRemove } />
+ )) }
+ </div>
+ );
+ }
+}
+
+class AccountDropdownItem extends Component {
+ props: {
+ label: string,
+ value: AccountToken,
+ onRemove: (value: AccountToken) => void,
+ onSelect: (value: AccountToken) => void
+ };
+
+ render() {
+ return (
+ <div className="login-form__account-dropdown__item">
+ <button className="login-form__account-dropdown__label"
+ onClick={ () => this.props.onSelect(this.props.value) }>{ this.props.label }</button>
+ <button className="login-form__account-dropdown__remove"
+ onClick={ () => this.props.onRemove(this.props.value) }>
+ <RemoveAccountSVG />
+ </button>
+ </div>
+ );
+ }
+}
diff --git a/app/components/Map.js b/app/components/Map.js
new file mode 100644
index 0000000000..337d5c812b
--- /dev/null
+++ b/app/components/Map.js
@@ -0,0 +1,51 @@
+// @flow
+
+import React, { Component } from 'react';
+import ReactMapboxGl, { Marker } from 'react-mapbox-gl';
+import { mapbox as mapboxConfig } from '../config';
+import cheapRuler from 'cheap-ruler';
+
+import type { Coordinate2d } from '../types';
+
+const ReactMap = ReactMapboxGl({
+ accessToken: mapboxConfig.accessToken,
+ attributionControl: false,
+ interactive: false,
+});
+
+export default class Map extends Component {
+ props: {
+ animate: boolean,
+ location: Coordinate2d,
+ altitude: number,
+ markerImagePath: string,
+ }
+
+ render() {
+
+ const mapBounds = this.calculateMapBounds(this.props.location, this.props.altitude);
+
+ const mapBoundsOptions = { offset: [0, -113], animate: this.props.animate };
+
+ return <ReactMap style={ mapboxConfig.styleURL }
+ containerStyle={{ height: '100%' }}
+ fitBounds={ mapBounds }
+ fitBoundsOptions={ mapBoundsOptions }>
+
+ <Marker coordinates={ this.convertToMapCoordinate(this.props.location) } offset={ [0, -10] }>
+ <img src={ this.props.markerImagePath } />
+ </Marker>
+ </ReactMap>;
+ }
+
+ calculateMapBounds(center: Coordinate2d, altitude: number): [Coordinate2d, Coordinate2d] {
+ const bounds = cheapRuler(center[0], 'meters').bufferPoint(center, altitude);
+ // convert [lat,lng] bounds to [lng,lat]
+ return [ [bounds[1], bounds[0]], [bounds[3], bounds[2]] ];
+ }
+
+ convertToMapCoordinate(pos: Coordinate2d): Coordinate2d {
+ // convert [lat,lng] bounds to [lng,lat]
+ return [pos[1], pos[0]];
+ }
+}
diff --git a/app/components/SelectLocation.css b/app/components/SelectLocation.css
new file mode 100644
index 0000000000..7e107f237f
--- /dev/null
+++ b/app/components/SelectLocation.css
@@ -0,0 +1,146 @@
+.select-location {
+ background: #192E45;
+ height: 100%;
+}
+
+.select-location__container {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+}
+
+.select-location__header {
+ flex: 0 0 auto;
+ padding: 40px 24px 16px;
+ position: relative; /* anchor for close button */
+}
+
+.select-location__close {
+ position: absolute;
+ display: block;
+ border: 0;
+ padding: 0;
+ margin: 0;
+ width: 24px;
+ height: 24px;
+ top: 24px;
+ left: 12px;
+ background-color: transparent;
+ background-image: url(../assets/images/icon-close.svg);
+ opacity: 0.6;
+ z-index: 1; /* part of .select-location__container covers the button */
+}
+
+.select-location__title {
+ font-family: DINPro;
+ font-size: 32px;
+ font-weight: 900;
+ line-height: 1.25em;
+ color: #FFFFFF;
+}
+
+.select-location__subtitle {
+ font-family: "Open Sans";
+ font-size: 13px;
+ font-weight: 600;
+ line-height: normal;
+ color: rgba(255,255,255,0.8);
+ padding: 0 24px 24px;
+}
+
+.select-location__cell {
+ background-color: rgba(41,71,115,1);
+ display: flex;
+ flex-direction: row;
+ align-items: stretch;
+}
+
+.select-location__cell-content {
+ padding: 15px 24px;
+ display: flex;
+ flex: 1 1 auto;
+ flex-direction: row;
+ align-items: center;
+}
+
+.select-location__cell--selectable:hover {
+ background-color:rgba(41,71,115,0.9);
+}
+
+.select-location__cell--selected,
+.select-location__cell--selected:hover {
+ background-color: #44AD4D;
+}
+
+.select-location__country + .select-location__country {
+ margin-top: 1px;
+}
+
+.select-location__cell-label {
+ font-family: DINPro;
+ font-size: 20px;
+ font-weight: 900;
+ line-height: 26px;
+ color: #FFFFFF;
+}
+
+.select-location__cell-label--inactive {
+ color: rgba(255, 255, 255, 0.2);
+}
+
+.select-location__cell-icon {
+ width: 24px;
+ height: 24px;
+ flex: 0 0 auto;
+ margin-right: 8px;
+ align-items: center;
+ justify-content: center;
+ display: flex;
+ color: #fff;
+}
+
+.select-location__collapse-button {
+ border: 0;
+ background: transparent;
+ padding: 0;
+ margin: 0 0 0 auto;
+ display: flex;
+ align-items: stretch;
+ padding: 12px;
+}
+
+.select-location__collapse-icon {
+ color: #fff;
+}
+
+.select-location__sub-cell {
+ background-color: rgb(36, 57, 84);
+ padding: 15px 24px 15px 40px;
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ margin-top: 1px;
+}
+
+.select-location__sub-cell--selectable:hover {
+ background-color: rgba(41,71,115,0.9);
+}
+
+.select-location__sub-cell--selected,
+.select-location__sub-cell--selected:hover {
+ background-color: #44AD4D;
+}
+
+.select-location-relay-status {
+ width: 16px;
+ height: 16px;
+ border-radius: 8px;
+}
+
+.select-location-relay-status--inactive {
+ background: rgba(208, 2, 27, 0.95);
+}
+
+.select-location-relay-status--active {
+ background: rgba(68, 173, 77, 0.9);
+} \ No newline at end of file
diff --git a/app/components/SelectLocation.js b/app/components/SelectLocation.js
new file mode 100644
index 0000000000..7fb124bf33
--- /dev/null
+++ b/app/components/SelectLocation.js
@@ -0,0 +1,225 @@
+// @flow
+import React, { Component } from 'react';
+import { Layout, Container, Header } from './Layout';
+import CustomScrollbars from './CustomScrollbars';
+
+import Accordion from './Accordion';
+import ChevronDownSVG from '../assets/images/icon-chevron-down.svg';
+import ChevronUpSVG from '../assets/images/icon-chevron-up.svg';
+import TickSVG from '../assets/images/icon-tick.svg';
+
+import type { SettingsReduxState, RelayLocationRedux, RelayLocationCityRedux } from '../redux/settings/reducers';
+import type { RelayLocation } from '../lib/ipc-facade';
+
+export type SelectLocationProps = {
+ settings: SettingsReduxState,
+ onClose: () => void;
+ onSelect: (location: RelayLocation) => void;
+};
+
+export default class SelectLocation extends Component {
+ props: SelectLocationProps;
+ _selectedCell: ?HTMLElement;
+
+ state = {
+ expanded: ([]: Array<string>),
+ };
+
+ constructor(props: SelectLocationProps, context?: any) {
+ super(props, context);
+
+ // set initially expanded country based on relaySettings
+ const relaySettings = this.props.settings.relaySettings;
+ if(relaySettings.normal) {
+ const { location } = relaySettings.normal;
+ if(location === 'any') {
+ // no-op
+ } else if(location.country) {
+ this.state.expanded.push(location.country);
+ } else if(location.city) {
+ this.state.expanded.push(location.city[0]);
+ }
+ }
+ }
+
+ componentDidMount() {
+ // restore scroll to selected cell
+ const cell = this._selectedCell;
+ if(cell) {
+ // this is non-standard webkit method but it works great!
+ if(typeof(cell.scrollIntoViewIfNeeded) !== 'function') {
+ console.warn('HTMLElement.scrollIntoViewIfNeeded() is not available anymore! Please replace it with viable alternative.');
+ return;
+ }
+ cell.scrollIntoViewIfNeeded(true);
+ }
+ }
+
+ render() {
+ return (
+ <Layout>
+ <Header hidden={ true } style={ 'defaultDark' } />
+ <Container>
+ <div className="select-location">
+ <button className="select-location__close" onClick={ this.props.onClose } />
+ <div className="select-location__container">
+ <div className="select-location__header">
+ <h2 className="select-location__title">Select location</h2>
+ </div>
+
+ <CustomScrollbars autoHide={ true }>
+ <div>
+ <div className="select-location__subtitle">
+ While connected, your real location is masked with a private and secure location in the selected region
+ </div>
+
+ { this.props.settings.relayLocations.map((relayCountry) => {
+ return this._renderCountry(relayCountry);
+ }) }
+
+ </div>
+ </CustomScrollbars>
+ </div>
+ </div>
+ </Container>
+ </Layout>
+ );
+ }
+
+ _isSelected(selectedLocation: RelayLocation) {
+ const { relaySettings } = this.props.settings;
+ if(relaySettings.normal) {
+ const otherLocation = relaySettings.normal.location;
+
+ if(selectedLocation.country && otherLocation.country &&
+ selectedLocation.country === otherLocation.country) {
+ return true;
+ }
+
+ if(Array.isArray(selectedLocation.city) && Array.isArray(otherLocation.city)) {
+ const selectedCity = selectedLocation.city;
+ const otherCity = otherLocation.city;
+
+ return selectedCity.length === otherCity.length &&
+ selectedCity.every((v, i) => v === otherCity[i]);
+ }
+ }
+ return false;
+ }
+
+ _toggleCollapse = (countryCode: string) => {
+ this.setState((state) => {
+ const expanded = state.expanded.slice();
+ const index = expanded.indexOf(countryCode);
+ if(index === -1) {
+ expanded.push(countryCode);
+ } else {
+ expanded.splice(index, 1);
+ }
+ return { expanded };
+ });
+ }
+
+ _relayStatusIndicator(active: boolean) {
+ const statusClass = active ? 'select-location-relay-status--active' : 'select-location-relay-status--inactive';
+
+ return (<div className={ 'select-location-relay-status ' + statusClass }></div>);
+ }
+
+ _renderCountry(relayCountry: RelayLocationRedux) {
+ const isSelected = this._isSelected({ country: relayCountry.code });
+
+ // either expanded by user or when the city selected within the country
+ const isExpanded = this.state.expanded.includes(relayCountry.code);
+
+ const handleSelect = (relayCountry.hasActiveRelays && !isSelected) ? () => {
+ this.props.onSelect({ country: relayCountry.code });
+ } : undefined;
+
+ const handleCollapse = (e) => {
+ this._toggleCollapse(relayCountry.code);
+ e.stopPropagation();
+ };
+
+ const countryClass = 'select-location__cell' +
+ (isSelected ? ' select-location__cell--selected' : '') +
+ (relayCountry.hasActiveRelays ? ' select-location__cell--selectable' : '');
+
+ const onRef = isSelected ? (element) => {
+ this._selectedCell = element;
+ } : undefined;
+
+ return (
+ <div key={ relayCountry.code } className="select-location__country">
+ <div className={ countryClass }
+ onClick={ handleSelect }
+ ref={ onRef }>
+ <div className="select-location__cell-content">
+
+ <div className="select-location__cell-icon">
+ { isSelected ?
+ <TickSVG /> :
+ this._relayStatusIndicator(relayCountry.hasActiveRelays) }
+ </div>
+
+ <div className={ 'select-location__cell-label' +
+ (relayCountry.hasActiveRelays ? '' : ' select-location__cell-label--inactive') }>
+ { relayCountry.name }
+ </div>
+ </div>
+
+ { relayCountry.hasActiveRelays && <button type="button" className="select-location__collapse-button" onClick={ handleCollapse }>
+ { isExpanded ?
+ <ChevronUpSVG className="select-location__collapse-icon" /> :
+ <ChevronDownSVG className="select-location__collapse-icon" /> }
+ </button> }
+
+ </div>
+
+ { relayCountry.hasActiveRelays && relayCountry.cities.length > 0 &&
+ (<Accordion className="select-location__cities" height={ isExpanded ? 'auto' : 0 }>
+ { relayCountry.cities.map((relayCity) => this._renderCity(relayCountry.code, relayCity)) }
+ </Accordion>)
+ }
+ </div>
+ );
+ }
+
+ _renderCity(countryCode: string, relayCity: RelayLocationCityRedux) {
+ const relayLocation: RelayLocation = { city: [countryCode, relayCity.code] };
+
+ const isSelected = this._isSelected(relayLocation);
+
+ const cityClass = 'select-location__sub-cell' +
+ (isSelected ? ' select-location__sub-cell--selected' : '') +
+ (relayCity.hasActiveRelays ? ' select-location__sub-cell--selectable' : '');
+
+ const handleSelect = (relayCity.hasActiveRelays && !isSelected) ? () => {
+ this.props.onSelect(relayLocation);
+ } : undefined;
+
+ const onRef = isSelected ? (element) => {
+ this._selectedCell = element;
+ } : undefined;
+
+ return (
+ <div key={ `${countryCode}_${relayCity.code}` }
+ className={ cityClass }
+ onClick={ handleSelect }
+ ref={ onRef }>
+
+ <div className="select-location__cell-icon">
+ { isSelected ?
+ <TickSVG /> :
+ this._relayStatusIndicator(relayCity.hasActiveRelays) }
+ </div>
+
+ <div className={ 'select-location__cell-label' +
+ (relayCity.hasActiveRelays ? '' : ' select-location__cell-label--inactive') }>
+ { relayCity.name }
+ </div>
+ </div>
+ );
+ }
+
+}
diff --git a/app/components/Settings.css b/app/components/Settings.css
new file mode 100644
index 0000000000..9e49c814f9
--- /dev/null
+++ b/app/components/Settings.css
@@ -0,0 +1,125 @@
+.settings {
+ background: #192E45;
+ height: 100%;
+}
+
+.settings__container {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+}
+
+.settings__header {
+ flex: 0 0 auto;
+ padding: 40px 24px 24px;
+ position: relative; /* anchor for close button */
+}
+
+.settings__content {
+ flex: 1 1 auto;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+}
+
+.settings__close {
+ position: absolute;
+ display: block;
+ border: 0;
+ padding: 0;
+ margin: 0;
+ width: 24px;
+ height: 24px;
+ top: 24px;
+ left: 12px;
+ background-color: transparent;
+ background-image: url(../assets/images/icon-close.svg);
+ opacity: 0.6;
+ z-index: 1; /* part of .settings__container covers the button */
+}
+
+.settings__title {
+ font-family: DINPro;
+ font-size: 32px;
+ font-weight: 900;
+ line-height: 40px;
+ color: #FFFFFF;
+}
+
+.settings__cell {
+ background-color:rgba(41,71,115,1);
+ padding: 15px 24px;
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+}
+
+.settings__cell-disclosure {
+ display: block;
+ margin-left: 8px;
+ color: rgba(255, 255, 255, 0.8);
+}
+
+.settings__cell-spacer {
+ height: 24px;
+}
+
+.settings__cell--selected,
+.settings__cell--selected:hover {
+ background-color: #44AD4D;
+}
+
+.settings__cell--active:hover {
+ background-color: rgba(41,71,115,0.9);
+}
+
+.settings__cell + .settings__cell {
+ margin-top: 1px;
+}
+
+.settings__cell-label {
+ font-family: DINPro;
+ font-size: 20px;
+ font-weight: 900;
+ line-height: 26px;
+ color: #FFFFFF;
+ flex: 1 0 auto;
+}
+
+.settings__cell-icon {
+ width: 16px;
+ flex: 0 0 auto;
+ color: rgba(255, 255, 255, 0.8);
+}
+
+.settings__cell-value {
+ flex: 0 0 auto;
+}
+
+.settings__account-paid-until-label {
+ font-family: "Open Sans";
+ font-size: 13px;
+ font-weight: 800;
+ line-height: 26px; /* matches .cell-label */
+ color: rgba(255, 255, 255, 0.8);
+ text-transform: uppercase;
+}
+
+.settings__account-paid-until-label--error {
+ color: #d0021b;
+}
+
+.settings__cell-footer {
+ padding: 8px 24px 24px;
+ font-family: "Open Sans";
+ font-size: 13px;
+ font-weight: 600;
+ line-height: 20px;
+ color: rgba(255,255,255,0.8);
+}
+
+.settings__footer {
+ padding: 24px;
+}
+
+.settings__footer .button + .button { margin-top: 16px; }
diff --git a/app/components/Settings.js b/app/components/Settings.js
new file mode 100644
index 0000000000..492b2bd403
--- /dev/null
+++ b/app/components/Settings.js
@@ -0,0 +1,120 @@
+// @flow
+import moment from 'moment';
+import React, { Component } from 'react';
+import { If, Then, Else } from 'react-if';
+import { Layout, Container, Header } from './Layout';
+import CustomScrollbars from './CustomScrollbars';
+
+import ChevronRightSVG from '../assets/images/icon-chevron.svg';
+import ExternalLinkSVG from '../assets/images/icon-extLink.svg';
+
+import type { AccountReduxState } from '../redux/account/reducers';
+import type { SettingsReduxState } from '../redux/settings/reducers';
+
+export type SettingsProps = {
+ account: AccountReduxState,
+ settings: SettingsReduxState,
+ onQuit: () => void,
+ onClose: () => void,
+ onViewAccount: () => void,
+ onViewSupport: () => void,
+ onViewAdvancedSettings: () => void,
+ onExternalLink: (type: string) => void
+};
+
+export default class Settings extends Component {
+
+ props: SettingsProps;
+
+ render() {
+ const isLoggedIn = this.props.account.status === 'ok';
+ let isOutOfTime = false, formattedExpiry = '';
+ let expiryIso = this.props.account.expiry;
+
+ if(isLoggedIn && expiryIso) {
+ let expiry = moment(this.props.account.expiry);
+ isOutOfTime = expiry.isSameOrBefore(moment());
+ formattedExpiry = expiry.fromNow(true) + ' left';
+ }
+
+ return (
+ <Layout>
+ <Header hidden={ true } style={ 'defaultDark' } />
+ <Container>
+ <div className="settings">
+ <button className="settings__close" onClick={ this.props.onClose } />
+ <div className="settings__container">
+ <div className="settings__header">
+ <h2 className="settings__title">Settings</h2>
+ </div>
+ <CustomScrollbars autoHide={ true }>
+ <div className="settings__content">
+ <div className="settings__main">
+
+ { /* show account options when logged in */ }
+ <If condition={ isLoggedIn }>
+ <Then>
+ <div className="settings__account">
+
+ <div className="settings__view-account settings__cell settings__cell--active" onClick={ this.props.onViewAccount }>
+ <div className="settings__cell-label">Account</div>
+ <div className="settings__cell-value">
+ <If condition={ isOutOfTime }>
+ <Then>
+ <span className="settings__account-paid-until-label settings__account-paid-until-label--error">OUT OF TIME</span>
+ </Then>
+ <Else>
+ <span className="settings__account-paid-until-label">{ formattedExpiry }</span>
+ </Else>
+ </If>
+ </div>
+ <div className="settings__cell-disclosure"><ChevronRightSVG /></div>
+ </div>
+ <div className="settings__cell-spacer"></div>
+ </div>
+ </Then>
+ </If>
+
+ <If condition={ isLoggedIn }>
+ <Then>
+ <div className="settings__advanced">
+ <div className="settings__cell settings__cell--active" onClick={ this.props.onViewAdvancedSettings }>
+ <div className="settings__cell-label">Advanced</div>
+ <div className="settings__cell-value">
+ <div className="settings__cell-disclosure"><ChevronRightSVG /></div>
+ </div>
+ </div>
+ <div className="settings__cell-spacer"></div>
+ </div>
+ </Then>
+ </If>
+
+ <div className="settings__external">
+ <div className="settings__cell settings__cell--active" onClick={ this.props.onExternalLink.bind(this, 'faq') }>
+ <div className="settings__cell-label">FAQs</div>
+ <div className="settings__cell-icon"><ExternalLinkSVG /></div>
+ </div>
+ <div className="settings__cell settings__cell--active" onClick={ this.props.onExternalLink.bind(this, 'guides') }>
+ <div className="settings__cell-label">Guides</div>
+ <div className="settings__cell-icon"><ExternalLinkSVG /></div>
+ </div>
+ <div className="settings__view-support settings__cell settings__cell--active" onClick={ this.props.onViewSupport }>
+ <div className="settings__cell-label">Report a problem</div>
+ <div className="settings__cell-disclosure"><ChevronRightSVG /></div>
+ </div>
+ </div>
+ </div>
+
+ <div className="settings__footer">
+ <button className="settings__quit button button--negative" onClick={ this.props.onQuit }>Quit app</button>
+ </div>
+
+ </div>
+ </CustomScrollbars>
+ </div>
+ </div>
+ </Container>
+ </Layout>
+ );
+ }
+}
diff --git a/app/components/Support.css b/app/components/Support.css
new file mode 100644
index 0000000000..8e8198742c
--- /dev/null
+++ b/app/components/Support.css
@@ -0,0 +1,169 @@
+.support {
+ background: #192E45;
+ height: 100%;
+}
+
+.support__container {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+}
+
+.support__header {
+ flex: 0 0 auto;
+ padding: 40px 24px 24px;
+ position: relative; /* anchor for close button */
+}
+
+.support__close {
+ position: absolute;
+ display: flex;
+ align-items: center;
+ border: 0;
+ padding: 0;
+ margin: 0;
+ top: 24px;
+ left: 12px;
+ z-index: 1; /* part of .support__container covers the button */
+}
+
+.support__close-icon {
+ opacity: 0.6;
+ margin-right: 8px;
+}
+
+.support__close-title {
+ font-family: "Open Sans";
+ font-size: 13px;
+ font-weight: 600;
+ color: rgba(255, 255, 255, 0.6);
+}
+
+.support__title {
+ font-family: DINPro;
+ font-size: 32px;
+ font-weight: 900;
+ line-height: 40px;
+ color: #FFFFFF;
+ margin-bottom: 16px;
+}
+
+.support__subtitle {
+ font-family: "Open Sans";
+ font-size: 13px;
+ font-weight: 600;
+ line-height: normal;
+ color: rgba(255,255,255,0.8);
+}
+
+.support__content {
+ flex: 1 1 auto;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+}
+
+.support__form {
+ display: flex;
+ flex: 1 1 auto;
+ flex-direction: column;
+}
+
+.support__form-row {
+ padding: 0 24px;
+}
+
+.support__form-row + .support__form-row {
+ margin-top: 8px;
+}
+
+.support__form-row-message {
+ display: flex;
+ flex: 1 1 auto;
+}
+
+.support__form-email {
+ width: 100%;
+ border-radius: 4px;
+ border: 0;
+ overflow: hidden;
+ padding: 10px 12px 12px 12px;
+ font-family: "Open Sans";
+ font-size: 13px;
+ font-weight: 600;
+ line-height: 26px;
+ color: #294D73;
+ background-color: #fff;
+}
+
+.support__form-email::-webkit-input-placeholder {
+ color: rgba(41,77,115,0.4);
+}
+
+.support__form-message-scroll-wrap {
+ width: 100%;
+ display: flex;
+ border-radius: 4px;
+ overflow: hidden;
+}
+
+.support__form-message {
+ width: 100%;
+ border: 0;
+ overflow-y: scroll;
+ resize: none;
+ padding: 10px 12px 12px 12px;
+ font-family: "Open Sans";
+ font-size: 13px;
+ font-weight: 600;
+ line-height: 1.4em;
+ color: #294D73;
+ background-color: #fff;
+}
+
+.support__form-message::-webkit-input-placeholder {
+ color: rgba(41,77,115,0.4);
+}
+
+.support__footer {
+ padding: 16px 24px 24px;
+}
+
+.support__footer .button + .button {
+ margin-top: 16px;
+}
+
+.support__sent-email {
+ display: inline;
+ font-weight: 900;
+ color: white;
+}
+
+.support__status-security--secure {
+ font-family: "Open Sans";
+ font-size: 16px;
+ font-weight: 800;
+ line-height: 22px;
+ margin-bottom: 4px;
+ color: #44AD4D;
+ text-transform: uppercase;
+}
+
+.support__send-status {
+ font-family: DINPro;
+ font-size: 38px;
+ font-weight: 900;
+ line-height: 1.16em;
+ max-height: calc(1.16em * 2);
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ letter-spacing: -0.9px;
+ color: #FFFFFF;
+ margin-bottom: 4px;
+}
+
+.support__status-icon {
+ text-align: center;
+ margin-bottom: 32px;
+}
diff --git a/app/components/Support.js b/app/components/Support.js
new file mode 100644
index 0000000000..04e6c7392d
--- /dev/null
+++ b/app/components/Support.js
@@ -0,0 +1,254 @@
+// @flow
+import React, { Component } from 'react';
+import { Layout, Container, Header } from './Layout';
+import ExternalLinkSVG from '../assets/images/icon-extLink.svg';
+
+import type { AccountReduxState } from '../redux/account/reducers';
+
+export type SupportReport = {
+ email: string,
+ message: string,
+ savedReport: ?string,
+};
+
+export type SupportState = {
+ email: string,
+ message: string,
+ savedReport: ?string,
+ sendState: 'INITIAL' | 'LOADING' | 'SUCCESS' | 'FAILED',
+};
+export type SupportProps = {
+ account: AccountReduxState,
+ onClose: () => void;
+ onViewLog: (string) => void;
+ onCollectLog: (Array<string>) => Promise<string>;
+ onSend: (email: string, message: string, savedReport: string) => void;
+};
+
+export default class Support extends Component {
+ props: SupportProps;
+ state: SupportState = {
+ email: '',
+ message: '',
+ savedReport: null,
+ sendState: 'INITIAL',
+ }
+
+ validate() {
+ return this.state.message.trim().length > 0;
+ }
+
+ onChangeEmail = (e: Event) => {
+ const input = e.target;
+ if(!(input instanceof HTMLInputElement)) {
+ throw new Error('input must be an instance of HTMLInputElement');
+ }
+ this.setState({ email: input.value });
+ }
+
+ onChangeDescription = (e: Event) => {
+ const input = e.target;
+ if(!(input instanceof HTMLTextAreaElement)) {
+ throw new Error('input must be an instance of HTMLTextAreaElement');
+ }
+ this.setState({ message: input.value });
+ }
+
+ onViewLog = () => {
+
+ this._getLog()
+ .then((path) => {
+ this.props.onViewLog(path);
+ });
+ }
+
+ _getLog() {
+ const toRedact = [];
+ if (this.props.account.accountToken) {
+ toRedact.push(this.props.account.accountToken.toString());
+ }
+
+ const { savedReport } = this.state;
+ return savedReport ?
+ Promise.resolve(savedReport) :
+ this.props.onCollectLog(toRedact)
+ .then( path => {
+ return new Promise(resolve => this.setState({ savedReport: path }, () => resolve(path)));
+ });
+ }
+
+ onSend = () => {
+ this.setState({
+ sendState: 'LOADING',
+ }, () => {
+ this._getLog()
+ .then((path) => {
+ return this.props.onSend(this.state.email, this.state.message, path);
+ })
+ .then( () => {
+ this.setState({
+ sendState: 'SUCCESS',
+ });
+ })
+ .catch( () => {
+ this.setState({
+ sendState: 'FAILED',
+ });
+ });
+ });
+ }
+
+ render() {
+
+ const header = <div className="support__header">
+ <h2 className="support__title">Report a problem</h2>
+ { this.state.sendState === 'INITIAL' && <div className="support__subtitle">
+ { `To help you more effectively, your app's log file will be attached to this message.
+ Your data will remain secure and private, as it is encrypted & anonymised before sending.` }
+ </div>
+ }
+ </div>;
+
+ const content = this._renderContent();
+
+ return (
+ <Layout>
+ <Header hidden={ true } style={ 'defaultDark' } />
+ <Container>
+ <div className="support">
+ <div className="support__close" onClick={ this.props.onClose }>
+ <img className="support__close-icon" src="./assets/images/icon-back.svg" />
+ <span className="support__close-title">Settings</span>
+ </div>
+ <div className="support__container">
+
+ { header }
+
+ { content }
+
+ </div>
+ </div>
+ </Container>
+ </Layout>
+ );
+ }
+
+ _renderContent() {
+ switch(this.state.sendState) {
+ case 'INITIAL':
+ return this._renderForm();
+ case 'LOADING':
+ return this._renderLoading();
+ case 'SUCCESS':
+ return this._renderSent();
+ case 'FAILED':
+ return this._renderFailed();
+ default:
+ return null;
+ }
+ }
+
+ _renderForm() {
+ return <div className="support__content">
+ <div className="support__form">
+ <div className="support__form-row">
+ <input className="support__form-email"
+ type="email"
+ placeholder="Your email"
+ value={ this.state.email }
+ onChange={ this.onChangeEmail }
+ autoFocus={ true } />
+ </div>
+ <div className="support__form-row support__form-row-message">
+ <div className="support__form-message-scroll-wrap">
+ <textarea className="support__form-message"
+ placeholder="Describe your problem"
+ value={ this.state.message }
+ onChange={ this.onChangeDescription } />
+ </div>
+ </div>
+ <div className="support__footer">
+ <button type="button"
+ className="support__form-view-logs button button--primary"
+ onClick={ this.onViewLog }>
+ <span className="button-label">View app logs</span>
+ <ExternalLinkSVG className="button-icon button-icon--16" />
+ </button>
+ <button type="button"
+ className="support__form-send button button--positive"
+ disabled={ !this.validate() }
+ onClick={ this.onSend }>Send</button>
+ </div>
+ </div>
+ </div>;
+ }
+
+ _renderLoading() {
+ return <div className="support__content">
+
+ <div className="support__form">
+ <div className="support__form-row">
+ <div className="support__status-icon">
+ <img src="./assets/images/icon-spinner.svg" alt="" />
+ </div>
+ <div className="support__status-security--secure">
+ Secure Connection
+ </div>
+ <div className="support__send-status">
+ <span>Sending...</span>
+ </div>
+ </div>
+ </div>
+ </div>;
+ }
+
+ _renderSent() {
+ return <div className="support__content">
+ <div className="support__form">
+ <div className="support__form-row">
+ <div className="support__status-icon">
+ <img src="./assets/images/icon-success.svg" alt="" />
+ </div>
+ <div className="support__status-security--secure">
+ Secure Connection
+ </div>
+ <div className="support__send-status">
+ <span>Sent</span>
+ </div>
+ <div className="support__subtitle">
+ Thanks! We will look into this. If needed we will contact you on {'\u00A0'}
+ <div className="support__sent-email">{ this.state.email }</div>
+ </div>
+ </div>
+ </div>
+ </div>;
+ }
+
+ _renderFailed() {
+ return <div className="support__content">
+ <div className="support__form">
+ <div className="support__form-row">
+ <div className="support__status-icon">
+ <img src="./assets/images/icon-fail.svg" alt="" />
+ </div>
+ <div className="support__status-security--secure">
+ Secure Connection
+ </div>
+ <div className="support__send-status">
+ <span>Failed to send</span>
+ </div>
+ </div>
+ </div>
+ <div className="support__footer">
+ <button type="button"
+ className="support__form-view-logs button button--primary"
+ onClick={ () => this.setState({ sendState: 'INITIAL' }) }>
+ <span className="button-label">Edit message</span>
+ </button>
+ <button type="button"
+ className="support__form-send button button--positive"
+ onClick={ this.onSend }>Try again</button>
+ </div>
+ </div>;
+ }
+}
diff --git a/app/components/Switch.css b/app/components/Switch.css
new file mode 100644
index 0000000000..aaa3821c34
--- /dev/null
+++ b/app/components/Switch.css
@@ -0,0 +1,44 @@
+.switch {
+ display: block;
+ position: relative;
+ -webkit-appearance: none;
+ border-radius: 16px;
+ width: 52px;
+ height: 32px;
+ border: 2px solid white;
+ background-color: transparent;
+ transition: 300ms ease-in-out all;
+}
+
+.switch:checked {
+ text-align: right;
+}
+
+.switch::after {
+ position: absolute;
+ left: 2px;
+ top: 2px;
+ display: block;
+ content: '';
+ width: 24px;
+ height: 24px;
+ border-radius: 24px;
+ background-color: #D0021B;
+ transition: 200ms ease-in-out all;
+ transform: translate3d(0, 0, 0);
+}
+
+.switch:active::after {
+ width: 28px;
+}
+
+.switch:active:checked::after {
+ transform: translate3d(0, 0, 0);
+ left: 18px;
+}
+
+.switch:checked::after {
+ background-color: #44AD4D;
+ transform: translate3d(0, 0, 0);
+ left: 22px;
+} \ No newline at end of file
diff --git a/app/components/Switch.js b/app/components/Switch.js
new file mode 100644
index 0000000000..f0ad3b41bc
--- /dev/null
+++ b/app/components/Switch.js
@@ -0,0 +1,142 @@
+// @flow
+import React, { Component } from 'react';
+
+import type { Point2d } from '../types';
+
+const CLICK_TIMEOUT = 1000;
+const MOVE_THRESHOLD = 10;
+
+export type SwitchProps = {
+ isOn: boolean;
+ onChange: ?((isOn: boolean) => void);
+};
+
+export default class Switch extends Component {
+ props: SwitchProps;
+ static defaultProps: SwitchProps = {
+ isOn: false,
+ onChange: null
+ }
+
+ isCapturingMouseEvents = false;
+ ref: ?HTMLInputElement;
+ onRef = (e: HTMLInputElement) => this.ref = e;
+
+ state = {
+ ignoreChange: false,
+ initialPos: ({x: 0, y: 0}: Point2d),
+ startTime: (null: ?number)
+ }
+
+ handleMouseDown = (e: MouseEvent) => {
+ const { clientX: x, clientY: y } = e;
+ this.startCapturingMouseEvents();
+ this.setState({
+ initialPos: { x, y },
+ startTime: e.timeStamp
+ });
+ }
+
+ handleMouseMove = (e: MouseEvent) => {
+ const inputElement = this.ref;
+ const { x: x0 } = this.state.initialPos;
+ const { clientX: x, clientY: y } = e;
+ const dx = Math.abs(x0 - x);
+
+ if(dx < MOVE_THRESHOLD) {
+ return;
+ }
+
+ const isOn = !!this.props.isOn;
+ let nextOn = isOn;
+
+ if(x < x0 && isOn) {
+ nextOn = false;
+ } else if(x > x0 && !isOn) {
+ nextOn = true;
+ }
+
+ if(isOn !== nextOn) {
+ this.setState({
+ initialPos: { x, y },
+ ignoreChange: true
+ });
+
+ if(inputElement) {
+ inputElement.checked = nextOn;
+ }
+
+ this.notify(nextOn);
+ }
+ }
+
+ handleMouseUp = () => {
+ this.stopCapturingMouseEvents();
+ }
+
+ handleChange = (e: Event) => {
+ const startTime = this.state.startTime;
+ const eventTarget: Object = e.target;
+
+ if(typeof(startTime) !== 'number') {
+ throw new Error('startTime must be a number.');
+ }
+
+ const dt = e.timeStamp - startTime;
+
+ if(this.state.ignoreChange) {
+ this.setState({ ignoreChange: false });
+ e.preventDefault();
+ } else if(dt > CLICK_TIMEOUT) {
+ e.preventDefault();
+ } else {
+ this.notify(eventTarget.checked);
+ }
+ }
+
+ notify(isOn: boolean) {
+ const onChange = this.props.onChange;
+ if(onChange) {
+ onChange(isOn);
+ }
+ }
+
+ startCapturingMouseEvents() {
+ if(this.isCapturingMouseEvents) {
+ throw new Error('startCapturingMouseEvents() is called out of order.');
+ }
+ document.addEventListener('mousemove', this.handleMouseMove);
+ document.addEventListener('mouseup', this.handleMouseUp);
+ this.isCapturingMouseEvents = true;
+ }
+
+ stopCapturingMouseEvents() {
+ if(!this.isCapturingMouseEvents) {
+ throw new Error('stopCapturingMouseEvents() is called out of order.');
+ }
+ document.removeEventListener('mousemove', this.handleMouseMove);
+ document.removeEventListener('mouseup', this.handleMouseUp);
+ this.isCapturingMouseEvents = false;
+ }
+
+ componentWillUnmount() {
+ // guard from abrupt programmatic unmount
+ if(this.isCapturingMouseEvents) {
+ this.stopCapturingMouseEvents();
+ }
+ }
+
+ render(): React.Element<*> {
+ const { isOn, onChange, ...otherProps } = this.props; // eslint-disable-line no-unused-vars
+ let className = ('switch' + ' ' + (otherProps.className || '')).trim();
+ return (
+ <input { ...otherProps }
+ type="checkbox"
+ ref={ this.onRef }
+ className={ className }
+ checked={ isOn }
+ onMouseDown={ this.handleMouseDown }
+ onChange={ this.handleChange } />
+ );
+ }
+}
diff --git a/app/components/WindowChrome.css b/app/components/WindowChrome.css
new file mode 100644
index 0000000000..2c6b820a40
--- /dev/null
+++ b/app/components/WindowChrome.css
@@ -0,0 +1,13 @@
+/* macOS app runs as menubar; create an app chrome with arrow using mask */
+.window-chrome--darwin {
+ -webkit-mask:
+ url(../assets/images/app-triangle.svg) 50% 0% no-repeat,
+ url(../assets/images/app-header-backdrop.svg) no-repeat;
+}
+
+.window-chrome {
+ flex: 1 1 auto;
+ height: 100%;
+ width: 100%;
+ display: flex;
+} \ No newline at end of file
diff --git a/app/components/WindowChrome.js b/app/components/WindowChrome.js
new file mode 100644
index 0000000000..d7094813db
--- /dev/null
+++ b/app/components/WindowChrome.js
@@ -0,0 +1,16 @@
+// @flow
+import React, { Component } from 'react';
+
+export default class WindowChrome extends Component {
+ props: {
+ children: Array<React.Element<*>> | React.Element<*>
+ }
+ render(): React.Element<*> {
+ const chromeClass = ['window-chrome', 'window-chrome--' + process.platform];
+ return (
+ <div className={ chromeClass.join(' ') }>
+ { this.props.children }
+ </div>
+ );
+ }
+} \ No newline at end of file