import { useMutation } from '@tanstack/react-query'; import clsx from 'clsx'; import { List as ImmutableList, Map as ImmutableMap } from 'immutable'; import { escape } from 'lodash'; import React, { useMemo, useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; import { openModal } from 'soapbox/actions/modals'; import { initReport, ReportableEntities } from 'soapbox/actions/reports'; import DropdownMenu from 'soapbox/components/dropdown-menu'; import { HStack, Icon, Stack, Text } from 'soapbox/components/ui'; import emojify from 'soapbox/features/emoji'; import Bundle from 'soapbox/features/ui/components/bundle'; import { MediaGallery } from 'soapbox/features/ui/util/async-components'; import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks'; import { normalizeAccount } from 'soapbox/normalizers'; import { ChatKeys, IChat, useChatActions } from 'soapbox/queries/chats'; import { queryClient } from 'soapbox/queries/client'; import { stripHTML } from 'soapbox/utils/html'; import { onlyEmoji } from 'soapbox/utils/rich-content'; import ChatMessageReaction from './chat-message-reaction'; import ChatMessageReactionWrapper from './chat-message-reaction-wrapper/chat-message-reaction-wrapper'; import type { Menu as IMenu } from 'soapbox/components/dropdown-menu'; import type { IMediaGallery } from 'soapbox/components/media-gallery'; import type { Account, ChatMessage as ChatMessageEntity } from 'soapbox/types/entities'; const messages = defineMessages({ copy: { id: 'chats.actions.copy', defaultMessage: 'Copy' }, delete: { id: 'chats.actions.delete', defaultMessage: 'Delete for both' }, deleteForMe: { id: 'chats.actions.deleteForMe', defaultMessage: 'Delete for me' }, more: { id: 'chats.actions.more', defaultMessage: 'More' }, report: { id: 'chats.actions.report', defaultMessage: 'Report' }, }); const BIG_EMOJI_LIMIT = 3; const makeEmojiMap = (record: any) => record.get('emojis', ImmutableList()).reduce((map: ImmutableMap, emoji: ImmutableMap) => { return map.set(`:${emoji.get('shortcode')}:`, emoji); }, ImmutableMap()); 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()); }; interface IChatMessage { chat: IChat chatMessage: ChatMessageEntity } const ChatMessage = (props: IChatMessage) => { const { chat, chatMessage } = props; const dispatch = useAppDispatch(); const features = useFeatures(); const intl = useIntl(); const me = useAppSelector((state) => state.me); const { createReaction, deleteChatMessage, deleteReaction } = useChatActions(chat.id); const [isReactionSelectorOpen, setIsReactionSelectorOpen] = useState(false); const [isMenuOpen, setIsMenuOpen] = useState(false); const handleDeleteMessage = useMutation((chatMessageId: string) => deleteChatMessage(chatMessageId), { onSettled: () => { queryClient.invalidateQueries(ChatKeys.chatMessages(chat.id)); }, }); const content = parseContent(chatMessage); const lastReadMessageDateString = chat.latest_read_message_by_account?.find((latest) => latest.id === chat.account.id)?.date; const lastReadMessageTimestamp = lastReadMessageDateString ? new Date(lastReadMessageDateString) : null; const isMyMessage = chatMessage.account_id === me; // did this occur before this time? const isRead = isMyMessage && lastReadMessageTimestamp && lastReadMessageTimestamp >= new Date(chatMessage.created_at); const isOnlyEmoji = useMemo(() => { const hiddenEl = document.createElement('div'); hiddenEl.innerHTML = content; return onlyEmoji(hiddenEl, BIG_EMOJI_LIMIT, false); }, []); const emojiReactionRows = useMemo(() => { if (!chatMessage.emoji_reactions) { return []; } return chatMessage.emoji_reactions.reduce((rows: any, key: any, index) => { return (index % 4 === 0 ? rows.push([key]) : rows[rows.length - 1].push(key)) && rows; }, []); }, [chatMessage.emoji_reactions]); const onOpenMedia = (media: any, index: number) => { dispatch(openModal('MEDIA', { media, index })); }; const maybeRenderMedia = (chatMessage: ChatMessageEntity) => { if (!chatMessage.media_attachments.size) return null; return ( {(Component: React.FC) => ( )} ); }; const handleCopyText = (chatMessage: ChatMessageEntity) => { if (navigator.clipboard) { const text = stripHTML(chatMessage.content); navigator.clipboard.writeText(text); } }; 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 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 menu = useMemo(() => { const menu: IMenu = []; 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(ReportableEntities.CHAT_MESSAGE, normalizeAccount(chat.account) as Account, { chatMessage })), 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; }, [chatMessage, chat]); return (
{features.chatEmojiReactions && ( createReaction.mutate({ emoji, messageId: chatMessage.id, chatMessage })} > )} {menu.length > 0 && ( setIsMenuOpen(true)} onClose={() => setIsMenuOpen(false)} > )}
{maybeRenderMedia(chatMessage)} {content && (
)}
{(chatMessage.emoji_reactions?.size) ? (
{emojiReactionRows?.map((emojiReactionRow: any, idx: number) => ( {emojiReactionRow.map((emojiReaction: any, idx: number) => ( createReaction.mutate({ emoji, messageId: chatMessage.id, chatMessage })} onRemoveReaction={(emoji) => deleteReaction.mutate({ emoji, messageId: chatMessage.id })} /> ))} ))}
) : null}
{intl.formatTime(chatMessage.created_at)} {(isMyMessage && features.chatsReadReceipts) ? ( <> {isRead ? ( ) : ( )} ) : null}
); }; export default ChatMessage;