-
Notifications
You must be signed in to change notification settings - Fork 43
/
Copy pathreact-chatview.js
176 lines (148 loc) · 6.02 KB
/
react-chatview.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import clone from 'lodash.clone';
let supportsPassive = false;
try {
const opts = Object.defineProperty({}, 'passive', {
get() {
supportsPassive = true;
},
});
window.addEventListener('test', null, opts);
} catch (e) { /* pass */ }
export default class ChatView extends Component {
static propTypes = {
flipped: PropTypes.bool,
reversed: PropTypes.bool,
scrollLoadThreshold: PropTypes.number,
shouldTriggerLoad: PropTypes.func,
onInfiniteLoad: PropTypes.func.isRequired,
loadingSpinnerDelegate: PropTypes.element,
className: PropTypes.string,
children: PropTypes.node,
returnScrollable: PropTypes.func,
};
constructor(props) {
super(props);
this.rafRequestId = null; // for cleaning up outstanding requestAnimationFrames on WillUnmount
this.scrollTop = 0; // regular mode initial scroll
this.scrollHeight = undefined; // it's okay, this won't be read until the second render.
// In flipped mode, we need to measure the scrollable height from the DOM to write to the scrollTop.
// Flipped and regular measured heights are symmetrical and don't depend on the scrollTop
this.state = {
isInfiniteLoading: false
};
}
componentDidMount() {
// If there are not yet any children (they are still loading),
// this is a no-op as we are at both the top and bottom of empty viewport
const heightDifference = this.props.flipped
? this.scrollable.scrollHeight - this.scrollable.clientHeight
: 0;
this.scrollable.scrollTop = heightDifference;
this.scrollTop = heightDifference;
// Unless passive events are supported, we must not hook onScroll event
// directly - that will break hardware accelerated scrolling. We poll it
// with requestAnimationFrame instead.
if (supportsPassive) {
this.scrollable.addEventListener('scroll', this.onScroll, { passive: true });
} else {
this.rafRequestId = window.requestAnimationFrame(this.pollScroll);
}
// upper ref
if (typeof this.props.returnScrollable === 'function') this.props.returnScrollable(this.scrollable);
}
// componentDidUpdate(prevProps, prevState) {
componentDidUpdate() {
this.updateScrollTop();
}
componentWillUnmount() {
this.scrollable.removeEventListener('scroll', this.onScroll, { passive: true });
window.cancelAnimationFrame(this.rafRequestId);
}
// componentWillUpdate(nextProps, nextState) {}
// detect when dom has changed underneath us- either scrollTop or scrollHeight (layout reflow)
// may have changed.
onScroll = () => {
if (this.scrollable.scrollTop !== this.scrollTop) {
if (this.shouldTriggerLoad()) {
this.setState({ isInfiniteLoading: true });
const p = this.props.onInfiniteLoad();
p.then(() => this.setState({ isInfiniteLoading: false }));
}
// the dom is ahead of the state
this.updateScrollTop();
}
}
pollScroll = () => {
this.onScroll();
this.rafRequestId = window.requestAnimationFrame(this.pollScroll);
}
isPassedThreshold = (flipped, scrollLoadThreshold, scrollTop, scrollHeight, clientHeight) => {
return flipped
? scrollTop <= scrollLoadThreshold
: scrollTop >= (scrollHeight - clientHeight - scrollLoadThreshold);
}
shouldTriggerLoad() {
const passedThreshold = this.isPassedThreshold(
this.props.flipped,
this.props.scrollLoadThreshold,
this.scrollable.scrollTop,
this.scrollable.scrollHeight,
this.scrollable.clientHeight);
return passedThreshold && !this.state.isInfiniteLoading && this.props.shouldTriggerLoad();
}
updateScrollTop() {
// todo this is only the happy path
let newScrollTop = this.scrollable.scrollTop + (this.props.flipped
? this.scrollable.scrollHeight - (this.scrollHeight || 0)
: 0);
// if scrollHeightDifference is > 0 then something was removed from list
const scrollHeightDifference = this.scrollHeight ? this.scrollHeight - this.scrollable.scrollHeight : 0;
// if something was removed from list we need to include this difference in new scroll top
if (this.props.flipped && scrollHeightDifference > 0) {
newScrollTop += scrollHeightDifference;
}
if (newScrollTop !== this.scrollable.scrollTop) {
this.scrollable.scrollTop = newScrollTop;
}
this.scrollTop = this.scrollable.scrollTop;
this.scrollHeight = this.scrollable.scrollHeight;
//Pin scroll position when new children are coming if user is not at bottom or top
let userIsAtBottom = this.scrollable.scrollHeight - this.scrollable.scrollTop === this.scrollable.clientHeight;
if(!userIsAtBottom && this.props.shouldTriggerLoad()){
this.scrollable.scrollTop = newScrollTop + scrollHeightDifference;
}
// Setting scrollTop can halt user scrolling (and disables hardware acceleration)
// Both cases - flipped and refular - have cases where the content expands in the proper direction,
// or the content expands in the wrong direciton. Either history or new message in both cases.
// We are only handling half of the cases. Or an image resized above or below us.
}
render() {
const displayables = clone(this.props.children);
if (this.props.flipped && !this.props.reversed) {
displayables.reverse();
}
const loadSpinner = (<div ref={e => { this.loadingSpinner = e; }}>
{this.state.isInfiniteLoading ? this.props.loadingSpinnerDelegate : null}
</div>);
return (
<div className={this.props.className} ref={e => { this.scrollable = e; }}
style={{ overflowX: 'hidden', overflowY: 'auto' }}
>
<div ref={e => { this.smoothScrollingWrapper = e; }}>
{this.props.flipped ? loadSpinner : null}
{displayables}
{this.props.flipped ? null : loadSpinner}
</div>
</div>
);
}
}
ChatView.defaultProps = {
flipped: false,
scrollLoadThreshold: 10,
shouldTriggerLoad: () => { return true; },
loadingSpinnerDelegate: <div />,
className: ''
};