-
Notifications
You must be signed in to change notification settings - Fork 37
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #903 from cozy/app-linker
feat: Add AppLinker
- Loading branch information
Showing
9 changed files
with
333 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
`; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 } |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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' | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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" | ||
|