diff --git a/assets/images/expensify-wordmark.svg b/assets/images/expensify-wordmark.svg index b90d7fb3c0f..73018497030 100644 --- a/assets/images/expensify-wordmark.svg +++ b/assets/images/expensify-wordmark.svg @@ -1,10 +1,7 @@ - + viewBox="0 0 78 18" style="enable-background:new 0 0 78 18;" xml:space="preserve"> diff --git a/assets/images/product-illustrations/rocket--blue.svg b/assets/images/product-illustrations/rocket--blue.svg new file mode 100644 index 00000000000..b59e8a28c8c --- /dev/null +++ b/assets/images/product-illustrations/rocket--blue.svg @@ -0,0 +1,286 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/config/electronBuilder.config.js b/config/electronBuilder.config.js index a23d942167e..ba018e04b8b 100644 --- a/config/electronBuilder.config.js +++ b/config/electronBuilder.config.js @@ -68,4 +68,8 @@ module.exports = { app: 'desktop', output: 'desktop-build', }, + protocols: { + name: 'New Expensify', + schemes: ['new-expensify'], + }, }; diff --git a/desktop/main.js b/desktop/main.js index 3ca61aa318b..d4bfd9dd2fe 100644 --- a/desktop/main.js +++ b/desktop/main.js @@ -227,6 +227,9 @@ const localizeMenuItems = (browserWindow, systemMenu) => { }; const mainWindow = (() => { + let deeplinkUrl; + let browserWindow; + const loadURL = __DEV__ ? win => win.loadURL(`http://localhost:${port}`) : serve({directory: `${__dirname}/www`}); @@ -238,6 +241,19 @@ const mainWindow = (() => { app.setName('New Expensify'); } + app.on('will-finish-launching', () => { + app.on('open-url', (event, url) => { + event.preventDefault(); + const urlObject = new URL(url); + deeplinkUrl = `${APP_DOMAIN}${urlObject.pathname}`; + + if (browserWindow) { + browserWindow.loadURL(deeplinkUrl); + browserWindow.show(); + } + }); + }); + /* * Starting from Electron 20, it shall be required to set sandbox option to false explicitly. * Running a preload script contextBridge.js require access to nodeJS modules from the javascript code. @@ -246,7 +262,15 @@ const mainWindow = (() => { * */ return app.whenReady() .then(() => { - const browserWindow = new BrowserWindow({ + /** + * We only want to register the scheme this way when in dev, since + * when the app is bundled electron-builder will take care of it. + */ + if (__DEV__) { + app.setAsDefaultProtocolClient('new-expensify'); + } + + browserWindow = new BrowserWindow({ backgroundColor: '#FAFAFA', width: 1200, height: 900, @@ -455,18 +479,26 @@ const mainWindow = (() => { }) // After initializing and configuring the browser window, load the compiled JavaScript - .then((browserWindow) => { - loadURL(browserWindow); - return browserWindow; + .then((browserWindowRef) => { + loadURL(browserWindow).then(() => { + if (!deeplinkUrl) { + return; + } + + browserWindow.loadURL(deeplinkUrl); + browserWindow.show(); + }); + + return browserWindowRef; }) // Start checking for JS updates - .then((browserWindow) => { + .then((browserWindowRef) => { if (__DEV__) { return; } - checkForUpdates(electronUpdater(browserWindow)); + checkForUpdates(electronUpdater(browserWindowRef)); }); }); diff --git a/src/CONST.js b/src/CONST.js index df6306afa7e..b8bb1b49636 100755 --- a/src/CONST.js +++ b/src/CONST.js @@ -155,6 +155,11 @@ const CONST = { AU: 'AU', CA: 'CA', }, + DESKTOP_DEEPLINK_APP_STATE: { + CHECKING: 'checking', + INSTALLED: 'installed', + NOT_INSTALLED: 'not-installed', + }, PLATFORM: { IOS: 'ios', ANDROID: 'android', diff --git a/src/Expensify.js b/src/Expensify.js index 25ac4b22fdc..8014aa3fb34 100644 --- a/src/Expensify.js +++ b/src/Expensify.js @@ -23,6 +23,7 @@ import withLocalize, {withLocalizePropTypes} from './components/withLocalize'; import * as User from './libs/actions/User'; import NetworkConnection from './libs/NetworkConnection'; import Navigation from './libs/Navigation/Navigation'; +import DeeplinkWrapper from './components/DeeplinkWrapper'; // This lib needs to be imported, but it has nothing to export since all it contains is an Onyx connection // eslint-disable-next-line no-unused-vars @@ -189,7 +190,7 @@ class Expensify extends PureComponent { } return ( - <> + {!this.state.isSplashShown && ( <> @@ -213,7 +214,7 @@ class Expensify extends PureComponent { onReady={this.setNavigationReady} authenticated={this.isAuthenticated()} /> - + ); } } diff --git a/src/components/DeeplinkWrapper/deeplinkRoutes.js b/src/components/DeeplinkWrapper/deeplinkRoutes.js new file mode 100644 index 00000000000..2e41db1a48c --- /dev/null +++ b/src/components/DeeplinkWrapper/deeplinkRoutes.js @@ -0,0 +1,46 @@ +import ROUTES from '../../ROUTES'; + +/** @type {Array} Routes regex used for desktop deeplinking */ +export default [ + { + // /reports/* + pattern: `/${ROUTES.REPORT}($|(//*))`, + }, + { + // /settings/* + pattern: `/${ROUTES.SETTINGS}($|(//*))`, + }, + { + // /setpassword/* + pattern: '/setpassword($|(//*))', + }, + { + // /details/* + pattern: `/${ROUTES.DETAILS}($|(//*))`, + }, + { + // /v/* + pattern: '/v($|(//*))', + }, + { + // /bank-account/* + pattern: `/${ROUTES.BANK_ACCOUNT}($|(//*))`, + }, + { + // /iou/* + pattern: '/iou($|(//*))', + }, + { + // /enable-payments/* + pattern: `/${ROUTES.ENABLE_PAYMENTS}($|(//*))`, + }, + { + // /statements/* + pattern: '/statements($|(//*))', + }, + { + // /concierge/* + pattern: `/${ROUTES.CONCIERGE}($|(//*))`, + }, +]; + diff --git a/src/components/DeeplinkWrapper/index.js b/src/components/DeeplinkWrapper/index.js new file mode 100644 index 00000000000..cd26d149cfe --- /dev/null +++ b/src/components/DeeplinkWrapper/index.js @@ -0,0 +1,16 @@ +import PropTypes from 'prop-types'; +import {PureComponent} from 'react'; + +const propTypes = { + /** Children to render. */ + children: PropTypes.node.isRequired, +}; + +class DeeplinkWrapper extends PureComponent { + render() { + return this.props.children; + } +} + +DeeplinkWrapper.propTypes = propTypes; +export default DeeplinkWrapper; diff --git a/src/components/DeeplinkWrapper/index.website.js b/src/components/DeeplinkWrapper/index.website.js new file mode 100644 index 00000000000..318b3461439 --- /dev/null +++ b/src/components/DeeplinkWrapper/index.website.js @@ -0,0 +1,158 @@ +import _ from 'underscore'; +import {View} from 'react-native'; +import PropTypes from 'prop-types'; +import React, {PureComponent} from 'react'; +import deeplinkRoutes from './deeplinkRoutes'; +import FullScreenLoadingIndicator from '../FullscreenLoadingIndicator'; +import TextLink from '../TextLink'; +import * as Illustrations from '../Icon/Illustrations'; +import withLocalize, {withLocalizePropTypes} from '../withLocalize'; +import Text from '../Text'; +import styles from '../../styles/styles'; +import CONST from '../../CONST'; +import CONFIG from '../../CONFIG'; +import Icon from '../Icon'; +import * as Expensicons from '../Icon/Expensicons'; +import colors from '../../styles/colors'; +import * as Browser from '../../libs/Browser'; + +const propTypes = { + /** Children to render. */ + children: PropTypes.node.isRequired, + + ...withLocalizePropTypes, +}; + +class DeeplinkWrapper extends PureComponent { + constructor(props) { + super(props); + + this.state = { + appInstallationCheckStatus: this.isMacOSWeb() ? CONST.DESKTOP_DEEPLINK_APP_STATE.CHECKING : CONST.DESKTOP_DEEPLINK_APP_STATE.NOT_INSTALLED, + }; + } + + componentDidMount() { + if (!this.isMacOSWeb()) { + return; + } + + let focused = true; + + window.addEventListener('blur', () => { + focused = false; + }); + + setTimeout(() => { + if (!focused) { + this.setState({appInstallationCheckStatus: CONST.DESKTOP_DEEPLINK_APP_STATE.INSTALLED}); + } else { + this.setState({appInstallationCheckStatus: CONST.DESKTOP_DEEPLINK_APP_STATE.NOT_INSTALLED}); + } + }, 500); + + // check if pathname matches with deeplink routes + const matchedRoute = _.find(deeplinkRoutes, (route) => { + const routeRegex = new RegExp(route.pattern); + return routeRegex.test(window.location.pathname); + }); + + if (matchedRoute) { + this.setState({deeplinkMatch: true}); + this.openRouteInDesktopApp(); + } else { + this.setState({deeplinkMatch: false}); + } + } + + openRouteInDesktopApp() { + const expensifyUrl = new URL(CONFIG.EXPENSIFY.NEW_EXPENSIFY_URL); + const expensifyDeeplinkUrl = `${CONST.DEEPLINK_BASE_URL}${expensifyUrl.host}${window.location.pathname}`; + + // This check is necessary for Safari, otherwise, if the user + // does NOT have the Expensify desktop app installed, it's gonna + // show an error in the page saying that the address is invalid + if (CONST.BROWSER.SAFARI === Browser.getBrowser()) { + const iframe = document.createElement('iframe'); + iframe.style.display = 'none'; + document.body.appendChild(iframe); + iframe.contentWindow.location.href = expensifyDeeplinkUrl; + + // Since we're creating an iframe for Safari to handle + // deeplink we need to give this iframe some time for + // it to do what it needs to do. After that we can just + // remove the iframe. + setTimeout(() => { + if (!iframe.parentNode) { + return; + } + + iframe.parentNode.removeChild(iframe); + }, 100); + } else { + window.location.href = expensifyDeeplinkUrl; + } + } + + isMacOSWeb() { + return !Browser.isMobile() && ( + typeof navigator === 'object' + && typeof navigator.userAgent === 'string' + && /Mac/i.test(navigator.userAgent) + && !/Electron/i.test(navigator.userAgent) + ); + } + + render() { + if (this.state.appInstallationCheckStatus === CONST.DESKTOP_DEEPLINK_APP_STATE.CHECKING) { + return ; + } + + if ( + this.state.deeplinkMatch + && this.state.appInstallationCheckStatus === CONST.DESKTOP_DEEPLINK_APP_STATE.INSTALLED + ) { + return ( + + + + + + + {this.props.translate('deeplinkWrapper.launching')} + + + + {this.props.translate('deeplinkWrapper.redirectedToDesktopApp')} + {'\n'} + {this.props.translate('deeplinkWrapper.youCanAlso')} + {' '} + this.setState({deeplinkMatch: false})}> + {this.props.translate('deeplinkWrapper.openLinkInBrowser')} + + . + + + + + + + + ); + } + + return this.props.children; + } +} + +DeeplinkWrapper.propTypes = propTypes; +export default withLocalize(DeeplinkWrapper); diff --git a/src/components/Icon/Expensicons.js b/src/components/Icon/Expensicons.js index d5b21cd31b5..adc4c479931 100644 --- a/src/components/Icon/Expensicons.js +++ b/src/components/Icon/Expensicons.js @@ -35,6 +35,7 @@ import Emoji from '../../../assets/images/emoji.svg'; import Exclamation from '../../../assets/images/exclamation.svg'; import Exit from '../../../assets/images/exit.svg'; import ExpensifyCard from '../../../assets/images/expensifycard.svg'; +import ExpensifyWordmark from '../../../assets/images/expensify-wordmark.svg'; import Expand from '../../../assets/images/expand.svg'; import Eye from '../../../assets/images/eye.svg'; import EyeDisabled from '../../../assets/images/eye-disabled.svg'; @@ -136,6 +137,7 @@ export { Exclamation, Exit, ExpensifyCard, + ExpensifyWordmark, Expand, Eye, EyeDisabled, diff --git a/src/components/Icon/Illustrations.js b/src/components/Icon/Illustrations.js index 20c2006a3b8..db666071718 100644 --- a/src/components/Icon/Illustrations.js +++ b/src/components/Icon/Illustrations.js @@ -13,6 +13,7 @@ import MoneyEnvelopeBlue from '../../../assets/images/product-illustrations/mone import MoneyMousePink from '../../../assets/images/product-illustrations/money-mouse--pink.svg'; import ReceiptsSearchYellow from '../../../assets/images/product-illustrations/receipts-search--yellow.svg'; import ReceiptYellow from '../../../assets/images/product-illustrations/receipt--yellow.svg'; +import RocketBlue from '../../../assets/images/product-illustrations/rocket--blue.svg'; import RocketOrange from '../../../assets/images/product-illustrations/rocket--orange.svg'; import TadaYellow from '../../../assets/images/product-illustrations/tada--yellow.svg'; import TadaBlue from '../../../assets/images/product-illustrations/tada--blue.svg'; @@ -50,6 +51,7 @@ export { MoneyMousePink, ReceiptsSearchYellow, ReceiptYellow, + RocketBlue, RocketOrange, TadaYellow, TadaBlue, diff --git a/src/languages/en.js b/src/languages/en.js index 7d97121f798..f3035298a22 100755 --- a/src/languages/en.js +++ b/src/languages/en.js @@ -139,6 +139,12 @@ export default { updateApp: 'Update app', updatePrompt: 'A new version of this app is available.\nUpdate now or restart the app at a later time to download the latest changes.', }, + deeplinkWrapper: { + launching: 'Launching Expensify', + redirectedToDesktopApp: 'We\'ve redirected you to the desktop app.', + youCanAlso: 'You can also', + openLinkInBrowser: 'open this link in your browser', + }, iOUConfirmationList: { whoPaid: 'WHO PAID?', whoWasThere: 'WHO WAS THERE?', diff --git a/src/languages/es.js b/src/languages/es.js index 9a0e0e6178c..e83df6d4ce6 100644 --- a/src/languages/es.js +++ b/src/languages/es.js @@ -139,6 +139,12 @@ export default { updateApp: 'Actualizar app', updatePrompt: 'Existe una nueva versión de esta aplicación.\nActualiza ahora or reinicia la aplicación más tarde para recibir la última versión.', }, + deeplinkWrapper: { + launching: 'Cargando Expensify', + redirectedToDesktopApp: 'Te hemos redirigido a la aplicación de escritorio.', + youCanAlso: 'También puedes', + openLinkInBrowser: 'abrir este enlace en tu navegador', + }, iOUConfirmationList: { whoPaid: '¿QUIÉN PAGO?', whoWasThere: '¿QUIÉN ASISTIÓ?', diff --git a/src/pages/ErrorPage/GenericErrorPage.js b/src/pages/ErrorPage/GenericErrorPage.js index 81487e9a6af..b407e094626 100644 --- a/src/pages/ErrorPage/GenericErrorPage.js +++ b/src/pages/ErrorPage/GenericErrorPage.js @@ -71,7 +71,7 @@ const GenericErrorPage = props => ( - + diff --git a/src/pages/signin/TermsAndLicenses.js b/src/pages/signin/TermsAndLicenses.js index a9f9b8d6b52..498a8299779 100644 --- a/src/pages/signin/TermsAndLicenses.js +++ b/src/pages/signin/TermsAndLicenses.js @@ -1,6 +1,7 @@ import React from 'react'; import {View} from 'react-native'; import styles from '../../styles/styles'; +import defaultTheme from '../../styles/themes/default'; import CONST from '../../CONST'; import Text from '../../components/Text'; import TextLink from '../../components/TextLink'; @@ -31,7 +32,7 @@ const TermsAndLicenses = props => ( . - + diff --git a/src/styles/styles.js b/src/styles/styles.js index 8e1bc40bfba..2b7b1be3eb0 100644 --- a/src/styles/styles.js +++ b/src/styles/styles.js @@ -2871,6 +2871,25 @@ const styles = { fontSize: variables.fontSizeNormal, maxWidth: 240, }, + + deeplinkWrapperContainer: { + padding: 20, + flex: 1, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: themeColors.appBG, + }, + + deeplinkWrapperMessage: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + }, + + deeplinkWrapperFooter: { + paddingTop: 80, + paddingBottom: 45, + }, }; export default styles;