diff --git a/apps/meteor/client/views/room/modals/ReadReceiptsModal/ReadReceiptRow.tsx b/apps/meteor/client/views/room/modals/ReadReceiptsModal/ReadReceiptRow.tsx index 6b10cc602804c..d3bf9ca20e105 100644 --- a/apps/meteor/client/views/room/modals/ReadReceiptsModal/ReadReceiptRow.tsx +++ b/apps/meteor/client/views/room/modals/ReadReceiptsModal/ReadReceiptRow.tsx @@ -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 }); diff --git a/apps/meteor/definition/IRoomTypeConfig.ts b/apps/meteor/definition/IRoomTypeConfig.ts index 54d5181b44fd8..0a8e7161c6f06 100644 --- a/apps/meteor/definition/IRoomTypeConfig.ts +++ b/apps/meteor/definition/IRoomTypeConfig.ts @@ -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; includeInRoomSearch: () => boolean; - getReadReceiptsExtraData: (message: IMessage) => Partial; + getReadReceiptsExtraData: (message: IMessage) => Partial; includeInDashboard: () => boolean; roomFind?: (rid: string) => Promise | Promise | IRoom | undefined; } diff --git a/apps/meteor/ee/server/api/chat.ts b/apps/meteor/ee/server/api/chat.ts index 568ebe8892788..51742c2f09717 100644 --- a/apps/meteor/ee/server/api/chat.ts +++ b/apps/meteor/ee/server/api/chat.ts @@ -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[]; }; }; } diff --git a/apps/meteor/ee/server/lib/message-read-receipt/ReadReceipt.js b/apps/meteor/ee/server/lib/message-read-receipt/ReadReceipt.ts similarity index 61% rename from apps/meteor/ee/server/lib/message-read-receipt/ReadReceipt.js rename to apps/meteor/ee/server/lib/message-read-receipt/ReadReceipt.ts index b1d9f43985ad8..8be6bc78ab1b8 100644 --- a/apps/meteor/ee/server/lib/message-read-receipt/ReadReceipt.js +++ b/apps/meteor/ee/server/lib/message-read-receipt/ReadReceipt.ts @@ -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 = {}; +const debounceByRoomId = function (fn: (room: IRoom) => Promise) { + 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[]>, + roomId: string, + userId: string, + extraData: Partial = {}, + ) { 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): Promise { 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(); diff --git a/apps/meteor/ee/server/local-services/message-reads/service.ts b/apps/meteor/ee/server/local-services/message-reads/service.ts index 8e7a2c093bdcd..6565b61c65430 100644 --- a/apps/meteor/ee/server/local-services/message-reads/service.ts +++ b/apps/meteor/ee/server/local-services/message-reads/service.ts @@ -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 }); } diff --git a/apps/meteor/ee/server/methods/getReadReceipts.ts b/apps/meteor/ee/server/methods/getReadReceipts.ts index d9ef843f15a95..ee323dc4f4837 100644 --- a/apps/meteor/ee/server/methods/getReadReceipts.ts +++ b/apps/meteor/ee/server/methods/getReadReceipts.ts @@ -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 { +export const getReadReceiptsFunction = async function (messageId: IMessage['_id'], userId: string): Promise { if (!License.hasModule('message-read-receipt')) { throw new Meteor.Error('error-action-not-allowed', 'This is an enterprise feature', { method: 'getReadReceipts' }); } diff --git a/apps/meteor/ee/server/models/raw/ReadReceipts.ts b/apps/meteor/ee/server/models/raw/ReadReceipts.ts index f356016b49d30..0f55c2fc226bc 100644 --- a/apps/meteor/ee/server/models/raw/ReadReceipts.ts +++ b/apps/meteor/ee/server/models/raw/ReadReceipts.ts @@ -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 implements IReadReceiptsModel { - constructor(db: Db, trash?: Collection>) { +export class ReadReceiptsRaw extends BaseRaw implements IReadReceiptsModel { + constructor(db: Db, trash?: Collection>) { super(db, 'read_receipts', trash); } @@ -14,7 +14,7 @@ export class ReadReceiptsRaw extends BaseRaw implements IReadReceip return [{ key: { roomId: 1, userId: 1, messageId: 1 }, unique: true }, { key: { messageId: 1 } }, { key: { userId: 1 } }]; } - findByMessageId(messageId: string): FindCursor { + findByMessageId(messageId: string): FindCursor { return this.find({ messageId }); } @@ -39,7 +39,7 @@ export class ReadReceiptsRaw extends BaseRaw implements IReadReceip } removeOTRReceiptsUntilDate(roomId: string, until: Date): Promise { - const query = { + return this.col.deleteMany({ roomId, t: { $in: [ @@ -50,8 +50,7 @@ export class ReadReceiptsRaw extends BaseRaw implements IReadReceip ], }, ts: { $lte: until }, - }; - return this.col.deleteMany(query); + }); } async removeByIdPinnedTimestampLimitAndUsers( @@ -62,7 +61,7 @@ export class ReadReceiptsRaw extends BaseRaw implements IReadReceip users: IUser['_id'][], ignoreThreads: boolean, ): Promise { - const query: Filter = { + const query: Filter = { roomId, ts, }; diff --git a/apps/meteor/server/lib/rooms/roomCoordinator.ts b/apps/meteor/server/lib/rooms/roomCoordinator.ts index 0f5ff97bdc213..54e35aafdde50 100644 --- a/apps/meteor/server/lib/rooms/roomCoordinator.ts +++ b/apps/meteor/server/lib/rooms/roomCoordinator.ts @@ -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 { + getReadReceiptsExtraData(_message: IMessage): Partial { return {}; }, includeInDashboard(): boolean { diff --git a/packages/core-typings/src/IReadReceipt.ts b/packages/core-typings/src/IReadReceipt.ts new file mode 100644 index 0000000000000..e83029ffd0c56 --- /dev/null +++ b/packages/core-typings/src/IReadReceipt.ts @@ -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 | undefined; +}; diff --git a/packages/core-typings/src/ReadReceipt.ts b/packages/core-typings/src/ReadReceipt.ts deleted file mode 100644 index 6576264eacc5f..0000000000000 --- a/packages/core-typings/src/ReadReceipt.ts +++ /dev/null @@ -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 | ILivechatVisitor | null; - userId: IUser['_id']; - _id: string; -}; diff --git a/packages/core-typings/src/index.ts b/packages/core-typings/src/index.ts index 4157b3702a281..7e8bba71d6b02 100644 --- a/packages/core-typings/src/index.ts +++ b/packages/core-typings/src/index.ts @@ -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'; diff --git a/packages/model-typings/src/models/IMessagesModel.ts b/packages/model-typings/src/models/IMessagesModel.ts index 20ba7048ac092..bd7ea6c2fa5be 100644 --- a/packages/model-typings/src/models/IMessagesModel.ts +++ b/packages/model-typings/src/models/IMessagesModel.ts @@ -274,10 +274,11 @@ export interface IMessagesModel extends IBaseModel { setVisibleMessagesAsRead(rid: string, until: Date): Promise; getMessageByFileIdAndUsername(fileID: string, userId: string): Promise; getMessageByFileId(fileID: string): Promise; - setThreadMessagesAsRead(tmid: string, until: Date): Promise; + setThreadMessagesAsRead(rid: string, tmid: string, until: Date): Promise; updateRepliesByThreadId(tmid: string, replies: string[], ts: Date): Promise; refreshDiscussionMetadata(room: Pick): Promise>; findUnreadThreadMessagesByDate( + rid: string, tmid: string, userId: string, after: Date, diff --git a/packages/model-typings/src/models/IReadReceiptsModel.ts b/packages/model-typings/src/models/IReadReceiptsModel.ts index 4d8933141db30..1e6cab8b8ef66 100644 --- a/packages/model-typings/src/models/IReadReceiptsModel.ts +++ b/packages/model-typings/src/models/IReadReceiptsModel.ts @@ -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 { - findByMessageId(messageId: string): FindCursor; +export interface IReadReceiptsModel extends IBaseModel { + findByMessageId(messageId: string): FindCursor; removeByUserId(userId: string): Promise; removeByRoomId(roomId: string): Promise; removeByRoomIds(roomIds: string[]): Promise; diff --git a/packages/models/src/dummy/ReadReceipts.ts b/packages/models/src/dummy/ReadReceipts.ts index 90a5cbdf900f9..5929ac1c7c60e 100644 --- a/packages/models/src/dummy/ReadReceipts.ts +++ b/packages/models/src/dummy/ReadReceipts.ts @@ -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 implements IReadReceiptsModel { +export class ReadReceiptsDummy extends BaseDummy implements IReadReceiptsModel { constructor() { super('read_receipts'); } - findByMessageId(_messageId: string): FindCursor { + findByMessageId(_messageId: string): FindCursor { return this.find({}); } diff --git a/packages/models/src/models/Messages.ts b/packages/models/src/models/Messages.ts index 452c3fbb05d4d..c87b403b0785b 100644 --- a/packages/models/src/models/Messages.ts +++ b/packages/models/src/models/Messages.ts @@ -60,6 +60,7 @@ export class MessagesRaw extends BaseRaw 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 implements IMessagesModel { ); } - setThreadMessagesAsRead(tmid: string, until: Date): Promise { + setThreadMessagesAsRead(rid: string, tmid: string, until: Date): Promise { return this.updateMany( { - tmid, + rid, unread: true, ts: { $lt: until }, + tmid, }, { $unset: { @@ -1599,8 +1601,8 @@ export class MessagesRaw extends BaseRaw implements IMessagesModel { findVisibleUnreadMessagesByRoomAndDate(rid: string, after: Date): FindCursor> { const query = { - unread: true, rid, + unread: true, $or: [ { tmid: { $exists: false }, @@ -1624,13 +1626,15 @@ export class MessagesRaw extends BaseRaw implements IMessagesModel { } findUnreadThreadMessagesByDate( + rid: string, tmid: string, userId: string, after: Date, ): FindCursor> { const query = { - 'u._id': { $ne: userId }, + rid, 'unread': true, + 'u._id': { $ne: userId }, tmid, 'tshow': { $exists: false }, ...(after && { ts: { $gt: after } }), diff --git a/packages/rest-typings/src/v1/chat.ts b/packages/rest-typings/src/v1/chat.ts index adf48ced2e460..5670bde058f67 100644 --- a/packages/rest-typings/src/v1/chat.ts +++ b/packages/rest-typings/src/v1/chat.ts @@ -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) => {