// 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, InteractionManager, Platform, RefreshControl, StyleSheet} from 'react-native'; import {intlShape} from 'react-intl'; 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'; 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, }; static defaultProps = { onLoadMoreUp: () => true, renderFooter: () => null, refreshing: false, serverURL: '', siteURL: '', postIds: [], }; static contextTypes = { intl: intlShape.isRequired, }; constructor(props) { super(props); this.cancelScrollToIndex = false; this.contentOffsetY = 0; this.contentHeight = 0; this.hasDoneInitialScroll = false; this.shouldScrollToBottom = false; this.cancelScrollToIndex = 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 && !this.cancelScrollToIndex && 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.hasDoneInitialScroll && this.props.initialIndex > 0 && !this.cancelScrollToIndex) { this.scrollToInitialIndexIfNeeded(this.props.initialIndex); } 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(() => { if (!this.cancelScrollToIndex) { this.flatListRef.current.scrollToIndex({ animated: false, 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(); } } }; handleScrollBeginDrag = () => { this.cancelScrollToIndex = true; } handleScrollToIndexFailed = (info) => { this.animationFrameIndexFailed = requestAnimationFrame(() => { if (this.props.initialIndex > 0 && this.contentHeight > 0) { this.hasDoneInitialScroll = false; if (info.highestMeasuredFrameIndex) { this.scrollToInitialIndexIfNeeded(info.highestMeasuredFrameIndex); } else { this.scrollAfterInteraction = InteractionManager.runAfterInteractions(() => { this.scrollToInitialIndexIfNeeded(info.index); }); } } }); }; handleScrollBeginDrag = () => { this.cancelScrollToIndex = true; }; 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 ( ); } else if (PostListUtils.isDateLine(item)) { return ( ); } // 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 ( ); } const postId = item; return ( ); }; resetPostList = () => { this.contentOffsetY = 0; this.hasDoneInitialScroll = false; this.cancelScrollToIndex = 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); }); } } } }; 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); } }; render() { const { channelId, extraData, highlightPostId, loadMorePostsVisible, postIds, refreshing, scrollViewNativeID, theme, } = this.props; const refreshControl = ( ); const hasPostsKey = postIds.length ? 'true' : 'false'; return ( ); } } const styles = StyleSheet.create({ flex: { flex: 1, }, postListContent: { paddingTop: 5, }, });