Skip to content

Commit

Permalink
Merge pull request #903 from cozy/app-linker
Browse files Browse the repository at this point in the history
feat: Add AppLinker
  • Loading branch information
ptbrowne authored Apr 18, 2019
2 parents 125519e + b1e72a4 commit b817a18
Show file tree
Hide file tree
Showing 9 changed files with 333 additions and 2 deletions.
15 changes: 13 additions & 2 deletions docs/styleguide.config.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
const path = require('path')
const webpackMerge = require('webpack-merge')

module.exports = {
title: 'Cozy UI React components',
Expand Down Expand Up @@ -70,7 +71,8 @@ module.exports = {
components: () => [
'../react/ActionMenu/index.jsx',
'../react/Menu/index.jsx',
'../react/Tabs/index.jsx'
'../react/Tabs/index.jsx',
'../react/AppLinker/index.jsx'
]
},
{
Expand Down Expand Up @@ -113,7 +115,16 @@ module.exports = {
base: 'Lato, sans-serif'
}
},
webpackConfig: require('./webpack.config.js'),
webpackConfig: webpackMerge(
require('./webpack.config.js'),
{
resolve: {
alias: {
'cozy-ui': path.join(__dirname, '..')
}
}
}
),
serverPort: 6161,
skipComponentsWithoutExample: true,
styleguideDir: path.resolve(__dirname, '../build/react'),
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
"browserslist-config-cozy": "0.2.0",
"commitlint-config-cozy": "0.3.24",
"copyfiles": "^1.2.0",
"cozy-device-helper": "1.7.1",
"css-loader": "^0.28.4",
"cssnano": "^4.1.8",
"cssnano-preset-advanced": "^4.0.6",
Expand Down Expand Up @@ -115,6 +116,7 @@
},
"peerDependencies": {
"@material-ui/core": "3.9.3",
"cozy-device-helper": "1.7.1",
"piwik-react-router": "^0.8.2",
"preact": "^8.3.1",
"preact-portal": "^1.1.3",
Expand Down
27 changes: 27 additions & 0 deletions react/AppLinker/Readme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
Render-props component that provides onClick/href handler to
apply to an anchor that needs to open an app.

If the app is known to Cozy (for example Drive or Banks), and
the user has installed it on its device, the native app will
be opened.

Handles several cases:

- On mobile app and other mobile app available
- On web (not mobile)
- On web mobile

As it uses the render props pattern, it is flexible and can be used to build components that are more complex than a simple
anchor.

```
window.__TARGET__ = 'web';
<AppLinker slug='banks' href='http://dalailama-banks.mycozy.cloud'>{
({ onClick, href, name }) => (
<a href={href} onClick={onClick}>
Open { name }
</a>
)
}</AppLinker>
```
13 changes: 13 additions & 0 deletions react/AppLinker/__snapshots__/index.spec.jsx.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`app icon should render correctly 1`] = `
<div>
<a
href="https://fake.link"
onClick={null}
>
Open
Cozy Drive
</a>
</div>
`;
13 changes: 13 additions & 0 deletions react/AppLinker/expiringMemoize.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export default function(fn, duration, keyFn) {
const memo = {}
return arg => {
const key = keyFn(arg)
const memoInfo = memo[key]
const uptodate =
memoInfo && memoInfo.result && memoInfo.date - Date.now() < duration
if (!uptodate) {
memo[key] = { result: fn(arg), date: Date.now() }
}
return memo[key].result
}
}
125 changes: 125 additions & 0 deletions react/AppLinker/index.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/* global __TARGET__ */

import React from 'react'
import PropTypes from 'prop-types'

import {
checkApp,
startApp,
isMobileApp,
isMobile,
openDeeplinkOrRedirect
} from 'cozy-device-helper'
import { NATIVE_APP_INFOS } from 'cozy-ui/transpiled/react/AppLinker/native'

import expiringMemoize from './expiringMemoize'

const expirationDelay = 10 * 1000
const memoizedCheckApp = expiringMemoize(
checkApp,
expirationDelay,
appInfo => appInfo.appId
)

export class AppLinker extends React.Component {
state = {
nativeAppIsAvailable: null
}

constructor(props) {
super(props)
this.openWeb = this.openWeb.bind(this)
this.openNativeFromNative = this.openNativeFromNative.bind(this)
this.openNativeFromWeb = this.openNativeFromWeb.bind(this)
}

componentDidMount() {
if (__TARGET__ === 'mobile') {
this.checkAppAvailability()
}
}

async checkAppAvailability() {
const { slug } = this.props.app
const appInfo = NATIVE_APP_INFOS[slug]
if (appInfo) {
const nativeAppIsAvailable = Boolean(await memoizedCheckApp(appInfo))
this.setState({ nativeAppIsAvailable })
}
}

onAppSwitch() {
const { onAppSwitch } = this.props
if (typeof onAppSwitch === 'function') {
onAppSwitch()
}
}

openNativeFromNative(ev) {
if (ev) {
ev.preventDefault()
}
const { slug } = this.props
const appInfo = NATIVE_APP_INFOS[slug]
this.onAppSwitch()
startApp(appInfo).catch(err => {
console.error('AppLinker: Could not open native app', err)
})
}

openNativeFromWeb(ev) {
if (ev) {
ev.preventDefault()
}

const { href, slug } = this.props
const appInfo = NATIVE_APP_INFOS[slug]
this.onAppSwitch()
openDeeplinkOrRedirect(appInfo.uri, function() {
window.location.href = href
})
}

openWeb() {
this.onAppSwitch()
}

render() {
const { children, slug } = this.props
const { nativeAppIsAvailable } = this.state
const appInfo = NATIVE_APP_INFOS[slug]

let href = this.props.href
let onClick = null
const usingNativeApp = isMobileApp()

if (usingNativeApp) {
if (nativeAppIsAvailable) {
// If we are on the native app and the other native app is available,
// we open the native app
onClick = this.openNativeFromNative
href = '#'
} else {
// If we are on a native app, but the other native app is not available
// we open the web link, this is done by the href prop. We still
// have to call the prop callback
onClick = this.openWeb
}
} else if (isMobile() && appInfo) {
// If we are on the "mobile web version", we try to open the native app
// if it exists. If it fails, we redirect to the web version of the
// requested app
onClick = this.openNativeFromWeb
}

return children({ ...appInfo, onClick: onClick, href })
}
}

AppLinker.propTypes = {
slug: PropTypes.string.isRequired,
href: PropTypes.string.isRequired
}

export default AppLinker
export { NATIVE_APP_INFOS }
119 changes: 119 additions & 0 deletions react/AppLinker/index.spec.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import React from 'react'
import { shallow } from 'enzyme'
import {
isMobileApp,
isMobile,
openDeeplinkOrRedirect,
startApp
} from 'cozy-device-helper'

import AppLinker from './index'

jest.useFakeTimers()

const tMock = x => x

class AppItem extends React.Component {
render() {
const { app, onAppSwitch } = this.props
return (
<AppLinker
onAppSwitch={onAppSwitch}
slug={app.slug}
href={'https://fake.link'}
>
{({ onClick, href, name }) => (
<div>
<a href={href} onClick={onClick}>
Open {name}
</a>
</div>
)}
</AppLinker>
)
}
}

jest.mock('cozy-device-helper', () => ({
...require.requireActual('cozy-device-helper'),
isMobileApp: jest.fn(),
isMobile: jest.fn(),
openDeeplinkOrRedirect: jest.fn(),
startApp: jest.fn().mockResolvedValue()
}))

const app = {
slug: 'drive',
name: 'Drive'
}

describe('app icon', () => {
let spyConsoleError, openNativeFromNativeSpy, appSwitchMock

beforeEach(() => {
global.__TARGET__ = 'browser'
spyConsoleError = jest.spyOn(console, 'error')
spyConsoleError.mockImplementation(message => {
if (message.lastIndexOf('Warning: Failed prop type:') === 0) {
throw new Error(message)
}
})
openNativeFromNativeSpy = jest.spyOn(
AppLinker.prototype,
'openNativeFromNative'
)
isMobileApp.mockReturnValue(false)
isMobile.mockReturnValue(false)
appSwitchMock = jest.fn()
})

afterEach(() => {
spyConsoleError.mockRestore()
jest.restoreAllMocks()
})

it('should render correctly', () => {
const root = shallow(<AppItem t={tMock} app={app} />).dive()
expect(root.getElement()).toMatchSnapshot()
})

it('should work for native -> native', () => {
const root = shallow(
<AppItem t={tMock} app={app} onAppSwitch={appSwitchMock} />
).dive()
root.find('a').simulate('click')
expect(appSwitchMock).not.toHaveBeenCalled()
isMobileApp.mockReturnValue(true)
root.setState({ nativeAppIsAvailable: true })
root.find('a').simulate('click')
expect(openNativeFromNativeSpy).toHaveBeenCalled()
expect(startApp).toHaveBeenCalledWith({
appId: 'io.cozy.drive.mobile',
name: 'Cozy Drive',
uri: 'cozydrive://'
})
expect(appSwitchMock).toHaveBeenCalled()
})

it('should work for web -> native', () => {
isMobile.mockReturnValue(true)
const root = shallow(
<AppItem t={tMock} app={app} onAppSwitch={appSwitchMock} />
).dive()
root.find('a').simulate('click', { preventDefault: () => {} })
expect(openDeeplinkOrRedirect).toHaveBeenCalledWith(
'cozydrive://',
expect.any(Function)
)
expect(appSwitchMock).toHaveBeenCalled()
})

it('should work for native -> web', () => {
isMobileApp.mockReturnValue(true)
const root = shallow(
<AppItem t={tMock} app={app} onAppSwitch={appSwitchMock} />
).dive()
root.find('a').simulate('click')
expect(appSwitchMock).toHaveBeenCalled()
})
})
14 changes: 14 additions & 0 deletions react/AppLinker/native.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { isAndroidApp } from 'cozy-device-helper'

export const NATIVE_APP_INFOS = {
drive: {
appId: 'io.cozy.drive.mobile',
uri: 'cozydrive://',
name: 'Cozy Drive'
},
banks: {
appId: isAndroidApp() ? 'io.cozy.banks.mobile' : 'io.cozy.banks',
uri: 'cozybanks://',
name: 'Cozy Banks'
}
}
7 changes: 7 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -3673,6 +3673,13 @@ cosmiconfig@^5.0.0, cosmiconfig@^5.0.1:
js-yaml "^3.9.0"
parse-json "^4.0.0"

[email protected]:
version "1.7.1"
resolved "https://registry.yarnpkg.com/cozy-device-helper/-/cozy-device-helper-1.7.1.tgz#56c57a14b423de2700a0a695c3f7710cf6a2383d"
integrity sha512-CTEJOzRK+AtrGN/Wqjj5n0DM1mMMvNGSZDxKlTCvY0FNYYko05vX5qIEb9vFVm7UhH7GFZjZ+S73g3Kwq/hF4Q==
dependencies:
lodash "4.17.11"

create-ecdh@^4.0.0:
version "4.0.3"
resolved "https://registry.yarnpkg.com/create-ecdh/-/create-ecdh-4.0.3.tgz#c9111b6f33045c4697f144787f9254cdc77c45ff"
Expand Down

0 comments on commit b817a18

Please sign in to comment.