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 6c32e03..6491403 100644 --- a/README.md +++ b/README.md @@ -12,19 +12,20 @@ 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 {}} onClose={() => {}} onUnreadCountChange={unreadCount => {}} @@ -33,13 +34,13 @@ 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` (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. -* `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. * `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? @@ -49,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 d7b022a..e83e124 100644 --- a/package.json +++ b/package.json @@ -1,45 +1,45 @@ { "name": "intercom-react", - "version": "1.0.0", + "version": "1.0.0-alpha.1", "description": "An Intercom component for React.", "repository": { "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", @@ -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..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 bf08bc7..c7e1eaa 100644 --- a/src/Intercom.scss +++ b/src/Intercom.scss @@ -3,19 +3,24 @@ 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; } .IntercomOpen { // copied over directly from the styles - // intercom injects inside on the iframe + // intercom injects inside the iframe width: 100%; max-width: 451px; diff --git a/src/Intercom.tsx b/src/Intercom.tsx index 8ab5bc5..15000e3 100644 --- a/src/Intercom.tsx +++ b/src/Intercom.tsx @@ -1,17 +1,21 @@ import * as React from 'react'; -import {Bind} from 'lodash-decorators'; +import {bind} from 'lodash-decorators'; import { getIntercomFromFrame, 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 */ -export interface UserData { +export interface User { user_id?: string; email?: string; [key: string]: any; @@ -20,9 +24,9 @@ export interface UserData { export interface Props { appId: string; - userData: UserData; + user?: User; open?: boolean; - locationKey?: string; + launcher?: boolean; onOpen?(): void; onClose?(): void; onUnreadCountChange?(unreadCount: number): void; @@ -30,31 +34,35 @@ 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, - 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 (nextUser && !objectEqual(user || {}, nextUser)) { + this.getIntercom()('update', nextUser); } } @@ -63,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, ); @@ -90,7 +124,7 @@ export default class Intercom extends React.PureComponent { frame.setAttribute('class', className); } - @Bind() + @bind() private initializeIntercom(frame: HTMLIFrameElement) { const { open, @@ -98,15 +132,16 @@ export default class Intercom extends React.PureComponent { onClose, appId, onUnreadCountChange, - userData, + user, onInitialization, } = this.props; const intercom = getIntercomFromFrame(frame); + this.setState({frame}); intercom('boot', { app_id: appId, - ...userData, + ...user, }); intercom('onShow', () => { @@ -115,6 +150,7 @@ export default class Intercom extends React.PureComponent { onOpen(); } }); + intercom('onHide', () => { this.updateState({open: true, animating: true}); setTimeout( @@ -130,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('