diff --git a/apps/meteor/client/components/ImageGallery/ImageGallery.tsx b/apps/meteor/client/components/ImageGallery/ImageGallery.tsx index 0804e6c79e3d4..47be6d72ea9d5 100644 --- a/apps/meteor/client/components/ImageGallery/ImageGallery.tsx +++ b/apps/meteor/client/components/ImageGallery/ImageGallery.tsx @@ -192,7 +192,7 @@ export const ImageGallery = ({ images, onClose, loadMore }: { images: IUpload[]; onReachBeginning={loadMore} initialSlide={images.length - 1} > - {[...images].reverse().map(({ _id, path, url, description }) => ( + {[...images].reverse().map(({ _id, path, url, name, description }) => (
{/* eslint-disable-next-line @@ -210,6 +210,19 @@ export const ImageGallery = ({ images, onClose, loadMore }: { images: IUpload[];
+ {name && ( + + {name} + + )}
))} diff --git a/apps/meteor/client/components/message/toolbar/useDeleteMessageAction.ts b/apps/meteor/client/components/message/toolbar/useDeleteMessageAction.ts index fff4a8c986734..2ea13743d96b5 100644 --- a/apps/meteor/client/components/message/toolbar/useDeleteMessageAction.ts +++ b/apps/meteor/client/components/message/toolbar/useDeleteMessageAction.ts @@ -5,6 +5,7 @@ import { useQuery } from '@tanstack/react-query'; import type { MessageActionConfig } from '../../../../app/ui-utils/client/lib/MessageAction'; import { roomCoordinator } from '../../../lib/rooms/roomCoordinator'; +import { getFileGroupMessages } from '../../../views/room/MessageList/lib/fileGroupStore'; import { useChat } from '../../../views/room/contexts/ChatContext'; export const useDeleteMessageAction = ( @@ -46,7 +47,8 @@ export const useDeleteMessageAction = ( variant: 'danger', type: 'management', async action() { - await chat?.flows.requestMessageDeletion(message); + const groupedMessages = getFileGroupMessages(message._id); + await chat?.flows.requestMessageDeletion(message, groupedMessages); }, order: 10, group: 'menu', diff --git a/apps/meteor/client/lib/chats/ChatAPI.ts b/apps/meteor/client/lib/chats/ChatAPI.ts index db2e61ad640af..14f6cda5ae1ea 100644 --- a/apps/meteor/client/lib/chats/ChatAPI.ts +++ b/apps/meteor/client/lib/chats/ChatAPI.ts @@ -180,7 +180,7 @@ export type ChatAPI = { ) => Promise; readonly processMessageUploads: (message: IMessage) => Promise; readonly processSetReaction: (message: Pick) => Promise; - readonly requestMessageDeletion: (message: IMessage) => Promise; + readonly requestMessageDeletion: (message: IMessage, groupedMessages?: IMessage[]) => Promise; readonly replyBroadcast: (message: IMessage) => Promise; }; }; diff --git a/apps/meteor/client/lib/chats/flows/processMessageUploads.ts b/apps/meteor/client/lib/chats/flows/processMessageUploads.ts index 10ac33f930e52..8b84e756295ef 100644 --- a/apps/meteor/client/lib/chats/flows/processMessageUploads.ts +++ b/apps/meteor/client/lib/chats/flows/processMessageUploads.ts @@ -116,14 +116,16 @@ async function continueSendingMessage(store: UploadsAPI, message: IMessage) { * The first message will keep the composedMessage, * subsequent messages will have a empty text * */ - const currentMsg = upload === validFiles[0] ? msg : ''; + const isFirst = upload === validFiles[0]; + const currentMsg = isFirst ? msg : ''; + const groupable = !isFirst; let content; if (!e2eRoom || !isEncryptedUpload(upload)) { confirmFilesQueue.push({ _id: upload.id, name: upload.file.name, - composedMessage: { tmid, msg: currentMsg, fileName: upload.file.name, description: upload.description }, + composedMessage: { tmid, msg: currentMsg, groupable, fileName: upload.file.name, description: upload.description }, }); continue; } @@ -139,6 +141,7 @@ async function continueSendingMessage(store: UploadsAPI, message: IMessage) { content, t: 'e2e', msg: '', + groupable, fileContent, } as const; diff --git a/apps/meteor/client/lib/chats/flows/requestMessageDeletion.ts b/apps/meteor/client/lib/chats/flows/requestMessageDeletion.ts index 4039b3ad8f6e3..481a5ce148c4e 100644 --- a/apps/meteor/client/lib/chats/flows/requestMessageDeletion.ts +++ b/apps/meteor/client/lib/chats/flows/requestMessageDeletion.ts @@ -6,7 +6,7 @@ import DeleteMessageConfirmModal from '../../../views/room/modals/DeleteMessageC import { dispatchToastMessage } from '../../toast'; import type { ChatAPI } from '../ChatAPI'; -export const requestMessageDeletion = async (chat: ChatAPI, message: IMessage): Promise => { +export const requestMessageDeletion = async (chat: ChatAPI, message: IMessage, groupedMessages?: IMessage[]): Promise => { if (!(await chat.data.canDeleteMessage(message))) { dispatchToastMessage({ type: 'error', message: t('Message_deleting_blocked') }); return; @@ -36,6 +36,7 @@ export const requestMessageDeletion = async (chat: ChatAPI, message: IMessage): resolve, reject, message, + groupedMessages, onCancel: onCloseModal, }, }); diff --git a/apps/meteor/client/views/room/MessageList/FileGroupImageGrid.tsx b/apps/meteor/client/views/room/MessageList/FileGroupImageGrid.tsx new file mode 100644 index 0000000000000..18d3f40a6f73d --- /dev/null +++ b/apps/meteor/client/views/room/MessageList/FileGroupImageGrid.tsx @@ -0,0 +1,53 @@ +import type { ImageAttachmentProps, MessageAttachmentBase } from '@rocket.chat/core-typings'; +import { Box } from '@rocket.chat/fuselage'; +import { useMediaUrl } from '@rocket.chat/ui-contexts'; +import { memo } from 'react'; + +type FileGroupImageGridProps = { + attachments: { attachment: MessageAttachmentBase; fileId: string | undefined }[]; + maxWidth: number; +}; + +const getAspectRatio = (attachment: ImageAttachmentProps): number => { + const { width, height } = attachment.image_dimensions ?? {}; + return width && height ? width / height : 1; +}; + +const FileGroupImageGrid = memo(({ attachments, maxWidth }: FileGroupImageGridProps) => { + const getURL = useMediaUrl(); + + return ( + + {attachments.map(({ attachment, fileId }) => { + const img = attachment as ImageAttachmentProps; + const url = getURL(img.image_url); + const link = img.title_link ? getURL(img.title_link) : url; + + return ( + + {img.title + + ); + })} + + ); +}); + +FileGroupImageGrid.displayName = 'FileGroupImageGrid'; + +export default FileGroupImageGrid; diff --git a/apps/meteor/client/views/room/MessageList/FileGroupMessage.tsx b/apps/meteor/client/views/room/MessageList/FileGroupMessage.tsx new file mode 100644 index 0000000000000..4ff798259d0e8 --- /dev/null +++ b/apps/meteor/client/views/room/MessageList/FileGroupMessage.tsx @@ -0,0 +1,202 @@ +import type { IMessage, ImageAttachmentProps, MessageAttachmentBase } from '@rocket.chat/core-typings'; +import { isFileAttachment, isFileImageAttachment, isQuoteAttachment } from '@rocket.chat/core-typings'; +import { Box, Bubble, Message, MessageContainer, MessageDivider, MessageLeftContainer } from '@rocket.chat/fuselage'; +import { MessageAvatar } from '@rocket.chat/ui-avatar'; +import { useMediaUrl, useUserId, useUserCard } from '@rocket.chat/ui-contexts'; +import { zipSync } from 'fflate'; +import { memo, useCallback, useEffect, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import FileGroupImageGrid from './FileGroupImageGrid'; +import { useIsMessageHighlight } from './contexts/MessageHighlightContext'; +import { useCountSelected, useIsSelectedMessage, useIsSelecting, useToggleSelect } from './contexts/SelectedMessagesContext'; +import { useJumpToMessage } from './hooks/useJumpToMessage'; +import { registerFileGroup, unregisterFileGroup } from './lib/fileGroupStore'; +import { isMessageNewDay } from './lib/isMessageNewDay'; +import Emoji from '../../../components/Emoji'; +import MessageContentBody from '../../../components/message/MessageContentBody'; +import MessageHeader from '../../../components/message/MessageHeader'; +import MessageToolbarHolder from '../../../components/message/MessageToolbarHolder'; +import ReadReceiptIndicator from '../../../components/message/ReadReceiptIndicator'; +import StatusIndicators from '../../../components/message/StatusIndicators'; +import Action from '../../../components/message/content/Action'; +import Attachments from '../../../components/message/content/Attachments'; +import Reactions from '../../../components/message/content/Reactions'; +import { useCollapse } from '../../../components/message/hooks/useCollapse'; +import { useNormalizedMessage } from '../../../components/message/hooks/useNormalizedMessage'; +import { useMessageListFormatDate, useMessageListReadReceipts } from '../../../components/message/list/MessageListContext'; +import { useDateRef } from '../providers/DateListProvider'; + +type FileGroupMessageProps = { + messages: IMessage[]; + previous?: IMessage; + showUnreadDivider: boolean; + sequential: boolean; + showUserAvatar: boolean; +}; + +const isImageAttachment = (attachment: MessageAttachmentBase): attachment is ImageAttachmentProps & { type: 'file' } => + isFileAttachment(attachment) && isFileImageAttachment(attachment); + +const getFileAttachments = (message: IMessage) => + (message.attachments?.filter((a) => !isQuoteAttachment(a)) ?? []).map((attachment) => ({ + attachment, + fileId: isFileAttachment(attachment) ? attachment.fileId : undefined, + })); + +const IMAGE_GROUP_MAX_WIDTH = 480; + +export const FileGroupMessage = memo(({ messages, previous, showUnreadDivider, sequential, showUserAvatar }: FileGroupMessageProps) => { + const { t } = useTranslation(); + const formatDate = useMessageListFormatDate(); + const uid = useUserId(); + const { openUserCard, triggerProps } = useUserCard(); + const ref = useDateRef(); + const getURL = useMediaUrl(); + + const firstMessage = messages[0]; + const groupedMessages = useMemo(() => messages.slice(1), [messages]); + + useEffect(() => { + if (groupedMessages.length > 0) { + registerFileGroup(firstMessage._id, groupedMessages); + } + return () => unregisterFileGroup(firstMessage._id); + }, [firstMessage._id, groupedMessages]); + + const newDay = isMessageNewDay(firstMessage, previous); + const showDivider = newDay || showUnreadDivider; + const shouldShowAsSequential = sequential && !newDay; + + const editing = useIsMessageHighlight(firstMessage._id); + const selecting = useIsSelecting(); + const toggleSelected = useToggleSelect(firstMessage._id); + const selected = useIsSelectedMessage(firstMessage._id); + const { enabled: readReceiptEnabled } = useMessageListReadReceipts(); + useCountSelected(); + const messageRef = useJumpToMessage(firstMessage._id); + const normalizedFirstMessage = useNormalizedMessage(firstMessage); + + const allAttachments = useMemo(() => messages.flatMap(getFileAttachments), [messages]); + + const allImages = allAttachments.every(({ attachment }) => isImageAttachment(attachment)); + const showImageGrid = allImages && allAttachments.length > 1; + const [collapsed, collapseAction] = useCollapse(); + + const handleDownloadAll = useCallback(async () => { + const files: Record = {}; + + await Promise.all( + allAttachments.map(async ({ attachment }) => { + const imgAttachment = attachment as ImageAttachmentProps; + const link = imgAttachment.title_link || imgAttachment.image_url; + const url = `${getURL(link)}?download`; + const fileName = imgAttachment.title || `file-${Object.keys(files).length}`; + + const response = await fetch(url); + const buffer = await response.arrayBuffer(); + files[fileName] = new Uint8Array(buffer); + }), + ); + + const zipped = zipSync(files); + const blob = new Blob([zipped], { type: 'application/zip' }); + const url = URL.createObjectURL(blob); + + const a = document.createElement('a'); + a.href = url; + a.download = 'files.zip'; + a.click(); + URL.revokeObjectURL(url); + }, [allAttachments, getURL]); + + return ( + <> + {showDivider && ( + + + {newDay && ( + + {formatDate(firstMessage.ts)} + + )} + + + )} + + + + {!shouldShowAsSequential && firstMessage.u.username && !selecting && showUserAvatar && ( + : undefined} + avatarUrl={firstMessage.avatar} + username={firstMessage.u.username} + size='x36' + onClick={(e) => openUserCard(e, firstMessage.u.username)} + style={{ cursor: 'pointer' }} + role='button' + {...triggerProps} + /> + )} + {shouldShowAsSequential && } + + + {!shouldShowAsSequential && } + + {!normalizedFirstMessage.blocks?.length && !!normalizedFirstMessage.md?.length && ( + + )} + + {showImageGrid ? ( + <> + + {t('__count__files', { count: allAttachments.length })} + {collapseAction} + + + {!collapsed && } + + ) : ( + attachment)} /> + )} + + {firstMessage.reactions && Object.keys(firstMessage.reactions).length > 0 && } + {readReceiptEnabled && } + + {!firstMessage.private && firstMessage?.e2e !== 'pending' && !selecting && } + + + ); +}); + +FileGroupMessage.displayName = 'FileGroupMessage'; diff --git a/apps/meteor/client/views/room/MessageList/MessageList.tsx b/apps/meteor/client/views/room/MessageList/MessageList.tsx index ea69e9c5fc91c..ffc86150a2d2e 100644 --- a/apps/meteor/client/views/room/MessageList/MessageList.tsx +++ b/apps/meteor/client/views/room/MessageList/MessageList.tsx @@ -1,15 +1,17 @@ -import type { IRoom } from '@rocket.chat/core-typings'; +import type { IMessage, IRoom } from '@rocket.chat/core-typings'; import { isThreadMessage } from '@rocket.chat/core-typings'; import { MessageTypes } from '@rocket.chat/message-types'; import { useSetting, useUserPreference } from '@rocket.chat/ui-contexts'; import type { ComponentProps } from 'react'; -import { Fragment } from 'react'; +import { Fragment, useMemo } from 'react'; +import { FileGroupMessage } from './FileGroupMessage'; import { MessageListItem } from './MessageListItem'; import { useRoomSubscription } from '../contexts/RoomContext'; import { useFirstUnreadMessageId } from '../hooks/useFirstUnreadMessageId'; import { SelectedMessagesProvider } from '../providers/SelectedMessagesProvider'; import { useMessages } from './hooks/useMessages'; +import { isFileMessage } from './lib/isFileMessage'; import { isMessageSequential } from './lib/isMessageSequential'; import MessageListProvider from './providers/MessageListProvider'; @@ -18,6 +20,53 @@ type MessageListProps = { messageListRef: ComponentProps['messageListRef']; }; +/** + * Computes file groups: consecutive sequential file-only messages are grouped together. + * Batch boundaries are determined by the `groupable` flag set during upload: + * the first file in a batch has `groupable: false`, subsequent files have `groupable: true`. + */ +const computeFileGroups = (messages: IMessage[], messageGroupingPeriod: number) => { + const groupLeaders = new Map(); + const groupedIds = new Set(); + + let i = 0; + while (i < messages.length) { + const msg = messages[i]; + if (!isFileMessage(msg) || MessageTypes.isSystemMessage(msg) || isThreadMessage(msg)) { + i++; + continue; + } + + const group: IMessage[] = [msg]; + let j = i + 1; + while (j < messages.length) { + const next = messages[j]; + const prev = messages[j - 1]; + if ( + !isFileMessage(next) || + MessageTypes.isSystemMessage(next) || + isThreadMessage(next) || + !isMessageSequential(next, prev, messageGroupingPeriod) + ) { + break; + } + group.push(next); + j++; + } + + if (group.length >= 2) { + groupLeaders.set(msg._id, group); + for (let k = 1; k < group.length; k++) { + groupedIds.add(group[k]._id); + } + } + + i = j; + } + + return { groupLeaders, groupedIds }; +}; + export const MessageList = function MessageList({ rid, messageListRef }: MessageListProps) { const messages = useMessages({ rid }); const subscription = useRoomSubscription(); @@ -25,15 +74,38 @@ export const MessageList = function MessageList({ rid, messageListRef }: Message const messageGroupingPeriod = useSetting('Message_GroupingPeriod', 300); const firstUnreadMessageId = useFirstUnreadMessageId(); + const { groupLeaders, groupedIds } = useMemo(() => computeFileGroups(messages, messageGroupingPeriod), [messages, messageGroupingPeriod]); + return ( {messages.map((message, index, { [index - 1]: previous }) => { + // Skip messages that are part of a file group (handled by their leader) + if (groupedIds.has(message._id)) { + return null; + } + const sequential = isMessageSequential(message, previous, messageGroupingPeriod); const showUnreadDivider = firstUnreadMessageId === message._id; const system = MessageTypes.isSystemMessage(message); const visible = !isThreadMessage(message) && !system; + // If this message is a file group leader, render the FileGroupMessage + const fileGroup = groupLeaders.get(message._id); + if (fileGroup && visible) { + return ( + + + + ); + } + return ( (); + +export const registerFileGroup = (leaderId: string, groupedMessages: IMessage[]) => { + fileGroups.set(leaderId, groupedMessages); +}; + +export const unregisterFileGroup = (leaderId: string) => { + fileGroups.delete(leaderId); +}; + +export const getFileGroupMessages = (leaderId: string): IMessage[] | undefined => fileGroups.get(leaderId); diff --git a/apps/meteor/client/views/room/MessageList/lib/isFileMessage.ts b/apps/meteor/client/views/room/MessageList/lib/isFileMessage.ts new file mode 100644 index 0000000000000..2afc4b3699a81 --- /dev/null +++ b/apps/meteor/client/views/room/MessageList/lib/isFileMessage.ts @@ -0,0 +1,8 @@ +import type { IMessage, MessageAttachmentBase } from '@rocket.chat/core-typings'; +import { isFileAttachment, isQuoteAttachment } from '@rocket.chat/core-typings'; + +export const isFileMessage = (message: IMessage): boolean => { + const attachments = message.attachments?.filter((a: MessageAttachmentBase) => !isQuoteAttachment(a)) ?? []; + + return attachments.length > 0 && attachments.every((a: MessageAttachmentBase) => isFileAttachment(a)); +}; diff --git a/apps/meteor/client/views/room/modals/DeleteMessageConfirmModal/DeleteMessageConfirmModal.tsx b/apps/meteor/client/views/room/modals/DeleteMessageConfirmModal/DeleteMessageConfirmModal.tsx index 0a3103f290cf9..f62616ab81673 100644 --- a/apps/meteor/client/views/room/modals/DeleteMessageConfirmModal/DeleteMessageConfirmModal.tsx +++ b/apps/meteor/client/views/room/modals/DeleteMessageConfirmModal/DeleteMessageConfirmModal.tsx @@ -13,12 +13,14 @@ const DeleteMessageConfirmModal = ({ reject, onCancel, message, + groupedMessages, }: { room?: IRoom; chat: ChatAPI; resolve: () => void; reject: (reason?: any) => void; message: IMessage; + groupedMessages?: IMessage[]; onCancel: () => void; }) => { const { t } = useTranslation(); @@ -32,6 +34,10 @@ const DeleteMessageConfirmModal = ({ } await chat.data.deleteMessage(message); + + if (groupedMessages?.length) { + await Promise.all(groupedMessages.map((msg) => chat.data.deleteMessage(msg))); + } }, onSuccess: () => { if (mid === message._id) { diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index eb71374580bd1..bf4be89b8928b 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -7133,6 +7133,10 @@ "one": "{{count}} file pruned", "other": "{{count}} files pruned" }, + "__count__files": { + "one": "{{count}} file", + "other": "{{count}} files" + }, "__count__files_failed_to_upload": { "one": "One file failed to upload and will not be sent: {{name}}", "other": "{{count}} files failed to upload and will not be sent."