import React, { useCallback, useEffect, useMemo, useState } from 'react' import { View, LayoutChangeEvent, ListRenderItemInfo, CellRendererProps, } from 'react-native' import { Pressable, Text } from 'react-native-gesture-handler' import Animated, { runOnJS, ScrollEvent, useAnimatedScrollHandler, useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated' import { LoadEarlierMessages } from '../LoadEarlierMessages' import { warning } from '../logging' import { IMessage } from '../Models' import stylesCommon from '../styles' import { TypingIndicator } from '../TypingIndicator' import { isSameDay, useCallbackThrottled } from '../utils' import { DayAnimated } from './components/DayAnimated' import { Item } from './components/Item' import { ItemProps } from './components/Item/types' import styles from './styles' import { MessagesContainerProps, DaysPositions, AnimatedFlatList } from './types' export * from './types' export const MessagesContainer = (props: MessagesContainerProps) => { const { messages = [], user, isTyping = false, renderChatEmpty: renderChatEmptyProp, isInverted = true, listProps, isScrollToBottomEnabled = false, scrollToBottomOffset = 200, isAlignedTop = false, scrollToBottomStyle, scrollToBottomContentStyle, loadEarlierMessagesProps, renderTypingIndicator: renderTypingIndicatorProp, renderFooter: renderFooterProp, renderLoadEarlier: renderLoadEarlierProp, forwardRef, scrollToBottomComponent: scrollToBottomComponentProp, renderDay: renderDayProp, isDayAnimationEnabled = true, } = props const listPropsOnScrollProp = listProps?.onScroll const scrollToBottomOpacity = useSharedValue(0) const isScrollingDown = useSharedValue(false) const lastScrolledY = useSharedValue(0) const [isScrollToBottomVisible, setIsScrollToBottomVisible] = useState(false) const scrollToBottomStyleAnim = useAnimatedStyle(() => ({ opacity: scrollToBottomOpacity.value, }), [scrollToBottomOpacity]) const daysPositions = useSharedValue({}) const listHeight = useSharedValue(0) const scrolledY = useSharedValue(0) const renderTypingIndicator = useCallback(() => { if (renderTypingIndicatorProp) return renderTypingIndicatorProp() return }, [isTyping, renderTypingIndicatorProp, props.typingIndicatorStyle]) const ListFooterComponent = useMemo(() => { if (renderFooterProp) return renderFooterProp(props) return renderTypingIndicator() }, [renderFooterProp, renderTypingIndicator, props]) const renderLoadEarlier = useCallback(() => { if (loadEarlierMessagesProps?.isAvailable) { if (renderLoadEarlierProp) return renderLoadEarlierProp(loadEarlierMessagesProps) return } return null }, [loadEarlierMessagesProps, renderLoadEarlierProp]) const changeScrollToBottomVisibility: (isVisible: boolean) => void = useCallbackThrottled((isVisible: boolean) => { if (isScrollingDown.value && isVisible) return if (isVisible) setIsScrollToBottomVisible(true) scrollToBottomOpacity.value = withTiming(isVisible ? 1 : 0, { duration: 250 }, isFinished => { if (isFinished && !isVisible) runOnJS(setIsScrollToBottomVisible)(false) }) }, [scrollToBottomOpacity, isScrollingDown], 50) const scrollTo = useCallback((options: { animated?: boolean, offset: number }) => { if (options) forwardRef?.current?.scrollToOffset(options) }, [forwardRef]) const doScrollToBottom = useCallback((animated: boolean = true) => { isScrollingDown.value = true changeScrollToBottomVisibility(false) if (isInverted) scrollTo({ offset: 0, animated }) else if (forwardRef?.current) forwardRef.current.scrollToEnd({ animated }) }, [forwardRef, isInverted, scrollTo, isScrollingDown, changeScrollToBottomVisibility]) const handleOnScroll = useCallback((event: ScrollEvent) => { listPropsOnScrollProp?.(event) const { contentOffset: { y: contentOffsetY }, contentSize: { height: contentSizeHeight }, layoutMeasurement: { height: layoutMeasurementHeight }, } = event isScrollingDown.value = (isInverted && lastScrolledY.value > contentOffsetY) || (!isInverted && lastScrolledY.value < contentOffsetY) lastScrolledY.value = contentOffsetY if (isInverted) if (contentOffsetY > scrollToBottomOffset!) changeScrollToBottomVisibility(true) else changeScrollToBottomVisibility(false) else if ( contentOffsetY < scrollToBottomOffset! && contentSizeHeight - layoutMeasurementHeight > scrollToBottomOffset! ) changeScrollToBottomVisibility(false) else changeScrollToBottomVisibility(false) }, [isInverted, scrollToBottomOffset, changeScrollToBottomVisibility, isScrollingDown, lastScrolledY, listPropsOnScrollProp]) const restProps = useMemo(() => { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { messages: _, ...rest } = props return rest }, [props]) const renderItem = useCallback(({ item, index }: ListRenderItemInfo): React.ReactElement | null => { const messageItem = item as TMessage if (!messageItem._id && messageItem._id !== 0) warning('GiftedChat: `_id` is missing for message', JSON.stringify(item)) if (!messageItem.user) { if (!messageItem.system) warning( 'GiftedChat: `user` is missing for message', JSON.stringify(messageItem) ) messageItem.user = { _id: 0 } } if (messages) { const previousMessage = (isInverted ? messages[index + 1] : messages[index - 1]) || {} const nextMessage = (isInverted ? messages[index - 1] : messages[index + 1]) || {} const messageProps: ItemProps = { position: user?._id != null && messageItem.user?._id === user._id ? 'right' : 'left', ...restProps, currentMessage: messageItem, previousMessage, nextMessage, scrolledY, daysPositions, listHeight, isDayAnimationEnabled, } return ( {...messageProps} /> ) } return null }, [messages, restProps, isInverted, scrolledY, daysPositions, listHeight, isDayAnimationEnabled, user]) const emptyContent = useMemo(() => { if (!renderChatEmptyProp) return null return renderChatEmptyProp() }, [renderChatEmptyProp]) const renderChatEmpty = useCallback(() => { if (renderChatEmptyProp) return isInverted ? ( emptyContent ) : ( {emptyContent} ) return }, [isInverted, renderChatEmptyProp, emptyContent]) const ListHeaderComponent = useMemo(() => { const content = renderLoadEarlier() if (!content) return null return ( {content} ) }, [renderLoadEarlier]) const renderScrollBottomComponent = useCallback(() => { if (scrollToBottomComponentProp) return scrollToBottomComponentProp() return {'V'} }, [scrollToBottomComponentProp]) const handleScrollToBottomPress = useCallback(() => { doScrollToBottom() }, [doScrollToBottom]) const scrollToBottomContent = useMemo(() => { return ( {renderScrollBottomComponent()} ) }, [scrollToBottomStyleAnim, scrollToBottomContentStyle, renderScrollBottomComponent]) const ScrollToBottomWrapper = useCallback(() => { if (!isScrollToBottomEnabled) return null if (!isScrollToBottomVisible) return null return ( {scrollToBottomContent} ) }, [isScrollToBottomEnabled, isScrollToBottomVisible, handleScrollToBottomPress, scrollToBottomContent, scrollToBottomStyle]) const onLayoutList = useCallback((event: LayoutChangeEvent) => { listHeight.value = event.nativeEvent.layout.height if ( !isInverted && messages?.length && isScrollToBottomEnabled ) setTimeout(() => { doScrollToBottom(false) }, 500) // listProps.onLayout may be a SharedValue in Reanimated types, but we only accept functions const onLayoutProp = listProps?.onLayout as ((event: LayoutChangeEvent) => void) | undefined onLayoutProp?.(event) }, [isInverted, messages, doScrollToBottom, listHeight, listProps, isScrollToBottomEnabled]) const onEndReached = useCallback(() => { if ( loadEarlierMessagesProps && loadEarlierMessagesProps.isAvailable && loadEarlierMessagesProps.isInfiniteScrollEnabled && !loadEarlierMessagesProps.isLoading ) loadEarlierMessagesProps.onPress() }, [loadEarlierMessagesProps]) const keyExtractor = useCallback((item: unknown) => (item as TMessage)._id.toString(), []) const renderCell = useCallback((props: CellRendererProps) => { const { item, onLayout: onLayoutProp, children } = props const id = (item as IMessage)._id.toString() const handleOnLayout = (event: LayoutChangeEvent) => { onLayoutProp?.(event) // Only track positions when day animation is enabled if (!isDayAnimationEnabled) return const { y, height } = event.nativeEvent.layout const newValue = { y, height, createdAt: new Date((item as IMessage).createdAt).getTime(), } daysPositions.modify(value => { 'worklet' const isSameDay = (date1: number, date2: number) => { const d1 = new Date(date1) const d2 = new Date(date2) return ( d1.getDate() === d2.getDate() && d1.getMonth() === d2.getMonth() && d1.getFullYear() === d2.getFullYear() ) } for (const [key, item] of Object.entries(value)) if (isSameDay(newValue.createdAt, item.createdAt) && (isInverted ? item.y <= newValue.y : item.y >= newValue.y)) { delete value[key] break } // @ts-expect-error: https://docs.swmansion.com/react-native-reanimated/docs/core/useSharedValue#remarks value[id] = newValue return value }) } return ( {children} ) }, [daysPositions, isInverted, isDayAnimationEnabled]) const scrollHandler = useAnimatedScrollHandler({ onScroll: event => { scrolledY.value = event.contentOffset.y runOnJS(handleOnScroll)(event) }, }, [handleOnScroll]) // removes unrendered days positions when messages are added/removed useEffect(() => { // Skip cleanup when day animation is disabled if (!isDayAnimationEnabled) return Object.keys(daysPositions.value).forEach(key => { const messageIndex = messages.findIndex(m => m._id.toString() === key) let shouldRemove = messageIndex === -1 if (!shouldRemove) { const prevMessage = messages[messageIndex + (isInverted ? 1 : -1)] const message = messages[messageIndex] shouldRemove = !!prevMessage && isSameDay(message, prevMessage) } if (shouldRemove) daysPositions.modify(value => { 'worklet' delete value[key] return value }) }) }, [messages, daysPositions, isInverted, isDayAnimationEnabled]) return ( {ListFooterComponent} } ListHeaderComponent={ isInverted ? <>{ListFooterComponent} : ListHeaderComponent } scrollEventThrottle={1} onEndReached={onEndReached} onEndReachedThreshold={0.1} keyboardDismissMode='interactive' keyboardShouldPersistTaps='handled' {...listProps} onScroll={scrollHandler} onLayout={onLayoutList} CellRendererComponent={renderCell} /> {isDayAnimationEnabled && ( )} ) }