Files
mattermost-mobile/app/components/post_list/post_list.js
Miguel Alatzar f79f9dc697 [MM-23761] [MM-25766] Add "More Messages" button (#4526)
* Add more messages button

* Update existing tests

* Various fixes:

* Use viewAreaCoveragePercentThreshold over itemVisiblePercentThreshold
* Remove currentUserId check when adding New Message line to postIds and
  instead update the channel last viewed time when an own post is received

* Update snapshots

* Add showMoreMessagesButton prop and default to false

* Android fixes

* Add tests

* Localize more messages text

* Use FormattedText

* i18 extract

* Style fixes

* Account for network indicator

* Fix for failing tests

* Various fixes:

* Set the unreadMessageCount when POST_UNREAD_SUCCESS is dispatched
  with a positive deltaMsgs
* Hide the more messages button when the unread count decreases or
  when the new message line is removed shortly after loading the
  channel

* No need for POST_UNREAD_SUCCESS if we manually call onViewableItemsChanged

* Reset unread count if current channel on channel mount

* Animate text opacity

* Compare indeces to determine when scrolling has ended

* Fix opacity animation trigger

* try with scrolling to the last rendered item

* Add onScrollEndIndex

* Improve animations

* Don't track moreCount in state

* Use moreText over prevNewMessageLineIndex to determine firstPage

* Update intl message format and call cancel in componentDidUpdate when needed

* Fix intl format

* Remove opacity animation and countText

* Fix pressed not being reset

* No need to separate intl func

* Return after resetting

* Fix accidental removal of setState call

* Reset pressed when newLineMessageIndex changes

* Use default windowSize and lower POST_CHUNK_SIZE and delays

* Queue a cancel timer that gets cleared only when the newMessageLineIndex changes

* Define uncancel func

* Increase cancelTimer delay

* Subtract read posts from unread count and account for retry indicator

* Add retry bar indicator tests

* Use props.unreadCount

* Fix handling of newMessageLineIndex change

* Fix handling of newMessageLineIndex change take 2

* Fix handling of newMessageLineIndex change take 3

* Use 'native' TouchableWithFeedback with dark overlay

* Fix handling of manually unread

* Update chunk and window sizes

* Fix hsl

* Update text only when newMessageLineIndex/endIndex is reached

* Don't delay cancel if when no more unreads

* Fixes for when opening the app

* No need to process viewableItems when unreadCount is 0

* Remove line

* Don't show if unreadCount is 0

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-07-09 13:30:30 -07:00

606 lines
20 KiB
JavaScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {Alert, FlatList, Platform, RefreshControl, StyleSheet} from 'react-native';
import {intlShape} from 'react-intl';
import {Posts} from '@mm-redux/constants';
import EventEmitter from '@mm-redux/utils/event_emitter';
import * as PostListUtils from '@mm-redux/utils/post_list';
import CombinedUserActivityPost from 'app/components/combined_user_activity_post';
import Post from 'app/components/post';
import {DeepLinkTypes, ListTypes} from 'app/constants';
import mattermostManaged from 'app/mattermost_managed';
import {makeExtraData} from 'app/utils/list_view';
import {changeOpacity} from 'app/utils/theme';
import {matchDeepLink} from 'app/utils/url';
import telemetry from 'app/telemetry';
import {showModalOverCurrentContext} from 'app/actions/navigation';
import {alertErrorWithFallback} from 'app/utils/general';
import {t} from 'app/utils/i18n';
import DateHeader from './date_header';
import NewMessagesDivider from './new_messages_divider';
import MoreMessagesButton from './more_messages_button';
const INITIAL_BATCH_TO_RENDER = 10;
const SCROLL_UP_MULTIPLIER = 3.5;
const SCROLL_POSITION_CONFIG = {
// To avoid scrolling the list when new messages arrives
// if the user is not at the bottom
minIndexForVisible: 0,
// If the user is at the bottom or 60px from the bottom
// auto scroll show the new message
autoscrollToTopThreshold: 60,
};
export default class PostList extends PureComponent {
static propTypes = {
actions: PropTypes.shape({
handleSelectChannelByName: PropTypes.func.isRequired,
loadChannelsByTeamName: PropTypes.func.isRequired,
refreshChannelWithRetry: PropTypes.func.isRequired,
selectFocusedPostId: PropTypes.func.isRequired,
setDeepLinkURL: PropTypes.func.isRequired,
}).isRequired,
channelId: PropTypes.string,
deepLinkURL: PropTypes.string,
extraData: PropTypes.any,
highlightPinnedOrFlagged: PropTypes.bool,
highlightPostId: PropTypes.string,
initialIndex: PropTypes.number,
isSearchResult: PropTypes.bool,
lastPostIndex: PropTypes.number.isRequired,
lastViewedAt: PropTypes.number, // Used by container // eslint-disable-line no-unused-prop-types
loadMorePostsVisible: PropTypes.bool,
onLoadMoreUp: PropTypes.func,
onHashtagPress: PropTypes.func,
onPermalinkPress: PropTypes.func,
onPostPress: PropTypes.func,
onRefresh: PropTypes.func,
postIds: PropTypes.array.isRequired,
refreshing: PropTypes.bool,
renderFooter: PropTypes.oneOfType([PropTypes.func, PropTypes.object]),
renderReplies: PropTypes.bool,
serverURL: PropTypes.string.isRequired,
shouldRenderReplyButton: PropTypes.bool,
siteURL: PropTypes.string.isRequired,
theme: PropTypes.object.isRequired,
location: PropTypes.string,
scrollViewNativeID: PropTypes.string,
showMoreMessagesButton: PropTypes.bool,
};
static defaultProps = {
onLoadMoreUp: () => true,
renderFooter: () => null,
refreshing: false,
serverURL: '',
siteURL: '',
postIds: [],
showMoreMessagesButton: false,
};
static contextTypes = {
intl: intlShape.isRequired,
};
constructor(props) {
super(props);
this.contentOffsetY = 0;
this.contentHeight = 0;
this.hasDoneInitialScroll = false;
this.shouldScrollToBottom = false;
this.makeExtraData = makeExtraData();
this.flatListRef = React.createRef();
}
componentDidMount() {
const {actions, deepLinkURL, highlightPostId, initialIndex} = this.props;
EventEmitter.on('scroll-to-bottom', this.handleSetScrollToBottom);
// Invoked when hitting a deep link and app is not already running.
if (deepLinkURL) {
this.handleDeepLink(deepLinkURL);
actions.setDeepLinkURL('');
}
// Scroll to highlighted post for permalinks
if (!this.hasDoneInitialScroll && initialIndex > 0 && highlightPostId) {
this.scrollToInitialIndexIfNeeded(initialIndex);
}
}
componentDidUpdate(prevProps) {
const {actions, channelId, deepLinkURL, postIds} = this.props;
if (this.props.channelId !== prevProps.channelId) {
this.resetPostList();
}
// Invoked when hitting a deep link and app is already running.
if (deepLinkURL && deepLinkURL !== prevProps.deepLinkURL) {
this.handleDeepLink(deepLinkURL);
actions.setDeepLinkURL('');
}
if (this.shouldScrollToBottom && prevProps.channelId === channelId && prevProps.postIds.length === postIds.length) {
this.scrollToBottom();
this.shouldScrollToBottom = false;
}
if (
this.props.channelId === prevProps.channelId &&
this.props.postIds.length &&
this.contentHeight &&
this.contentHeight < this.postListHeight &&
this.props.extraData
) {
this.loadToFillContent();
}
}
componentWillUnmount() {
EventEmitter.off('scroll-to-bottom', this.handleSetScrollToBottom);
this.resetPostList();
}
flatListScrollToIndex = (index) => {
this.animationFrameInitialIndex = requestAnimationFrame(() => {
this.flatListRef.current.scrollToIndex({
animated: true,
index,
viewOffset: 0,
viewPosition: 1, // 0 is at bottom
});
});
}
getItemCount = () => {
return this.props.postIds.length;
};
handleClosePermalink = () => {
const {actions} = this.props;
actions.selectFocusedPostId('');
this.showingPermalink = false;
};
handleContentSizeChange = (contentWidth, contentHeight, forceLoad) => {
this.contentHeight = contentHeight;
const loadMore = forceLoad || !this.props.extraData;
if (this.postListHeight && contentHeight < this.postListHeight && loadMore) {
// We still have less than 1 screen of posts loaded with more to get, so load more
this.props.onLoadMoreUp();
}
};
handleDeepLink = (url) => {
const {serverURL, siteURL} = this.props;
const match = matchDeepLink(url, serverURL, siteURL);
if (match) {
if (match.type === DeepLinkTypes.CHANNEL) {
this.props.actions.handleSelectChannelByName(match.channelName, match.teamName, this.permalinkBadChannel);
} else if (match.type === DeepLinkTypes.PERMALINK) {
this.handlePermalinkPress(match.postId, match.teamName);
}
} else {
const {formatMessage} = this.context.intl;
Alert.alert(
formatMessage({
id: 'mobile.server_link.error.title',
defaultMessage: 'Link Error',
}),
formatMessage({
id: 'mobile.server_link.error.text',
defaultMessage: 'The link could not be found on this server.',
}),
);
}
};
handleLayout = (event) => {
const {height} = event.nativeEvent.layout;
if (this.postListHeight !== height) {
this.postListHeight = height;
}
};
handlePermalinkPress = (postId, teamName) => {
telemetry.start(['post_list:permalink']);
const {actions, onPermalinkPress} = this.props;
if (onPermalinkPress) {
onPermalinkPress(postId, true);
} else {
actions.loadChannelsByTeamName(teamName, this.permalinkBadTeam);
this.showPermalinkView(postId);
}
};
handleRefresh = () => {
const {
actions,
channelId,
onRefresh,
} = this.props;
if (channelId) {
actions.refreshChannelWithRetry(channelId);
}
if (onRefresh) {
onRefresh();
}
};
handleScroll = (event) => {
const pageOffsetY = event.nativeEvent.contentOffset.y;
if (pageOffsetY > 0) {
const contentHeight = event.nativeEvent.contentSize.height;
const direction = (this.contentOffsetY < pageOffsetY) ? ListTypes.VISIBILITY_SCROLL_UP : ListTypes.VISIBILITY_SCROLL_DOWN;
this.contentOffsetY = pageOffsetY;
if (
direction === ListTypes.VISIBILITY_SCROLL_UP &&
(contentHeight - pageOffsetY) < (this.postListHeight * SCROLL_UP_MULTIPLIER)
) {
this.props.onLoadMoreUp();
}
}
};
handleScrollToIndexFailed = (info) => {
this.animationFrameIndexFailed = requestAnimationFrame(() => {
if (this.onScrollEndIndexListener) {
this.onScrollEndIndexListener(info.highestMeasuredFrameIndex);
}
this.flatListScrollToIndex(info.highestMeasuredFrameIndex);
});
};
handleSetScrollToBottom = () => {
this.shouldScrollToBottom = true;
}
keyExtractor = (item) => {
// All keys are strings (either post IDs or special keys)
return item;
};
loadToFillContent = () => {
this.fillContentTimer = setTimeout(() => {
this.handleContentSizeChange(0, this.contentHeight, true);
});
};
permalinkBadTeam = () => {
const {intl} = this.context;
const message = {
id: t('mobile.server_link.unreachable_team.error'),
defaultMessage: 'This link belongs to a deleted team or to a team to which you do not have access.',
};
alertErrorWithFallback(intl, {}, message);
};
permalinkBadChannel = () => {
const {intl} = this.context;
const message = {
id: t('mobile.server_link.unreachable_channel.error'),
defaultMessage: 'This link belongs to a deleted channel or to a channel to which you do not have access.',
};
alertErrorWithFallback(intl, {}, message);
};
renderItem = ({item, index}) => {
const {
highlightPinnedOrFlagged,
highlightPostId,
isSearchResult,
lastPostIndex,
location,
onHashtagPress,
onPostPress,
postIds,
renderReplies,
shouldRenderReplyButton,
theme,
} = this.props;
if (PostListUtils.isStartOfNewMessages(item)) {
// postIds includes a date item after the new message indicator so 2
// needs to be added to the index for the length check to be correct.
const moreNewMessages = postIds.length === index + 2;
// The date line and new message line each count for a line. So the
// goal of this is to check for the 3rd previous, which for the start
// of a thread would be null as it doesn't exist.
const checkForPostId = index < postIds.length - 3;
return (
<NewMessagesDivider
index={index}
theme={theme}
moreMessages={moreNewMessages && checkForPostId}
/>
);
} else if (PostListUtils.isDateLine(item)) {
return (
<DateHeader
date={PostListUtils.getDateForDateLine(item)}
index={index}
/>
);
}
// Remember that the list is rendered with item 0 at the bottom so the "previous" post
// comes after this one in the list
const previousPostId = index < postIds.length - 1 ? postIds[index + 1] : null;
const beforePrevPostId = index < postIds.length - 2 ? postIds[index + 2] : null;
const nextPostId = index > 0 ? postIds[index - 1] : null;
const postProps = {
previousPostId,
nextPostId,
highlightPinnedOrFlagged,
isSearchResult,
location,
managedConfig: mattermostManaged.getCachedConfig(),
onHashtagPress,
onPermalinkPress: this.handlePermalinkPress,
onPress: onPostPress,
renderReplies,
shouldRenderReplyButton,
beforePrevPostId,
};
if (PostListUtils.isCombinedUserActivityPost(item)) {
return (
<CombinedUserActivityPost
combinedId={item}
{...postProps}
/>
);
}
const postId = item;
return (
<MemoizedPost
postId={postId}
highlight={highlightPostId === postId}
isLastPost={lastPostIndex === index}
{...postProps}
/>
);
};
resetPostList = () => {
this.contentOffsetY = 0;
this.hasDoneInitialScroll = false;
if (this.scrollAfterInteraction) {
this.scrollAfterInteraction.cancel();
}
if (this.animationFrameIndexFailed) {
cancelAnimationFrame(this.animationFrameIndexFailed);
}
if (this.animationFrameInitialIndex) {
cancelAnimationFrame(this.animationFrameInitialIndex);
}
if (this.fillContentTimer) {
clearTimeout(this.fillContentTimer);
}
if (this.scrollToBottomTimer) {
clearTimeout(this.scrollToBottomTimer);
}
if (this.scrollToInitialTimer) {
clearTimeout(this.scrollToInitialTimer);
}
if (this.contentHeight !== 0) {
this.contentHeight = 0;
}
}
scrollToBottom = () => {
this.scrollToBottomTimer = setTimeout(() => {
if (this.flatListRef.current) {
this.flatListRef.current.scrollToOffset({offset: 0, animated: true});
}
}, 250);
};
scrollToIndex = (index) => {
this.animationFrameInitialIndex = requestAnimationFrame(() => {
if (this.flatListRef.current && index > 0 && index <= this.getItemCount()) {
this.flatListScrollToIndex(index);
}
});
};
scrollToInitialIndexIfNeeded = (index) => {
if (!this.hasDoneInitialScroll) {
if (index > 0 && index <= this.getItemCount()) {
this.hasDoneInitialScroll = true;
this.scrollToIndex(index);
if (index !== this.props.initialIndex) {
this.hasDoneInitialScroll = false;
this.scrollToInitialTimer = setTimeout(() => {
this.scrollToInitialIndexIfNeeded(this.props.initialIndex);
}, 10);
}
}
}
};
showPermalinkView = (postId, error = '') => {
const {actions} = this.props;
actions.selectFocusedPostId(postId);
if (!this.showingPermalink) {
const screen = 'Permalink';
const passProps = {
isPermalink: true,
onClose: this.handleClosePermalink,
error,
};
const options = {
layout: {
componentBackgroundColor: changeOpacity('#000', 0.2),
},
};
this.showingPermalink = true;
showModalOverCurrentContext(screen, passProps, options);
}
};
registerViewableItemsListener = (listener) => {
this.onViewableItemsChangedListener = listener;
const removeListener = () => {
this.onViewableItemsChangedListener = null;
};
return removeListener;
}
registerScrollEndIndexListener = (listener) => {
this.onScrollEndIndexListener = listener;
const removeListener = () => {
this.onScrollEndIndexListener = null;
};
return removeListener;
}
onViewableItemsChanged = ({viewableItems}) => {
if (!this.onViewableItemsChangedListener || !viewableItems.length || this.props.deepLinkURL) {
return;
}
this.onViewableItemsChangedListener(viewableItems);
}
render() {
const {
channelId,
extraData,
highlightPostId,
loadMorePostsVisible,
postIds,
refreshing,
scrollViewNativeID,
theme,
initialIndex,
deepLinkURL,
showMoreMessagesButton,
} = this.props;
const refreshControl = (
<RefreshControl
refreshing={refreshing}
onRefresh={channelId ? this.handleRefresh : null}
colors={[theme.centerChannelColor]}
tintColor={theme.centerChannelColor}
/>);
const hasPostsKey = postIds.length ? 'true' : 'false';
return (
<>
<FlatList
contentContainerStyle={styles.postListContent}
data={postIds}
extraData={this.makeExtraData(channelId, highlightPostId, extraData, loadMorePostsVisible)}
initialNumToRender={INITIAL_BATCH_TO_RENDER}
inverted={true}
key={`recyclerFor-${channelId}-${hasPostsKey}`}
keyboardDismissMode={'interactive'}
keyboardShouldPersistTaps={'handled'}
keyExtractor={this.keyExtractor}
ListFooterComponent={this.props.renderFooter}
listKey={`recyclerFor-${channelId}`}
maintainVisibleContentPosition={SCROLL_POSITION_CONFIG}
maxToRenderPerBatch={Platform.select({android: 5})}
nativeID={scrollViewNativeID}
onContentSizeChange={this.handleContentSizeChange}
onLayout={this.handleLayout}
onScroll={this.handleScroll}
onScrollToIndexFailed={this.handleScrollToIndexFailed}
ref={this.flatListRef}
refreshControl={refreshControl}
removeClippedSubviews={false}
renderItem={this.renderItem}
scrollEventThrottle={60}
style={styles.flex}
windowSize={Posts.POST_CHUNK_SIZE / 2}
viewabilityConfig={{
itemVisiblePercentThreshold: 1,
minimumViewTime: 100,
}}
onViewableItemsChanged={showMoreMessagesButton ? this.onViewableItemsChanged : null}
/>
{showMoreMessagesButton &&
<MoreMessagesButton
theme={theme}
postIds={postIds}
channelId={channelId}
deepLinkURL={deepLinkURL}
newMessageLineIndex={initialIndex}
scrollToIndex={this.scrollToIndex}
registerViewableItemsListener={this.registerViewableItemsListener}
registerScrollEndIndexListener={this.registerScrollEndIndexListener}
/>
}
</>
);
}
}
function PostComponent({postId, highlightPostId, lastPostIndex, index, ...postProps}) {
return (
<Post
postId={postId}
highlight={highlightPostId === postId}
isLastPost={lastPostIndex === index}
{...postProps}
/>
);
}
PostComponent.propTypes = {
postId: PropTypes.string.isRequired,
highlightPostId: PropTypes.string,
lastPostIndex: PropTypes.number,
index: PropTypes.number,
};
const MemoizedPost = React.memo(PostComponent);
const styles = StyleSheet.create({
flex: {
flex: 1,
},
postListContent: {
paddingTop: 5,
},
});