├── screenshot.png ├── .eslintignore ├── .babelrc ├── .npmignore ├── .gitignore ├── webpack.dist.js ├── CHANGELOG.md ├── webpack.config.js ├── LICENSE.txt ├── .eslintrc ├── package.json ├── README.md ├── src └── react-chatview.js ├── lib └── react-chatview.js └── dist └── react-chatview.min.js /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jsdevkr/react-chatview/HEAD/screenshot.png -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | webpack/* 2 | karma.conf.js 3 | tests.webpack.js 4 | ApiClient.js 5 | validation.js 6 | __tests__ 7 | dist/* 8 | lib/* 9 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["react", "es2015"], 3 | "plugins": [ 4 | "add-module-exports", 5 | "transform-class-properties" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .babelrc 2 | .eslintignore 3 | .eslintrc 4 | .gitignore 5 | .npmignore 6 | screenshot.png 7 | webpack.config.js 8 | webpack.dist.js 9 | lib 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.map 2 | coverage 3 | node_modules 4 | bower_components 5 | examples/**/*.js 6 | !examples/*.js 7 | .c9/ 8 | build/ 9 | .idea/ 10 | npm-debug.log 11 | .coveralls.yml 12 | *.tgz 13 | -------------------------------------------------------------------------------- /webpack.dist.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | var webpack = require('webpack'); 3 | var config = require('./webpack.config'); 4 | 5 | config.devtool = false; 6 | 7 | config.output = { 8 | path: path.resolve('./dist'), 9 | filename: 'react-chatview.min.js', 10 | libraryTarget: 'umd', 11 | library: 'ReactChatView' 12 | }; 13 | 14 | config.plugins = [ 15 | new webpack.DefinePlugin({ 'process.env': { NODE_ENV: JSON.stringify('production') } }), 16 | new webpack.optimize.UglifyJsPlugin({ minimize: true }) 17 | ]; 18 | 19 | module.exports = config; 20 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 0.2.5 2 | * Fixed wrong calling context for pollScroll #37 3 | 4 | 0.2.4 5 | * Remove src directory from .npmigore #29 6 | 7 | 0.2.3 8 | * fix lodash.clone import #27 9 | 10 | 0.2.2 11 | * Refactoring to es6 class syntax, dependencies updated (react 15) #17 12 | * Add 'reversed' property. 13 | * Add 'returnScrollable' method. 14 | 15 | 0.1.3 16 | * fix warnings in react 15.5.* #22 17 | * add shouldTriggerLoad callback function prop. #21 18 | 19 | 20 | 0.1.2 21 | * fix issue when element is removed from list #14 22 | * Favor passive onScroll listener to polling when available. #15 23 | * documentation 24 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | var webpack = require('webpack'); 2 | var path = require('path'); 3 | 4 | module.exports = { 5 | devtool: 'inline-source-map', 6 | entry: './src/react-chatview', 7 | 8 | output: { 9 | path: path.resolve('./dist'), 10 | filename: 'react-chatview.js', 11 | libraryTarget: 'umd', 12 | library: 'ReactChatView', 13 | publicPath: '/static/' 14 | }, 15 | 16 | resolve: { 17 | modules: [ 18 | path.join(__dirname, "src"), 19 | "node_modules" 20 | ] 21 | }, 22 | 23 | plugins: [ 24 | new webpack.NoEmitOnErrorsPlugin() 25 | ], 26 | 27 | module: { 28 | rules: [ 29 | { test: /\.js$/, use: ['babel-loader'], include: path.resolve('./src') } 30 | ] 31 | }, 32 | 33 | externals: { 34 | 'react': { 35 | 'commonjs': 'react', 36 | 'commonjs2': 'react', 37 | 'amd': 'react', 38 | // React dep should be available as window.React, not window.react 39 | 'root': 'React' 40 | }, 41 | 'react-dom': { 42 | 'commonjs': 'react-dom', 43 | 'commonjs2': 'react-dom', 44 | 'amd': 'react-dom', 45 | 'root': 'ReactDOM' 46 | } 47 | } 48 | }; 49 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014, SeatGeek, Inc. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 5 | 6 | Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 7 | 8 | Neither the name of the SeatGeek nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 9 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 10 | 11 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { "extends": "eslint-config-airbnb", 2 | "env": { 3 | "browser": true, 4 | "node": true, 5 | "mocha": true 6 | }, 7 | "rules": { 8 | "new-cap": [2, { "capIsNewExceptions": ["List", "Map", "Set", "Router"] }], 9 | "react/no-multi-comp": 0, 10 | "import/default": 0, 11 | "import/no-duplicates": 0, 12 | "import/named": 0, 13 | "import/namespace": 0, 14 | "import/no-unresolved": 0, 15 | "import/no-named-as-default": 2, 16 | "comma-dangle": 0, // not sure why airbnb turned this on. gross! 17 | "indent": [2, 2, {"SwitchCase": 1}], 18 | "no-console": 0, 19 | "no-alert": 0, 20 | "import/no-extraneous-dependencies": 0, 21 | "no-underscore-dangle": 0, 22 | "react/jsx-filename-extension": 0, 23 | "react/jsx-first-prop-new-line": 0, 24 | "max-len": 0, 25 | "global-require": 0, 26 | "arrow-parens": 0, 27 | "no-param-reassign": 0, 28 | "prefer-template": 0, 29 | "import/extensions": 0, 30 | "react/jsx-space-before-closing": 0, 31 | "arrow-body-style": 0, 32 | "react/prefer-stateless-function": 0, 33 | "jsx-a11y/anchor-has-content": 0, 34 | "jsx-a11y/no-static-element-interactions": 0, 35 | "consistent-return": 0, 36 | "no-plusplus": 0, 37 | "prefer-arrow-callback": 0, 38 | "func-names": 0, 39 | "no-unneeded-ternary": 0, 40 | "react/require-default-props": 0, 41 | "jsx-a11y/href-no-hash": 0 42 | }, 43 | "parser": "babel-eslint", 44 | "plugins": [ 45 | "react", "import" 46 | ], 47 | "settings": { 48 | "import/resolve": { 49 | "moduleDirectory": ["node_modules", "src"] 50 | } 51 | }, 52 | "globals": { 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-chatview", 3 | "version": "0.2.5", 4 | "description": "Infinite scroll chat or feed component for React.js", 5 | "main": "dist/react-chatview.js", 6 | "jsnext:main": "src/react-chatview.js", 7 | "author": "Dustin Getz (https://github.com/dustingetz)", 8 | "maintainers": "Dongwoo Gim (https://github.com/gimdongwoo)", 9 | "license": "BSD-2-Clause", 10 | "scripts": { 11 | "build": "npm run lint && webpack && npm run dist && npm run lib", 12 | "lint": "eslint -c .eslintrc src", 13 | "dist": "NODE_ENV=prod webpack --config webpack.dist.js", 14 | "lib": "BABEL_ENV=commonjs babel src --out-dir lib" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/jsdevkr/react-chatview.git" 19 | }, 20 | "keywords": [ 21 | "react", 22 | "chat", 23 | "scroll", 24 | "infinite" 25 | ], 26 | "dependencies": { 27 | "lodash.clone": "^4.5.0" 28 | }, 29 | "peerDependencies": { 30 | "react": ">= 0.15.0", 31 | "react-dom": ">= 0.15.0" 32 | }, 33 | "devDependencies": { 34 | "babel-cli": "^6.8.0", 35 | "babel-core": "^6.22.1", 36 | "babel-eslint": "^7.1.1", 37 | "babel-loader": "^7.0.0", 38 | "babel-plugin-add-module-exports": "^0.2.1", 39 | "babel-plugin-transform-class-properties": "^6.24.1", 40 | "babel-preset-es2015": "^6.22.0", 41 | "babel-preset-react": "^6.16.0", 42 | "eslint": "^3.14.0", 43 | "eslint-config-airbnb": "^14.0.0", 44 | "eslint-loader": "^1.6.1", 45 | "eslint-plugin-import": "^2.2.0", 46 | "eslint-plugin-jsx-a11y": "^4.0.0", 47 | "eslint-plugin-react": "^6.9.0", 48 | "react": "^15.3.2", 49 | "react-dom": "^15.3.2", 50 | "webpack": "^2.2.0" 51 | }, 52 | "bugs": { 53 | "url": "https://github.com/jsdevkr/react-chatview/issues" 54 | }, 55 | "homepage": "https://github.com/jsdevkr/react-chatview#readme" 56 | } 57 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | react-chatview 2 | ============== 3 | 4 | [![npm version](https://badge.fury.io/js/react-chatview.svg)](https://badge.fury.io/js/react-chatview) 5 | 6 | > Infinite scroll chat or feed component for React.js 7 | 8 | [Changelog](CHANGELOG.md) 9 | 10 | ### Live Demo 11 | [![Live Demo](screenshot.png?raw=true)](http://musician-peggy-71735.bitballoon.com/) 12 | 13 | Here is the [live demo](http://musician-peggy-71735.bitballoon.com/), and [source code to the live demo](https://github.com/jsdevkr/react-chatview-sample), also [here is a simpler fiddle](https://jsfiddle.net/gimdongwoo/xo4fccbu/). 14 | 15 | ### Why another infinite scroll component? 16 | 17 | As of time of this writing, other efforts are missing killer features: 18 | * browser layout & resize "just works" (no need to know any heights in advance) 19 | * Works as newsfeed (infinite load down) or chat (infinite load up) 20 | * hardware accelerated scrolling 21 | 22 | This work originated as a fork and modifications of [seatgeek/react-infinite](https://github.com/seatgeek/react-infinite), and was subsequently rewritten several times. 23 | 24 | ### Getting started 25 | 26 | Install `react-chatview` using npm. 27 | 28 | ```shell 29 | npm install react-chatview --save 30 | ``` 31 | 32 | You can also use a global-friendly UMD build: 33 | 34 | ```html 35 | 36 | ``` 37 | 38 | You can also use a es5 commonjs build: 39 | 40 | ```html 41 | 42 | ``` 43 | 44 | ### Documentation 45 | 46 | It is really easy to use. The actual rows of content should be passed as **children**. There are four interesting props: 47 | 48 | * `className` extra css class string for the container 49 | * `flipped` true for chat (newest at bottom), regular for newsfeed (newest at top) 50 | * `reversed` true for don't reverse elements 51 | * `scrollLoadThreshold` pixel distance from top that triggers an infinite load 52 | * `shouldTriggerLoad` callback function to check if chat view should trigger infinite load cycle when scroll passed `scrollLoadThreshold`. This callback is optional and by default `onInfiniteLoad` is always triggered. 53 | * `onInfiniteLoad` load request callback, should cause a state change which renders more children 54 | * `returnScrollable` return scollable object for scrollable event handling 55 | 56 | See the [jsfiddle example](https://jsfiddle.net/gimdongwoo/xo4fccbu/) for a complete working example. 57 | 58 | ### Todo 59 | 60 | * Not actually infinite - currently all elements that have been loaded remain the dom 61 | * auto-scroll to newest message when appropriate (pinning) 62 | 63 | > use `returnScrollable` and set `scrollable.scrollTop` to `scrollable.scrollHeight` 64 | 65 | * auto-correct scroll jitter when content resizes or is added above/below the focus point 66 | * configurable loading spinner 67 | * optimize for mobile (but it works) 68 | 69 | 70 | There are probably more features missing. Please open an issue! 71 | 72 | ### Please write me if you use this! :) 73 | 74 | If this project is valued I will invest more time in it. 75 | -------------------------------------------------------------------------------- /src/react-chatview.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import clone from 'lodash.clone'; 4 | 5 | let supportsPassive = false; 6 | try { 7 | const opts = Object.defineProperty({}, 'passive', { 8 | get() { 9 | supportsPassive = true; 10 | }, 11 | }); 12 | window.addEventListener('test', null, opts); 13 | } catch (e) { /* pass */ } 14 | 15 | export default class ChatView extends Component { 16 | static propTypes = { 17 | flipped: PropTypes.bool, 18 | reversed: PropTypes.bool, 19 | scrollLoadThreshold: PropTypes.number, 20 | shouldTriggerLoad: PropTypes.func, 21 | onInfiniteLoad: PropTypes.func.isRequired, 22 | loadingSpinnerDelegate: PropTypes.element, 23 | className: PropTypes.string, 24 | children: PropTypes.node, 25 | returnScrollable: PropTypes.func, 26 | }; 27 | 28 | constructor(props) { 29 | super(props); 30 | 31 | this.rafRequestId = null; // for cleaning up outstanding requestAnimationFrames on WillUnmount 32 | this.scrollTop = 0; // regular mode initial scroll 33 | this.scrollHeight = undefined; // it's okay, this won't be read until the second render. 34 | // In flipped mode, we need to measure the scrollable height from the DOM to write to the scrollTop. 35 | // Flipped and regular measured heights are symmetrical and don't depend on the scrollTop 36 | 37 | this.state = { 38 | isInfiniteLoading: false 39 | }; 40 | } 41 | 42 | componentDidMount() { 43 | // If there are not yet any children (they are still loading), 44 | // this is a no-op as we are at both the top and bottom of empty viewport 45 | const heightDifference = this.props.flipped 46 | ? this.scrollable.scrollHeight - this.scrollable.clientHeight 47 | : 0; 48 | 49 | this.scrollable.scrollTop = heightDifference; 50 | this.scrollTop = heightDifference; 51 | 52 | // Unless passive events are supported, we must not hook onScroll event 53 | // directly - that will break hardware accelerated scrolling. We poll it 54 | // with requestAnimationFrame instead. 55 | if (supportsPassive) { 56 | this.scrollable.addEventListener('scroll', this.onScroll, { passive: true }); 57 | } else { 58 | this.rafRequestId = window.requestAnimationFrame(this.pollScroll); 59 | } 60 | 61 | // upper ref 62 | if (typeof this.props.returnScrollable === 'function') this.props.returnScrollable(this.scrollable); 63 | } 64 | 65 | // componentDidUpdate(prevProps, prevState) { 66 | componentDidUpdate() { 67 | this.updateScrollTop(); 68 | } 69 | 70 | componentWillUnmount() { 71 | this.scrollable.removeEventListener('scroll', this.onScroll, { passive: true }); 72 | window.cancelAnimationFrame(this.rafRequestId); 73 | } 74 | 75 | // componentWillUpdate(nextProps, nextState) {} 76 | 77 | // detect when dom has changed underneath us- either scrollTop or scrollHeight (layout reflow) 78 | // may have changed. 79 | onScroll = () => { 80 | if (this.scrollable.scrollTop !== this.scrollTop) { 81 | if (this.shouldTriggerLoad()) { 82 | this.setState({ isInfiniteLoading: true }); 83 | const p = this.props.onInfiniteLoad(); 84 | p.then(() => this.setState({ isInfiniteLoading: false })); 85 | } 86 | // the dom is ahead of the state 87 | this.updateScrollTop(); 88 | } 89 | } 90 | 91 | pollScroll = () => { 92 | this.onScroll(); 93 | this.rafRequestId = window.requestAnimationFrame(this.pollScroll); 94 | } 95 | 96 | isPassedThreshold = (flipped, scrollLoadThreshold, scrollTop, scrollHeight, clientHeight) => { 97 | return flipped 98 | ? scrollTop <= scrollLoadThreshold 99 | : scrollTop >= (scrollHeight - clientHeight - scrollLoadThreshold); 100 | } 101 | 102 | shouldTriggerLoad() { 103 | const passedThreshold = this.isPassedThreshold( 104 | this.props.flipped, 105 | this.props.scrollLoadThreshold, 106 | this.scrollable.scrollTop, 107 | this.scrollable.scrollHeight, 108 | this.scrollable.clientHeight); 109 | return passedThreshold && !this.state.isInfiniteLoading && this.props.shouldTriggerLoad(); 110 | } 111 | 112 | updateScrollTop() { 113 | // todo this is only the happy path 114 | let newScrollTop = this.scrollable.scrollTop + (this.props.flipped 115 | ? this.scrollable.scrollHeight - (this.scrollHeight || 0) 116 | : 0); 117 | 118 | // if scrollHeightDifference is > 0 then something was removed from list 119 | const scrollHeightDifference = this.scrollHeight ? this.scrollHeight - this.scrollable.scrollHeight : 0; 120 | 121 | // if something was removed from list we need to include this difference in new scroll top 122 | if (this.props.flipped && scrollHeightDifference > 0) { 123 | newScrollTop += scrollHeightDifference; 124 | } 125 | 126 | if (newScrollTop !== this.scrollable.scrollTop) { 127 | this.scrollable.scrollTop = newScrollTop; 128 | } 129 | 130 | this.scrollTop = this.scrollable.scrollTop; 131 | this.scrollHeight = this.scrollable.scrollHeight; 132 | 133 | // Setting scrollTop can halt user scrolling (and disables hardware acceleration) 134 | 135 | // Both cases - flipped and refular - have cases where the content expands in the proper direction, 136 | // or the content expands in the wrong direciton. Either history or new message in both cases. 137 | // We are only handling half of the cases. Or an image resized above or below us. 138 | } 139 | 140 | render() { 141 | const displayables = clone(this.props.children); 142 | if (this.props.flipped && !this.props.reversed) { 143 | displayables.reverse(); 144 | } 145 | 146 | const loadSpinner = (
{ this.loadingSpinner = e; }}> 147 | {this.state.isInfiniteLoading ? this.props.loadingSpinnerDelegate : null} 148 |
); 149 | 150 | return ( 151 |
{ this.scrollable = e; }} 152 | style={{ overflowX: 'hidden', overflowY: 'auto' }} 153 | > 154 |
{ this.smoothScrollingWrapper = e; }}> 155 | {this.props.flipped ? loadSpinner : null} 156 | {displayables} 157 | {this.props.flipped ? null : loadSpinner} 158 |
159 |
160 | ); 161 | } 162 | } 163 | 164 | ChatView.defaultProps = { 165 | flipped: false, 166 | scrollLoadThreshold: 10, 167 | shouldTriggerLoad: () => { return true; }, 168 | loadingSpinnerDelegate:
, 169 | className: '' 170 | }; 171 | -------------------------------------------------------------------------------- /lib/react-chatview.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | 7 | var _createClass = function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; }(); 8 | 9 | var _react = require('react'); 10 | 11 | var _react2 = _interopRequireDefault(_react); 12 | 13 | var _propTypes = require('prop-types'); 14 | 15 | var _propTypes2 = _interopRequireDefault(_propTypes); 16 | 17 | var _lodash = require('lodash.clone'); 18 | 19 | var _lodash2 = _interopRequireDefault(_lodash); 20 | 21 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 22 | 23 | function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } 24 | 25 | function _possibleConstructorReturn(self, call) { if (!self) { throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); } return call && (typeof call === "object" || typeof call === "function") ? call : self; } 26 | 27 | function _inherits(subClass, superClass) { if (typeof superClass !== "function" && superClass !== null) { throw new TypeError("Super expression must either be null or a function, not " + typeof superClass); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, enumerable: false, writable: true, configurable: true } }); if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass; } 28 | 29 | var supportsPassive = false; 30 | try { 31 | var opts = Object.defineProperty({}, 'passive', { 32 | get: function get() { 33 | supportsPassive = true; 34 | } 35 | }); 36 | window.addEventListener('test', null, opts); 37 | } catch (e) {/* pass */} 38 | 39 | var ChatView = function (_Component) { 40 | _inherits(ChatView, _Component); 41 | 42 | function ChatView(props) { 43 | _classCallCheck(this, ChatView); 44 | 45 | var _this = _possibleConstructorReturn(this, (ChatView.__proto__ || Object.getPrototypeOf(ChatView)).call(this, props)); 46 | 47 | _this.onScroll = function () { 48 | if (_this.scrollable.scrollTop !== _this.scrollTop) { 49 | if (_this.shouldTriggerLoad()) { 50 | _this.setState({ isInfiniteLoading: true }); 51 | var p = _this.props.onInfiniteLoad(); 52 | p.then(function () { 53 | return _this.setState({ isInfiniteLoading: false }); 54 | }); 55 | } 56 | // the dom is ahead of the state 57 | _this.updateScrollTop(); 58 | } 59 | }; 60 | 61 | _this.pollScroll = function () { 62 | _this.onScroll(); 63 | _this.rafRequestId = window.requestAnimationFrame(_this.pollScroll); 64 | }; 65 | 66 | _this.isPassedThreshold = function (flipped, scrollLoadThreshold, scrollTop, scrollHeight, clientHeight) { 67 | return flipped ? scrollTop <= scrollLoadThreshold : scrollTop >= scrollHeight - clientHeight - scrollLoadThreshold; 68 | }; 69 | 70 | _this.rafRequestId = null; // for cleaning up outstanding requestAnimationFrames on WillUnmount 71 | _this.scrollTop = 0; // regular mode initial scroll 72 | _this.scrollHeight = undefined; // it's okay, this won't be read until the second render. 73 | // In flipped mode, we need to measure the scrollable height from the DOM to write to the scrollTop. 74 | // Flipped and regular measured heights are symmetrical and don't depend on the scrollTop 75 | 76 | _this.state = { 77 | isInfiniteLoading: false 78 | }; 79 | return _this; 80 | } 81 | 82 | _createClass(ChatView, [{ 83 | key: 'componentDidMount', 84 | value: function componentDidMount() { 85 | // If there are not yet any children (they are still loading), 86 | // this is a no-op as we are at both the top and bottom of empty viewport 87 | var heightDifference = this.props.flipped ? this.scrollable.scrollHeight - this.scrollable.clientHeight : 0; 88 | 89 | this.scrollable.scrollTop = heightDifference; 90 | this.scrollTop = heightDifference; 91 | 92 | // Unless passive events are supported, we must not hook onScroll event 93 | // directly - that will break hardware accelerated scrolling. We poll it 94 | // with requestAnimationFrame instead. 95 | if (supportsPassive) { 96 | this.scrollable.addEventListener('scroll', this.onScroll, { passive: true }); 97 | } else { 98 | this.rafRequestId = window.requestAnimationFrame(this.pollScroll); 99 | } 100 | 101 | // upper ref 102 | if (typeof this.props.returnScrollable === 'function') this.props.returnScrollable(this.scrollable); 103 | } 104 | 105 | // componentDidUpdate(prevProps, prevState) { 106 | 107 | }, { 108 | key: 'componentDidUpdate', 109 | value: function componentDidUpdate() { 110 | this.updateScrollTop(); 111 | } 112 | }, { 113 | key: 'componentWillUnmount', 114 | value: function componentWillUnmount() { 115 | this.scrollable.removeEventListener('scroll', this.onScroll, { passive: true }); 116 | window.cancelAnimationFrame(this.rafRequestId); 117 | } 118 | 119 | // componentWillUpdate(nextProps, nextState) {} 120 | 121 | // detect when dom has changed underneath us- either scrollTop or scrollHeight (layout reflow) 122 | // may have changed. 123 | 124 | }, { 125 | key: 'shouldTriggerLoad', 126 | value: function shouldTriggerLoad() { 127 | var passedThreshold = this.isPassedThreshold(this.props.flipped, this.props.scrollLoadThreshold, this.scrollable.scrollTop, this.scrollable.scrollHeight, this.scrollable.clientHeight); 128 | return passedThreshold && !this.state.isInfiniteLoading && this.props.shouldTriggerLoad(); 129 | } 130 | }, { 131 | key: 'updateScrollTop', 132 | value: function updateScrollTop() { 133 | // todo this is only the happy path 134 | var newScrollTop = this.scrollable.scrollTop + (this.props.flipped ? this.scrollable.scrollHeight - (this.scrollHeight || 0) : 0); 135 | 136 | // if scrollHeightDifference is > 0 then something was removed from list 137 | var scrollHeightDifference = this.scrollHeight ? this.scrollHeight - this.scrollable.scrollHeight : 0; 138 | 139 | // if something was removed from list we need to include this difference in new scroll top 140 | if (this.props.flipped && scrollHeightDifference > 0) { 141 | newScrollTop += scrollHeightDifference; 142 | } 143 | 144 | if (newScrollTop !== this.scrollable.scrollTop) { 145 | this.scrollable.scrollTop = newScrollTop; 146 | } 147 | 148 | this.scrollTop = this.scrollable.scrollTop; 149 | this.scrollHeight = this.scrollable.scrollHeight; 150 | 151 | // Setting scrollTop can halt user scrolling (and disables hardware acceleration) 152 | 153 | // Both cases - flipped and refular - have cases where the content expands in the proper direction, 154 | // or the content expands in the wrong direciton. Either history or new message in both cases. 155 | // We are only handling half of the cases. Or an image resized above or below us. 156 | } 157 | }, { 158 | key: 'render', 159 | value: function render() { 160 | var _this2 = this; 161 | 162 | var displayables = (0, _lodash2.default)(this.props.children); 163 | if (this.props.flipped && !this.props.reversed) { 164 | displayables.reverse(); 165 | } 166 | 167 | var loadSpinner = _react2.default.createElement( 168 | 'div', 169 | { ref: function ref(e) { 170 | _this2.loadingSpinner = e; 171 | } }, 172 | this.state.isInfiniteLoading ? this.props.loadingSpinnerDelegate : null 173 | ); 174 | 175 | return _react2.default.createElement( 176 | 'div', 177 | { className: this.props.className, ref: function ref(e) { 178 | _this2.scrollable = e; 179 | }, 180 | style: { overflowX: 'hidden', overflowY: 'auto' } 181 | }, 182 | _react2.default.createElement( 183 | 'div', 184 | { ref: function ref(e) { 185 | _this2.smoothScrollingWrapper = e; 186 | } }, 187 | this.props.flipped ? loadSpinner : null, 188 | displayables, 189 | this.props.flipped ? null : loadSpinner 190 | ) 191 | ); 192 | } 193 | }]); 194 | 195 | return ChatView; 196 | }(_react.Component); 197 | 198 | ChatView.propTypes = { 199 | flipped: _propTypes2.default.bool, 200 | reversed: _propTypes2.default.bool, 201 | scrollLoadThreshold: _propTypes2.default.number, 202 | shouldTriggerLoad: _propTypes2.default.func, 203 | onInfiniteLoad: _propTypes2.default.func.isRequired, 204 | loadingSpinnerDelegate: _propTypes2.default.element, 205 | className: _propTypes2.default.string, 206 | children: _propTypes2.default.node, 207 | returnScrollable: _propTypes2.default.func 208 | }; 209 | exports.default = ChatView; 210 | 211 | 212 | ChatView.defaultProps = { 213 | flipped: false, 214 | scrollLoadThreshold: 10, 215 | shouldTriggerLoad: function shouldTriggerLoad() { 216 | return true; 217 | }, 218 | loadingSpinnerDelegate: _react2.default.createElement('div', null), 219 | className: '' 220 | }; 221 | module.exports = exports['default']; -------------------------------------------------------------------------------- /dist/react-chatview.min.js: -------------------------------------------------------------------------------- 1 | !function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e(require("react")):"function"==typeof define&&define.amd?define(["react"],e):"object"==typeof exports?exports.ReactChatView=e(require("react")):t.ReactChatView=e(t.React)}(this,function(t){return function(t){function e(n){if(r[n])return r[n].exports;var o=r[n]={i:n,l:!1,exports:{}};return t[n].call(o.exports,o,o.exports,e),o.l=!0,o.exports}var r={};return e.m=t,e.c=r,e.i=function(t){return t},e.d=function(t,r,n){e.o(t,r)||Object.defineProperty(t,r,{configurable:!1,enumerable:!0,get:n})},e.n=function(t){var r=t&&t.__esModule?function(){return t.default}:function(){return t};return e.d(r,"a",r),r},e.o=function(t,e){return Object.prototype.hasOwnProperty.call(t,e)},e.p="",e(e.s=3)}([function(t,e,r){(function(t,r){function n(t,e){return t.set(e[0],e[1]),t}function o(t,e){return t.add(e),t}function i(t,e){for(var r=-1,n=t?t.length:0;++r-1}function S(t,e){var r=this.__data__,n=C(r,t);return n<0?r.push([t,e]):r[n][1]=e,this}function x(t){var e=-1,r=t?t.length:0;for(this.clear();++e-1&&t%1==0&&t-1&&t%1==0&&t<=Rt}function Ot(t){var e=typeof t;return!!t&&("object"==e||"function"==e)}function Tt(t){return!!t&&"object"==typeof t}function St(t){return gt(t)?D(t):Y(t)}function xt(){return[]}function Pt(){return!1}var At=200,Et="__lodash_hash_undefined__",Rt=9007199254740991,It="[object Arguments]",Lt="[object Boolean]",kt="[object Date]",Ht="[object Function]",Ft="[object GeneratorFunction]",Mt="[object Map]",qt="[object Number]",Dt="[object Object]",Ut="[object RegExp]",Ct="[object Set]",$t="[object String]",Nt="[object Symbol]",Wt="[object ArrayBuffer]",Bt="[object DataView]",Vt="[object Float32Array]",zt="[object Float64Array]",Yt="[object Int8Array]",Gt="[object Int16Array]",Xt="[object Int32Array]",Jt="[object Uint8Array]",Kt="[object Uint8ClampedArray]",Qt="[object Uint16Array]",Zt="[object Uint32Array]",te=/[\\^$.*+?()[\]{}|]/g,ee=/\w*$/,re=/^\[object .+?Constructor\]$/,ne=/^(?:0|[1-9]\d*)$/,oe={};oe[It]=oe["[object Array]"]=oe[Wt]=oe[Bt]=oe[Lt]=oe[kt]=oe[Vt]=oe[zt]=oe[Yt]=oe[Gt]=oe[Xt]=oe[Mt]=oe[qt]=oe[Dt]=oe[Ut]=oe[Ct]=oe[$t]=oe[Nt]=oe[Jt]=oe[Kt]=oe[Qt]=oe[Zt]=!0,oe["[object Error]"]=oe[Ht]=oe["[object WeakMap]"]=!1;var ie="object"==typeof t&&t&&t.Object===Object&&t,ce="object"==typeof self&&self&&self.Object===Object&&self,ue=ie||ce||Function("return this")(),ae="object"==typeof e&&e&&!e.nodeType&&e,le=ae&&"object"==typeof r&&r&&!r.nodeType&&r,se=le&&le.exports===ae,fe=Array.prototype,pe=Function.prototype,he=Object.prototype,de=ue["__core-js_shared__"],be=function(){var t=/[^.]+$/.exec(de&&de.keys&&de.keys.IE_PROTO||"");return t?"Symbol(src)_1."+t:""}(),ye=pe.toString,ve=he.hasOwnProperty,_e=he.toString,ge=RegExp("^"+ye.call(ve).replace(te,"\\$&").replace(/hasOwnProperty|(function).*?(?=\\\()| for .+?(?=\\\])/g,"$1.*?")+"$"),je=se?ue.Buffer:void 0,me=ue.Symbol,we=ue.Uint8Array,Oe=p(Object.getPrototypeOf,Object),Te=Object.create,Se=he.propertyIsEnumerable,xe=fe.splice,Pe=Object.getOwnPropertySymbols,Ae=je?je.isBuffer:void 0,Ee=p(Object.keys,Object),Re=ut(ue,"DataView"),Ie=ut(ue,"Map"),Le=ut(ue,"Promise"),ke=ut(ue,"Set"),He=ut(ue,"WeakMap"),Fe=ut(Object,"create"),Me=bt(Re),qe=bt(Ie),De=bt(Le),Ue=bt(ke),Ce=bt(He),$e=me?me.prototype:void 0,Ne=$e?$e.valueOf:void 0;d.prototype.clear=b,d.prototype.delete=y,d.prototype.get=v,d.prototype.has=_,d.prototype.set=g,j.prototype.clear=m,j.prototype.delete=w,j.prototype.get=O,j.prototype.has=T,j.prototype.set=S,x.prototype.clear=P,x.prototype.delete=A,x.prototype.get=E,x.prototype.has=R,x.prototype.set=I,L.prototype.clear=k,L.prototype.delete=H,L.prototype.get=F,L.prototype.has=M,L.prototype.set=q;var We=Pe?p(Pe,Object):xt,Be=V;(Re&&Be(new Re(new ArrayBuffer(1)))!=Bt||Ie&&Be(new Ie)!=Mt||Le&&"[object Promise]"!=Be(Le.resolve())||ke&&Be(new ke)!=Ct||He&&"[object WeakMap]"!=Be(new He))&&(Be=function(t){var e=_e.call(t),r=e==Dt?t.constructor:void 0,n=r?bt(r):void 0;if(n)switch(n){case Me:return Bt;case qe:return Mt;case De:return"[object Promise]";case Ue:return Ct;case Ce:return"[object WeakMap]"}return e});var Ve=Array.isArray,ze=Ae||Pt;r.exports=yt}).call(e,r(8),r(9)(t))},function(t,e,r){t.exports=r(6)()},function(e,r){e.exports=t},function(t,e,r){"use strict";function n(t){return t&&t.__esModule?t:{default:t}}function o(t,e){if(!(t instanceof e))throw new TypeError("Cannot call a class as a function")}function i(t,e){if(!t)throw new ReferenceError("this hasn't been initialised - super() hasn't been called");return!e||"object"!=typeof e&&"function"!=typeof e?t:e}function c(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Super expression must either be null or a function, not "+typeof e);t.prototype=Object.create(e&&e.prototype,{constructor:{value:t,enumerable:!1,writable:!0,configurable:!0}}),e&&(Object.setPrototypeOf?Object.setPrototypeOf(t,e):t.__proto__=e)}Object.defineProperty(e,"__esModule",{value:!0});var u=function(){function t(t,e){for(var r=0;r=n-o-e},r.rafRequestId=null,r.scrollTop=0,r.scrollHeight=void 0,r.state={isInfiniteLoading:!1},r}return c(e,t),u(e,[{key:"componentDidMount",value:function(){var t=this.props.flipped?this.scrollable.scrollHeight-this.scrollable.clientHeight:0;this.scrollable.scrollTop=t,this.scrollTop=t,d?this.scrollable.addEventListener("scroll",this.onScroll,{passive:!0}):this.rafRequestId=window.requestAnimationFrame(this.pollScroll),"function"==typeof this.props.returnScrollable&&this.props.returnScrollable(this.scrollable)}},{key:"componentDidUpdate",value:function(){this.updateScrollTop()}},{key:"componentWillUnmount",value:function(){this.scrollable.removeEventListener("scroll",this.onScroll,{passive:!0}),window.cancelAnimationFrame(this.rafRequestId)}},{key:"shouldTriggerLoad",value:function(){return this.isPassedThreshold(this.props.flipped,this.props.scrollLoadThreshold,this.scrollable.scrollTop,this.scrollable.scrollHeight,this.scrollable.clientHeight)&&!this.state.isInfiniteLoading&&this.props.shouldTriggerLoad()}},{key:"updateScrollTop",value:function(){var t=this.scrollable.scrollTop+(this.props.flipped?this.scrollable.scrollHeight-(this.scrollHeight||0):0),e=this.scrollHeight?this.scrollHeight-this.scrollable.scrollHeight:0;this.props.flipped&&e>0&&(t+=e),t!==this.scrollable.scrollTop&&(this.scrollable.scrollTop=t),this.scrollTop=this.scrollable.scrollTop,this.scrollHeight=this.scrollable.scrollHeight}},{key:"render",value:function(){var t=this,e=(0,h.default)(this.props.children);this.props.flipped&&!this.props.reversed&&e.reverse();var r=l.default.createElement("div",{ref:function(e){t.loadingSpinner=e}},this.state.isInfiniteLoading?this.props.loadingSpinnerDelegate:null);return l.default.createElement("div",{className:this.props.className,ref:function(e){t.scrollable=e},style:{overflowX:"hidden",overflowY:"auto"}},l.default.createElement("div",{ref:function(e){t.smoothScrollingWrapper=e}},this.props.flipped?r:null,e,this.props.flipped?null:r))}}]),e}(a.Component);y.propTypes={flipped:f.default.bool,reversed:f.default.bool,scrollLoadThreshold:f.default.number,shouldTriggerLoad:f.default.func,onInfiniteLoad:f.default.func.isRequired,loadingSpinnerDelegate:f.default.element,className:f.default.string,children:f.default.node,returnScrollable:f.default.func},e.default=y,y.defaultProps={flipped:!1,scrollLoadThreshold:10,shouldTriggerLoad:function(){return!0},loadingSpinnerDelegate:l.default.createElement("div",null),className:""},t.exports=e.default},function(t,e,r){"use strict";function n(t){return function(){return t}}var o=function(){};o.thatReturns=n,o.thatReturnsFalse=n(!1),o.thatReturnsTrue=n(!0),o.thatReturnsNull=n(null),o.thatReturnsThis=function(){return this},o.thatReturnsArgument=function(t){return t},t.exports=o},function(t,e,r){"use strict";function n(t,e,r,n,i,c,u,a){if(o(e),!t){var l;if(void 0===e)l=new Error("Minified exception occurred; use the non-minified dev environment for the full error message and additional helpful warnings.");else{var s=[r,n,i,c,u,a],f=0;l=new Error(e.replace(/%s/g,function(){return s[f++]})),l.name="Invariant Violation"}throw l.framesToPop=1,l}}var o=function(t){};t.exports=n},function(t,e,r){"use strict";var n=r(4),o=r(5),i=r(7);t.exports=function(){function t(t,e,r,n,c,u){u!==i&&o(!1,"Calling PropTypes validators directly is not supported by the `prop-types` package. Use PropTypes.checkPropTypes() to call them. Read more at http://fb.me/use-check-prop-types")}function e(){return t}t.isRequired=t;var r={array:t,bool:t,func:t,number:t,object:t,string:t,symbol:t,any:t,arrayOf:e,element:t,instanceOf:e,node:t,objectOf:e,oneOf:e,oneOfType:e,shape:e,exact:e};return r.checkPropTypes=n,r.PropTypes=r,r}},function(t,e,r){"use strict";t.exports="SECRET_DO_NOT_PASS_THIS_OR_YOU_WILL_BE_FIRED"},function(t,e){var r;r=function(){return this}();try{r=r||Function("return this")()||(0,eval)("this")}catch(t){"object"==typeof window&&(r=window)}t.exports=r},function(t,e){t.exports=function(t){return t.webpackPolyfill||(t.deprecate=function(){},t.paths=[],t.children||(t.children=[]),Object.defineProperty(t,"loaded",{enumerable:!0,get:function(){return t.l}}),Object.defineProperty(t,"id",{enumerable:!0,get:function(){return t.i}}),t.webpackPolyfill=1),t}}])}); --------------------------------------------------------------------------------