import { useMutation } from '@tanstack/react-query'; import clsx from 'clsx'; import { List as ImmutableList, Map as ImmutableMap } from 'immutable'; import escape from 'lodash/escape'; import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { useIntl, defineMessages } from 'react-intl'; import { Components, Virtuoso, VirtuosoHandle } from 'react-virtuoso'; import { openModal } from 'soapbox/actions/modals'; import { initReport } from 'soapbox/actions/reports'; import { Avatar, Button, Divider, HStack, Icon, IconButton, Spinner, Stack, Text } from 'soapbox/components/ui'; import DropdownMenuContainer from 'soapbox/containers/dropdown-menu-container'; import emojify from 'soapbox/features/emoji/emoji'; import PlaceholderChatMessage from 'soapbox/features/placeholder/components/placeholder-chat-message'; import Bundle from 'soapbox/features/ui/components/bundle'; import { MediaGallery } from 'soapbox/features/ui/util/async-components'; import { useAppSelector, useAppDispatch, useOwnAccount, useFeatures } from 'soapbox/hooks'; import { normalizeAccount } from 'soapbox/normalizers'; import { ChatKeys, IChat, IChatMessage, useChatActions, useChatMessages } from 'soapbox/queries/chats'; import { queryClient } from 'soapbox/queries/client'; import { stripHTML } from 'soapbox/utils/html'; import { onlyEmoji } from 'soapbox/utils/rich-content'; import ChatMessageListIntro from './chat-message-list-intro'; import type { Menu } from 'soapbox/components/dropdown-menu'; import type { ChatMessage as ChatMessageEntity } from 'soapbox/types/entities'; const BIG_EMOJI_LIMIT = 3; 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: IChatMessage, curr: IChatMessage): 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 makeEmojiMap = (record: any) => record.get('emojis', ImmutableList()).reduce((map: ImmutableMap, emoji: ImmutableMap) => { return map.set(`:${emoji.get('shortcode')}:`, emoji); }, ImmutableMap()); 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 dispatch = useAppDispatch(); const account = useOwnAccount(); const features = useFeatures(); const lastReadMessageDateString = chat.latest_read_message_by_account?.find((latest) => latest.id === chat.account.id)?.date; const myLastReadMessageDateString = chat.latest_read_message_by_account?.find((latest) => latest.id === account?.id)?.date; const lastReadMessageTimestamp = lastReadMessageDateString ? new Date(lastReadMessageDateString) : null; const myLastReadMessageTimestamp = myLastReadMessageDateString ? new Date(myLastReadMessageDateString) : null; const node = useRef(null); const [firstItemIndex, setFirstItemIndex] = useState(START_INDEX - 20); const { deleteChatMessage, markChatAsRead } = useChatActions(chat.id); const { data: chatMessages, fetchNextPage, hasNextPage, isError, isFetching, isFetchingNextPage, isLoading, refetch, } = useChatMessages(chat); const formattedChatMessages = chatMessages || []; const me = useAppSelector((state) => state.me); const isBlocked = useAppSelector((state) => state.getIn(['relationships', chat.account.id, 'blocked_by'])); const handleDeleteMessage = useMutation((chatMessageId: string) => deleteChatMessage(chatMessageId), { onSettled: () => { queryClient.invalidateQueries(ChatKeys.chatMessages(chat.id)); }, }); const lastChatMessage = chatMessages ? chatMessages[chatMessages.length - 1] : null; const cachedChatMessages = useMemo(() => { if (!chatMessages) { return []; } const nextFirstItemIndex = START_INDEX - chatMessages.length; setFirstItemIndex(nextFirstItemIndex); return chatMessages.reduce((acc: any, curr: any, idx: number) => { const lastMessage = formattedChatMessages[idx - 1]; 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(new Date(curr.created_at), { weekday: 'short', hour: 'numeric', minute: '2-digit', month: 'short', day: 'numeric' }), }); break; } } acc.push(curr); return acc; }, []); }, [chatMessages?.length, lastChatMessage]); const initialTopMostItemIndex = process.env.NODE_ENV === 'test' ? 0 : cachedChatMessages.length - 1; const getFormattedTimestamp = (chatMessage: ChatMessageEntity) => { return intl.formatDate(new Date(chatMessage.created_at), { hour12: false, year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit', }); }; const setBubbleRef = (c: HTMLDivElement) => { if (!c) return; const links = c.querySelectorAll('a[rel="ugc"]'); links.forEach(link => { link.classList.add('chat-link'); link.setAttribute('rel', 'ugc nofollow noopener'); link.setAttribute('target', '_blank'); }); }; const handleStartReached = useCallback(() => { if (hasNextPage && !isFetching) { fetchNextPage(); } return false; }, [firstItemIndex, hasNextPage, isFetching]); const onOpenMedia = (media: any, index: number) => { dispatch(openModal('MEDIA', { media, index })); }; const maybeRenderMedia = (chatMessage: ChatMessageEntity) => { if (!chatMessage.media_attachments.size) return null; return ( {(Component: any) => ( )} ); }; const parsePendingContent = (content: string) => { return escape(content).replace(/(?:\r\n|\r|\n)/g, '
'); }; const parseContent = (chatMessage: ChatMessageEntity) => { const content = chatMessage.content || ''; const pending = chatMessage.pending; const deleting = chatMessage.deleting; const formatted = (pending && !deleting) ? parsePendingContent(content) : content; const emojiMap = makeEmojiMap(chatMessage); return emojify(formatted, emojiMap.toJS()); }; const renderDivider = (key: React.Key, text: string) => ; const handleCopyText = (chatMessage: ChatMessageEntity) => { if (navigator.clipboard) { const text = stripHTML(chatMessage.content); navigator.clipboard.writeText(text); } }; const renderMessage = (chatMessage: ChatMessageEntity) => { const content = parseContent(chatMessage); const hiddenEl = document.createElement('div'); hiddenEl.innerHTML = content; const isOnlyEmoji = onlyEmoji(hiddenEl, BIG_EMOJI_LIMIT, false); const isMyMessage = chatMessage.account_id === me; // did this occur before this time? const isRead = isMyMessage && lastReadMessageTimestamp && lastReadMessageTimestamp >= new Date(chatMessage.created_at); const menu: Menu = []; if (navigator.clipboard && chatMessage.content) { menu.push({ text: intl.formatMessage(messages.copy), action: () => handleCopyText(chatMessage), icon: require('@tabler/icons/copy.svg'), }); } if (isMyMessage) { menu.push({ text: intl.formatMessage(messages.delete), action: () => handleDeleteMessage.mutate(chatMessage.id), icon: require('@tabler/icons/trash.svg'), destructive: true, }); } else { if (features.reportChats) { menu.push({ text: intl.formatMessage(messages.report), action: () => dispatch(initReport(normalizeAccount(chat.account) as any, { chatMessage } as any)), icon: require('@tabler/icons/flag.svg'), }); } menu.push({ text: intl.formatMessage(messages.deleteForMe), action: () => handleDeleteMessage.mutate(chatMessage.id), icon: require('@tabler/icons/trash.svg'), destructive: true, }); } return (
{menu.length > 0 && (
)} {maybeRenderMedia(chatMessage)} {content && (
)}
{intl.formatTime(chatMessage.created_at)} {(isMyMessage && features.chatsReadReceipts) ? ( <> {isRead ? ( ) : ( )} ) : null}
); }; 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 (
{renderMessage(chatMessage)}
); } }} components={{ List, Scroller, Header: () => { if (hasNextPage || isFetchingNextPage) { return ; } if (!hasNextPage && !isLoading) { return ; } return null; }, }} />
); }; export default ChatMessageList;