Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions __tests__/unit/app/components/mobileapplink/MobileAppLink.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/* global jest */
/* global expect */
/* global describe */
/* global it */
/* global beforeEach */
/* global afterEach */

import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

import MobileAppLink, {
ANDROID_APP_URL,
APP_STORE_URL,
IOS_APP_URL,
PLAY_STORE_URL,
} from '@app/components/mobileapplink/MobileAppLink';
import utils from '@app/core/utils';

const OPEN_APP_LABEL = 'Open the Tidepool Mobile app';
const STORE_BADGE_LABEL = 'Download Tidepool Mobile';

describe('MobileAppLink', () => {
let getMobilePlatform;
let trackMetric;

beforeEach(() => {
getMobilePlatform = jest.spyOn(utils, 'getMobilePlatform');
trackMetric = jest.fn();
});

afterEach(() => {
getMobilePlatform.mockRestore();
});

describe('on a desktop user agent', () => {
it('should render nothing, since the link cannot work there', () => {
getMobilePlatform.mockReturnValue(null);
const { container } = render(<MobileAppLink trackMetric={trackMetric} />);

expect(container).toBeEmptyDOMElement();
});
});

describe('on iOS', () => {
beforeEach(() => {
getMobilePlatform.mockReturnValue('ios');
});

it('should link to the app with the plain custom scheme', () => {
render(<MobileAppLink trackMetric={trackMetric} />);

expect(screen.getByRole('link', { name: OPEN_APP_LABEL }))
.toHaveAttribute('href', IOS_APP_URL);
expect(IOS_APP_URL).toBe('org.tidepool.mobile://signup-complete');
});

it('should fall back to the App Store listing', () => {
render(<MobileAppLink trackMetric={trackMetric} />);

expect(screen.getByRole('link', { name: STORE_BADGE_LABEL }))
.toHaveAttribute('href', APP_STORE_URL);
});

it('should track a metric when the app link is clicked', async () => {
render(<MobileAppLink trackMetric={trackMetric} />);
await userEvent.click(screen.getByRole('link', { name: OPEN_APP_LABEL }));

expect(trackMetric).toHaveBeenCalledWith('Clicked Open Tidepool Mobile App', { platform: 'ios' });
});
});

describe('on Android', () => {
beforeEach(() => {
getMobilePlatform.mockReturnValue('android');
});

it('should link to the app with the intent:// scheme, so a missing app falls through to the Play Store', () => {
render(<MobileAppLink trackMetric={trackMetric} />);

expect(screen.getByRole('link', { name: OPEN_APP_LABEL }))
.toHaveAttribute('href', ANDROID_APP_URL);
expect(ANDROID_APP_URL).toBe('intent://signup-complete#Intent;scheme=org.tidepool.mobile;package=io.tidepool.urchin;S.browser_fallback_url=https%3A%2F%2Fplay.google.com%2Fstore%2Fapps%2Fdetails%3Fid%3Dio.tidepool.urchin;end');
});

it('should fall back to the Play Store listing', () => {
render(<MobileAppLink trackMetric={trackMetric} />);

expect(screen.getByRole('link', { name: STORE_BADGE_LABEL }))
.toHaveAttribute('href', PLAY_STORE_URL);
});

// The visible button must be the anchor itself: nesting a real <button> inside the link is
// invalid HTML and broke iOS Safari's long-press menu on the link.
it('should render the app link as a single control, with no button nested inside', () => {
render(<MobileAppLink trackMetric={trackMetric} />);

expect(screen.getByRole('link', { name: OPEN_APP_LABEL })).toBeInTheDocument();
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});

it('should track a metric when the app link is clicked', async () => {
render(<MobileAppLink trackMetric={trackMetric} />);
await userEvent.click(screen.getByRole('link', { name: OPEN_APP_LABEL }));

expect(trackMetric).toHaveBeenCalledWith('Clicked Open Tidepool Mobile App', { platform: 'android' });
});
});
});
61 changes: 61 additions & 0 deletions __tests__/unit/app/pages/browserwarning/BrowserWarning.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/* global expect */
/* global describe */
/* global it */

import { mapStateToProps } from '@app/pages/browserwarning/browserwarning';

const makeState = (blip) => ({ blip });

describe('BrowserWarning mapStateToProps', () => {
it('should show the mobile app link for a logged-in patient', () => {
const state = makeState({
isLoggedIn: true,
loggedInUserId: 'p1',
allUsersMap: { p1: { userid: 'p1', roles: [] } },
});

expect(mapStateToProps(state).showMobileAppLink).toBe(true);
});

// Clinicians reach this page from mobile browsers too (requireSupportedBrowserForUserType), and
// the Tidepool Mobile app is for patients.
it('should not show the mobile app link for a clinician', () => {
const state = makeState({
isLoggedIn: true,
loggedInUserId: 'c1',
allUsersMap: { c1: { userid: 'c1', roles: ['clinician'] } },
});

expect(mapStateToProps(state).showMobileAppLink).toBe(false);
});

it('should not show the mobile app link for a clinic member without a clinician role', () => {
const state = makeState({
isLoggedIn: true,
loggedInUserId: 'c2',
allUsersMap: { c2: { userid: 'c2', roles: [], isClinicMember: true } },
});

expect(mapStateToProps(state).showMobileAppLink).toBe(false);
});

it('should not show the mobile app link when logged out', () => {
const state = makeState({
isLoggedIn: false,
loggedInUserId: null,
allUsersMap: {},
});

expect(mapStateToProps(state).showMobileAppLink).toBe(false);
});

it('should not show the mobile app link while the user has not loaded yet', () => {
const state = makeState({
isLoggedIn: true,
loggedInUserId: 'p1',
allUsersMap: {},
});

expect(mapStateToProps(state).showMobileAppLink).toBe(false);
});
});
8 changes: 7 additions & 1 deletion app/components/browserwarning/browserwarning.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,19 @@ import React, { Component } from 'react';
import { withTranslation, Trans } from 'react-i18next';

import utils from '../../core/utils';
import MobileAppLink from '../mobileapplink';

const COPY_STATUS_NULL = 0;
const COPY_STATUS_SUCCESS = 10;
const COPY_STATUS_FAIL = 20;

export default withTranslation()(class BrowserWarning extends Component {
static propTypes = {
trackMetric: PropTypes.func.isRequired
trackMetric: PropTypes.func.isRequired,
// Offer the Tidepool Mobile app as the way forward. Off by default: this page also catches
// clinicians on mobile browsers, and the app is for patients — the caller decides from the
// logged-in user's type. MobileAppLink itself renders nothing on desktop user agents.
showMobileAppLink: PropTypes.bool,
};

constructor(props) {
Expand Down Expand Up @@ -88,6 +93,7 @@ export default withTranslation()(class BrowserWarning extends Component {
<span className="browser-warning-nowrap">{t('Mac or Windows.')}</span>
</h1>
{downloadBrowserCopy}
{this.props.showMobileAppLink && <MobileAppLink trackMetric={this.props.trackMetric} />}
</div>
</div>
);
Expand Down
115 changes: 115 additions & 0 deletions app/components/mobileapplink/MobileAppLink.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import React from 'react';
import PropTypes from 'prop-types';
import { withTranslation } from 'react-i18next';
import { Box, Flex, Image, Link } from 'theme-ui';

import { Paragraph1 } from '../elements/FontStyles';
import utils from '../../core/utils';

import AppStoreBadge from './images/appstore-badge.svg';
import GooglePlayBadge from './images/google-play-badge.png';

export const APP_STORE_URL = 'https://apps.apple.com/us/app/tidepool-mobile/id1026395200';
export const PLAY_STORE_URL = 'https://play.google.com/store/apps/details?id=io.tidepool.urchin';

// A custom scheme for now. The nicer alternative, an iOS universal link, is postponed to post-MVP
// because it needs real infrastructure: a dedicated link host (iOS refuses to open universal links
// pointing at the host of the page they're tapped on) and an associated-domains entitlement in the
// app, i.e. a mobile release. A verified, working implementation is parked on the
// universal-link-prototype branch. This link only foregrounds the app — it carries no parameters
// and the app does no routing on it.
export const IOS_APP_URL = 'org.tidepool.mobile://signup-complete';

// Chrome's intent:// syntax, so that a missing app falls through to the Play Store listing instead
// of failing silently. iOS has no equivalent fallback — Safari shows an "address is invalid" alert —
// which is why the store badge sits directly below the button.
export const ANDROID_APP_URL = [
'intent://signup-complete#Intent',
'scheme=org.tidepool.mobile',
'package=io.tidepool.urchin',
`S.browser_fallback_url=${encodeURIComponent(PLAY_STORE_URL)}`,
'end',
].join(';');

const platformConfig = {
ios: {
appUrl: IOS_APP_URL,
storeUrl: APP_STORE_URL,
badgeImage: AppStoreBadge,
badgeMetric: 'Clicked App Store Badge',
},
android: {
appUrl: ANDROID_APP_URL,
storeUrl: PLAY_STORE_URL,
badgeImage: GooglePlayBadge,
badgeMetric: 'Clicked Play Store Badge',
},
};

/**
* Renders a link back to the Tidepool Mobile app, for users who arrived on the web to complete
* signup and would otherwise be stranded here. Renders nothing outside of iOS and Android, where
* the link cannot work.
*/
export const MobileAppLink = (props) => {
const { t, trackMetric } = props;
const platform = utils.getMobilePlatform();

if (!platform) return null;

const { appUrl, storeUrl, badgeImage, badgeMetric } = platformConfig[platform];

return (
<Flex
id="mobile-app-link"
mb={4}
sx={{ flexDirection: 'column', alignItems: 'center', textAlign: 'center' }}
>
{/* A single anchor styled as a primary button — a real <Button> nested inside the Link is
invalid HTML (interactive inside interactive), and it broke iOS Safari's long-press
menu on the link. The variant assumes flex centering for its lineHeight: 0, hence the
overrides. */}
<Link
id="mobile-app-link-open"
href={appUrl}
onClick={() => trackMetric('Clicked Open Tidepool Mobile App', { platform })}
sx={{
variant: 'buttons.primary',
display: 'inline-block',
lineHeight: 'normal',
fontSize: 2,
textDecoration: 'none',
}}
>
{t('Open the Tidepool Mobile app')}
</Link>

<Paragraph1 mt={3} mb={2} sx={{ fontWeight: 'medium' }}>
{t('Don\'t have the app yet?')}
</Paragraph1>

<Box>
<Link
id="mobile-app-link-store"
href={storeUrl}
target="_blank"
rel="noreferrer noopener"
onClick={() => trackMetric(badgeMetric, { platform })}
>
<Image
src={badgeImage}
alt={t('Download Tidepool Mobile')}
sx={{ height: '40px' }}
/>
</Link>
</Box>
</Flex>
);
};

MobileAppLink.propTypes = {
t: PropTypes.func.isRequired,
trackMetric: PropTypes.func.isRequired,
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

export default withTranslation()(MobileAppLink);
3 changes: 3 additions & 0 deletions app/components/mobileapplink/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import MobileAppLink from './MobileAppLink';

export default MobileAppLink;
18 changes: 18 additions & 0 deletions app/core/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,24 @@ utils.isMobile = () => {
return (userAgent.indexOf('mobi') > -1);
};

/**
* Identify the mobile platform from the user agent, for cases where a viewport-width media query
* (see MobileOnly) isn't sufficient because the behaviour differs per platform.
*
* Note that iPadOS 13+ requests desktop sites by default and reports itself as a Mac, so an iPad
* will generally return null here.
*
* @return {String|null} 'ios', 'android', or null when neither
*/
utils.getMobilePlatform = () => {
const userAgent = navigator.userAgent.toLowerCase();

if (/iphone|ipad|ipod/.test(userAgent)) return 'ios';
if (userAgent.indexOf('android') > -1) return 'android';

return null;
};

utils.validateEmail = email => {
var re = /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
return re.test(email);
Expand Down
14 changes: 12 additions & 2 deletions app/pages/browserwarning/browserwarning.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
* not, you can obtain one from Tidepool Project at tidepool.org.
*/

import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
Expand All @@ -21,30 +22,39 @@ import { bindActionCreators } from 'redux';
import cx from 'classnames';

import BrowserWarningComponent from '../../components/browserwarning';
import personUtils from '../../core/personutils';

export class BrowserWarning extends Component {
static propTypes = {
authenticated: PropTypes.bool.isRequired,
showMobileAppLink: PropTypes.bool.isRequired,
trackMetric: PropTypes.func.isRequired
};

render() {
var classes = {
'container-box-outer': true,
'container-box-outer': true,
'browser-warning-logged-out': !this.props.authenticated
}
return <div className={cx(classes)}>
<div className="browser-warning-container">
<BrowserWarningComponent
showMobileAppLink={this.props.showMobileAppLink}
trackMetric={this.props.trackMetric} />
</div>
</div>;
}
}

export function mapStateToProps(state) {
const user = _.get(state.blip.allUsersMap, state.blip.loggedInUserId);

return {
authenticated: state.blip.isLoggedIn
authenticated: state.blip.isLoggedIn,
// The Tidepool Mobile app is for patients: clinicians land on this page from mobile browsers
// too (requireSupportedBrowserForUserType), and logged-out visitors are of unknown type, so
// the app link renders only for a logged-in, loaded, non-clinician user.
showMobileAppLink: state.blip.isLoggedIn && !!user && !personUtils.isClinicianAccount(user),
};
}

Expand Down
Loading