import { useMutation } from '@tanstack/react-query'; import classNames from 'clsx'; import { List as ImmutableList } from 'immutable'; import escape from 'lodash/escape'; import throttle from 'lodash/throttle'; import React, { useState, useEffect, useRef } from 'react'; import { useIntl, defineMessages } from 'react-intl'; import { openModal } from 'soapbox/actions/modals'; import { Avatar, Button, Divider, HStack, 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 } from 'soapbox/hooks'; import { IChat, IChatMessage, useChat, useChatMessages } from 'soapbox/queries/chats'; import { queryClient } from 'soapbox/queries/client'; 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 = 1; 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' }, 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()); interface IChatMessageList { /** Chat the messages are being rendered from. */ chat: IChat, /** Whether to make the chatbox fill the height of the screen. */ autosize?: boolean, } /** Scrollable list of chat messages. */ const ChatMessageList: React.FC = ({ chat, autosize }) => { const intl = useIntl(); const dispatch = useAppDispatch(); const account = useOwnAccount(); const [initialLoad, setInitialLoad] = useState(true); const [scrollPosition, setScrollPosition] = useState(0); const { deleteChatMessage, markChatAsRead } = useChat(chat.id); const { data: chatMessages, fetchNextPage, isError, isFetched, isFetching, isFetchingNextPage, isLoading, isPlaceholderData, refetch, } = useChatMessages(chat.id); const formattedChatMessages = chatMessages || []; const me = useAppSelector((state) => state.me); const isBlocked = useAppSelector((state) => state.getIn(['relationships', chat.account.id, 'blocked_by'])); const node = useRef(null); const messagesEnd = useRef(null); const lastComputedScroll = useRef(undefined); const scrollBottom = useRef(undefined); const handleDeleteMessage = useMutation((chatMessageId: string) => deleteChatMessage(chatMessageId), { onSettled: () => { queryClient.invalidateQueries(['chats', 'messages', chat.id]); }, }); const scrollToBottom = () => { messagesEnd.current?.scrollIntoView(false); }; 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'); }); if (onlyEmoji(c, BIG_EMOJI_LIMIT, false)) { c.classList.add('chat-message__bubble--onlyEmoji'); } else { c.classList.remove('chat-message__bubble--onlyEmoji'); } }; const isNearBottom = (): boolean => { const elem = node.current; if (!elem) return false; const scrollBottom = elem.scrollHeight - elem.offsetHeight - elem.scrollTop; return scrollBottom < elem.offsetHeight * 1.5; }; const restoreScrollPosition = () => { if (node.current && scrollBottom.current) { lastComputedScroll.current = node.current.scrollHeight - scrollBottom.current; node.current.scrollTop = lastComputedScroll.current; } }; const handleLoadMore = () => { // const maxId = chatMessages.getIn([0, 'id']) as string; // dispatch(fetchChatMessages(chat.id, maxId as any)); // setIsLoading(true); if (!isFetching) { // setMaxId(formattedChatMessages[0].id); fetchNextPage() .then(() => { if (node.current) { setScrollPosition(node.current.scrollHeight - node.current.scrollTop); } }) .catch(() => null); } }; const handleScroll = throttle(() => { if (node.current) { const { scrollTop, offsetHeight } = node.current; const computedScroll = lastComputedScroll.current === scrollTop; const nearTop = scrollTop < offsetHeight; setScrollPosition(node.current.scrollHeight - node.current.scrollTop); if (nearTop && !isFetching && !initialLoad && !computedScroll) { handleLoadMore(); } } }, 150, { trailing: true, }); const onOpenMedia = (media: any, index: number) => { dispatch(openModal('MEDIA', { media, index })); }; const maybeRenderMedia = (chatMessage: ChatMessageEntity) => { const { attachment } = chatMessage; if (!attachment) 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; return formatted; // const emojiMap = makeEmojiMap(chatMessage); // return emojify(formatted, emojiMap.toJS()); }; const renderDivider = (key: React.Key, text: string) => ; const handleCopyText = (chatMessage: IChatMessage) => { if (navigator.clipboard) { navigator.clipboard.writeText(chatMessage.content); } }; const renderMessage = (chatMessage: any) => { const isMyMessage = chatMessage.account_id === me; const menu: Menu = []; if (navigator.clipboard) { 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, }); } return (
{menu.length > 0 && (
)}
{maybeRenderMedia(chatMessage)}
{intl.formatTime(chatMessage.created_at)}
); }; useEffect(() => { if (isFetched) { setInitialLoad(false); scrollToBottom(); } }, [isFetched]); // Store the scroll position. // useLayoutEffect(() => { // if (node.current) { // const { scrollHeight, scrollTop } = node.current; // scrollBottom.current = scrollHeight - scrollTop; // } // }); // Stick scrollbar to bottom. useEffect(() => { if (isNearBottom()) { setTimeout(() => { scrollToBottom(); }, 25); } // First load. // if (chatMessages.count() !== initialCount) { // setInitialLoad(false); // setIsLoading(false); // scrollToBottom(); // } }, [formattedChatMessages.length]); useEffect(() => { const lastMessage = formattedChatMessages.pop(); const lastMessageId = lastMessage?.id; if (lastMessageId && !lastMessage.pending) { markChatAsRead(lastMessageId); } }, [formattedChatMessages.length]); useEffect(() => { // Restore scroll bar position when loading old messages. if (!initialLoad) { restoreScrollPosition(); } }, [formattedChatMessages.length, initialLoad]); if (isPlaceholderData) { return ( ); } if (isBlocked) { return ( <> {intl.formatMessage(messages.blockedBy)} {' '} @{chat.account.acct} ); } if (isError) { return ( {intl.formatMessage(messages.networkFailureTitle)} {intl.formatMessage(messages.networkFailureSubtitle)}
); } return (
{/* style={{ height: autosize ? 'calc(100vh - 16rem)' : undefined }} */} {!isLoading ? ( ) : null} {isFetchingNextPage ? (
) : null}
{isLoading ? ( <> ) : ( formattedChatMessages.reduce((acc: any, curr: any, idx: number) => { const lastMessage = formattedChatMessages[idx - 1]; if (lastMessage) { const key = `${curr.id}_divider`; switch (timeChange(lastMessage, curr)) { case 'today': acc.push(renderDivider(key, intl.formatMessage(messages.today))); break; case 'date': acc.push(renderDivider(key, intl.formatDate(new Date(curr.created_at), { weekday: 'short', hour: 'numeric', minute: '2-digit', month: 'short', day: 'numeric' }))); break; } } acc.push(renderMessage(curr)); return acc; }, [] as React.ReactNode[]) )}
); }; export default ChatMessageList;