post_list.jsx 22.9 KB
Newer Older
1
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
2
// See LICENSE.txt for license information.
3

4
5
6
import PropTypes from 'prop-types';
import React from 'react';
import ReactDOM from 'react-dom';
7
import {FormattedMessage} from 'react-intl';
8

9
import {isUserActivityPost} from 'mattermost-redux/utils/post_utils';
10
import {debounce} from 'mattermost-redux/actions/helpers';
11

12
import Constants, {PostTypes} from 'utils/constants.jsx';
13
import DelayedAction from 'utils/delayed_action.jsx';
14
15
import EventTypes from 'utils/event_types.jsx';
import GlobalEventEmitter from 'utils/global_event_emitter.jsx';
16
17
import * as UserAgent from 'utils/user_agent.jsx';
import * as Utils from 'utils/utils.jsx';
18
import {isFromWebhook} from 'utils/post_utils.jsx';
19

20
import LoadingScreen from 'components/loading_screen.jsx';
21
import DateSeparator from 'components/post_view/date_separator.jsx';
22

23
24
25
26
import FloatingTimestamp from './floating_timestamp.jsx';
import NewMessageIndicator from './new_message_indicator.jsx';
import Post from './post';
import ScrollToBottomArrows from './scroll_to_bottom_arrows.jsx';
27
import CreateChannelIntroMessage from './channel_intro_message';
28
29
30

const CLOSE_TO_BOTTOM_SCROLL_MARGIN = 10;
const POSTS_PER_PAGE = Constants.POST_CHUNK_SIZE / 2;
31
const MAX_EXTRA_PAGES_LOADED = 10;
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48

export default class PostList extends React.PureComponent {
    static propTypes = {

        /**
         * Array of posts in the channel, ordered from oldest to newest
         */
        posts: PropTypes.array,

        /**
         * The number of posts that should be rendered
         */
        postVisibility: PropTypes.number,

        /**
         * The channel the posts are in
         */
49
        channel: PropTypes.object.isRequired,
50
51
52
53
54
55
56
57
58
59
60
61
62
63

        /**
         * The last time the channel was viewed, sets the new message separator
         */
        lastViewedAt: PropTypes.number,

        /**
         * The user id of the logged in user
         */
        currentUserId: PropTypes.string,

        /**
         * Set to focus this post
         */
64
        focusedPostId: PropTypes.string,
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

        /**
         * Whether to display the channel intro at full width
         */
        fullWidth: PropTypes.bool,

        actions: PropTypes.shape({

            /**
             * Function to get posts in the channel
             */
            getPosts: PropTypes.func.isRequired,

            /**
             * Function to get posts in the channel older than the focused post
             */
            getPostsBefore: PropTypes.func.isRequired,

            /**
             * Function to get posts in the channel newer than the focused post
             */
            getPostsAfter: PropTypes.func.isRequired,

            /**
             * Function to get the post thread for the focused post
             */
            getPostThread: PropTypes.func.isRequired,

            /**
             * Function to increase the number of posts being rendered
             */
96
97
98
99
100
            increasePostVisibility: PropTypes.func.isRequired,

            /**
             * Function to check and set if app is in mobile view
             */
101
102
            checkAndSetMobileView: PropTypes.func.isRequired,
        }).isRequired,
103
104
105
106
107
108
109
    }

    constructor(props) {
        super(props);

        this.scrollStopAction = new DelayedAction(this.handleScrollStop);

110
111
112
113
        this.previousScrollTop = Number.MAX_SAFE_INTEGER;
        this.previousScrollHeight = 0;
        this.previousClientHeight = 0;
        this.atBottom = false;
114

115
        this.extraPagesLoaded = 0;
116
        this.atBottom = false;
117

118
119
120
        this.state = {
            atEnd: false,
            unViewedCount: 0,
Chris's avatar
Chris committed
121
            isDoingInitialLoad: true,
122
            isScrolling: false,
123
            lastViewed: props.lastViewedAt,
124
125
126
127
128
        };
    }

    componentDidMount() {
        this.loadPosts(this.props.channel.id, this.props.focusedPostId);
129
        this.props.actions.checkAndSetMobileView();
130
        GlobalEventEmitter.addListener(EventTypes.POST_LIST_SCROLL_CHANGE, this.handleResize);
131

132
        window.addEventListener('resize', this.handleWindowResize);
133
134

        this.initialScroll();
135
136
137
    }

    componentWillUnmount() {
138
        GlobalEventEmitter.removeListener(EventTypes.POST_LIST_SCROLL_CHANGE, this.handleResize);
139
        window.removeEventListener('resize', this.handleWindowResize);
140
141
    }

142
143
144
    UNSAFE_componentWillReceiveProps(nextProps) { // eslint-disable-line camelcase
        // Focusing on a new post so load posts around it
        if (nextProps.focusedPostId && this.props.focusedPostId !== nextProps.focusedPostId) {
145
146
            this.hasScrolledToFocusedPost = false;
            this.hasScrolledToNewMessageSeparator = false;
147
148
149
150
151
152
153
            this.setState({atEnd: false, isDoingInitialLoad: !nextProps.posts});
            this.loadPosts(nextProps.channel.id, nextProps.focusedPostId);
            return;
        }

        const channel = this.props.channel || {};
        const nextChannel = nextProps.channel || {};
154

155
156
157
158
159
160
161
        if (nextProps.focusedPostId == null) {
            // Channel changed so load posts for new channel
            if (channel.id !== nextChannel.id) {
                this.hasScrolled = false;
                this.hasScrolledToFocusedPost = false;
                this.hasScrolledToNewMessageSeparator = false;
                this.atBottom = false;
162

163
164
165
166
167
168
169
170
                this.extraPagesLoaded = 0;

                this.setState({atEnd: false, lastViewed: nextProps.lastViewedAt, isDoingInitialLoad: !nextProps.posts, unViewedCount: 0});

                if (nextChannel.id) {
                    this.loadPosts(nextChannel.id);
                }
            }
171
        }
172
173
174
175
176
177
178
179
180
    }

    UNSAFE_componentWillUpdate() { // eslint-disable-line camelcase
        if (this.refs.postlist) {
            this.previousScrollTop = this.refs.postlist.scrollTop;
            this.previousScrollHeight = this.refs.postlist.scrollHeight;
            this.previousClientHeight = this.refs.postlist.clientHeight;
        }
    }
181

182
    componentDidUpdate(prevProps, prevState) {
183
184
        this.loadPostsToFillScreenIfNecessary();

Joram Wilander's avatar
Joram Wilander committed
185
        // Do not update scrolling unless posts, visibility or intro message change
186
        if (this.props.posts === prevProps.posts && this.props.postVisibility === prevProps.postVisibility && this.state.atEnd === prevState.atEnd) {
187
188
189
            return;
        }

190
191
192
193
194
        const prevPosts = prevProps.posts || [];
        const posts = this.props.posts || [];

        if (this.props.focusedPostId == null) {
            const hasNewPosts = (prevPosts.length === 0 && posts.length > 0) || (prevPosts.length > 0 && posts.length > 0 && prevPosts[0].id !== posts[0].id);
195

196
            if (!this.checkBottom() && hasNewPosts) {
197
198
199
200
                this.setUnreadsBelow(posts, this.props.currentUserId);
            }
        }

201
        const postList = this.refs.postlist;
202
203
204
205
        if (!postList) {
            return;
        }

206
207
        // Scroll to focused post on first load
        const focusedPost = this.refs[this.props.focusedPostId];
208
209
        if (focusedPost && this.props.posts) {
            if (!this.hasScrolledToFocusedPost) {
210
211
                const element = ReactDOM.findDOMNode(focusedPost);
                const rect = element.getBoundingClientRect();
212
                const listHeight = postList.clientHeight / 2;
213
                postList.scrollTop += rect.top - listHeight;
214
                this.atBottom = this.checkBottom();
215
216
            } else if (this.previousScrollHeight !== postList.scrollHeight && posts[0].id === prevPosts[0].id) {
                postList.scrollTop = this.previousScrollTop + (postList.scrollHeight - this.previousScrollHeight);
217
218
219
220
            }
            return;
        }

221
222
223
224
225
226
227
228
229
230
        const didInitialScroll = this.initialScroll();

        if (posts.length >= POSTS_PER_PAGE) {
            this.hasScrolledToNewMessageSeparator = true;
        }

        if (didInitialScroll) {
            return;
        }

231
        if (postList && prevPosts && posts && posts[0] && prevPosts[0]) {
232
233
            // A new message was posted, so scroll to bottom if user
            // was already scrolled close to bottom
234
            let doScrollToBottom = false;
235
236
237
            const postId = posts[0].id;
            const prevPostId = prevPosts[0].id;
            const pendingPostId = posts[0].pending_post_id;
238
            if (postId !== prevPostId && pendingPostId !== prevPostId) {
239
                // If already scrolled to bottom
240
                if (this.atBottom) {
241
242
243
244
245
246
247
248
249
250
                    doScrollToBottom = true;
                }

                // If new post was ephemeral
                if (Utils.isPostEphemeral(posts[0])) {
                    doScrollToBottom = true;
                }
            }

            if (doScrollToBottom) {
251
                this.atBottom = true;
252
                postList.scrollTop = postList.scrollHeight;
253
254
255
256
                return;
            }

            // New posts added at the top, maintain scroll position
257
258
            if (this.previousScrollHeight !== postList.scrollHeight && posts[0].id === prevPosts[0].id) {
                postList.scrollTop = this.previousScrollTop + (postList.scrollHeight - this.previousScrollHeight);
259
260
261
262
            }
        }
    }

263
    loadPostsToFillScreenIfNecessary = () => {
264
265
266
267
        if (this.props.focusedPostId) {
            return;
        }

268
        if (this.state.isDoingInitialLoad) {
269
270
271
272
            // Should already be loading posts
            return;
        }

273
        if (this.state.atEnd || !this.refs.postListContent || !this.refs.postlist) {
274
275
276
277
            // No posts to load
            return;
        }

278
        if (this.refs.postListContent.scrollHeight >= this.refs.postlist.clientHeight) {
279
280
281
282
283
284
285
286
287
            // Screen is full
            return;
        }

        if (this.extraPagesLoaded > MAX_EXTRA_PAGES_LOADED) {
            // Prevent this from loading a lot of pages in a channel with only hidden messages
            return;
        }

288
        this.doLoadPostsToFillScreen();
289
    };
290

291
292
293
294
295
296
    doLoadPostsToFillScreen = debounce(() => {
        this.extraPagesLoaded += 1;

        this.loadMorePosts();
    }, 100);

297
298
    // Scroll to new message indicator or bottom on first load. Returns true
    // if we just scrolled for the initial load.
299
    initialScroll = () => {
300
301
302
303
304
        if (this.hasScrolledToNewMessageSeparator) {
            // Already scrolled to new messages indicator
            return false;
        }

305
        const postList = this.refs.postlist;
306
        const posts = this.props.posts;
307
308
309
        if (!postList || !posts) {
            // Not able to do initial scroll yet
            return false;
310
311
312
        }

        const messageSeparator = this.refs.newMessageSeparator;
313
314
315

        // Scroll to new message indicator since we have unread posts and we can't show every new post in the screen
        if (messageSeparator && (postList.scrollHeight - messageSeparator.offsetTop) > postList.clientHeight) {
316
            messageSeparator.scrollIntoView();
317
            this.setUnreadsBelow(posts, this.props.currentUserId);
318
            return true;
319
320
        }

321
        // Scroll to bottom since we don't have unread posts or we can show every new post in the screen
322
        this.atBottom = true;
323
324
        postList.scrollTop = postList.scrollHeight;
        return true;
325
326
    }

327
328
329
330
331
332
333
334
335
336
337
338
    setUnreadsBelow = (posts, currentUserId) => {
        const unViewedCount = posts.reduce((count, post) => {
            if (post.create_at > this.state.lastViewed &&
                post.user_id !== currentUserId &&
                post.state !== Constants.POST_DELETED) {
                return count + 1;
            }
            return count;
        }, 0);
        this.setState({unViewedCount});
    }

339
340
    handleScrollStop = () => {
        this.setState({
341
            isScrolling: false,
342
343
344
        });
    }

345
    checkBottom = () => {
346
        if (!this.refs.postlist) {
347
            return true;
348
349
350
        }

        // No scroll bar so we're at the bottom
351
        if (this.refs.postlist.scrollHeight <= this.refs.postlist.clientHeight) {
352
353
354
            return true;
        }

355
        return this.refs.postlist.clientHeight + this.refs.postlist.scrollTop >= this.refs.postlist.scrollHeight - CLOSE_TO_BOTTOM_SCROLL_MARGIN;
356
357
    }

358
359
360
361
    handleWindowResize = () => {
        this.handleResize();
    }

362
    handleResize = (forceScrollToBottom) => {
363
364
365
366
367
368
369
370
371
372
        const postList = this.refs.postlist;
        const messageSeparator = this.refs.newMessageSeparator;
        const doScrollToBottom = this.atBottom || forceScrollToBottom;

        if (postList) {
            if (doScrollToBottom) {
                postList.scrollTop = postList.scrollHeight;
            } else if (!this.hasScrolled && messageSeparator) {
                const element = ReactDOM.findDOMNode(messageSeparator);
                element.scrollIntoView();
373
            }
374
375
376
377
378
379
380
381
382

            this.previousScrollHeight = postList.scrollHeight;
            this.previousScrollTop = postList.scrollTop;
            this.previousClientHeight = postList.clientHeight;

            this.atBottom = this.checkBottom();
        }

        this.props.actions.checkAndSetMobileView();
383
384
385
386
387
    }

    loadPosts = async (channelId, focusedPostId) => {
        let posts;
        if (focusedPostId) {
388
            const getPostThreadAsync = this.props.actions.getPostThread(focusedPostId, false);
389
390
391
            const getPostsBeforeAsync = this.props.actions.getPostsBefore(channelId, focusedPostId, 0, POSTS_PER_PAGE);
            const getPostsAfterAsync = this.props.actions.getPostsAfter(channelId, focusedPostId, 0, POSTS_PER_PAGE);

392
393
            const result = await getPostsBeforeAsync;
            posts = result.data;
394
395
396
397
398
            await getPostsAfterAsync;
            await getPostThreadAsync;

            this.hasScrolledToFocusedPost = true;
        } else {
399
400
            const result = await this.props.actions.getPosts(channelId, 0, POSTS_PER_PAGE);
            posts = result.data;
401
402
403
404
405
406

            if (!this.checkBottom()) {
                const postsArray = posts.order.map((id) => posts.posts[id]);
                this.setUnreadsBelow(postsArray, this.props.currentUserId);
            }

407
408
409
            this.hasScrolledToNewMessageSeparator = true;
        }

410
411
412
413
        this.setState({
            isDoingInitialLoad: false,
            atEnd: Boolean(posts && posts.order.length < POSTS_PER_PAGE),
        });
414
415
    }

416
417
418
    loadMorePosts = (e) => {
        if (e) {
            e.preventDefault();
419
        }
420
421
422
423

        this.props.actions.increasePostVisibility(this.props.channel.id, this.props.focusedPostId).then((moreToLoad) => {
            this.setState({atEnd: !moreToLoad && this.props.posts.length < this.props.postVisibility});
        });
424
425
426
    }

    handleScroll = () => {
427
428
429
430
        // Only count as user scroll if we've already performed our first load scroll
        this.hasScrolled = this.hasScrolledToNewMessageSeparator || this.hasScrolledToFocusedPost;
        if (!this.refs.postlist) {
            return;
431
432
        }

433
        this.previousScrollTop = this.refs.postlist.scrollTop;
434

435
436
437
        if (this.refs.postlist.scrollHeight === this.previousScrollHeight) {
            this.atBottom = this.checkBottom();
        }
438

439
        this.updateFloatingTimestamp();
440

441
442
443
444
445
        if (!this.state.isScrolling) {
            this.setState({
                isScrolling: true,
            });
        }
446

447
448
449
450
451
452
453
        if (this.checkBottom()) {
            this.setState({
                lastViewed: new Date().getTime(),
                unViewedCount: 0,
                isScrolling: false,
            });
        }
454

455
        this.scrollStopAction.fireAfter(Constants.SCROLL_DELAY);
456
457
458
459
460
461
462
463
464
465
466
467
468
    }

    updateFloatingTimestamp = () => {
        // skip this in non-mobile view since that's when the timestamp is visible
        if (!Utils.isMobile()) {
            return;
        }

        if (this.props.posts) {
            // iterate through posts starting at the bottom since users are more likely to be viewing newer posts
            for (let i = 0; i < this.props.posts.length; i++) {
                const post = this.props.posts[i];
                const element = this.refs[post.id];
469
                const domNode = ReactDOM.findDOMNode(element);
470

471
                if (!element || !domNode || domNode.offsetTop + domNode.clientHeight <= this.refs.postlist.scrollTop) {
472
473
474
475
476
477
478
479
480
481
482
483
                    // this post is off the top of the screen so the last one is at the top of the screen
                    let topPost;

                    if (i > 0) {
                        topPost = this.props.posts[i - 1];
                    } else {
                        // the first post we look at should always be on the screen, but handle that case anyway
                        topPost = post;
                    }

                    if (!this.state.topPost || topPost.id !== this.state.topPost.id) {
                        this.setState({
484
                            topPost,
485
486
487
488
489
490
491
492
493
494
                        });
                    }

                    break;
                }
            }
        }
    }

    scrollToBottom = () => {
495
496
        if (this.refs.postlist) {
            this.refs.postlist.scrollTop = this.refs.postlist.scrollHeight;
Joram Wilander's avatar
Joram Wilander committed
497
        }
498
499
500
501
502
503
504
505
506
507
508
509
510
    }

    createPosts = (posts) => {
        const postCtls = [];
        let previousPostDay = new Date(0);
        const currentUserId = this.props.currentUserId;
        const lastViewed = this.props.lastViewedAt || 0;

        let renderedLastViewed = false;

        for (let i = posts.length - 1; i >= 0; i--) {
            const post = posts[i];

511
512
            if (
                post == null ||
513
514
                post.type === PostTypes.EPHEMERAL_ADD_TO_CHANNEL ||
                isUserActivityPost(post.type)
515
            ) {
516
517
518
                continue;
            }

519
520
521
522
523
524
525
526
527
528
529
530
531
            const postCtl = (
                <Post
                    ref={post.id}
                    key={'post ' + (post.id || post.pending_post_id)}
                    post={post}
                    lastPostCount={(i >= 0 && i < Constants.TEST_ID_COUNT) ? i : -1}
                    getPostList={this.getPostList}
                />
            );

            const currentPostDay = Utils.getDateForUnixTicks(post.create_at);
            if (currentPostDay.toDateString() !== previousPostDay.toDateString()) {
                postCtls.push(
532
533
534
535
                    <DateSeparator
                        key={currentPostDay}
                        date={currentPostDay}
                    />
536
537
538
                );
            }

539
540
            const isNotCurrentUser = post.user_id !== currentUserId || isFromWebhook(post);
            if (isNotCurrentUser &&
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
                    lastViewed !== 0 &&
                    post.create_at > lastViewed &&
                    !Utils.isPostEphemeral(post) &&
                    !renderedLastViewed) {
                renderedLastViewed = true;

                // Temporary fix to solve ie11 rendering issue
                let newSeparatorId = '';
                if (!UserAgent.isInternetExplorer()) {
                    newSeparatorId = 'new_message_' + post.id;
                }
                postCtls.push(
                    <div
                        id={newSeparatorId}
                        key='unviewed'
                        ref='newMessageSeparator'
                        className='new-separator'
                    >
                        <hr
                            className='separator__hr'
                        />
                        <div className='separator__text'>
                            <FormattedMessage
                                id='posts_view.newMsg'
                                defaultMessage='New Messages'
                            />
                        </div>
                    </div>
                );
            }

            postCtls.push(postCtl);
            previousPostDay = currentPostDay;
        }

        return postCtls;
    }

    getPostList = () => {
580
        return this.refs.postlist;
581
582
583
    }

    render() {
Chris's avatar
Chris committed
584
        const posts = this.props.posts || [];
585
586
        const channel = this.props.channel;

587
        if ((posts.length === 0 && this.state.isDoingInitialLoad) || channel == null) {
588
589
590
591
592
593
594
595
596
597
598
599
            return (
                <div id='post-list'>
                    <LoadingScreen
                        position='absolute'
                        key='loading'
                    />
                </div>
            );
        }

        let topRow;
        if (this.state.atEnd) {
600
601
602
603
604
605
            topRow = (
                <CreateChannelIntroMessage
                    channel={channel}
                    fullWidth={this.props.fullWidth}
                />
            );
606
607
608
609
610
611
612
613
614
        } else if (this.props.postVisibility >= Constants.MAX_POST_VISIBILITY) {
            topRow = (
                <div className='post-list__loading post-list__loading-search'>
                    <FormattedMessage
                        id='posts_view.maxLoaded'
                        defaultMessage='Looking for a specific message? Try searching for it'
                    />
                </div>
            );
615
616
        } else if (this.state.isDoingInitialLoad) {
            topRow = <LoadingScreen style={{height: '0px'}}/>;
617
618
        } else {
            topRow = (
Asaad Mahmood's avatar
Asaad Mahmood committed
619
                <button
620
                    ref='loadmoretop'
Asaad Mahmood's avatar
Asaad Mahmood committed
621
                    className='more-messages-text theme style--none color--link'
622
623
624
625
626
627
                    onClick={this.loadMorePosts}
                >
                    <FormattedMessage
                        id='posts_view.loadMore'
                        defaultMessage='Load more messages'
                    />
Asaad Mahmood's avatar
Asaad Mahmood committed
628
                </button>
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
            );
        }

        const topPostCreateAt = this.state.topPost ? this.state.topPost.create_at : 0;

        let postVisibility = this.props.postVisibility;

        // In focus mode there's an extra (Constants.POST_CHUNK_SIZE / 2) posts to show
        if (this.props.focusedPostId) {
            postVisibility += Constants.POST_CHUNK_SIZE / 2;
        }

        return (
            <div id='post-list'>
                <FloatingTimestamp
                    isScrolling={this.state.isScrolling}
                    isMobile={Utils.isMobile()}
                    createAt={topPostCreateAt}
                />
                <ScrollToBottomArrows
                    isScrolling={this.state.isScrolling}
650
651
                    atBottom={this.atBottom}
                    onClick={this.scrollToBottom}
652
653
654
655
656
657
                />
                <NewMessageIndicator
                    newMessages={this.state.unViewedCount}
                    onClick={this.scrollToBottom}
                />
                <div
658
                    ref='postlist'
659
660
661
662
663
664
                    className='post-list-holder-by-time'
                    key={'postlist-' + channel.id}
                    onScroll={this.handleScroll}
                >
                    <div className='post-list__table'>
                        <div
665
                            id='postListContent'
666
                            ref='postListContent'
667
668
669
670
671
672
673
674
675
676
677
                            className='post-list__content'
                        >
                            {topRow}
                            {this.createPosts(posts.slice(0, postVisibility))}
                        </div>
                    </div>
                </div>
            </div>
        );
    }
}