import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { useIntl, defineMessages } from 'react-intl'; import { Components, Virtuoso, VirtuosoHandle } from 'react-virtuoso'; import { Avatar, Button, Divider, Spinner, Stack, Text } from 'soapbox/components/ui'; import PlaceholderChatMessage from 'soapbox/features/placeholder/components/placeholder-chat-message'; import { useAppSelector, useOwnAccount } from 'soapbox/hooks'; import { IChat, useChatActions, useChatMessages } from 'soapbox/queries/chats'; import ChatMessage from './chat-message'; import ChatMessageListIntro from './chat-message-list-intro'; import type { ChatMessage as ChatMessageEntity } from 'soapbox/types/entities'; const messages = defineMessages({ today: { id: 'chats.dividers.today', defaultMessage: 'Today' }, more: { id: 'chats.actions.more', defaultMessage: 'More' }, delete: { id: 'chats.actions.delete', defaultMessage: 'Delete for both' }, copy: { id: 'chats.actions.copy', defaultMessage: 'Copy' }, report: { id: 'chats.actions.report', defaultMessage: 'Report' }, deleteForMe: { id: 'chats.actions.deleteForMe', defaultMessage: 'Delete for me' }, blockedBy: { id: 'chat_message_list.blockedBy', defaultMessage: 'You are blocked by' }, networkFailureTitle: { id: 'chat_message_list.network_failure.title', defaultMessage: 'Whoops!' }, networkFailureSubtitle: { id: 'chat_message_list.network_failure.subtitle', defaultMessage: 'We encountered a network failure.' }, networkFailureAction: { id: 'chat_message_list.network_failure.action', defaultMessage: 'Try again' }, }); type TimeFormat = 'today' | 'date'; const timeChange = (prev: ChatMessageEntity, curr: ChatMessageEntity): TimeFormat | null => { const prevDate = new Date(prev.created_at).getDate(); const currDate = new Date(curr.created_at).getDate(); const nowDate = new Date().getDate(); if (prevDate !== currDate) { return currDate === nowDate ? 'today' : 'date'; } return null; }; const START_INDEX = 10000; const List: Components['List'] = React.forwardRef((props, ref) => { const { context, ...rest } = props; return
; }); const Scroller: Components['Scroller'] = React.forwardRef((props, ref) => { const { style, context, ...rest } = props; return (
); }); interface IChatMessageList { /** Chat the messages are being rendered from. */ chat: IChat } /** Scrollable list of chat messages. */ const ChatMessageList: React.FC = ({ chat }) => { const intl = useIntl(); const { account } = useOwnAccount(); const myLastReadMessageDateString = chat.latest_read_message_by_account?.find((latest) => latest.id === account?.id)?.date; const myLastReadMessageTimestamp = myLastReadMessageDateString ? new Date(myLastReadMessageDateString) : null; const node = useRef(null); const [firstItemIndex, setFirstItemIndex] = useState(START_INDEX - 20); const { markChatAsRead } = useChatActions(chat.id); const { data: chatMessages, fetchNextPage, hasNextPage, isError, isFetching, isFetchingNextPage, isLoading, refetch, } = useChatMessages(chat); const formattedChatMessages = chatMessages || []; const isBlocked = useAppSelector((state) => state.getIn(['relationships', chat.account.id, 'blocked_by'])); const lastChatMessage = chatMessages ? chatMessages[chatMessages.length - 1] : null; useEffect(() => { if (!chatMessages) { return; } const nextFirstItemIndex = START_INDEX - chatMessages.length; setFirstItemIndex(nextFirstItemIndex); }, [lastChatMessage]); const buildCachedMessages = () => { if (!chatMessages) { return []; } const currentYear = new Date().getFullYear(); return chatMessages.reduce((acc: any, curr: any, idx: number) => { const lastMessage = formattedChatMessages[idx - 1]; const messageDate = new Date(curr.created_at); if (lastMessage) { switch (timeChange(lastMessage, curr)) { case 'today': acc.push({ type: 'divider', text: intl.formatMessage(messages.today), }); break; case 'date': acc.push({ type: 'divider', text: intl.formatDate(messageDate, { weekday: 'short', hour: 'numeric', minute: '2-digit', month: 'short', day: 'numeric', year: messageDate.getFullYear() !== currentYear ? '2-digit' : undefined, }), }); break; } } acc.push(curr); return acc; }, []); }; const cachedChatMessages = buildCachedMessages(); const initialScrollPositionProps = useMemo(() => { if (process.env.NODE_ENV === 'test') { return {}; } return { initialTopMostItemIndex: cachedChatMessages.length - 1, firstItemIndex: Math.max(0, firstItemIndex), }; }, [cachedChatMessages.length, firstItemIndex]); const handleStartReached = useCallback(() => { if (hasNextPage && !isFetching) { fetchNextPage(); } return false; }, [firstItemIndex, hasNextPage, isFetching]); const renderDivider = (key: React.Key, text: string) => ; useEffect(() => { const lastMessage = formattedChatMessages[formattedChatMessages.length - 1]; if (!lastMessage) { return; } const lastMessageId = lastMessage.id; const isMessagePending = lastMessage.pending; const isAlreadyRead = myLastReadMessageTimestamp ? myLastReadMessageTimestamp >= new Date(lastMessage.created_at) : false; /** * Only "mark the message as read" if.. * 1) it is not pending and * 2) it has not already been read */ if (!isMessagePending && !isAlreadyRead) { markChatAsRead(lastMessageId); } }, [formattedChatMessages.length]); if (isBlocked) { return ( <> {intl.formatMessage(messages.blockedBy)} {' '} @{chat.account.acct} ); } if (isError) { return ( {intl.formatMessage(messages.networkFailureTitle)} {intl.formatMessage(messages.networkFailureSubtitle)}
); } if (isLoading) { return (
); } return (
{ if (chatMessage.type === 'divider') { return renderDivider(index, chatMessage.text); } else { return ; } }} components={{ List, Scroller, Header: () => { if (hasNextPage || isFetchingNextPage) { return ; } if (!hasNextPage && !isLoading) { return ; } return null; }, }} />
); }; export default ChatMessageList;