// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import React, {ReactElement, useCallback, useEffect, useLayoutEffect, useRef, useState} from 'react'; import {injectIntl, intlShape} from 'react-intl'; import {DeviceEventEmitter, FlatList, NativeScrollEvent, NativeSyntheticEvent, Platform, StyleSheet, ViewToken} from 'react-native'; import {DeepLinkTypes, NavigationTypes} from '@constants'; import * as Screens from '@constants/screen'; import {useResetNativeScrollView} from '@hooks'; import {Posts} from '@mm-redux/constants'; import EventEmitter from '@mm-redux/utils/event_emitter'; import {getDateForDateLine, isCombinedUserActivityPost, isDateLine, isStartOfNewMessages} from '@mm-redux/utils/post_list'; import telemetry, {PERF_MARKERS} from '@telemetry'; import {badDeepLink, errorBadChannel} from '@utils/draft'; import {emptyFunction} from '@utils/general'; import {makeExtraData} from '@utils/list_view'; import {matchDeepLink, PERMALINK_GENERIC_TEAM_NAME_REDIRECT} from '@utils/url'; import CombinedUserActivity from './combined_user_activity'; import DateSeparator from './date_separator'; import MoreMessagesButton from './more_messages_button'; import NewMessagesLine from './new_message_line'; import Post from './post'; import {INITIAL_BATCH_TO_RENDER, SCROLL_POSITION_CONFIG, VIEWABILITY_CONFIG} from './post_list_config'; import PostListRefreshControl from './post_list_refresh_control'; import type {ActionResult} from '@mm-redux/types/actions'; import type {Theme} from '@mm-redux/types/preferences'; type PostListProps = { channelId?: string; closePermalink: () => Promise; currentTeamName: string; deepLinkURL?: string; extraData: never; getPostThread: (rootId: string) => Promise; handleSelectChannelByName: (channelName: string, teamName: string, errorHandler: (intl: typeof intlShape) => void, intl: typeof intlShape) => Promise; highlightPinnedOrFlagged?: boolean; highlightPostId?: string; initialIndex: number; intl: typeof intlShape; loadMorePostsVisible?: boolean; location: string; onLoadMoreUp: () => void; postIds: string[]; refreshChannelWithRetry: (channelId: string) => Promise; renderFooter: () => ReactElement | null; rootId?: string; scrollViewNativeID?: string; serverURL: string; shouldRenderReplyButton?: boolean; showMoreMessagesButton?: boolean; siteURL: string; setDeepLinkURL: (url?: string) => void; showPermalink: (intl: typeof intlShape, teamName: string, postId: string, openAsPermalink?: boolean) => Promise<{}>; testID?: string; theme: Theme; } type ViewableItemsChanged = { viewableItems: Array; changed: Array; } type onScrollEndIndexListenerEvent = (endIndex: number) => void; type ViewableItemsChangedListenerEvent = (viewableItms: ViewToken[]) => void; type ScrollIndexFailed = { index: number; highestMeasuredFrameIndex: number; averageItemLength: number; }; const styles = StyleSheet.create({ flex: { flex: 1, }, postListContent: { paddingTop: 5, }, scale: { ...Platform.select({ android: { scaleY: -1, }, }), }, }); const buildExtraData = makeExtraData(); const PostList = ({ channelId, currentTeamName = '', closePermalink, deepLinkURL, extraData, getPostThread, handleSelectChannelByName, highlightPostId, highlightPinnedOrFlagged, initialIndex, intl, loadMorePostsVisible, location, onLoadMoreUp = emptyFunction, postIds = [], refreshChannelWithRetry, renderFooter = (() => null), rootId, serverURL = '', setDeepLinkURL, showMoreMessagesButton, showPermalink, siteURL = '', scrollViewNativeID, shouldRenderReplyButton, testID, theme, }: PostListProps) => { const prevChannelId = useRef(channelId); const hasPostsKey = postIds.length ? 'true' : 'false'; const flatListRef = useRef>(null); const onScrollEndIndexListener = useRef(); const onViewableItemsChangedListener = useRef(); const [refreshing, setRefreshing] = useState(false); const [offsetY, setOffsetY] = useState(0); const registerViewableItemsListener = useCallback((listener) => { onViewableItemsChangedListener.current = listener; const removeListener = () => { onViewableItemsChangedListener.current = undefined; }; return removeListener; }, []); const registerScrollEndIndexListener = useCallback((listener) => { onScrollEndIndexListener.current = listener; const removeListener = () => { onScrollEndIndexListener.current = undefined; }; return removeListener; }, []); const keyExtractor = useCallback((item) => { // All keys are strings (either post IDs or special keys) return item; }, []); const onPermalinkPress = (postId: string, teamName: string) => { showPermalink(intl, teamName, postId); }; const onRefresh = async () => { if (location === Screens.CHANNEL && channelId) { setRefreshing(true); await refreshChannelWithRetry(channelId); setRefreshing(false); } else if (location === Screens.THREAD && rootId) { setRefreshing(true); await getPostThread(rootId); setRefreshing(false); } }; const onScrollToIndexFailed = useCallback((info: ScrollIndexFailed) => { const animationFrameIndexFailed = requestAnimationFrame(() => { const index = Math.min(info.highestMeasuredFrameIndex, info.index); if (onScrollEndIndexListener.current) { onScrollEndIndexListener.current(index); } scrollToIndex(index); cancelAnimationFrame(animationFrameIndexFailed); }); }, []); const onViewableItemsChanged = useCallback(({viewableItems}: ViewableItemsChanged) => { if (!viewableItems.length) { return; } const viewableItemsMap = viewableItems.reduce((acc: Record, {item, isViewable}) => { if (isViewable) { acc[item] = true; } return acc; }, {}); DeviceEventEmitter.emit('scrolled', viewableItemsMap); if (onViewableItemsChangedListener.current && !deepLinkURL) { onViewableItemsChangedListener.current(viewableItems); } }, []); const renderItem = useCallback(({item, index}) => { if (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 (isDateLine(item)) { return ( ); } if (isCombinedUserActivityPost(item)) { const postProps = { postId: item, style: styles.scale, testID: `${testID}.combined_user_activity`, theme, }; return (); } let previousPostId: string|undefined; let nextPostId: string|undefined; if (index < postIds.length - 1) { previousPostId = postIds.slice(index + 1).find((v) => !isStartOfNewMessages(v) && !isDateLine(v)); } if (index > 0) { const next = postIds.slice(0, index); for (let i = next.length - 1; i >= 0; i--) { const v = next[i]; if (!isStartOfNewMessages(v) && !isDateLine(v)) { nextPostId = v; break; } } } const postProps = { highlightPinnedOrFlagged, location, nextPostId, previousPostId, shouldRenderReplyButton, theme, }; return ( ); }, [postIds, theme]); const scrollToIndex = useCallback((index: number, animated = true) => { flatListRef.current?.scrollToIndex({ animated, index, viewOffset: 0, viewPosition: 1, // 0 is at bottom }); }, []); const onScroll = useCallback((event: NativeSyntheticEvent) => { if (Platform.OS === 'android') { const {y} = event.nativeEvent.contentOffset; if (y === 0) { setOffsetY(y); } else if (offsetY === 0 && y !== 0) { setOffsetY(y); } } }, [offsetY]); useResetNativeScrollView(scrollViewNativeID, postIds); useEffect(() => { const scrollToBottom = (screen: string) => { if (screen === location) { const scrollToBottomTimer = setTimeout(() => { flatListRef.current?.scrollToOffset({offset: 0, animated: true}); clearTimeout(scrollToBottomTimer); }, 400); } }; EventEmitter.on('scroll-to-bottom', scrollToBottom); EventEmitter.on(NavigationTypes.NAVIGATION_DISMISS_AND_POP_TO_ROOT, closePermalink); return () => { EventEmitter.off('scroll-to-bottom', scrollToBottom); EventEmitter.off(NavigationTypes.NAVIGATION_DISMISS_AND_POP_TO_ROOT, closePermalink); }; }, []); useEffect(() => { if (deepLinkURL) { const match = matchDeepLink(deepLinkURL, serverURL, siteURL); if (match) { if (match.type === DeepLinkTypes.CHANNEL) { handleSelectChannelByName(match.channelName!, match.teamName, errorBadChannel, intl); } else if (match.type === DeepLinkTypes.PERMALINK) { const teamName = match.teamName === PERMALINK_GENERIC_TEAM_NAME_REDIRECT ? currentTeamName : match.teamName; onPermalinkPress(match.postId!, teamName); } } else { badDeepLink(intl); } setDeepLinkURL(''); } }, [deepLinkURL]); useLayoutEffect(() => { let scrollFrame: number; if (postIds.length && channelId !== prevChannelId.current) { telemetry.end([PERF_MARKERS.CHANNEL_RENDER]); prevChannelId.current = channelId; scrollFrame = requestAnimationFrame(() => { flatListRef.current?.scrollToOffset({animated: true, offset: 0}); }); } return () => { if (scrollFrame) { cancelAnimationFrame(scrollFrame); } }; }, [channelId, postIds]); useLayoutEffect(() => { if (initialIndex > 0 && initialIndex <= postIds.length && highlightPostId) { scrollToIndex(initialIndex, false); } }, [initialIndex, highlightPostId]); const list = ( ); return ( <> {list} {showMoreMessagesButton && } ); }; export default injectIntl(PostList);