Skip to content
Merged
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
7 changes: 7 additions & 0 deletions .changeset/cold-insects-guess.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@rocket.chat/core-services': patch
'@rocket.chat/ddp-client': patch
'@rocket.chat/meteor': patch
---

Fixes missing UI updates after pruning messages with "Files only" enabled.
34 changes: 23 additions & 11 deletions apps/meteor/app/lib/server/functions/cleanRoomHistory.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { api } from '@rocket.chat/core-services';
import type { IRoom, MessageAttachment } from '@rocket.chat/core-typings';
import type { IRoom } from '@rocket.chat/core-typings';
import { Messages, Rooms, Subscriptions, ReadReceipts, Users } from '@rocket.chat/models';

import { deleteRoom } from './deleteRoom';
Expand All @@ -8,14 +8,6 @@ import { FileUpload } from '../../../file-upload/server';
import { notifyOnRoomChangedById, notifyOnSubscriptionChangedById } from '../lib/notifyListener';

const FILE_CLEANUP_BATCH_SIZE = 1000;
async function performFileAttachmentCleanupBatch(idsSet: Set<string>, replaceWith?: MessageAttachment) {
if (idsSet.size === 0) return;

const ids = [...idsSet];
await Messages.removeFileAttachmentsByMessageIds(ids, replaceWith);
await Messages.clearFilesByMessageIds(ids);
idsSet.clear();
}

export async function cleanRoomHistory({
rid = '',
Expand Down Expand Up @@ -55,6 +47,26 @@ export async function cleanRoomHistory({
});

const targetMessageIdsForAttachmentRemoval = new Set<string>();
const pruneMessageAttachment = { color: '#FD745E', text };

async function performFileAttachmentCleanupBatch() {
if (targetMessageIdsForAttachmentRemoval.size === 0) return;

const ids = [...targetMessageIdsForAttachmentRemoval];
await Messages.removeFileAttachmentsByMessageIds(ids, pruneMessageAttachment);
await Messages.clearFilesByMessageIds(ids);
void api.broadcast('notify.deleteMessageBulk', rid, {
rid,
excludePinned,
ignoreDiscussion,
ts,
users: fromUsers,
ids,
filesOnly: true,
replaceFileAttachmentsWith: pruneMessageAttachment,
});
targetMessageIdsForAttachmentRemoval.clear();
}

for await (const document of cursor) {
const uploadsStore = FileUpload.getStore('Uploads');
Expand All @@ -67,12 +79,12 @@ export async function cleanRoomHistory({
}

if (targetMessageIdsForAttachmentRemoval.size >= FILE_CLEANUP_BATCH_SIZE) {
await performFileAttachmentCleanupBatch(targetMessageIdsForAttachmentRemoval, { color: '#FD745E', text });
await performFileAttachmentCleanupBatch();
}
}

if (targetMessageIdsForAttachmentRemoval.size > 0) {
await performFileAttachmentCleanupBatch(targetMessageIdsForAttachmentRemoval, { color: '#FD745E', text });
await performFileAttachmentCleanupBatch();
}

if (filesOnly) {
Expand Down
92 changes: 56 additions & 36 deletions apps/meteor/app/ui-utils/client/lib/LegacyRoomManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { RoomManager } from '../../../../client/lib/RoomManager';
import { roomCoordinator } from '../../../../client/lib/rooms/roomCoordinator';
import { fireGlobalEvent } from '../../../../client/lib/utils/fireGlobalEvent';
import { getConfig } from '../../../../client/lib/utils/getConfig';
import { modifyMessageOnFilesDelete } from '../../../../client/lib/utils/modifyMessageOnFilesDelete';
import { callbacks } from '../../../../lib/callbacks';
import { Messages, Subscriptions } from '../../../models/client';
import { sdk } from '../../../utils/client/lib/SDKClient';
Expand Down Expand Up @@ -110,6 +111,41 @@ function getOpenedRoomByRid(rid: IRoom['_id']) {
.find((openedRoom) => openedRoom.rid === rid);
}

function createDeleteQuery({
excludePinned,
ignoreDiscussion,
rid,
ts,
users,
ids,
}: {
rid: IMessage['rid'];
excludePinned: boolean;
ignoreDiscussion: boolean;
ts: Record<string, Date>;
users: string[];
ids?: string[];
}) {
const query: Filter<IMessage> = { rid };

if (ids) {
query._id = { $in: ids };
} else {
query.ts = ts;
}
if (excludePinned) {
query.pinned = { $ne: true };
}
if (ignoreDiscussion) {
query.drid = { $exists: false };
}
if (users?.length) {
query['u.username'] = { $in: users };
}

return query;
}

const openRoom = (typeName: string, record: OpenedRoom) => {
if (record.ready === true && record.streamActive === true) {
return;
Expand Down Expand Up @@ -174,44 +210,28 @@ const openRoom = (typeName: string, record: OpenedRoom) => {
({ tmid: _, ...record }) => record,
);
}),
sdk.stream(
'notify-room',
[`${record.rid}/deleteMessageBulk`],
({ rid, ts, excludePinned, ignoreDiscussion, users, ids, showDeletedStatus }) => {
const query: Filter<IMessage> = { rid };

if (ids) {
query._id = { $in: ids };
} else {
query.ts = ts;
}
if (excludePinned) {
query.pinned = { $ne: true };
}
if (ignoreDiscussion) {
query.drid = { $exists: false };
}
if (users?.length) {
query['u.username'] = { $in: users };
}
sdk.stream('notify-room', [`${record.rid}/deleteMessageBulk`], async (params) => {
const query = createDeleteQuery(params);
const predicate = createPredicateFromFilter(query);

const predicate = createPredicateFromFilter(query);

if (showDeletedStatus) {
return Messages.state.update(predicate, (record) => ({
...record,
t: 'rm',
msg: '',
urls: [],
mentions: [],
attachments: [],
reactions: {},
}));
}
if (params.filesOnly) {
return Messages.state.update(predicate, (record) => modifyMessageOnFilesDelete(record, params.replaceFileAttachmentsWith));
}

if (params.showDeletedStatus) {
return Messages.state.update(predicate, (record) => ({
...record,
t: 'rm',
msg: '',
urls: [],
mentions: [],
attachments: [],
reactions: {},
}));
}

return Messages.state.remove(predicate);
},
),
return Messages.state.remove(predicate);
}),

sdk.stream('notify-room', [`${record.rid}/messagesRead`], ({ tmid, until }) => {
if (tmid) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,22 +1,30 @@
import type { IMessage, IRoom, IUser } from '@rocket.chat/core-typings';
import type { IMessage, IRoom, IUser, MessageAttachment } from '@rocket.chat/core-typings';
import { createPredicateFromFilter } from '@rocket.chat/mongo-adapter';
import { useStream } from '@rocket.chat/ui-contexts';
import type { Condition, Filter } from 'mongodb';
import { useEffect } from 'react';

import type { MessageList } from '../../lib/lists/MessageList';
import { modifyMessageOnFilesDelete } from '../../lib/utils/modifyMessageOnFilesDelete';

type NotifyRoomRidDeleteMessageBulkEvent = {
type NotifyRoomRidDeleteBulkEvent = {
rid: IMessage['rid'];
excludePinned: boolean;
ignoreDiscussion: boolean;
ts: Condition<Date>;
users: string[];
ids?: string[]; // message ids have priority over ts
showDeletedStatus?: boolean;
};
} & (
| {
filesOnly: true;
replaceFileAttachmentsWith?: MessageAttachment;
}
| {
filesOnly?: false;
}
);

const createDeleteCriteria = (params: NotifyRoomRidDeleteMessageBulkEvent): ((message: IMessage) => boolean) => {
const createDeleteCriteria = (params: NotifyRoomRidDeleteBulkEvent): ((message: IMessage) => boolean) => {
const query: Filter<IMessage> = {};

if (params.ids) {
Expand Down Expand Up @@ -59,6 +67,14 @@ export const useStreamUpdatesForMessageList = (messageList: MessageList, uid: IU

const unsubscribeFromDeleteMessageBulk = subscribeToNotifyRoom(`${rid}/deleteMessageBulk`, (params) => {
const matchDeleteCriteria = createDeleteCriteria(params);
if (params.filesOnly) {
return messageList.batchHandle(async () => {
const items = messageList.items.filter(matchDeleteCriteria).map((message) => {
return modifyMessageOnFilesDelete(message, params.replaceFileAttachmentsWith);
});
return { items };
});
}
messageList.prune(matchDeleteCriteria);
});

Expand Down
92 changes: 92 additions & 0 deletions apps/meteor/client/lib/utils/modifyMessageOnFilesDelete.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import type { IMessage, MessageAttachment, FileAttachmentProps, MessageQuoteAttachment } from '@rocket.chat/core-typings';

import { modifyMessageOnFilesDelete } from './modifyMessageOnFilesDelete';

global.structuredClone = (val: any) => JSON.parse(JSON.stringify(val));

const fileAttachment: FileAttachmentProps = { title: 'Image', title_link: 'url', image_url: 'image.png', type: 'file' };
const nonFileAttachment: MessageAttachment = { text: 'Non-file attachment', color: '#ff0000' };
const quoteAttachment: MessageQuoteAttachment = {
text: 'Quoted message',
message_link: 'some-link',
attachments: [{ text: 'Quoted inner message' }],
author_icon: 'icon',
author_link: 'link',
author_name: 'name',
};

const messageBase: IMessage = {
_id: 'msg1',
msg: 'Here is a file',
file: {
_id: 'file1',
name: 'image.png',
type: '',
format: '',
size: 0,
},
files: [
{
_id: 'file1',
name: 'image.png',
type: '',
format: '',
size: 0,
},
],
attachments: [fileAttachment, nonFileAttachment, quoteAttachment],
rid: '',
ts: new Date().toISOString() as any,
u: { username: 'username', _id: '12345' },
_updatedAt: new Date().toISOString() as any,
};

const createMessage = () => {
const msg = structuredClone(messageBase);
return msg as IMessage;
};

describe('modifyMessageOnFilesDelete', () => {
it('should remove `file`, empty `files`, and remove file-type attachments', () => {
const message = createMessage();

const result = modifyMessageOnFilesDelete(message);

expect(result).not.toHaveProperty('file');
expect(Array.isArray(result.files)).toBe(true);
expect(result.files).toHaveLength(0);
expect(result.attachments).toHaveLength(2);
expect(result.attachments?.some((att) => att.text === 'Non-file attachment')).toBe(true);
});

it('should replace file-type attachments if `replaceFileAttachmentsWith` is provided', () => {
const message = createMessage();

const replacement: MessageAttachment = { text: 'File removed by prune' };

const result = modifyMessageOnFilesDelete(message, replacement);

expect(result).not.toHaveProperty('file');
expect(Array.isArray(result.files)).toBe(true);
expect(result.files).toHaveLength(0);
expect(result.attachments?.some((att) => att.text === 'File removed by prune')).toBe(true);
});

it('should not mutate the original message', () => {
const message = createMessage();

const original = JSON.parse(JSON.stringify(message));
modifyMessageOnFilesDelete(message);

expect(message).toEqual(original);
});

it('should not remove non-file attachments such as text and quote', () => {
const message = createMessage();

const result = modifyMessageOnFilesDelete(message);

expect(result.attachments?.some((att) => att.text === 'Non-file attachment')).toBe(true);
expect(result.attachments?.some((att) => att.text === 'Quoted message')).toBe(true);
});
});
30 changes: 30 additions & 0 deletions apps/meteor/client/lib/utils/modifyMessageOnFilesDelete.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { isFileAttachment } from '@rocket.chat/core-typings';
import type { IMessage, MessageAttachment } from '@rocket.chat/core-typings';

/**
* Clone a message and clear or replace its file attachments.
*
* - Performs a deep clone via `structuredClone` to avoid mutating the source.
* - Removes the single-file `file` field and empties the `files` array.
* - If `replaceFileAttachmentsWith` is given, swaps out any file-type
* attachments; otherwise filters them out entirely.
*
* @param message The original `IMessage` to copy.
* @param replaceFileAttachmentsWith
* Optional attachment to substitute for each file attachment.
* @returns A new message object with `file`, `files`, and `attachments` updated.
*/

export const modifyMessageOnFilesDelete = <T extends IMessage = IMessage>(message: T, replaceFileAttachmentsWith?: MessageAttachment) => {
const { attachments, file: _, ...rest } = message;

if (!attachments?.length) {
return message;
}

if (replaceFileAttachmentsWith) {
return { ...rest, files: [], attachments: attachments?.map((att) => (isFileAttachment(att) ? replaceFileAttachmentsWith : att)) };
}

return { ...rest, files: [], attachments: attachments?.filter((att) => !isFileAttachment(att)) };
};
Loading
Loading