import { date as formatDate } from '@planningcenter/datetime-fmt' import { noop } from 'lodash' import React, { RefObject, useCallback, useEffect, useRef, useState } from 'react' import { FlatList, FlatListProps, StyleSheet, View } from 'react-native' import { useTheme } from '../../hooks' import { ConversationResource } from '../../types/resources/conversation' import { MessageResource } from '../../types/resources/message' import { getRelativeDateStatus } from '../../utils/date' import { groupMessages, type DateSeparator, type EnrichedMessage } from '../../utils/group_messages' import { CONVERSATION_MESSAGE_LIST_PADDING_HORIZONTAL } from '../../utils/styles' import { isSystemMessage } from '../../utils/system_messages' import { Text } from '../display' import { ConversationStartBanner } from './conversation_start_banner' import { Message } from './message' import { MissingMessage } from './missing_message' import { ReplyShadowMessage } from './reply_shadow_message' import { SystemMessage } from './system_message' // While behind, the bottom of the window isn't the live edge. Following there pulls // each newly paged-in page into view and chains the next fetch. const HOLD_AND_FOLLOW = { minIndexForVisible: 0, autoscrollToTopThreshold: 100 } const HOLD_ONLY = { minIndexForVisible: 0 } type ScrollHandlers = Pick< FlatListProps, | 'onScroll' | 'onLayout' | 'onContentSizeChange' | 'onScrollBeginDrag' | 'onScrollEndDrag' | 'onMomentumScrollEnd' > interface MessageListProps extends ScrollHandlers { listRef: RefObject conversation: ConversationResource conversationId: number messages: MessageResource[] replyRootId?: string | null anchorMessageId?: string | null behind?: boolean hasNextPage: boolean onEndReached: () => void onStartReached?: () => void } const ANCHOR_CLEARS_JUMP_BUTTON = 56 const ANCHOR_VIEWABILITY = { itemVisiblePercentThreshold: 50 } /** MessageList * The inverted list of messages, reply previews, and date separators for a conversation */ export function MessageList({ listRef, conversation, conversationId, messages, replyRootId, anchorMessageId, behind = false, hasNextPage, onEndReached, onStartReached, onScroll, onLayout, onContentSizeChange, onScrollBeginDrag, onScrollEndDrag, onMomentumScrollEnd, }: MessageListProps) { const styles = useStyles() const inReplyScreen = !!replyRootId const canDeleteNonAuthoredMessages = conversation.memberAbility?.canDeleteNonAuthoredMessages ?? false const conversationDisabled = conversation.disabled ?? false const latestRead = conversation.latestReadMessageSortKey const messagesWithSeparators = groupMessages({ ms: messages, inReplyScreen }) const anchorIndex = anchorMessageId ? messagesWithSeparators.findIndex(item => item.id === anchorMessageId) : -1 const layoutHeight = useRef(0) const contentHeight = useRef(0) const userHasScrolled = useRef(false) const draggedSinceStartReached = useRef(false) const latestAnchorMessageId = useRef(anchorMessageId) const flashedAnchorId = useRef(null) const [highlightedMessageId, setHighlightedMessageId] = useState(null) useEffect(() => { latestAnchorMessageId.current = anchorMessageId }, [anchorMessageId]) // Row heights keep settling after the first layout (images, attachments), so we // re-pin on every layout pass until the reader takes over with a drag. const pinAnchorToBottomEdge = () => { const anchorIsLoaded = anchorIndex >= 0 if (!anchorIsLoaded || userHasScrolled.current) return const scrollable = layoutHeight.current > 0 && contentHeight.current > layoutHeight.current if (!scrollable) return listRef.current?.scrollToIndex({ index: anchorIndex, viewPosition: 0, viewOffset: ANCHOR_CLEARS_JUMP_BUTTON, animated: false, }) } const handleLayout: FlatListProps['onLayout'] = event => { onLayout?.(event) layoutHeight.current = event.nativeEvent.layout.height pinAnchorToBottomEdge() } const handleContentSizeChange: FlatListProps['onContentSizeChange'] = ( width, height ) => { onContentSizeChange?.(width, height) contentHeight.current = height pinAnchorToBottomEdge() } const handleScrollBeginDrag: FlatListProps['onScrollBeginDrag'] = event => { userHasScrolled.current = true draggedSinceStartReached.current = true onScrollBeginDrag?.(event) } const flashAnchorOnceViewable: FlatListProps['onViewableItemsChanged'] = ({ viewableItems, }) => { const anchorId = latestAnchorMessageId.current if (!anchorId || anchorId === flashedAnchorId.current) return if (!viewableItems.some(token => token.item.id === anchorId)) return flashedAnchorId.current = anchorId setHighlightedMessageId(anchorId) } const clearHighlight = useCallback(() => setHighlightedMessageId(null), []) // VirtualizedList re-checks its edges on every content size change using a JS offset // the native hold never updates, so without this each merged page re-fires it. const handleStartReached = () => { if (!draggedSinceStartReached.current) return draggedSinceStartReached.current = false onStartReached?.() } return ( item.id} onScroll={onScroll} onLayout={handleLayout} onContentSizeChange={handleContentSizeChange} onScrollBeginDrag={handleScrollBeginDrag} // Expected while rows are still measuring; the next layout pass pins again. onScrollToIndexFailed={noop} onScrollEndDrag={onScrollEndDrag} onMomentumScrollEnd={onMomentumScrollEnd} onViewableItemsChanged={flashAnchorOnceViewable} viewabilityConfig={ANCHOR_VIEWABILITY} scrollEventThrottle={10} maintainVisibleContentPosition={behind ? HOLD_ONLY : HOLD_AND_FOLLOW} ListFooterComponent={ hasNextPage ? null : ( ) } renderItem={({ item }) => { if (item.type === 'DateSeparator') { return } if (item.type === 'Message' && item.deletedAt && !item.replyRootId) { return } if (item.type === 'ReplyShadowMessage') { return ( ) } if (isSystemMessage(item)) { return ( ) } const readByOthers = item.mine && !!latestRead && item.id <= latestRead return ( ) }} onEndReached={onEndReached} onStartReached={handleStartReached} ListHeaderComponent={} /> ) } function InlineDateSeparator({ date }: DateSeparator) { const styles = useDateSeparatorStyles() const { isThisYear } = getRelativeDateStatus(date) const showYear = !isThisYear const dateStamp = formatDate(date, { style: 'long', year: showYear }) return ( {dateStamp} ) } const useDateSeparatorStyles = () => { const theme = useTheme() return StyleSheet.create({ container: { alignItems: 'center', flexDirection: 'row', paddingHorizontal: CONVERSATION_MESSAGE_LIST_PADDING_HORIZONTAL, paddingVertical: 16, }, separator: { flex: 1, height: 1, borderTopWidth: 1, borderTopColor: theme.colors.borderColorDefaultBase, }, dateText: { paddingHorizontal: 8, }, }) } const useStyles = () => StyleSheet.create({ listContainer: { paddingVertical: 12, }, listHeader: { // Just whitespace to provide space where the typing indicator can be height: 16, }, })