chore: prevent read receipts from loading unnecessary data when not saving detailed info (#37280)

This commit is contained in:
Rodrigo Nascimento 2025-10-25 21:25:15 -03:00 committed by GitHub
parent a86fbe845e
commit 2b2aa3e5d0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 116 additions and 78 deletions

View File

@ -1,4 +1,4 @@
import type { ReadReceipt } from '@rocket.chat/core-typings';
import type { IReadReceiptWithUser } from '@rocket.chat/core-typings';
import { Box } from '@rocket.chat/fuselage';
import { UserAvatar } from '@rocket.chat/ui-avatar';
import { useUserDisplayName } from '@rocket.chat/ui-client';
@ -6,7 +6,7 @@ import type { ReactElement } from 'react';
import { useFormatDateAndTime } from '../../../../hooks/useFormatDateAndTime';
const ReadReceiptRow = ({ user, ts }: ReadReceipt): ReactElement => {
const ReadReceiptRow = ({ user, ts }: IReadReceiptWithUser): ReactElement => {
const displayName = useUserDisplayName(user || {});
const formatDateAndTime = useFormatDateAndTime({ withSeconds: true });

View File

@ -3,7 +3,7 @@ import type {
RoomType,
IUser,
IMessage,
ReadReceipt,
IReadReceipt,
ValueOf,
AtLeast,
ISubscription,
@ -106,7 +106,7 @@ export interface IRoomTypeServerDirectives {
) => Promise<{ title: string | undefined; text: string; name: string | undefined }>;
getMsgSender: (message: IMessage) => Promise<IUser | null>;
includeInRoomSearch: () => boolean;
getReadReceiptsExtraData: (message: IMessage) => Partial<ReadReceipt>;
getReadReceiptsExtraData: (message: IMessage) => Partial<IReadReceipt>;
includeInDashboard: () => boolean;
roomFind?: (rid: string) => Promise<IRoom | undefined> | Promise<IOmnichannelRoom | null> | IRoom | undefined;
}

View File

@ -1,4 +1,4 @@
import type { IMessage, ReadReceipt } from '@rocket.chat/core-typings';
import type { IMessage, IReadReceiptWithUser } from '@rocket.chat/core-typings';
import { License } from '@rocket.chat/license';
import { Meteor } from 'meteor/meteor';
@ -14,7 +14,7 @@ declare module '@rocket.chat/rest-typings' {
interface Endpoints {
'/v1/chat.getMessageReadReceipts': {
GET: (params: GetMessageReadReceiptsProps) => {
receipts: ReadReceipt[];
receipts: IReadReceiptWithUser[];
};
};
}

View File

@ -1,4 +1,5 @@
import { api } from '@rocket.chat/core-services';
import type { IMessage, IRoom, IReadReceipt, IReadReceiptWithUser } from '@rocket.chat/core-typings';
import { LivechatVisitors, ReadReceipts, Messages, Rooms, Subscriptions, Users } from '@rocket.chat/models';
import { Random } from '@rocket.chat/random';
@ -8,21 +9,21 @@ import { SystemLogger } from '../../../../server/lib/logger/system';
import { roomCoordinator } from '../../../../server/lib/rooms/roomCoordinator';
// debounced function by roomId, so multiple calls within 2 seconds to same roomId runs only once
const list = {};
const debounceByRoomId = function (fn) {
return function (roomId, ...args) {
clearTimeout(list[roomId]);
list[roomId] = setTimeout(() => {
fn.call(this, roomId, ...args);
delete list[roomId];
const list: Record<string, NodeJS.Timeout> = {};
const debounceByRoomId = function (fn: (room: IRoom) => Promise<void>) {
return function (this: unknown, room: IRoom) {
clearTimeout(list[room._id]);
list[room._id] = setTimeout(() => {
void fn.call(this, room);
delete list[room._id];
}, 2000);
};
};
const updateMessages = debounceByRoomId(async ({ _id, lm }) => {
const updateMessages = debounceByRoomId(async ({ _id, lm }: IRoom) => {
// @TODO maybe store firstSubscription in room object so we don't need to call the above update method
const firstSubscription = await Subscriptions.getMinimumLastSeenByRoomId(_id);
if (!firstSubscription || !firstSubscription.ls) {
if (!firstSubscription?.ls) {
return;
}
@ -31,14 +32,14 @@ const updateMessages = debounceByRoomId(async ({ _id, lm }) => {
void api.broadcast('notify.messagesRead', { rid: _id, until: firstSubscription.ls });
}
if (lm <= firstSubscription.ls) {
if (lm && lm <= firstSubscription.ls) {
await Rooms.setLastMessageAsRead(_id);
void notifyOnRoomChangedById(_id);
}
});
export const ReadReceipt = {
async markMessagesAsRead(roomId, userId, userLastSeen) {
class ReadReceiptClass {
async markMessagesAsRead(roomId: string, userId: string, userLastSeen: Date) {
if (!settings.get('Message_Read_Receipt_Enabled')) {
return;
}
@ -46,16 +47,22 @@ export const ReadReceipt = {
const room = await Rooms.findOneById(roomId, { projection: { lm: 1 } });
// if users last seen is greater than room's last message, it means the user already have this room marked as read
if (!room || userLastSeen > room.lm) {
if (!room || (room.lm && userLastSeen > room.lm)) {
return;
}
this.storeReadReceipts(await Messages.findVisibleUnreadMessagesByRoomAndDate(roomId, userLastSeen).toArray(), roomId, userId);
void this.storeReadReceipts(
() => {
return Messages.findVisibleUnreadMessagesByRoomAndDate(roomId, userLastSeen).toArray();
},
roomId,
userId,
);
await updateMessages(room);
},
updateMessages(room);
}
async markMessageAsReadBySender(message, { _id: roomId, t }, userId) {
async markMessageAsReadBySender(message: IMessage, { _id: roomId, t }: { _id: string; t: string }, userId: string) {
if (!settings.get('Message_Read_Receipt_Enabled')) {
return;
}
@ -76,10 +83,17 @@ export const ReadReceipt = {
}
const extraData = roomCoordinator.getRoomDirectives(t).getReadReceiptsExtraData(message);
this.storeReadReceipts([message], roomId, userId, extraData);
},
void this.storeReadReceipts(
() => {
return Promise.resolve([message]);
},
roomId,
userId,
extraData,
);
}
async storeThreadMessagesReadReceipts(tmid, userId, userLastSeen) {
async storeThreadMessagesReadReceipts(tmid: string, userId: string, userLastSeen: Date) {
if (!settings.get('Message_Read_Receipt_Enabled')) {
return;
}
@ -87,17 +101,28 @@ export const ReadReceipt = {
const message = await Messages.findOneById(tmid, { projection: { tlm: 1, rid: 1 } });
// if users last seen is greater than thread's last message, it means the user has already marked this thread as read
if (!message || userLastSeen > message.tlm) {
if (!message || (message.tlm && userLastSeen > message.tlm)) {
return;
}
this.storeReadReceipts(await Messages.findUnreadThreadMessagesByDate(tmid, userId, userLastSeen).toArray(), message.rid, userId);
},
void this.storeReadReceipts(
() => {
return Messages.findUnreadThreadMessagesByDate(message.rid, tmid, userId, userLastSeen).toArray();
},
message.rid,
userId,
);
}
async storeReadReceipts(messages, roomId, userId, extraData = {}) {
private async storeReadReceipts(
getMessages: () => Promise<Pick<IMessage, '_id' | 't' | 'pinned' | 'drid' | 'tmid'>[]>,
roomId: string,
userId: string,
extraData: Partial<IReadReceipt> = {},
) {
if (settings.get('Message_Read_Receipt_Store_Users')) {
const ts = new Date();
const receipts = messages.map((message) => ({
const receipts = (await getMessages()).map((message) => ({
_id: Random.id(),
roomId,
userId,
@ -120,18 +145,20 @@ export const ReadReceipt = {
SystemLogger.error({ msg: 'Error inserting read receipts per user', err });
}
}
},
}
async getReceipts(message) {
async getReceipts(message: Pick<IMessage, '_id'>): Promise<IReadReceiptWithUser[]> {
const receipts = await ReadReceipts.findByMessageId(message._id).toArray();
return Promise.all(
receipts.map(async (receipt) => ({
...receipt,
user: receipt.token
user: (receipt.token
? await LivechatVisitors.getVisitorByToken(receipt.token, { projection: { username: 1, name: 1 } })
: await Users.findOneById(receipt.userId, { projection: { username: 1, name: 1 } }),
: await Users.findOneById(receipt.userId, { projection: { username: 1, name: 1, token: 1 } })) as IReadReceiptWithUser['user'],
})),
);
},
};
}
}
export const ReadReceipt = new ReadReceiptClass();

View File

@ -42,7 +42,7 @@ export class MessageReadsService extends ServiceClassInternal implements IMessag
const firstRead = await MessageReads.getMinimumLastSeenByThreadId(tmid);
if (firstRead?.ls) {
const result = await Messages.setThreadMessagesAsRead(tmid, firstRead.ls);
const result = await Messages.setThreadMessagesAsRead(threadMessage.rid, tmid, firstRead.ls);
if (result.modifiedCount > 0) {
void api.broadcast('notify.messagesRead', { rid: threadMessage.rid, tmid, until: firstRead.ls });
}

View File

@ -1,4 +1,4 @@
import type { ReadReceipt as ReadReceiptType, IMessage } from '@rocket.chat/core-typings';
import type { IReadReceiptWithUser, IMessage } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { License } from '@rocket.chat/license';
import { Messages } from '@rocket.chat/models';
@ -11,11 +11,11 @@ import { ReadReceipt } from '../lib/message-read-receipt/ReadReceipt';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
getReadReceipts(options: { messageId: IMessage['_id'] }): ReadReceiptType[];
getReadReceipts(options: { messageId: IMessage['_id'] }): IReadReceiptWithUser[];
}
}
export const getReadReceiptsFunction = async function (messageId: IMessage['_id'], userId: string): Promise<ReadReceiptType[]> {
export const getReadReceiptsFunction = async function (messageId: IMessage['_id'], userId: string): Promise<IReadReceiptWithUser[]> {
if (!License.hasModule('message-read-receipt')) {
throw new Meteor.Error('error-action-not-allowed', 'This is an enterprise feature', { method: 'getReadReceipts' });
}

View File

@ -1,12 +1,12 @@
import type { IUser, IMessage, ReadReceipt, RocketChatRecordDeleted } from '@rocket.chat/core-typings';
import type { IUser, IMessage, IReadReceipt, RocketChatRecordDeleted } from '@rocket.chat/core-typings';
import type { IReadReceiptsModel } from '@rocket.chat/model-typings';
import { BaseRaw } from '@rocket.chat/models';
import type { Collection, FindCursor, Db, IndexDescription, DeleteResult, Filter, UpdateResult, Document } from 'mongodb';
import { otrSystemMessages } from '../../../../app/otr/lib/constants';
export class ReadReceiptsRaw extends BaseRaw<ReadReceipt> implements IReadReceiptsModel {
constructor(db: Db, trash?: Collection<RocketChatRecordDeleted<ReadReceipt>>) {
export class ReadReceiptsRaw extends BaseRaw<IReadReceipt> implements IReadReceiptsModel {
constructor(db: Db, trash?: Collection<RocketChatRecordDeleted<IReadReceipt>>) {
super(db, 'read_receipts', trash);
}
@ -14,7 +14,7 @@ export class ReadReceiptsRaw extends BaseRaw<ReadReceipt> implements IReadReceip
return [{ key: { roomId: 1, userId: 1, messageId: 1 }, unique: true }, { key: { messageId: 1 } }, { key: { userId: 1 } }];
}
findByMessageId(messageId: string): FindCursor<ReadReceipt> {
findByMessageId(messageId: string): FindCursor<IReadReceipt> {
return this.find({ messageId });
}
@ -39,7 +39,7 @@ export class ReadReceiptsRaw extends BaseRaw<ReadReceipt> implements IReadReceip
}
removeOTRReceiptsUntilDate(roomId: string, until: Date): Promise<DeleteResult> {
const query = {
return this.col.deleteMany({
roomId,
t: {
$in: [
@ -50,8 +50,7 @@ export class ReadReceiptsRaw extends BaseRaw<ReadReceipt> implements IReadReceip
],
},
ts: { $lte: until },
};
return this.col.deleteMany(query);
});
}
async removeByIdPinnedTimestampLimitAndUsers(
@ -62,7 +61,7 @@ export class ReadReceiptsRaw extends BaseRaw<ReadReceipt> implements IReadReceip
users: IUser['_id'][],
ignoreThreads: boolean,
): Promise<DeleteResult> {
const query: Filter<ReadReceipt> = {
const query: Filter<IReadReceipt> = {
roomId,
ts,
};

View File

@ -1,5 +1,5 @@
import { getUserDisplayName } from '@rocket.chat/core-typings';
import type { IRoom, RoomType, IUser, IMessage, ReadReceipt, ValueOf, AtLeast } from '@rocket.chat/core-typings';
import type { IRoom, RoomType, IUser, IMessage, IReadReceipt, ValueOf, AtLeast } from '@rocket.chat/core-typings';
import { Users } from '@rocket.chat/models';
import { settings } from '../../../app/settings/server';
@ -56,7 +56,7 @@ class RoomCoordinatorServer extends RoomCoordinator {
includeInRoomSearch(): boolean {
return false;
},
getReadReceiptsExtraData(_message: IMessage): Partial<ReadReceipt> {
getReadReceiptsExtraData(_message: IMessage): Partial<IReadReceipt> {
return {};
},
includeInDashboard(): boolean {

View File

@ -0,0 +1,20 @@
import type { IMessage } from './IMessage/IMessage';
import type { IRoom } from './IRoom';
import type { IUser } from './IUser';
export type IReadReceipt = {
token?: string;
messageId: IMessage['_id'];
roomId: IRoom['_id'];
ts: Date;
t?: IMessage['t'];
pinned?: IMessage['pinned'];
drid?: IMessage['drid'];
tmid?: IMessage['tmid'];
userId: IUser['_id'];
_id: string;
};
export type IReadReceiptWithUser = IReadReceipt & {
user?: Pick<IUser, '_id' | 'name' | 'username'> | undefined;
};

View File

@ -1,13 +0,0 @@
import type { ILivechatVisitor } from './ILivechatVisitor';
import type { IMessage } from './IMessage/IMessage';
import type { IRoom } from './IRoom';
import type { IUser } from './IUser';
export type ReadReceipt = {
messageId: IMessage['_id'];
roomId: IRoom['_id'];
ts: Date;
user: Pick<IUser, '_id' | 'name' | 'username'> | ILivechatVisitor | null;
userId: IUser['_id'];
_id: string;
};

View File

@ -65,7 +65,7 @@ export * from './IAvatar';
export * from './ICustomUserStatus';
export * from './IEmailMessageHistory';
export * from './ReadReceipt';
export * from './IReadReceipt';
export * from './MessageReads';
export * from './IUpload';
export * from './IOEmbedCache';

View File

@ -274,10 +274,11 @@ export interface IMessagesModel extends IBaseModel<IMessage> {
setVisibleMessagesAsRead(rid: string, until: Date): Promise<UpdateResult | Document>;
getMessageByFileIdAndUsername(fileID: string, userId: string): Promise<IMessage | null>;
getMessageByFileId(fileID: string): Promise<IMessage | null>;
setThreadMessagesAsRead(tmid: string, until: Date): Promise<UpdateResult | Document>;
setThreadMessagesAsRead(rid: string, tmid: string, until: Date): Promise<UpdateResult | Document>;
updateRepliesByThreadId(tmid: string, replies: string[], ts: Date): Promise<UpdateResult>;
refreshDiscussionMetadata(room: Pick<IRoom, '_id' | 'msgs' | 'lm'>): Promise<null | WithId<IMessage>>;
findUnreadThreadMessagesByDate(
rid: string,
tmid: string,
userId: string,
after: Date,

View File

@ -1,10 +1,10 @@
import type { ReadReceipt, IUser, IMessage } from '@rocket.chat/core-typings';
import type { IReadReceipt, IUser, IMessage } from '@rocket.chat/core-typings';
import type { FindCursor, DeleteResult, UpdateResult, Document, Filter } from 'mongodb';
import type { IBaseModel } from './IBaseModel';
export interface IReadReceiptsModel extends IBaseModel<ReadReceipt> {
findByMessageId(messageId: string): FindCursor<ReadReceipt>;
export interface IReadReceiptsModel extends IBaseModel<IReadReceipt> {
findByMessageId(messageId: string): FindCursor<IReadReceipt>;
removeByUserId(userId: string): Promise<DeleteResult>;
removeByRoomId(roomId: string): Promise<DeleteResult>;
removeByRoomIds(roomIds: string[]): Promise<DeleteResult>;

View File

@ -1,15 +1,15 @@
import type { IUser, IMessage, ReadReceipt } from '@rocket.chat/core-typings';
import type { IUser, IMessage, IReadReceipt } from '@rocket.chat/core-typings';
import type { IReadReceiptsModel } from '@rocket.chat/model-typings';
import type { FindCursor, DeleteResult, Filter, UpdateResult, Document } from 'mongodb';
import { BaseDummy } from './BaseDummy';
export class ReadReceiptsDummy extends BaseDummy<ReadReceipt> implements IReadReceiptsModel {
export class ReadReceiptsDummy extends BaseDummy<IReadReceipt> implements IReadReceiptsModel {
constructor() {
super('read_receipts');
}
findByMessageId(_messageId: string): FindCursor<ReadReceipt> {
findByMessageId(_messageId: string): FindCursor<IReadReceipt> {
return this.find({});
}

View File

@ -60,6 +60,7 @@ export class MessagesRaw extends BaseRaw<IMessage> implements IMessagesModel {
{ key: { location: '2dsphere' } },
{ key: { slackTs: 1, slackBotId: 1 }, sparse: true },
{ key: { unread: 1 }, sparse: true },
{ key: { rid: 1, unread: 1, ts: 1, tmid: 1, tshow: 1 }, partialFilterExpression: { unread: { $exists: true } } },
{ key: { 'pinnedBy._id': 1 }, sparse: true },
{ key: { 'starred._id': 1 }, sparse: true },
@ -1569,12 +1570,13 @@ export class MessagesRaw extends BaseRaw<IMessage> implements IMessagesModel {
);
}
setThreadMessagesAsRead(tmid: string, until: Date): Promise<UpdateResult | Document> {
setThreadMessagesAsRead(rid: string, tmid: string, until: Date): Promise<UpdateResult | Document> {
return this.updateMany(
{
tmid,
rid,
unread: true,
ts: { $lt: until },
tmid,
},
{
$unset: {
@ -1599,8 +1601,8 @@ export class MessagesRaw extends BaseRaw<IMessage> implements IMessagesModel {
findVisibleUnreadMessagesByRoomAndDate(rid: string, after: Date): FindCursor<Pick<IMessage, '_id' | 't' | 'pinned' | 'drid' | 'tmid'>> {
const query = {
unread: true,
rid,
unread: true,
$or: [
{
tmid: { $exists: false },
@ -1624,13 +1626,15 @@ export class MessagesRaw extends BaseRaw<IMessage> implements IMessagesModel {
}
findUnreadThreadMessagesByDate(
rid: string,
tmid: string,
userId: string,
after: Date,
): FindCursor<Pick<IMessage, '_id' | 't' | 'pinned' | 'drid' | 'tmid'>> {
const query = {
'u._id': { $ne: userId },
rid,
'unread': true,
'u._id': { $ne: userId },
tmid,
'tshow': { $exists: false },
...(after && { ts: { $gt: after } }),

View File

@ -2,7 +2,7 @@ import type {
IMessage,
IRoom,
MessageAttachment,
ReadReceipt,
IReadReceiptWithUser,
OtrSystemMessages,
MessageUrl,
IThreadMainMessage,
@ -1022,7 +1022,7 @@ export type ChatEndpoints = {
};
};
'/v1/chat.getMessageReadReceipts': {
GET: (params: ChatGetMessageReadReceipts) => { receipts: ReadReceipt[] };
GET: (params: ChatGetMessageReadReceipts) => { receipts: IReadReceiptWithUser[] };
};
'/v1/chat.getStarredMessages': {
GET: (params: GetStarredMessages) => {