From 732e7550c0f1339a388ff9ce3ef485a4b4563dec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Poizat?= Date: Fri, 13 Dec 2024 16:22:18 +0100 Subject: [PATCH 1/3] feat: Remove cordova stuff from AppLinker --- .../index.deprecated.spec.jsx.snap | 12 -- .../__snapshots__/index.spec.jsx.snap | 12 -- react/AppLinker/expiringMemoize.js | 13 -- react/AppLinker/index.deprecated.spec.jsx | 93 +----------- react/AppLinker/index.jsx | 136 ++---------------- react/AppLinker/index.spec.jsx | 95 +----------- react/AppLinker/native.config.js | 14 -- 7 files changed, 10 insertions(+), 365 deletions(-) delete mode 100644 react/AppLinker/expiringMemoize.js diff --git a/react/AppLinker/__snapshots__/index.deprecated.spec.jsx.snap b/react/AppLinker/__snapshots__/index.deprecated.spec.jsx.snap index 822d072414..c40db496bf 100644 --- a/react/AppLinker/__snapshots__/index.deprecated.spec.jsx.snap +++ b/react/AppLinker/__snapshots__/index.deprecated.spec.jsx.snap @@ -1,16 +1,5 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`app icon should not crash if no href 1`] = ` -
-
- - Open - Cozy Drive - -
-
-`; - exports[`app icon should render correctly 1`] = `
@@ -18,7 +7,6 @@ exports[`app icon should render correctly 1`] = ` href="https://fake.link" > Open - Cozy Drive
diff --git a/react/AppLinker/__snapshots__/index.spec.jsx.snap b/react/AppLinker/__snapshots__/index.spec.jsx.snap index 822d072414..c40db496bf 100644 --- a/react/AppLinker/__snapshots__/index.spec.jsx.snap +++ b/react/AppLinker/__snapshots__/index.spec.jsx.snap @@ -1,16 +1,5 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`app icon should not crash if no href 1`] = ` -
-
- - Open - Cozy Drive - -
-
-`; - exports[`app icon should render correctly 1`] = `
@@ -18,7 +7,6 @@ exports[`app icon should render correctly 1`] = ` href="https://fake.link" > Open - Cozy Drive
diff --git a/react/AppLinker/expiringMemoize.js b/react/AppLinker/expiringMemoize.js deleted file mode 100644 index 91cf50b9e2..0000000000 --- a/react/AppLinker/expiringMemoize.js +++ /dev/null @@ -1,13 +0,0 @@ -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 - } -} diff --git a/react/AppLinker/index.deprecated.spec.jsx b/react/AppLinker/index.deprecated.spec.jsx index 6003616663..e7fb9e4462 100644 --- a/react/AppLinker/index.deprecated.spec.jsx +++ b/react/AppLinker/index.deprecated.spec.jsx @@ -10,17 +10,8 @@ import { render } from '@testing-library/react' import userEvent from '@testing-library/user-event' import React from 'react' -import { - isMobileApp, - isMobile, - openDeeplinkOrRedirect, - startApp, - isAndroid, - checkApp -} from 'cozy-device-helper' - import AppLinker from '.' -import { generateUniversalLink } from './native' + jest.useFakeTimers() const setup = ({ app, onAppSwitch }) => { @@ -44,21 +35,6 @@ const setup = ({ app, onAppSwitch }) => { } } -jest.mock('./native', () => ({ - ...jest.requireActual('./native'), - generateUniversalLink: jest.fn() -})) - -jest.mock('cozy-device-helper', () => ({ - ...jest.requireActual('cozy-device-helper'), - isMobileApp: jest.fn(), - isMobile: jest.fn(), - openDeeplinkOrRedirect: jest.fn(), - startApp: jest.fn().mockResolvedValue(), - isAndroid: jest.fn(), - checkApp: jest.fn() -})) - const app = { slug: 'drive', name: 'Drive' @@ -68,14 +44,10 @@ describe('app icon', () => { let spyConsoleError, spyConsoleWarn, appSwitchMock beforeEach(() => { - isMobileApp.mockReturnValue(false) spyConsoleError = jest.spyOn(console, 'error') spyConsoleError.mockImplementation(() => {}) spyConsoleWarn = jest.spyOn(console, 'warn') spyConsoleWarn.mockImplementation(() => {}) - isMobileApp.mockReturnValue(false) - isMobile.mockReturnValue(false) - isAndroid.mockReturnValue(false) appSwitchMock = jest.fn() }) @@ -91,72 +63,9 @@ describe('app icon', () => { }) it('should work for web -> web', async () => { - isMobileApp.mockReturnValue(false) const { container, user } = setup({ app, onAppSwitch: appSwitchMock }) const link = container.querySelector('a') await user.click(link) expect(appSwitchMock).not.toHaveBeenCalled() - expect(startApp).not.toHaveBeenCalled() - }) - - it('should work for native -> native', async () => { - isMobileApp.mockReturnValue(true) - checkApp.mockResolvedValue(true) - const { container, user } = setup({ app, onAppSwitch: appSwitchMock }) - const link = container.querySelector('a') - await user.click(link) - - expect(startApp).toHaveBeenCalledWith({ - appId: 'io.cozy.drive.mobile', - name: 'Cozy Drive', - uri: 'cozydrive://' - }) - expect(appSwitchMock).toHaveBeenCalled() - }) - - it('should work for web -> native for Android (custom schema)', async () => { - isMobile.mockReturnValue(true) - isAndroid.mockResolvedValue(true) - const { container, user } = setup({ app, onAppSwitch: appSwitchMock }) - const link = container.querySelector('a') - await user.click(link) - expect(openDeeplinkOrRedirect).toHaveBeenCalledWith( - 'cozydrive://', - expect.any(Function) - ) - expect(appSwitchMock).toHaveBeenCalled() - }) - - it('should work for web -> native for iOS (universal link)', async () => { - isMobile.mockReturnValue(true) - const { container, user } = setup({ app, onAppSwitch: appSwitchMock }) - const link = container.querySelector('a') - await user.click(link) - - expect(generateUniversalLink).toHaveBeenCalled() - }) - - it('should work for native -> web', async () => { - isMobileApp.mockReturnValue(true) - const { container, user } = setup({ app, onAppSwitch: appSwitchMock }) - const link = container.querySelector('a') - await user.click(link) - expect(appSwitchMock).toHaveBeenCalled() - }) - - it('should not crash if no href', () => { - isMobileApp.mockReturnValue(true) - const { container } = render( - - {({ onClick, href, name }) => ( -
- - Open {name} - -
- )} -
- ) - expect(container).toMatchSnapshot() }) }) diff --git a/react/AppLinker/index.jsx b/react/AppLinker/index.jsx index d63aaf9f67..109152ab73 100644 --- a/react/AppLinker/index.jsx +++ b/react/AppLinker/index.jsx @@ -2,40 +2,20 @@ import PropTypes from 'prop-types' import React from 'react' import { withClient } from 'cozy-client' -import { - checkApp, - startApp, - isMobileApp, - isMobile, - openDeeplinkOrRedirect, - isAndroid, - isFlagshipApp -} from 'cozy-device-helper' +import { isFlagshipApp } from 'cozy-device-helper' import { WebviewContext } from 'cozy-intent' import logger from 'cozy-logger' -import expiringMemoize from './expiringMemoize' import { generateUniversalLink, generateWebLink, getUniversalLinkDomain } from './native' -import { NATIVE_APP_INFOS } from './native.config' - -const expirationDelay = 10 * 1000 -const memoizedCheckApp = expiringMemoize( - appInfo => checkApp(appInfo).catch(() => false), - expirationDelay, - appInfo => appInfo.appId -) export class AppLinker extends React.Component { static contextType = WebviewContext - state = { - nativeAppIsAvailable: null, - isFetchingAppInfo: false - } + state = {} constructor(props) { super(props) @@ -48,21 +28,6 @@ export class AppLinker extends React.Component { this.setState({ imgRef: this.imgRef }) } - componentDidMount() { - if (isMobileApp()) { - this.checkAppAvailability() - } - } - - async checkAppAvailability() { - const slug = AppLinker.getSlug(this.props) - const appInfo = NATIVE_APP_INFOS[slug] - if (appInfo) { - const nativeAppIsAvailable = Boolean(await memoizedCheckApp(appInfo)) - this.setState({ nativeAppIsAvailable }) - } - } - static getSlug(props) { if (props.app && props.app.slug) { return props.app.slug @@ -79,13 +44,10 @@ export class AppLinker extends React.Component { } } - static getOnClickHref(props, nativeAppIsAvailable, context, imgRef) { - const { app, client, nativePath } = props - const slug = AppLinker.getSlug(props) + static getOnClickHref(props, context, imgRef) { + const { app, client } = props let href = props.href let onClick = null - const usingNativeApp = isMobileApp() - const appInfo = NATIVE_APP_INFOS[slug] if (isFlagshipApp()) { const { app: currentApp } = client @@ -114,59 +76,11 @@ export class AppLinker extends React.Component { } } - if (usingNativeApp) { - if (nativeAppIsAvailable) { - // If we are on the native app and the other native app is available, - // we open the native app - onClick = AppLinker.openNativeFromNative.bind(this, props) - 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 = AppLinker.openWeb.bind(this, props) - } - } else if (isMobile() && appInfo) { - // If we are on the "mobile web version", we try to open the native app - // if it exists with an universal links. If it fails, we redirect to the web - // version of the requested app - // Only on iOS ATM - if (isAndroid()) { - onClick = AppLinker.openNativeFromWeb.bind(this, props) - } else { - // Since generateUniversalLink can rise an error, let's catch it to not crash - // all the page. - try { - href = generateUniversalLink({ slug, nativePath, fallbackUrl: href }) - } catch (err) { - console.error(err) - href = '#' - } - } - } - return { href, onClick } } - static openNativeFromWeb(props, ev) { - const { href, nativePath, onAppSwitch } = props - const slug = AppLinker.getSlug(props) - const appInfo = NATIVE_APP_INFOS[slug] - - if (ev) { - ev.preventDefault() - } - - AppLinker.onAppSwitch(onAppSwitch) - openDeeplinkOrRedirect( - appInfo.uri + (nativePath === '/' ? '' : nativePath), - function () { - window.location.href = href - } - ) - } static onAppSwitch(onAppSwitchFn) { if (typeof onAppSwitchFn === 'function') { @@ -174,38 +88,22 @@ export class AppLinker extends React.Component { } } - static openNativeFromNative(props, ev) { - const { onAppSwitch } = props - const slug = AppLinker.getSlug(props) - if (ev) { - ev.preventDefault() - } - const appInfo = NATIVE_APP_INFOS[slug] - AppLinker.onAppSwitch(onAppSwitch) - startApp(appInfo).catch(err => { - console.error('AppLinker: Could not open native app', err) - }) - } - static openWeb(props) { AppLinker.onAppSwitch(props.onAppSwitch) } render() { const { children } = this.props + AppLinker.deprecateSlug(this.props) - const slug = AppLinker.getSlug(this.props) - const { nativeAppIsAvailable } = this.state - const appInfo = NATIVE_APP_INFOS[slug] + const { href, onClick } = AppLinker.getOnClickHref( this.props, - nativeAppIsAvailable, this.context, this.state.imgRef ) return children({ - ...appInfo, iconRef: this.setImgRef, onClick: onClick, href @@ -213,9 +111,6 @@ export class AppLinker extends React.Component { } } -AppLinker.defaultProps = { - nativePath: '/' -} AppLinker.propTypes = { /** DEPRECATED: please use app.slug prop */ slug: PropTypes.string, @@ -224,27 +119,12 @@ AppLinker.propTypes = { Used as a fallback_uri on mobile web */ href: PropTypes.string, - /* - Path used for "native link" - */ - nativePath: PropTypes.string, onAppSwitch: PropTypes.func, app: PropTypes.shape({ // Slug of the app : drive / banks ... - slug: PropTypes.string.isRequired, - // Information about mobile native app - mobile: PropTypes.shape({ - schema: PropTypes.string, - id_playstore: PropTypes.string, - id_appstore: PropTypes.string - }) + slug: PropTypes.string.isRequired }).isRequired } export default withClient(AppLinker) -export { - NATIVE_APP_INFOS, - getUniversalLinkDomain, - generateWebLink, - generateUniversalLink -} +export { getUniversalLinkDomain, generateWebLink, generateUniversalLink } diff --git a/react/AppLinker/index.spec.jsx b/react/AppLinker/index.spec.jsx index 336ef3550e..c66e9b26e9 100644 --- a/react/AppLinker/index.spec.jsx +++ b/react/AppLinker/index.spec.jsx @@ -2,17 +2,8 @@ import { render } from '@testing-library/react' import userEvent from '@testing-library/user-event' import React from 'react' -import { - isMobileApp, - isMobile, - openDeeplinkOrRedirect, - startApp, - isAndroid, - checkApp -} from 'cozy-device-helper' - import AppLinker from '.' -import { generateUniversalLink } from './native' + jest.useFakeTimers() const setup = ({ app, onAppSwitch }) => { @@ -32,21 +23,6 @@ const setup = ({ app, onAppSwitch }) => { } } -jest.mock('./native', () => ({ - ...jest.requireActual('./native'), - generateUniversalLink: jest.fn() -})) - -jest.mock('cozy-device-helper', () => ({ - ...jest.requireActual('cozy-device-helper'), - isMobileApp: jest.fn(), - isMobile: jest.fn(), - openDeeplinkOrRedirect: jest.fn(), - startApp: jest.fn().mockResolvedValue(), - isAndroid: jest.fn(), - checkApp: jest.fn() -})) - const app = { slug: 'drive', name: 'Drive' @@ -56,16 +32,12 @@ describe('app icon', () => { let spyConsoleError, appSwitchMock beforeEach(() => { - isMobileApp.mockReturnValue(false) spyConsoleError = jest.spyOn(console, 'error') spyConsoleError.mockImplementation(message => { if (message.lastIndexOf('Warning: Failed prop type:') === 0) { throw new Error(message) } }) - isMobileApp.mockReturnValue(false) - isMobile.mockReturnValue(false) - isAndroid.mockReturnValue(false) appSwitchMock = jest.fn() }) @@ -80,74 +52,9 @@ describe('app icon', () => { }) it('should work for web -> web', async () => { - isMobileApp.mockReturnValue(false) const { container, user } = setup({ app, onAppSwitch: appSwitchMock }) const link = container.querySelector('a') await user.click(link) expect(appSwitchMock).not.toHaveBeenCalled() - expect(startApp).not.toHaveBeenCalled() - }) - - it('should work for native -> native', async () => { - isMobileApp.mockReturnValue(true) - checkApp.mockResolvedValue(true) - const { container, user } = setup({ app, onAppSwitch: appSwitchMock }) - const link = container.querySelector('a') - await user.click(link) - expect(startApp).toHaveBeenCalledWith({ - appId: 'io.cozy.drive.mobile', - name: 'Cozy Drive', - uri: 'cozydrive://' - }) - expect(appSwitchMock).toHaveBeenCalled() - }) - - it('should work for web -> native for Android (custom schema) ', async () => { - isMobile.mockReturnValue(true) - isAndroid.mockResolvedValue(true) - const { container, user } = setup({ app, onAppSwitch: appSwitchMock }) - const link = container.querySelector('a') - await user.click(link) - expect(openDeeplinkOrRedirect).toHaveBeenCalledWith( - 'cozydrive://', - expect.any(Function) - ) - expect(appSwitchMock).toHaveBeenCalled() - }) - - it('should work for web -> native for iOS (universal link)', async () => { - isMobile.mockReturnValue(true) - const { container, user } = setup({ app, onAppSwitch: appSwitchMock }) - const link = container.querySelector('a') - await user.click(link) - - expect(generateUniversalLink).toHaveBeenCalled() - }) - - it('should work for native -> web', async () => { - isMobileApp.mockReturnValue(true) - const { container, user } = setup({ app, onAppSwitch: appSwitchMock }) - const link = container.querySelector('a') - await user.click(link) - expect(appSwitchMock).toHaveBeenCalled() - }) - - it('should not crash if no href', () => { - isMobileApp.mockReturnValue(true) - spyConsoleError.mockImplementation(() => {}) - const { container } = render( - - {({ onClick, href, name }) => { - return ( -
- - Open {name} - -
- ) - }} -
- ) - expect(container).toMatchSnapshot() }) }) diff --git a/react/AppLinker/native.config.js b/react/AppLinker/native.config.js index c5ba033a8a..720efa6b15 100644 --- a/react/AppLinker/native.config.js +++ b/react/AppLinker/native.config.js @@ -1,15 +1 @@ -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' - } -} export const UNIVERSAL_LINK_URL = 'https://links.mycozy.cloud' From 60977b44abf9403a0f87996a550a4e34c5c3524f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Poizat?= Date: Mon, 6 Jan 2025 16:27:31 +0100 Subject: [PATCH 2/3] feat: Remove deprecated slug prop from AppLinker BREAKING CHANGE: Use app={{ slug: 'drive' }} instead of slug='drive' as AppLinker prop. Also, `name` is not passed anymore as render prop. --- react/AppLinker/Readme.md | 34 +-------- .../index.deprecated.spec.jsx.snap | 13 ---- react/AppLinker/index.deprecated.spec.jsx | 71 ------------------- react/AppLinker/index.jsx | 18 +---- 4 files changed, 4 insertions(+), 132 deletions(-) delete mode 100644 react/AppLinker/__snapshots__/index.deprecated.spec.jsx.snap delete mode 100644 react/AppLinker/index.deprecated.spec.jsx diff --git a/react/AppLinker/Readme.md b/react/AppLinker/Readme.md index d18d74eddb..da55fce984 100644 --- a/react/AppLinker/Readme.md +++ b/react/AppLinker/Readme.md @@ -1,15 +1,10 @@ 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. - The app's manifest can be set in the `app` prop. Then, in a ReactNative environment, the AppLinker will be able to send `openApp` message to the native environment with this `app` -data. Ideally the `mobile` member should be set with all data -needed to open the native app ([more info](https://github.com/cozy/cozy-stack/blob/master/docs/apps.md#mobile)) +data. Handles several cases: @@ -28,32 +23,9 @@ const app = { }; { - ({ onClick, href, name }) => ( - - Open { name } - - ) -} -``` - -### Exemple with mobile data - -```jsx -import AppLinker from 'cozy-ui/transpiled/react/AppLinker'; - -const app = { - slug: 'passwords', - mobile: { - schema: 'cozypass://', - id_playstore: 'io.cozy.pass', - id_appstore: 'cozy-pass/id1502262449' - } -}; - -{ - ({ onClick, href, name }) => ( + ({ onClick, href }) => ( - Open { name } + Open ) } diff --git a/react/AppLinker/__snapshots__/index.deprecated.spec.jsx.snap b/react/AppLinker/__snapshots__/index.deprecated.spec.jsx.snap deleted file mode 100644 index c40db496bf..0000000000 --- a/react/AppLinker/__snapshots__/index.deprecated.spec.jsx.snap +++ /dev/null @@ -1,13 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`app icon should render correctly 1`] = ` -
-
- - Open - -
-
-`; diff --git a/react/AppLinker/index.deprecated.spec.jsx b/react/AppLinker/index.deprecated.spec.jsx deleted file mode 100644 index e7fb9e4462..0000000000 --- a/react/AppLinker/index.deprecated.spec.jsx +++ /dev/null @@ -1,71 +0,0 @@ -/** - * Those tests are here to test previous AppLinker implementation base on - * `slug` prop. - * Now that AppLinkers implements `app` prop and uses `app.slug`, the old - * `slug` prop is deprecated - * Those tests should be kept until `slug` prop is completely removed - */ - -import { render } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import React from 'react' - -import AppLinker from '.' - -jest.useFakeTimers() - -const setup = ({ app, onAppSwitch }) => { - return { - user: userEvent.setup({ delay: null }), - ...render( - - {({ onClick, href, name }) => ( -
- - Open {name} - -
- )} -
- ) - } -} - -const app = { - slug: 'drive', - name: 'Drive' -} - -describe('app icon', () => { - let spyConsoleError, spyConsoleWarn, appSwitchMock - - beforeEach(() => { - spyConsoleError = jest.spyOn(console, 'error') - spyConsoleError.mockImplementation(() => {}) - spyConsoleWarn = jest.spyOn(console, 'warn') - spyConsoleWarn.mockImplementation(() => {}) - appSwitchMock = jest.fn() - }) - - afterEach(() => { - spyConsoleError.mockRestore() - spyConsoleWarn.mockRestore() - jest.restoreAllMocks() - }) - - it('should render correctly', () => { - const { container } = setup({ app }) - expect(container).toMatchSnapshot() - }) - - it('should work for web -> web', async () => { - const { container, user } = setup({ app, onAppSwitch: appSwitchMock }) - const link = container.querySelector('a') - await user.click(link) - expect(appSwitchMock).not.toHaveBeenCalled() - }) -}) diff --git a/react/AppLinker/index.jsx b/react/AppLinker/index.jsx index 109152ab73..9ba3a52add 100644 --- a/react/AppLinker/index.jsx +++ b/react/AppLinker/index.jsx @@ -29,19 +29,7 @@ export class AppLinker extends React.Component { } static getSlug(props) { - if (props.app && props.app.slug) { - return props.app.slug - } - - return props.slug - } - - static deprecateSlug(props) { - if (props.slug) { - console.warn( - `AppLinker's 'slug' prop is deprecated, please use 'app.slug' instead` - ) - } + return props.app.slug } static getOnClickHref(props, context, imgRef) { @@ -95,8 +83,6 @@ export class AppLinker extends React.Component { render() { const { children } = this.props - AppLinker.deprecateSlug(this.props) - const { href, onClick } = AppLinker.getOnClickHref( this.props, this.context, @@ -112,8 +98,6 @@ export class AppLinker extends React.Component { } AppLinker.propTypes = { - /** DEPRECATED: please use app.slug prop */ - slug: PropTypes.string, /* Full web url : Used by default on desktop browser Used as a fallback_uri on mobile web From 3c676d99b5b5aea21144b756c0eccf8a149a623b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Poizat?= Date: Wed, 8 Jan 2025 10:47:16 +0100 Subject: [PATCH 3/3] refactor: Improve readability by moving imgRef in state --- react/AppLinker/index.jsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/react/AppLinker/index.jsx b/react/AppLinker/index.jsx index 9ba3a52add..de1346b19d 100644 --- a/react/AppLinker/index.jsx +++ b/react/AppLinker/index.jsx @@ -15,12 +15,12 @@ import { export class AppLinker extends React.Component { static contextType = WebviewContext - state = {} + state = { + imgRef: null + } constructor(props) { super(props) - - this.imgRef = null } setImgRef = img => {