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;