From 96341884909fccef57fc53bf2c7e55745d5c8fd5 Mon Sep 17 00:00:00 2001 From: Koen Vendrik Date: Fri, 2 Nov 2018 13:01:38 -0400 Subject: [PATCH 1/8] cleans up the interface --- README.md | 4 +--- src/Intercom.tsx | 24 ++++++++---------------- src/index.ts | 2 +- src/tests/Intercom.test.tsx | 29 ++++++++--------------------- 4 files changed, 18 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 6c32e03..eb40991 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,6 @@ yarn add intercom-react created_at: 1234567890, name: 'John Doe', }} - locationKey="/home" onOpen={() => {}} onClose={() => {}} onUnreadCountChange={unreadCount => {}} @@ -33,9 +32,8 @@ yarn add intercom-react ``` * `appId`: the ID of your app. -* `userData`: all user data. If this changes during the lifecycle the component will call `intercom('update', userData)`. +* `user`: all user data. If this changes during the lifecycle the component will call `intercom('update', userData)`. * `open` (optional): whether Intercom is showing or not. -* `locationKey`: (optional): a key for the component to detect if the location changes. If this changes during the lifecycle the component will call `intercom('update', userData)`. * `onOpen` (optional): called when intercom opens. * `onClose` (optional): called when intercom closes. * `onUnreadCountChange` (optional): called when the unread count changes. diff --git a/src/Intercom.tsx b/src/Intercom.tsx index 8ab5bc5..7b6aa4f 100644 --- a/src/Intercom.tsx +++ b/src/Intercom.tsx @@ -11,7 +11,7 @@ import {ImportIsolatedRemote} from './components'; import * as styles from './Intercom.scss'; /* eslint-disable camelcase */ -export interface UserData { +export interface User { user_id?: string; email?: string; [key: string]: any; @@ -20,9 +20,8 @@ export interface UserData { export interface Props { appId: string; - userData: UserData; + user: User; open?: boolean; - locationKey?: string; onOpen?(): void; onClose?(): void; onUnreadCountChange?(unreadCount: number): void; @@ -39,22 +38,15 @@ const ANIMATION_DURATION = 300; export default class Intercom extends React.PureComponent { private frame: HTMLIFrameElement | null = null; - componentWillReceiveProps({ - open: nextOpen, - locationKey: nextLocationKey, - userData: nextUserData, - }: Props) { - const {userData, locationKey} = this.props; + componentWillReceiveProps({open: nextOpen, user: nextUser}: Props) { + const {user} = this.props; if (nextOpen) { this.getIntercom()('show'); } - if ( - nextLocationKey !== locationKey || - !objectEqual(userData, nextUserData) - ) { - this.getIntercom()('update', nextUserData); + if (!objectEqual(user, nextUser)) { + this.getIntercom()('update', nextUser); } } @@ -98,7 +90,7 @@ export default class Intercom extends React.PureComponent { onClose, appId, onUnreadCountChange, - userData, + user, onInitialization, } = this.props; @@ -106,7 +98,7 @@ export default class Intercom extends React.PureComponent { intercom('boot', { app_id: appId, - ...userData, + ...user, }); intercom('onShow', () => { diff --git a/src/index.ts b/src/index.ts index aeeecc7..3812ae0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1 +1 @@ -export {default, Props, UserData} from './Intercom'; +export {default, Props, User} from './Intercom'; diff --git a/src/tests/Intercom.test.tsx b/src/tests/Intercom.test.tsx index 69da1b9..7029e81 100644 --- a/src/tests/Intercom.test.tsx +++ b/src/tests/Intercom.test.tsx @@ -12,7 +12,7 @@ jest.mock('../utilities', () => ({ describe('', () => { const mockProps = { appId: 'fyq3wodw', - userData: { + user: { user_id: '9876', email: 'john.doe@example.com', created_at: 1234567890, @@ -54,7 +54,7 @@ describe('', () => { expect(mockIntercomSpy).toBeCalledWith('boot', { app_id: mockProps.appId, - ...mockProps.userData, + ...mockProps.user, }); }); @@ -105,33 +105,20 @@ describe('', () => { }); describe('update event', () => { - it('updates Intercom when the locationKey changes', async () => { + it('updates Intercom when the user data changes', async () => { const fakeIframe = document.createElement('iframe'); const intercom = await mount( - , - ); - intercom.find(ImportIsolatedRemote).prop('onImported')(fakeIframe); - intercom.setProps({locationKey: '/about'}); - expect(mockIntercomSpy).toHaveBeenCalledWith( - 'update', - mockProps.userData, - ); - }); - - it('updates Intercom when the userData changes', async () => { - const fakeIframe = document.createElement('iframe'); - const intercom = await mount( - , + , ); intercom.find(ImportIsolatedRemote).prop('onImported')(fakeIframe); - const newUserData = { - ...mockProps.userData, + const newUser = { + ...mockProps.user, email: 'john2@gmail.com', }; - intercom.setProps({userData: newUserData}); - expect(mockIntercomSpy).toHaveBeenCalledWith('update', newUserData); + intercom.setProps({user: newUser}); + expect(mockIntercomSpy).toHaveBeenCalledWith('update', newUser); }); }); From dd2dfbb2a910c1d49164d05abe68af98bc157ba3 Mon Sep 17 00:00:00 2001 From: Koen Vendrik Date: Fri, 2 Nov 2018 13:34:29 -0400 Subject: [PATCH 2/8] updates lodash-decorators --- package.json | 2 +- playground/Playground.tsx | 2 +- src/Intercom.tsx | 4 ++-- yarn.lock | 17 ++++++----------- 4 files changed, 10 insertions(+), 15 deletions(-) diff --git a/package.json b/package.json index d7b022a..38d75db 100644 --- a/package.json +++ b/package.json @@ -92,7 +92,7 @@ "webpack-dev-server": "^3.1.3" }, "dependencies": { - "lodash-decorators": "^5.0.1", + "lodash-decorators": "^6.0.0", "react": "^16.3.2", "react-dom": "^16.3.2" } diff --git a/playground/Playground.tsx b/playground/Playground.tsx index 9210a30..b9144b9 100644 --- a/playground/Playground.tsx +++ b/playground/Playground.tsx @@ -2,4 +2,4 @@ import * as React from 'react'; import Intercom from '../src'; -export default () => ; +export default () => ; diff --git a/src/Intercom.tsx b/src/Intercom.tsx index 7b6aa4f..35a27fd 100644 --- a/src/Intercom.tsx +++ b/src/Intercom.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import {Bind} from 'lodash-decorators'; +import {bind} from 'lodash-decorators'; import { getIntercomFromFrame, objectEqual, @@ -82,7 +82,7 @@ export default class Intercom extends React.PureComponent { frame.setAttribute('class', className); } - @Bind() + @bind() private initializeIntercom(frame: HTMLIFrameElement) { const { open, diff --git a/yarn.lock b/yarn.lock index 9ceebc6..5cf5b20 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6224,12 +6224,12 @@ locate-path@^2.0.0: p-locate "^2.0.0" path-exists "^3.0.0" -lodash-decorators@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/lodash-decorators/-/lodash-decorators-5.0.1.tgz#5b0eca821787bf748c8311b20db88556df10ce11" - integrity sha512-w7nEBd14/7cpi2o/M/j5RrKZmxjN7uVASwz4naevDQr+Dy0i+5jqAe9T7CACVQfAOVlDirjvq1t13TgtmFJiig== +lodash-decorators@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/lodash-decorators/-/lodash-decorators-6.0.0.tgz#4e0639ba639d738e5f4993acf54bf292cc95d851" + integrity sha512-rGNmvlPs/hmXM53Bso2+OAj/x7k6MiQxI2GIW537vuQ2ojfCJdohjzM+WC7r3glJgC5yBNzD5IdXkF+vluTr0A== dependencies: - tslib "^1.7.1" + tslib "^1.9.2" lodash._reinterpolate@~3.0.0: version "3.0.0" @@ -10255,16 +10255,11 @@ ts-jest@^22.4.6: source-map-support "^0.5.5" yargs "^11.0.0" -tslib@1.9.3: +tslib@1.9.3, tslib@^1.9.2: version "1.9.3" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.9.3.tgz#d7e4dd79245d85428c4d7e4822a79917954ca286" integrity sha512-4krF8scpejhaOgqzBEcGM7yDIEfi0/8+8zDRZhNZZ2kjmHJ4hv3zCbQWxoJGz1iw5U0Jl0nma13xzHXcncMavQ== -tslib@^1.7.1: - version "1.9.2" - resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.9.2.tgz#8be0cc9a1f6dc7727c38deb16c2ebd1a2892988e" - integrity sha512-AVP5Xol3WivEr7hnssHDsaM+lVrVXWUvd1cfXTRkTj80b//6g2wIFEH6hZG0muGZRnHGrfttpdzRk3YlBkWjKw== - tslib@^1.8.0, tslib@^1.8.1: version "1.9.0" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.9.0.tgz#e37a86fda8cbbaf23a057f473c9f4dc64e5fc2e8" From 7cca2baaad34e5115165de8bb8fcb142ab3c863a Mon Sep 17 00:00:00 2001 From: Koen Vendrik Date: Fri, 2 Nov 2018 13:41:18 -0400 Subject: [PATCH 3/8] makes user optional --- README.md | 2 +- playground/Playground.tsx | 2 +- src/Intercom.tsx | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index eb40991..a9cf76f 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ yarn add intercom-react ``` * `appId`: the ID of your app. -* `user`: all user data. If this changes during the lifecycle the component will call `intercom('update', userData)`. +* `user` (optional): all user data. If this changes during the lifecycle the component will call `intercom('update', userData)`. * `open` (optional): whether Intercom is showing or not. * `onOpen` (optional): called when intercom opens. * `onClose` (optional): called when intercom closes. diff --git a/playground/Playground.tsx b/playground/Playground.tsx index b9144b9..3572a62 100644 --- a/playground/Playground.tsx +++ b/playground/Playground.tsx @@ -2,4 +2,4 @@ import * as React from 'react'; import Intercom from '../src'; -export default () => ; +export default () => ; diff --git a/src/Intercom.tsx b/src/Intercom.tsx index 35a27fd..daa3227 100644 --- a/src/Intercom.tsx +++ b/src/Intercom.tsx @@ -20,7 +20,7 @@ export interface User { export interface Props { appId: string; - user: User; + user?: User; open?: boolean; onOpen?(): void; onClose?(): void; @@ -45,7 +45,7 @@ export default class Intercom extends React.PureComponent { this.getIntercom()('show'); } - if (!objectEqual(user, nextUser)) { + if (nextUser && !objectEqual(user || {}, nextUser)) { this.getIntercom()('update', nextUser); } } From 5dfe7a5469ba14c9c63cb634ac7cdc68bd3d9ace Mon Sep 17 00:00:00 2001 From: Koen Vendrik Date: Fri, 2 Nov 2018 13:59:37 -0400 Subject: [PATCH 4/8] corrects minor typo --- src/Intercom.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Intercom.scss b/src/Intercom.scss index bf08bc7..43b76a6 100644 --- a/src/Intercom.scss +++ b/src/Intercom.scss @@ -15,7 +15,7 @@ .IntercomOpen { // copied over directly from the styles - // intercom injects inside on the iframe + // intercom injects inside the iframe width: 100%; max-width: 451px; From 52e9c5f9722e1079ed194df12a15065b48bacb43 Mon Sep 17 00:00:00 2001 From: Koen Vendrik Date: Fri, 2 Nov 2018 14:54:02 -0400 Subject: [PATCH 5/8] fixes minor typo --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a9cf76f..6787146 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ yarn add intercom-react Date: Thu, 6 Dec 2018 15:15:52 -0500 Subject: [PATCH 6/8] Adds support for the borderless frame (#15) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * adds support for borderless frame * updates playground ts config * adds notification frame and hidden launcher support * adds note to readme * adds ⚠️ emoji * adds link to installation instructions * fixes typos * fixes build errors * fixes tests * adds missing tests * adds missing tests * replaces mocks with noops * fixes type issue * fixes type issue * rm js utils * increments max bundle size --- .eslintrc | 5 +- README.md | 11 + package.json | 30 +- playground/Playground.tsx | 32 +- playground/tsconfig.json | 5 + src/Intercom.scss | 9 +- src/Intercom.tsx | 99 +++++- .../BorderlessFrameListener.ts | 108 ++++++ .../BorderlessFrameListener/index.ts | 1 + .../ImportIsolatedRemote.tsx | 18 +- .../tests/ImportIsolatedRemote.test.tsx | 111 +++--- src/components/Portal/tests/Portal.test.tsx | 41 ++- src/components/index.ts | 6 +- src/tests/Intercom.test.tsx | 315 ++++++++++++++---- src/utilities/index.ts | 1 + src/utilities/injectCustomStyles.ts | 9 + yarn.lock | 45 ++- 17 files changed, 663 insertions(+), 183 deletions(-) create mode 100644 playground/tsconfig.json create mode 100644 src/components/BorderlessFrameListener/BorderlessFrameListener.ts create mode 100644 src/components/BorderlessFrameListener/index.ts create mode 100644 src/utilities/injectCustomStyles.ts diff --git a/.eslintrc b/.eslintrc index 563eaf1..77d58b2 100644 --- a/.eslintrc +++ b/.eslintrc @@ -9,6 +9,7 @@ "react/jsx-filename-extension": "off", "import/no-unresolved": "off", "import/extensions": "off", + "import/prefer-default-export": "off", "jsx-a11y/anchor-is-valid": "off", "react/no-unescaped-entities": "off", "react/sort-comp": "off", @@ -20,7 +21,9 @@ "devDependencies": ["**/*.test.tsx"] } ], - "no-use-before-define": "off" + "no-use-before-define": "off", + "no-restricted-syntax": "off", + "no-continue": "off" }, "env": { "browser": true, diff --git a/README.md b/README.md index 6787146..6491403 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,8 @@ An [Intercom](http://intercom.com/) component for React that truly encapsulates yarn add intercom-react ``` +_Before you install the library make sure to read the [notes on reliability](#️-a-few-notes-on-reliability)._ + ## Setup ```tsx @@ -38,6 +40,7 @@ yarn add intercom-react * `onClose` (optional): called when intercom closes. * `onUnreadCountChange` (optional): called when the unread count changes. * `onInitialization` (optional): called when intercom has initialized. The component passes the `intercom` method to this callback in case you require advanced usage like [emitting events](https://developers.intercom.com/docs/intercom-javascript#section-intercomtrackevent) or [pre-populating content](https://developers.intercom.com/docs/intercom-javascript#section-intercomshownewmessage). +* `launcher` (optional): whether a launcher button should be shown. Defaults to `true`. ## How is this different? @@ -47,6 +50,14 @@ What this means is that if you would get into a situation where you would have t I wrote this component to create an isolated Intercom component that cleans up after itself when unmounted for a "true" React experience. +## ⚠️ A few notes on reliability + +The main purpose of this component is to provide a way for you to integrate Intercom into your project without having it live in the global scope and it therefor being unmountable. + +Getting that to work took quite a bit of [reverse engineering](https://github.com/kvendrik/intercom-react/pull/15) and I haven't been able to find a way to include a specific version of the library yet which means that **things might stop working in future versions of the Intercom library**. I would therefor recommend that you only use this library if you have a solid reason for needing Intercom to be unmountable. If not I recommend you use a solution like [`react-intercom`](https://github.com/nhagen/react-intercom) which simply mounts Intercom to the global scope. + +Having that said I appreciate your interest in the library and look forward to hearing your experience with it 🙌 . + ## 🏗 Contributing 1. Make your changes. diff --git a/package.json b/package.json index 38d75db..2231efa 100644 --- a/package.json +++ b/package.json @@ -6,40 +6,40 @@ "type": "git", "url": "https://github.com/kvendrik/intercom-react.git" }, - "keywords": [ - "intercom", - "react", - "live", - "chat", - "support" - ], + "keywords": ["intercom", "react", "live", "chat", "support"], "main": "build/index.js", "module": "build/index.es.js", "types": "build/types/index.d.ts", "author": "Koen Vendrik ", "license": "MIT", "scripts": { - "playground": "webpack-dev-server --hot --inline --config ./playground/webpack.config.js", + "playground": + "webpack-dev-server --hot --inline --config ./playground/webpack.config.js", "lint": "yarn lint:ts && yarn lint:tslint && yarn lint:eslint", "lint:ts": "tsc --noEmit", - "lint:eslint": "eslint src --ext .tsx --ext .ts && eslint playground --ext .tsx --ext .ts", - "lint:tslint": "tslint -c tslint.json --project tsconfig.json 'src/**/*.ts' 'src/**/*.tsx' --exclude 'src/**/*.d.ts' && tslint -c tslint.json --project tsconfig.json 'playground/**/*.ts' 'playground/**/*.tsx' --exclude 'playground/**/*.d.ts'", - "test": "jest --silent", + "lint:eslint": + "eslint src --ext .tsx --ext .ts && eslint playground --ext .tsx --ext .ts", + "lint:tslint": + "tslint -c tslint.json --project tsconfig.json 'src/**/*.ts' 'src/**/*.tsx' --exclude 'src/**/*.d.ts' && tslint -c tslint.json --project tsconfig.json 'playground/**/*.ts' 'playground/**/*.tsx' --exclude 'playground/**/*.d.ts'", + "test": "jest", "test:bundle": "yarn build && bundlesize", "test:debug": "jest", "test:coverage": "yarn test --coverage", - "test:coveralls": "yarn test --coverage --coverageReporters=text-lcov | coveralls", - "test:ci": "yarn lint && yarn test && yarn test:coveralls && yarn test:bundle", - "build": "rollup -c && mv build/src build/types", + "test:coveralls": + "yarn test --coverage --coverageReporters=text-lcov | coveralls", + "test:ci": + "yarn lint && yarn test && yarn test:coveralls && yarn test:bundle", + "build": "yarn clean && rollup -c && mv build/src build/types", "clean": "rm -rf build" }, "bundlesize": [ { "path": "build/index.js", - "maxSize": "3 kB" + "maxSize": "4 kB" } ], "devDependencies": { + "@shopify/enzyme-utilities": "^1.1.4", "@types/enzyme": "^3.1.9", "@types/jest": "^22.2.3", "@types/react": "^16.3.11", diff --git a/playground/Playground.tsx b/playground/Playground.tsx index 3572a62..37c8a02 100644 --- a/playground/Playground.tsx +++ b/playground/Playground.tsx @@ -1,5 +1,33 @@ import * as React from 'react'; - +import {bind} from 'lodash-decorators'; import Intercom from '../src'; -export default () => ; +interface State { + open: boolean; +} + +export default class Playground extends React.PureComponent<{}, State> { + state = { + open: false, + }; + + render() { + const {open} = this.state; + return ( + <> + + + + ); + } + + @bind() + private openIntercom() { + this.setState({open: true}); + } + + @bind() + private closeIntercom() { + this.setState({open: false}); + } +} diff --git a/playground/tsconfig.json b/playground/tsconfig.json new file mode 100644 index 0000000..c22ff7c --- /dev/null +++ b/playground/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../tsconfig.json", + "include": ["./**/*"], + "exclude": [] +} diff --git a/src/Intercom.scss b/src/Intercom.scss index 43b76a6..c7e1eaa 100644 --- a/src/Intercom.scss +++ b/src/Intercom.scss @@ -3,12 +3,17 @@ bottom: 0; right: 0; z-index: 999; - width: 90px; - height: 90px; + width: 0; + height: 0; background: transparent; border: none; } +.IntercomHasLauncher { + width: 90px; + height: 90px; +} + .IntercomAnimating { pointer-events: none; } diff --git a/src/Intercom.tsx b/src/Intercom.tsx index daa3227..15000e3 100644 --- a/src/Intercom.tsx +++ b/src/Intercom.tsx @@ -5,9 +5,13 @@ import { objectEqual, classNames, IntercomType, + injectCustomStyles, } from './utilities'; -import {ImportIsolatedRemote} from './components'; - +import { + ImportIsolatedRemote, + BorderlessFrameListener, + BorderlessFrameSizes, +} from './components'; import * as styles from './Intercom.scss'; /* eslint-disable camelcase */ @@ -22,6 +26,7 @@ export interface Props { appId: string; user?: User; open?: boolean; + launcher?: boolean; onOpen?(): void; onClose?(): void; onUnreadCountChange?(unreadCount: number): void; @@ -29,14 +34,25 @@ export interface Props { } interface FakeState { - open: boolean; - animating: boolean; + open?: boolean; + animating?: boolean; + borderlessFrameSizes?: BorderlessFrameSizes | null; +} + +export interface State { + frame: HTMLIFrameElement | null; } const ANIMATION_DURATION = 300; -export default class Intercom extends React.PureComponent { - private frame: HTMLIFrameElement | null = null; +class Intercom extends React.PureComponent { + static defaultProps: Partial = { + launcher: true, + }; + + state: State = { + frame: null, + }; componentWillReceiveProps({open: nextOpen, user: nextUser}: Props) { const {user} = this.props; @@ -55,26 +71,52 @@ export default class Intercom extends React.PureComponent { } render() { - const {appId} = this.props; + const {appId, launcher} = this.props; + const {frame} = this.state; const importUrl = `https://widget.intercom.io/widget/${appId}`; - return ( - ); + + return ( + <> + + {borderlessFrameListener} + + ); } - private updateState({open, animating}: FakeState) { - const {frame} = this; + private updateState({ + open = false, + animating = false, + borderlessFrameSizes = null, + }: FakeState) { + const {launcher} = this.props; + const {frame} = this.state; if (!frame) { return; } + if (borderlessFrameSizes) { + const {width, height} = borderlessFrameSizes; + frame.setAttribute('style', `width: ${width}; height: ${height};`); + } else { + frame.removeAttribute('style'); + } + const className = classNames( styles.Intercom, + launcher && styles.IntercomHasLauncher, open && styles.IntercomOpen, animating && styles.IntercomAnimating, ); @@ -95,6 +137,7 @@ export default class Intercom extends React.PureComponent { } = this.props; const intercom = getIntercomFromFrame(frame); + this.setState({frame}); intercom('boot', { app_id: appId, @@ -107,6 +150,7 @@ export default class Intercom extends React.PureComponent { onOpen(); } }); + intercom('onHide', () => { this.updateState({open: true, animating: true}); setTimeout( @@ -122,24 +166,45 @@ export default class Intercom extends React.PureComponent { intercom('onUnreadCountChange', onUnreadCountChange); } - this.frame = frame; - if (open) { intercom('show'); } else { this.updateState({open: false, animating: false}); } + this.injectCustomLauncherStyles(); + if (onInitialization) { onInitialization(intercom); } } private getIntercom() { - const {frame} = this; + const {frame} = this.state; if (!frame) { return () => {}; } return getIntercomFromFrame(frame); } + + private injectCustomLauncherStyles() { + const {frame} = this.state; + injectCustomStyles( + frame!, + ` + .intercom-launcher-frame-shadow { + box-shadow: none !important; + } + `, + ); + } + + @bind() + private handleBorderlessFrameSizesUpdate( + borderlessFrameSizes: BorderlessFrameSizes, + ) { + this.updateState({borderlessFrameSizes}); + } } + +export default Intercom; diff --git a/src/components/BorderlessFrameListener/BorderlessFrameListener.ts b/src/components/BorderlessFrameListener/BorderlessFrameListener.ts new file mode 100644 index 0000000..1a6c05c --- /dev/null +++ b/src/components/BorderlessFrameListener/BorderlessFrameListener.ts @@ -0,0 +1,108 @@ +import * as React from 'react'; + +export interface BorderlessFrameSizes { + width: string; + height: string; +} + +export interface Props { + frame: HTMLIFrameElement; + launcher: boolean; + onSizesUpdate(newSizes: BorderlessFrameSizes): void; +} + +const LAUNCHER_SIZE_PIXELS = 60; +const LAUNCHER_MARGIN_PIXELS = 20; + +class BorderlessFrameListener extends React.Component { + private observer: MutationObserver = this.getObserver( + this.props.frame, + this.props.onSizesUpdate, + ); + + componentWillMount() { + this.injectCustomStyles(); + } + + componentWillUnmount() { + this.observer.disconnect(); + } + + render() { + return null; + } + + private injectCustomStyles() { + const { + frame: {contentWindow}, + } = this.props; + + const node = document.createElement('style'); + + node.innerHTML = ` + .intercom-gradient { + width: 100% !important; + height: 100% !important; + } + `; + + contentWindow!.document.head!.appendChild(node); + } + + private getObserver( + frame: HTMLIFrameElement, + onSizesUpdate: Props['onSizesUpdate'], + ) { + const {launcher} = this.props; + const frameBody = frame.contentWindow!.document.body; + + const observer = new MutationObserver(mutations => { + for (const {target} of mutations) { + const node = target as HTMLIFrameElement; + + if ( + !node.classList.contains('intercom-borderless-frame') && + !node.classList.contains('intercom-notifications-frame') + ) { + continue; + } + + const { + style: {height}, + offsetWidth, + } = node; + + const finalWidth = launcher + ? offsetWidth + LAUNCHER_MARGIN_PIXELS + : offsetWidth; + let finalHeight = height || '0px'; + + if (height && launcher) { + finalHeight = addMarginToPixels( + height, + LAUNCHER_SIZE_PIXELS + LAUNCHER_MARGIN_PIXELS * 2, + ); + } + + onSizesUpdate({ + width: `${finalWidth}px`, + height: finalHeight, + }); + } + }); + + observer.observe(frameBody, { + attributes: true, + subtree: true, + }); + + return observer; + } +} + +function addMarginToPixels(pixelsString: string, margin: number) { + const pixels = Number(pixelsString.replace('px', '')); + return `${pixels + margin}px`; +} + +export default BorderlessFrameListener; diff --git a/src/components/BorderlessFrameListener/index.ts b/src/components/BorderlessFrameListener/index.ts new file mode 100644 index 0000000..cc331b8 --- /dev/null +++ b/src/components/BorderlessFrameListener/index.ts @@ -0,0 +1 @@ +export {default, Props, BorderlessFrameSizes} from './BorderlessFrameListener'; diff --git a/src/components/ImportIsolatedRemote/ImportIsolatedRemote.tsx b/src/components/ImportIsolatedRemote/ImportIsolatedRemote.tsx index d95c3af..5a7e34a 100644 --- a/src/components/ImportIsolatedRemote/ImportIsolatedRemote.tsx +++ b/src/components/ImportIsolatedRemote/ImportIsolatedRemote.tsx @@ -29,14 +29,20 @@ export default class ImportIsolatedRemote extends React.PureComponent< script.src = source; this.scriptNode = script; - frame.onload = () => { + function loadScript() { contentWindow.document.body.appendChild(script); - script.onload = () => onImported(frame); - }; + script.onload = () => onImported(frame!); + } + + // fix for FF which refreshes the content of the iframe + // when done loading and therefor doesn't support + // immediately loading the script + if ((frame.contentDocument as any).readyState === 'uninitialized') { + frame.onload = loadScript; + return; + } - // some browsers don't trigger iframe.onload (like Safari 11.1.1) - // this immediate page refresh fixes that - contentWindow.location.reload(); + loadScript(); } componentWillUnmount() { diff --git a/src/components/ImportIsolatedRemote/tests/ImportIsolatedRemote.test.tsx b/src/components/ImportIsolatedRemote/tests/ImportIsolatedRemote.test.tsx index 40f7039..3267ac2 100644 --- a/src/components/ImportIsolatedRemote/tests/ImportIsolatedRemote.test.tsx +++ b/src/components/ImportIsolatedRemote/tests/ImportIsolatedRemote.test.tsx @@ -1,77 +1,78 @@ import * as React from 'react'; -import {mount} from 'enzyme'; -import ImportIsolatedRemote from '..'; +import {mount, shallow} from 'enzyme'; +import ImportIsolatedRemote from '../ImportIsolatedRemote'; describe('', () => { const mockProps = { title: 'some-title', - source: 'https://js.intercomcdn.com/shim.d862c967.js', + source: 'some-url', onImported: () => {}, }; - it('renders an iframe', async () => { - const node = await mount(); - expect(node.find('iframe').exists()).toBeTruthy(); + describe('title', () => { + it('gets passed into the iframe', () => { + const node = shallow(); + expect(node.find('iframe').prop('title')).toBe(mockProps.title); + }); }); - it('imports the given source', async () => { - const onImportedSpy = jest.fn(); - const node = await mount( - , - ); - - const iframeNode = node.find('iframe').getDOMNode() as HTMLIFrameElement; - const frameOnLoad = iframeNode!.onload!; - frameOnLoad.call({}); - - const script = iframeNode.contentWindow!.document.querySelector('script'); - expect(script!.src).toBe(mockProps.source); + describe('source', () => { + it('imports the given source', () => { + const onImportedSpy = jest.fn(); + const node = mount( + , + ); + const iframeNode = node.find('iframe').getDOMNode() as HTMLIFrameElement; + const script = iframeNode.contentWindow!.document.querySelector('script'); + expect(script!.src).toBe(mockProps.source); + }); + + it('doesnt load script when the iframe has loaded after the component unmounted', () => { + const node = mount(); + const iframeNode = node.find('iframe').getDOMNode() as HTMLIFrameElement; + const script = iframeNode.contentWindow!.document.querySelector('script'); + + node.unmount(); + + expect(script!.onload!).toBeNull(); + }); }); - it('triggers onImported when the script has loaded', async () => { - const onImportedSpy = jest.fn(); - const node = await mount( - , - ); - - const iframeNode = node.find('iframe').getDOMNode() as HTMLIFrameElement; - const frameOnLoad = iframeNode!.onload!; - frameOnLoad.call({}); - - const script = iframeNode.contentWindow!.document.querySelector('script'); - const callback = script!.onload!; + describe('onImported()', () => { + it('triggers onImported when the script has loaded', async () => { + const onImportedSpy = jest.fn(); + const node = mount( + , + ); - callback.call(script, {} as Event); - expect(onImportedSpy).toBeCalled(); - }); + const iframeNode = node.find('iframe').getDOMNode() as HTMLIFrameElement; + const script = iframeNode.contentWindow!.document.querySelector('script'); + const callback = script!.onload!; - it('doesnt trigger onImported when the script loads after the component has already been unmounted', async () => { - const onImportedSpy = jest.fn(); - const node = await mount( - , - ); + callback.call(script, {} as Event); + expect(onImportedSpy).toBeCalled(); + }); - const iframeNode = node.find('iframe').getDOMNode() as HTMLIFrameElement; - const frameOnLoad = iframeNode!.onload!; - frameOnLoad.call({}); + it('doesnt trigger onImported when the script loads after the component has already been unmounted', () => { + const onImportedSpy = jest.fn(); + const node = mount( + , + ); - const script = iframeNode.contentWindow!.document.querySelector('script'); + const iframeNode = node.find('iframe').getDOMNode() as HTMLIFrameElement; + const script = iframeNode.contentWindow!.document.querySelector('script'); - node.unmount(); + node.unmount(); - expect(script!.onload!).toBeNull(); - expect(onImportedSpy).not.toBeCalled(); + expect(script!.onload!).toBeNull(); + expect(onImportedSpy).not.toBeCalled(); + }); }); - it('doesnt load script when the iframe has loaded after the component unmounted', async () => { - const node = await mount(); - const iframeNode = node.find('iframe').getDOMNode() as HTMLIFrameElement; - - const script = iframeNode.contentWindow!.document.querySelector('script'); - - node.unmount(); - - expect(iframeNode!.onload!).toBeNull(); - expect(script).toBeFalsy(); + describe('