soapbox/app/soapbox/features/chats/components/chat-message-list.tsx

431 wiersze
14 KiB
TypeScript
Czysty Zwykły widok Historia

2022-08-16 12:39:58 +00:00
import { useMutation } from '@tanstack/react-query';
import classNames from 'clsx';
2022-08-26 16:41:25 +00:00
import { List as ImmutableList } from 'immutable';
import escape from 'lodash/escape';
2022-10-04 14:48:37 +00:00
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { useIntl, defineMessages } from 'react-intl';
2022-10-04 14:48:37 +00:00
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
import { openModal } from 'soapbox/actions/modals';
2022-09-29 13:45:57 +00:00
import { initReport } from 'soapbox/actions/reports';
2022-09-08 16:47:19 +00:00
import { Avatar, Button, Divider, HStack, Spinner, Stack, Text } from 'soapbox/components/ui';
import DropdownMenuContainer from 'soapbox/containers/dropdown_menu_container';
2022-08-26 16:41:25 +00:00
// import emojify from 'soapbox/features/emoji/emoji';
2022-08-18 16:52:04 +00:00
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';
2022-08-26 16:41:25 +00:00
import { useAppSelector, useAppDispatch, useOwnAccount } from 'soapbox/hooks';
2022-09-29 13:45:57 +00:00
import { normalizeAccount } from 'soapbox/normalizers';
import { ChatKeys, IChat, IChatMessage, useChatActions, useChatMessages } from 'soapbox/queries/chats';
2022-08-16 12:39:58 +00:00
import { queryClient } from 'soapbox/queries/client';
import { stripHTML } from 'soapbox/utils/html';
import { onlyEmoji } from 'soapbox/utils/rich_content';
2022-08-17 19:48:04 +00:00
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' },
2022-09-22 15:51:12 +00:00
delete: { id: 'chats.actions.delete', defaultMessage: 'Delete for both' },
2022-08-30 14:42:55 +00:00
copy: { id: 'chats.actions.copy', defaultMessage: 'Copy' },
report: { id: 'chats.actions.report', defaultMessage: 'Report' },
deleteForMe: { id: 'chats.actions.deleteForMe', defaultMessage: 'Delete for me' },
2022-09-14 14:35:32 +00:00
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';
2022-08-10 12:38:49 +00:00
const timeChange = (prev: IChatMessage, curr: IChatMessage): TimeFormat | null => {
const prevDate = new Date(prev.created_at).getDate();
const currDate = new Date(curr.created_at).getDate();
2022-08-10 12:38:49 +00:00
const nowDate = new Date().getDate();
if (prevDate !== currDate) {
return currDate === nowDate ? 'today' : 'date';
}
return null;
};
2022-08-10 12:38:49 +00:00
// const makeEmojiMap = (record: any) => record.get('emojis', ImmutableList()).reduce((map: ImmutableMap<string, any>, emoji: ImmutableMap<string, any>) => {
// return map.set(`:${emoji.get('shortcode')}:`, emoji);
// }, ImmutableMap());
interface IChatMessageList {
2022-06-17 22:37:09 +00:00
/** Chat the messages are being rendered from. */
2022-08-10 12:38:49 +00:00
chat: IChat,
2022-06-21 20:58:03 +00:00
/** Whether to make the chatbox fill the height of the screen. */
autosize?: boolean,
}
2022-10-04 14:48:37 +00:00
const START_INDEX = 10000;
2022-06-17 22:37:09 +00:00
/** Scrollable list of chat messages. */
2022-08-26 16:41:25 +00:00
const ChatMessageList: React.FC<IChatMessageList> = ({ chat, autosize }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
2022-08-10 12:38:49 +00:00
const account = useOwnAccount();
2022-10-04 14:48:37 +00:00
const node = useRef<VirtuosoHandle>(null);
const [firstItemIndex, setFirstItemIndex] = useState(START_INDEX - 20);
2022-08-10 12:38:49 +00:00
2022-09-28 20:20:59 +00:00
const { deleteChatMessage, markChatAsRead } = useChatActions(chat.id);
2022-09-08 16:47:19 +00:00
const {
data: chatMessages,
fetchNextPage,
2022-10-04 14:48:37 +00:00
hasNextPage,
2022-09-08 16:47:19 +00:00
isError,
isFetching,
isFetchingNextPage,
isLoading,
refetch,
} = useChatMessages(chat.id);
2022-10-04 14:48:37 +00:00
2022-08-10 12:38:49 +00:00
const formattedChatMessages = chatMessages || [];
2022-09-08 16:47:19 +00:00
const me = useAppSelector((state) => state.me);
2022-08-16 12:39:58 +00:00
const handleDeleteMessage = useMutation((chatMessageId: string) => deleteChatMessage(chatMessageId), {
onSettled: () => {
queryClient.invalidateQueries(ChatKeys.chatMessages(chat.id));
2022-08-16 12:39:58 +00:00
},
});
2022-10-04 14:48:37 +00:00
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]);
2022-10-17 16:08:46 +00:00
const initialTopMostItemIndex = process.env.NODE_ENV === 'test' ? 0 : cachedChatMessages.length - 1;
const getFormattedTimestamp = (chatMessage: ChatMessageEntity) => {
2022-09-08 16:47:19 +00:00
return intl.formatDate(new Date(chatMessage.created_at), {
2022-08-10 12:38:49 +00:00
hour12: false,
year: 'numeric',
month: 'short',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
2022-09-08 16:47:19 +00:00
});
};
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');
}
};
2022-10-04 14:48:37 +00:00
const handleStartReached = useCallback(() => {
if (hasNextPage && !isFetching) {
fetchNextPage();
2022-08-10 12:38:49 +00:00
}
2022-10-04 14:48:37 +00:00
return false;
}, [firstItemIndex, hasNextPage, isFetching]);
const onOpenMedia = (media: any, index: number) => {
dispatch(openModal('MEDIA', { media, index }));
};
const maybeRenderMedia = (chatMessage: ChatMessageEntity) => {
const { attachment } = chatMessage;
if (!attachment) return null;
return (
<div className='chat-message__media'>
<Bundle fetchComponent={MediaGallery}>
{(Component: any) => (
<Component
media={ImmutableList([attachment])}
height={120}
onOpenMedia={onOpenMedia}
2022-10-10 20:56:11 +00:00
visible
/>
)}
</Bundle>
</div>
);
};
const parsePendingContent = (content: string) => {
return escape(content).replace(/(?:\r\n|\r|\n)/g, '<br>');
};
const parseContent = (chatMessage: ChatMessageEntity) => {
const content = chatMessage.content || '';
const pending = chatMessage.pending;
const deleting = chatMessage.deleting;
const formatted = (pending && !deleting) ? parsePendingContent(content) : content;
2022-08-10 12:38:49 +00:00
return formatted;
// const emojiMap = makeEmojiMap(chatMessage);
// return emojify(formatted, emojiMap.toJS());
};
2022-08-26 16:41:25 +00:00
const renderDivider = (key: React.Key, text: string) => <Divider key={key} text={text} textSize='sm' />;
2022-08-30 14:42:55 +00:00
const handleCopyText = (chatMessage: IChatMessage) => {
if (navigator.clipboard) {
const text = stripHTML(chatMessage.content);
navigator.clipboard.writeText(text);
2022-08-30 14:42:55 +00:00
}
};
2022-08-10 12:38:49 +00:00
const renderMessage = (chatMessage: any) => {
const isMyMessage = chatMessage.account_id === me;
2022-08-30 14:42:55 +00:00
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),
2022-08-16 12:39:58 +00:00
action: () => handleDeleteMessage.mutate(chatMessage.id),
icon: require('@tabler/icons/trash.svg'),
destructive: true,
});
} else {
menu.push({
text: intl.formatMessage(messages.report),
2022-09-29 13:45:57 +00:00
action: () => dispatch(initReport(normalizeAccount(chat.account) as any, { chatMessage })),
icon: require('@tabler/icons/flag.svg'),
});
menu.push({
text: intl.formatMessage(messages.deleteForMe),
2022-10-25 14:13:08 +00:00
action: () => handleDeleteMessage.mutate(chatMessage.id),
icon: require('@tabler/icons/trash.svg'),
destructive: true,
});
}
return (
2022-09-12 18:42:15 +00:00
<div key={chatMessage.id} className='group' data-testid='chat-message'>
2022-08-16 12:39:58 +00:00
<Stack
space={1}
2022-08-10 12:38:49 +00:00
className={classNames({
'ml-auto': isMyMessage,
})}
>
2022-08-16 12:39:58 +00:00
<HStack
alignItems='center'
justifyContent={isMyMessage ? 'end' : 'start'}
2022-08-10 12:38:49 +00:00
className={classNames({
2022-08-16 12:39:58 +00:00
'opacity-50': chatMessage.pending,
2022-08-10 12:38:49 +00:00
})}
>
2022-08-30 14:42:55 +00:00
{menu.length > 0 && (
<div
className={classNames({
'hidden focus:block group-hover:block text-gray-500': true,
'mr-2 order-1': isMyMessage,
'ml-2 order-2': !isMyMessage,
})}
2022-10-17 16:08:46 +00:00
data-testid='chat-message-menu'
2022-08-30 14:42:55 +00:00
>
2022-08-16 12:39:58 +00:00
<DropdownMenuContainer
items={menu}
src={require('@tabler/icons/dots.svg')}
title={intl.formatMessage(messages.more)}
/>
</div>
2022-08-30 14:42:55 +00:00
)}
2022-08-16 12:39:58 +00:00
<HStack
alignItems='center'
2022-08-30 14:42:55 +00:00
className={classNames({
'max-w-[85%]': true,
'order-2': isMyMessage,
'order-1': !isMyMessage,
})}
2022-08-16 12:39:58 +00:00
justifyContent={isMyMessage ? 'end' : 'start'}
>
<div
title={getFormattedTimestamp(chatMessage)}
className={
classNames({
'text-ellipsis break-all relative rounded-md p-2 max-w-full': true,
2022-08-16 12:39:58 +00:00
'bg-primary-500 text-white mr-2': isMyMessage,
2022-08-25 17:44:19 +00:00
'bg-gray-200 dark:bg-gray-800 text-gray-900 dark:text-gray-100 order-2 ml-2': !isMyMessage,
2022-08-16 12:39:58 +00:00
})
}
ref={setBubbleRef}
tabIndex={0}
>
{maybeRenderMedia(chatMessage)}
<Text size='sm' theme='inherit' dangerouslySetInnerHTML={{ __html: parseContent(chatMessage) }} />
</div>
<div className={classNames({ 'order-1': !isMyMessage })}>
2022-09-08 16:47:19 +00:00
<Avatar src={isMyMessage ? account?.avatar as string : chat.account.avatar as string} size={34} />
2022-08-16 12:39:58 +00:00
</div>
</HStack>
</HStack>
<HStack
alignItems='center'
space={2}
className={classNames({
'ml-auto': isMyMessage,
})}
>
<Text
theme='muted'
size='xs'
className={classNames({
'text-right': isMyMessage,
'order-2': !isMyMessage,
})}
>
{intl.formatTime(chatMessage.created_at)}
</Text>
<div className={classNames({ 'order-1': !isMyMessage })}>
<div className='w-[34px]' />
</div>
</HStack>
</Stack>
</div>
);
};
2022-08-26 16:41:25 +00:00
useEffect(() => {
2022-08-31 17:20:37 +00:00
const lastMessage = formattedChatMessages.pop();
const lastMessageId = lastMessage?.id;
2022-08-30 14:10:31 +00:00
2022-08-31 17:20:37 +00:00
if (lastMessageId && !lastMessage.pending) {
2022-08-30 14:10:31 +00:00
markChatAsRead(lastMessageId);
}
2022-08-26 16:41:25 +00:00
}, [formattedChatMessages.length]);
2022-09-08 16:47:19 +00:00
if (isError) {
return (
<Stack alignItems='center' justifyContent='center' className='h-full flex-grow'>
<Stack space={4}>
<Stack space={1}>
2022-09-14 14:35:32 +00:00
<Text size='lg' weight='bold' align='center'>
{intl.formatMessage(messages.networkFailureTitle)}
</Text>
2022-09-08 16:47:19 +00:00
<Text theme='muted' align='center'>
2022-09-14 14:35:32 +00:00
{intl.formatMessage(messages.networkFailureSubtitle)}
2022-09-08 16:47:19 +00:00
</Text>
</Stack>
<div className='mx-auto'>
<Button theme='primary' onClick={() => refetch()}>
2022-09-14 14:35:32 +00:00
{intl.formatMessage(messages.networkFailureAction)}
2022-09-08 16:47:19 +00:00
</Button>
</div>
</Stack>
</Stack>
);
}
2022-10-05 20:13:29 +00:00
if (isLoading) {
return (
<div className='flex-grow flex flex-col justify-end'>
<div className='px-4'>
<PlaceholderChatMessage isMyMessage />
<PlaceholderChatMessage />
<PlaceholderChatMessage isMyMessage />
<PlaceholderChatMessage isMyMessage />
<PlaceholderChatMessage />
</div>
</div>
);
}
return (
2022-10-04 14:48:37 +00:00
<div className='h-full flex flex-col flex-grow overflow-y-scroll space-y-6'>
<div className='flex-grow flex flex-col justify-end'>
<Virtuoso
ref={node}
2022-10-05 19:25:56 +00:00
alignToBottom
2022-10-04 14:48:37 +00:00
firstItemIndex={Math.max(0, firstItemIndex)}
2022-10-17 16:08:46 +00:00
initialTopMostItemIndex={initialTopMostItemIndex}
2022-10-04 14:48:37 +00:00
data={cachedChatMessages}
startReached={handleStartReached}
2022-10-17 16:08:46 +00:00
followOutput='auto'
2022-10-04 14:48:37 +00:00
itemContent={(_index, chatMessage) => {
if (chatMessage.type === 'divider') {
return renderDivider(_index, chatMessage.text);
} else {
return (
<div className='py-2 px-4'>
{renderMessage(chatMessage)}
</div>
);
2022-08-16 12:39:58 +00:00
}
2022-10-04 14:48:37 +00:00
}}
components={{
Header: () => {
if (hasNextPage || isFetchingNextPage) {
return <Spinner withText={false} />;
2022-10-04 14:48:37 +00:00
}
2022-08-16 12:39:58 +00:00
2022-10-04 14:48:37 +00:00
if (!hasNextPage && !isLoading) {
2022-10-05 19:25:56 +00:00
return <ChatMessageListIntro />;
2022-10-04 14:48:37 +00:00
}
return null;
},
2022-10-04 14:48:37 +00:00
}}
/>
</div>
</div>
);
};
export default ChatMessageList;