Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion apps/meteor/client/components/ImageGallery/ImageGallery.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => (
<SwiperSlide key={_id}>
<div className='swiper-zoom-container'>
{/* eslint-disable-next-line
Expand All @@ -210,6 +210,19 @@ export const ImageGallery = ({ images, onClose, loadMore }: { images: IUpload[];
<Throbber inheritColor />
</div>
</div>
{name && (
<Box
display='flex'
justifyContent='center'
color='font-pure-white'
fontScale='p2'
pbs={8}
withTruncatedText
onClick={preventPropagation}
>
{name}
</Box>
)}
</SwiperSlide>
))}
</Swiper>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down Expand Up @@ -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',
Expand Down
2 changes: 1 addition & 1 deletion apps/meteor/client/lib/chats/ChatAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ export type ChatAPI = {
) => Promise<boolean>;
readonly processMessageUploads: (message: IMessage) => Promise<boolean>;
readonly processSetReaction: (message: Pick<IMessage, 'msg'>) => Promise<boolean>;
readonly requestMessageDeletion: (message: IMessage) => Promise<void>;
readonly requestMessageDeletion: (message: IMessage, groupedMessages?: IMessage[]) => Promise<void>;
readonly replyBroadcast: (message: IMessage) => Promise<void>;
};
};
7 changes: 5 additions & 2 deletions apps/meteor/client/lib/chats/flows/processMessageUploads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -139,6 +141,7 @@ async function continueSendingMessage(store: UploadsAPI, message: IMessage) {
content,
t: 'e2e',
msg: '',
groupable,
fileContent,
} as const;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> => {
export const requestMessageDeletion = async (chat: ChatAPI, message: IMessage, groupedMessages?: IMessage[]): Promise<void> => {
if (!(await chat.data.canDeleteMessage(message))) {
dispatchToastMessage({ type: 'error', message: t('Message_deleting_blocked') });
return;
Expand Down Expand Up @@ -36,6 +36,7 @@ export const requestMessageDeletion = async (chat: ChatAPI, message: IMessage):
resolve,
reject,
message,
groupedMessages,
onCancel: onCloseModal,
},
});
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<Box display='flex' flexWrap='wrap' maxWidth={maxWidth} style={{ gap: '4px' }} borderRadius={4} overflow='hidden'>
{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 (
<Box
key={fileId || url}
className='gallery-item-container'
data-id={fileId}
style={{ flex: `${getAspectRatio(img)} 1 0%`, minWidth: '30%', height: 160, cursor: 'pointer' }}
overflow='hidden'
borderRadius={2}
>
<img
className='gallery-item'
data-id={fileId}
data-src={link}
src={url}
alt={img.title || ''}
loading='lazy'
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
/>
</Box>
);
})}
</Box>
);
});

FileGroupImageGrid.displayName = 'FileGroupImageGrid';

export default FileGroupImageGrid;
202 changes: 202 additions & 0 deletions apps/meteor/client/views/room/MessageList/FileGroupMessage.tsx
Original file line number Diff line number Diff line change
@@ -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<string, Uint8Array> = {};

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 && (
<Box
ref={ref}
data-id={firstMessage.ts}
role='listitem'
{...(newDay && {
'data-time': new Date(firstMessage.ts)
.toISOString()
.replaceAll(/[-T:.]/g, '')
.substring(0, 8),
})}
>
<MessageDivider unreadLabel={showUnreadDivider ? t('Unread_Messages').toLowerCase() : undefined}>
{newDay && (
<Bubble small secondary>
{formatDate(firstMessage.ts)}
</Bubble>
)}
</MessageDivider>
</Box>
)}

<Message
ref={messageRef}
id={firstMessage._id}
role='listitem'
aria-roledescription={t('message')}
tabIndex={0}
onClick={selecting ? toggleSelected : undefined}
isSelected={selected}
isEditing={editing}
isPending={firstMessage.temp}
sequential={shouldShowAsSequential}
data-id={firstMessage._id}
data-mid={firstMessage._id}
data-own={firstMessage.u._id === uid}
aria-busy={firstMessage.temp}
>
<MessageLeftContainer>
{!shouldShowAsSequential && firstMessage.u.username && !selecting && showUserAvatar && (
<MessageAvatar
emoji={firstMessage.emoji ? <Emoji emojiHandle={firstMessage.emoji} fillContainer /> : undefined}
avatarUrl={firstMessage.avatar}
username={firstMessage.u.username}
size='x36'
onClick={(e) => openUserCard(e, firstMessage.u.username)}
style={{ cursor: 'pointer' }}
role='button'
{...triggerProps}
/>
)}
{shouldShowAsSequential && <StatusIndicators message={firstMessage} />}
</MessageLeftContainer>
<MessageContainer>
{!shouldShowAsSequential && <MessageHeader message={firstMessage} />}

{!normalizedFirstMessage.blocks?.length && !!normalizedFirstMessage.md?.length && (
<MessageContentBody
id={`${normalizedFirstMessage._id}-content`}
md={normalizedFirstMessage.md}
mentions={normalizedFirstMessage.mentions}
channels={normalizedFirstMessage.channels}
/>
)}

{showImageGrid ? (
<>
<Box display='flex' flexDirection='row' color='hint' fontScale='c1' alignItems='center'>
<Box withTruncatedText>{t('__count__files', { count: allAttachments.length })}</Box>
{collapseAction}
<Action icon='cloud-arrow-down' title={t('Download')} onClick={handleDownloadAll} />
</Box>
{!collapsed && <FileGroupImageGrid attachments={allAttachments} maxWidth={IMAGE_GROUP_MAX_WIDTH} />}
</>
) : (
<Attachments id={firstMessage.files?.[0]?._id} attachments={allAttachments.map(({ attachment }) => attachment)} />
)}

{firstMessage.reactions && Object.keys(firstMessage.reactions).length > 0 && <Reactions message={firstMessage} />}
{readReceiptEnabled && <ReadReceiptIndicator mid={firstMessage._id} unread={firstMessage.unread} />}
</MessageContainer>
{!firstMessage.private && firstMessage?.e2e !== 'pending' && !selecting && <MessageToolbarHolder message={firstMessage} />}
</Message>
</>
);
});

FileGroupMessage.displayName = 'FileGroupMessage';
Loading
Loading