diff options
| author | Linus Färnstrand <linus@mullvad.net> | 2017-12-20 11:32:55 +0100 |
|---|---|---|
| committer | Linus Färnstrand <linus@mullvad.net> | 2017-12-20 11:34:21 +0100 |
| commit | 2aae380b0af018bf0187bb31fb0fedf6a457ebf1 (patch) | |
| tree | a8ad6ee12956d92e6257bea07dedc44063f3017f /app/components | |
| parent | 7b47ddf735af7f3d6065fb6c3ffea6e9ddfd86cb (diff) | |
| parent | 8b146934260739ae609791a1fb676d48ceb954c0 (diff) | |
| download | mullvadvpn-2aae380b0af018bf0187bb31fb0fedf6a457ebf1.tar.xz mullvadvpn-2aae380b0af018bf0187bb31fb0fedf6a457ebf1.zip | |
Merge backend and frontend repo master branches
Conflicts:
.gitignore
.travis.yml
README.md
Diffstat (limited to 'app/components')
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 |
