// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import {FlatList} from '@stream-io/flat-list-mvcp'; import React, {type ReactElement, useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {DeviceEventEmitter, type GestureResponderEvent, type ListRenderItemInfo, Platform, type StyleProp, StyleSheet, type ViewStyle, type NativeSyntheticEvent, type NativeScrollEvent} from 'react-native'; import {useKeyboardState} from 'react-native-keyboard-controller'; import Animated, {runOnJS, useAnimatedProps, useAnimatedReaction, useSharedValue, type AnimatedStyle} from 'react-native-reanimated'; import {removePost} from '@actions/local/post'; import {fetchPosts, fetchPostThread} from '@actions/remote/post'; import CombinedUserActivity from '@components/post_list/combined_user_activity'; import DateSeparator from '@components/post_list/date_separator'; import NewMessagesLine from '@components/post_list/new_message_line'; import Post from '@components/post_list/post'; import ThreadOverview from '@components/post_list/thread_overview'; import {Events, Screens} from '@constants'; import {PostTypes} from '@constants/post'; import {useKeyboardAnimationContext} from '@context/keyboard_animation'; import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; import {DEFAULT_INPUT_ACCESSORY_HEIGHT} from '@hooks/useInputAccessoryView'; import {getDateForDateLine, preparePostList} from '@utils/post_list'; import {INITIAL_BATCH_TO_RENDER, SCROLL_POSITION_CONFIG, VIEWABILITY_CONFIG} from './config'; import MoreMessages from './more_messages'; import ScrollToEndView from './scroll_to_end_view'; import type {PostListItem, PostListOtherItem, ViewableItemsChanged, ViewableItemsChangedListenerEvent} from '@typings/components/post_list'; import type PostModel from '@typings/database/models/servers/post'; import type {AvailableScreens} from '@typings/screens/navigation'; type Props = { appsEnabled: boolean; channelId: string; contentContainerStyle?: StyleProp>; currentTimezone: string | null; currentUserId: string; currentUsername: string; customEmojiNames: string[]; disablePullToRefresh?: boolean; highlightedId?: PostModel['id']; highlightPinnedOrSaved?: boolean; isCRTEnabled?: boolean; isPostAcknowledgementEnabled?: boolean; lastViewedAt: number; location: AvailableScreens; onEndReached?: () => void; posts: PostModel[]; rootId?: string; shouldRenderReplyButton?: boolean; shouldShowJoinLeaveMessages: boolean; showMoreMessages?: boolean; showNewMessageLine?: boolean; footer?: ReactElement; header?: ReactElement; testID: string; currentCallBarVisible?: boolean; savedPostIds: Set; isChannelAutotranslated: boolean; listRef?: React.RefObject>; onTouchMove?: (event: GestureResponderEvent) => void; onTouchEnd?: () => void; } type onScrollEndIndexListenerEvent = (endIndex: number) => void; type ScrollIndexFailed = { index: number; highestMeasuredFrameIndex: number; averageItemLength: number; }; const CONTENT_OFFSET_THRESHOLD = 160; const SCROLL_EVENT_THROTTLE = Platform.select({android: 17, default: 60}); const keyExtractor = (item: PostListItem | PostListOtherItem) => (item.type === 'post' ? item.value.currentPost.id : item.value); const styles = StyleSheet.create({ flex: { flex: 1, }, container: { flex: 1, }, }); const PostList = ({ appsEnabled, channelId, contentContainerStyle, currentTimezone, currentUserId, currentUsername, customEmojiNames, disablePullToRefresh, footer, header, highlightedId, highlightPinnedOrSaved = true, isCRTEnabled, isPostAcknowledgementEnabled, lastViewedAt, location, onEndReached, posts, rootId, shouldRenderReplyButton = true, shouldShowJoinLeaveMessages, showMoreMessages, showNewMessageLine = true, testID, savedPostIds, isChannelAutotranslated, listRef, onTouchMove, onTouchEnd, }: Props) => { const firstIdInPosts = posts[0]?.id; const { keyboardTranslateY: keyboardHeightValue, bottomInset: contentInset, onScroll: onScrollProp, postInputContainerHeight, keyboardHeight, isKeyboardFullyOpen, isKeyboardFullyClosed, inputAccessoryViewAnimatedHeight, isInputAccessoryViewMode, } = useKeyboardAnimationContext(); const onScrollEndIndexListener = useRef(); const onViewableItemsChangedListener = useRef(); const scrolledToHighlighted = useRef(false); const [refreshing, setRefreshing] = useState(false); const [showScrollToEndBtn, setShowScrollToEndBtn] = useState(false); const [lastPostId, setLastPostId] = useState(firstIdInPosts); const [progressViewOffset, setProgressViewOffset] = useState(postInputContainerHeight); const [emojiPickerPadding, setEmojiPickerPadding] = useState(0); const theme = useTheme(); const serverUrl = useServerUrl(); const {isVisible: isKeyboardVisible} = useKeyboardState(); // Update progressViewOffset to position RefreshControl correctly when keyboard-aware props are applied. // Only update when keyboard state changes (fully open ↔ fully closed) to prevent flickering during animation. const prevIsFullyOpen = useSharedValue(false); const prevIsFullyClosed = useSharedValue(true); useAnimatedReaction( () => ({ isFullyOpen: isKeyboardFullyOpen.value, isFullyClosed: isKeyboardFullyClosed.value, keyboardTranslateY: keyboardHeightValue.value, }), ({isFullyOpen, isFullyClosed, keyboardTranslateY}) => { // Only update when state actually changes (transition detected) const stateChanged = (prevIsFullyClosed.value !== isFullyClosed) || (prevIsFullyOpen.value !== isFullyOpen); if (stateChanged && (isFullyOpen || isFullyClosed)) { const offset = postInputContainerHeight + keyboardTranslateY; runOnJS(setProgressViewOffset)(offset); } prevIsFullyOpen.value = isFullyOpen; prevIsFullyClosed.value = isFullyClosed; }, [postInputContainerHeight], ); const orderedPosts = useMemo(() => { return preparePostList(posts, lastViewedAt, showNewMessageLine, currentUserId, currentUsername, shouldShowJoinLeaveMessages, currentTimezone, location === Screens.THREAD, savedPostIds); }, [posts, lastViewedAt, showNewMessageLine, currentUserId, currentUsername, shouldShowJoinLeaveMessages, currentTimezone, location, savedPostIds]); const initialIndex = useMemo(() => { return orderedPosts.findIndex((i) => i.type === 'start-of-new-messages'); }, [orderedPosts]); const isNewMessage = lastPostId ? firstIdInPosts !== lastPostId : false; const scrollToEnd = useCallback(() => { const activeHeight = Math.max(keyboardHeight.value, inputAccessoryViewAnimatedHeight.value); const targetOffset = -activeHeight; listRef?.current?.scrollToOffset({offset: targetOffset, animated: true}); setShowScrollToEndBtn(false); }, [inputAccessoryViewAnimatedHeight, keyboardHeight, listRef]); useEffect(() => { const t = setTimeout(() => { scrollToEnd(); }, 300); return () => clearTimeout(t); }, [channelId, rootId, scrollToEnd]); useEffect(() => { const scrollToBottom = (screen: string) => { if (screen === location) { const scrollToBottomTimer = setTimeout(() => { scrollToEnd(); clearTimeout(scrollToBottomTimer); }, 400); } }; const scrollBottomListener = DeviceEventEmitter.addListener(Events.POST_LIST_SCROLL_TO_BOTTOM, scrollToBottom); return () => { scrollBottomListener.remove(); }; }, [location, scrollToEnd]); const onRefresh = useCallback(async () => { if (disablePullToRefresh) { return; } setRefreshing(true); if (location === Screens.CHANNEL && channelId) { await fetchPosts(serverUrl, channelId); } else if (location === Screens.THREAD && rootId) { const options: FetchPaginatedThreadOptions = {}; const lastPost = posts[0]; if (lastPost) { options.fromCreateAt = lastPost.createAt; options.fromPost = lastPost.id; options.direction = 'down'; } await fetchPostThread(serverUrl, rootId, options); } const removalPromises = posts. filter((post) => post.type === PostTypes.EPHEMERAL). map((post) => removePost(serverUrl, post)); await Promise.all(removalPromises); setRefreshing(false); }, [disablePullToRefresh, location, channelId, rootId, posts, serverUrl]); const scrollToIndex = useCallback((index: number, animated = true, applyOffset = true) => { if (index < 0 || !listRef?.current) { return; } listRef.current.scrollToIndex({ animated, index, viewOffset: applyOffset ? Platform.select({ios: -45, default: 0}) : 0, viewPosition: 1, // 0 is at bottom }); }, [listRef]); const internalOnScroll = useCallback((event: NativeSyntheticEvent) => { const {y} = event.nativeEvent.contentOffset; const isThresholdReached = y > CONTENT_OFFSET_THRESHOLD; if (isThresholdReached !== showScrollToEndBtn) { setShowScrollToEndBtn(isThresholdReached); } if (!y && lastPostId !== firstIdInPosts) { setLastPostId(firstIdInPosts); } }, [firstIdInPosts, lastPostId, showScrollToEndBtn]); const onScrollToIndexFailed = useCallback((info: ScrollIndexFailed) => { const index = Math.min(info.highestMeasuredFrameIndex, info.index); if (!highlightedId) { if (onScrollEndIndexListener.current) { onScrollEndIndexListener.current(index); } scrollToIndex(index); } }, [highlightedId, scrollToIndex]); const onViewableItemsChanged = useCallback(({viewableItems}: ViewableItemsChanged) => { if (!viewableItems.length) { return; } const viewableItemsMap = viewableItems.reduce((acc: Record, {item, isViewable}) => { if (isViewable && item.type === 'post') { acc[`${location}-${item.value.currentPost.id}`] = true; } return acc; }, {}); DeviceEventEmitter.emit(Events.ITEM_IN_VIEWPORT, viewableItemsMap); if (onViewableItemsChangedListener.current) { onViewableItemsChangedListener.current(viewableItems); } }, [location]); const registerScrollEndIndexListener = useCallback((listener: onScrollEndIndexListenerEvent) => { onScrollEndIndexListener.current = listener; const removeListener = () => { onScrollEndIndexListener.current = undefined; }; return removeListener; }, []); const registerViewableItemsListener = useCallback((listener: ViewableItemsChangedListenerEvent) => { onViewableItemsChangedListener.current = listener; const removeListener = () => { onViewableItemsChangedListener.current = undefined; }; return removeListener; }, []); const renderItem = useCallback(({item}: ListRenderItemInfo) => { switch (item.type) { case 'start-of-new-messages': return ( ); case 'date': return ( ); case 'thread-overview': return ( ); case 'user-activity': { const postProps = { currentUsername, postId: item.value, location, style: styles.container, testID: `${testID}.combined_user_activity`, showJoinLeave: shouldShowJoinLeaveMessages, theme, }; return ( ); } default: { const post = item.value.currentPost; const {isSaved, nextPost, previousPost} = item.value; const skipSaveddHeader = (location === Screens.THREAD && post.id === rootId); const postProps = { appsEnabled, customEmojiNames, isCRTEnabled, isPostAcknowledgementEnabled, highlight: highlightedId === post.id, highlightPinnedOrSaved, isSaved, location, nextPost, post, previousPost, rootId, shouldRenderReplyButton, skipSaveddHeader, testID: `${testID}.post`, isChannelAutotranslated, }; return ( ); } } }, [appsEnabled, currentTimezone, currentUsername, customEmojiNames, highlightPinnedOrSaved, highlightedId, isCRTEnabled, isChannelAutotranslated, isPostAcknowledgementEnabled, location, rootId, shouldRenderReplyButton, shouldShowJoinLeaveMessages, testID, theme]); useEffect(() => { const t = setTimeout(() => { if (highlightedId && orderedPosts && !scrolledToHighlighted.current) { scrolledToHighlighted.current = true; // eslint-disable-next-line max-nested-callbacks const index = orderedPosts.findIndex((p) => p.type === 'post' && p.value.currentPost.id === highlightedId); if (index >= 0 && listRef?.current) { listRef.current?.scrollToIndex({ animated: true, index, viewOffset: 0, viewPosition: 0.5, // 0 is at bottom }); } } }, 500); return () => clearTimeout(t); // - listRef is a ref (stable reference, doesn't need to be in deps) // - scrolledToHighlighted is a ref (stable reference, doesn't need to be in deps) // - We only need to re-run when the posts list changes or the highlighted post changes // eslint-disable-next-line react-hooks/exhaustive-deps }, [orderedPosts, highlightedId]); // Sync emoji picker padding from SharedValue to React state // This ensures the padding updates when SharedValues change useAnimatedReaction( () => { const shouldAddEmojiPickerPadding = Platform.OS === 'android' && !isKeyboardVisible && isInputAccessoryViewMode.value; const emojiPickerHeight = shouldAddEmojiPickerPadding ? (inputAccessoryViewAnimatedHeight.value || DEFAULT_INPUT_ACCESSORY_HEIGHT) : 0; return emojiPickerHeight; }, (emojiPickerHeight) => { runOnJS(setEmojiPickerPadding)(emojiPickerHeight); }, [isKeyboardVisible], ); // Combine contentContainerStyle with padding style // Use regular style with state value synced from SharedValues const contentContainerStyleWithPadding = useMemo(() => { const paddingStyle = {paddingTop: location === Screens.PERMALINK ? 0 : postInputContainerHeight + emojiPickerPadding}; return contentContainerStyle ? [contentContainerStyle, paddingStyle] : paddingStyle; }, [location, postInputContainerHeight, emojiPickerPadding, contentContainerStyle]); // contentInset only for dynamic keyboard height const animatedProps = useAnimatedProps( () => ({ contentInset: { top: contentInset.value, // For inverted FlatList, applies to visual bottom }, }), [contentInset], ); return ( <> {location !== Screens.PERMALINK && } {showMoreMessages && } ); }; export default PostList;