diff --git a/apps/meteor/app/api/server/v1/chat.ts b/apps/meteor/app/api/server/v1/chat.ts index 64dc7d1f6f2e0..0fbde59d9107d 100644 --- a/apps/meteor/app/api/server/v1/chat.ts +++ b/apps/meteor/app/api/server/v1/chat.ts @@ -29,6 +29,7 @@ import { isChatSyncThreadMessagesProps, isChatGetStarredMessagesProps, isChatGetDiscussionsProps, + isChatGetMessageReadCountProps, validateBadRequestErrorResponse, validateUnauthorizedErrorResponse, } from '@rocket.chat/rest-typings'; @@ -48,6 +49,7 @@ import { processWebhookMessage } from '../../../lib/server/functions/processWebh import { getSingleMessage } from '../../../lib/server/methods/getSingleMessage'; import { executeSendMessage } from '../../../lib/server/methods/sendMessage'; import { executeUpdateMessage } from '../../../lib/server/methods/updateMessage'; +import { getMessageReadCount as getMessageReadCountHelper } from '../../../read-counter/server/getMessageReadCount'; import { applyAirGappedRestrictionsValidation } from '../../../license/server/airGappedRestrictionsWrapper'; import { pinMessage, unpinMessage } from '../../../message-pin/server/pinMessage'; import { starMessage } from '../../../message-star/server/starMessage'; @@ -174,6 +176,37 @@ API.v1.addRoute( }, ); +API.v1.addRoute( + 'chat.getMessageReadCount', + { + authRequired: true, + validateParams: isChatGetMessageReadCountProps, + }, + { + async get() { + const { messageId } = this.queryParams; + + const message = await Messages.findOneById(messageId, { projection: { rid: 1 } }); + + if (!message) { + return API.v1.failure('The required "messageId" param is missing or invalid.'); + } + + if (!(await canAccessRoomIdAsync(message.rid, this.userId))) { + throw new Meteor.Error('error-not-allowed', 'Not allowed'); + } + + const result = await getMessageReadCountHelper(messageId, this.userId); + + if (!result) { + return API.v1.failure(); + } + + return API.v1.success(result); + }, + }, +); + type ChatPinMessage = { messageId: IMessage['_id']; }; diff --git a/apps/meteor/app/lib/server/functions/loadMessageHistory.ts b/apps/meteor/app/lib/server/functions/loadMessageHistory.ts index 5addbd896889b..14f0bef872e64 100644 --- a/apps/meteor/app/lib/server/functions/loadMessageHistory.ts +++ b/apps/meteor/app/lib/server/functions/loadMessageHistory.ts @@ -5,6 +5,8 @@ import type { FindOptions } from 'mongodb'; import { settings } from '../../../settings/server/cached'; import { normalizeMessagesForUser } from '../../../utils/server/lib/normalizeMessagesForUser'; import { getHiddenSystemMessages } from '../lib/getHiddenSystemMessages'; +import { getMessageReadCount } from '../../../read-counter/server/getMessageReadCount'; +import { SystemLogger } from '../../../../server/lib/logger/system'; export async function loadMessageHistory({ userId, @@ -52,6 +54,20 @@ export async function loadMessageHistory({ ).toArray() : await Messages.findVisibleByRoomIdNotContainingTypes(rid, hiddenMessageTypes, options, showThreadMessages).toArray(); const messages = await normalizeMessagesForUser(records, userId); + + if (messages[0]?._id && userId) { + const readCountResult = await getMessageReadCount(messages[0]._id, userId); + + if (readCountResult) { + SystemLogger.info({ + msg: '[read-count demo] newest message readCount', + rid, + mid: messages[0]._id, + readCount: readCountResult.readCount, + uid: userId, + }); + } + } let unreadNotLoaded = 0; let firstUnread; diff --git a/apps/meteor/app/read-counter/server/getMessageReadCount.ts b/apps/meteor/app/read-counter/server/getMessageReadCount.ts new file mode 100644 index 0000000000000..0515d134d12a2 --- /dev/null +++ b/apps/meteor/app/read-counter/server/getMessageReadCount.ts @@ -0,0 +1,45 @@ +import type { IMessage, IRoom, IUser } from '@rocket.chat/core-typings'; +import { Authorization } from '@rocket.chat/core-services'; +import { Messages, Rooms, Subscriptions } from '@rocket.chat/models'; + +export const getMessageReadCount = async ( + messageId: IMessage['_id'], + userId: IUser['_id'], +): Promise<{ readCount: number } | null> => { + const message = await Messages.findOneById(messageId, { + projection: { _id: 1, rid: 1, ts: 1, 'u._id': 1 }, + }); + + if (!message) { + return null; + } + + const room = await Rooms.findOneById(message.rid as IRoom['_id'], { + projection: { _id: 1, t: 1 }, + }); + + if (!room) { + return null; + } + + // Exclude DMs from read counter + if (room.t === 'd') { + return null; + } + + // Ensure the requesting user can access the room + if (!(await Authorization.canReadRoom(room, { _id: userId }))) { + return null; + } + + if (!message.ts) { + return { readCount: 0 }; + } + + const excludeUserId = message.u?._id; + + const readCount = await Subscriptions.countReadersByRoomIdAndMessageTs(message.rid, message.ts, excludeUserId); + + return { readCount }; +}; + diff --git a/apps/meteor/server/methods/getMessageReadCount.ts b/apps/meteor/server/methods/getMessageReadCount.ts new file mode 100644 index 0000000000000..d330c14719e43 --- /dev/null +++ b/apps/meteor/server/methods/getMessageReadCount.ts @@ -0,0 +1,27 @@ +import type { IMessage } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { getMessageReadCount as getMessageReadCountHelper } from '../../app/read-counter/server/getMessageReadCount'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + getMessageReadCount(options: { messageId: IMessage['_id'] }): { readCount: number } | null; + } +} + +Meteor.methods({ + async getMessageReadCount({ messageId }) { + check(messageId, String); + + const uid = Meteor.userId(); + if (!uid) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'getMessageReadCount' }); + } + + return getMessageReadCountHelper(messageId, uid); + }, +}); + diff --git a/apps/meteor/server/methods/index.ts b/apps/meteor/server/methods/index.ts index dd738080e05d6..bc400337ead9b 100644 --- a/apps/meteor/server/methods/index.ts +++ b/apps/meteor/server/methods/index.ts @@ -13,6 +13,7 @@ import './createDirectMessage'; import './deleteFileMessage'; import './deleteUser'; import './getAvatarSuggestion'; +import './getMessageReadCount'; import './getRoomById'; import './getRoomIdByNameOrId'; import './getRoomNameById'; diff --git a/packages/model-typings/src/models/ISubscriptionsModel.ts b/packages/model-typings/src/models/ISubscriptionsModel.ts index 121da7e8ad1c3..efd12fff81a20 100644 --- a/packages/model-typings/src/models/ISubscriptionsModel.ts +++ b/packages/model-typings/src/models/ISubscriptionsModel.ts @@ -22,6 +22,8 @@ import type { DocumentWithProjection } from '../types/DocumentWithProjection'; export interface ISubscriptionsModel extends IBaseModel { getBadgeCount(uid: string): Promise; + countReadersByRoomIdAndMessageTs(rid: string, messageTs: Date, excludeUserId?: string): Promise; + findOneByRoomIdAndUserId(rid: string, uid: string, options?: FindOptions): Promise; findByUserIdAndRoomIds(userId: string, roomIds: Array, options?: FindOptions): FindCursor; diff --git a/packages/models/src/models/Subscriptions.ts b/packages/models/src/models/Subscriptions.ts index dbd72d644cf11..8cc5161e15ac8 100644 --- a/packages/models/src/models/Subscriptions.ts +++ b/packages/models/src/models/Subscriptions.ts @@ -86,6 +86,20 @@ export class SubscriptionsRaw extends BaseRaw implements ISubscri return result?.total || 0; } + countReadersByRoomIdAndMessageTs(rid: string, messageTs: Date, excludeUserId?: string): Promise { + const query: Filter = { + rid, + archived: { $ne: true }, + ls: { $gte: messageTs }, + 'u._id': { + ...(excludeUserId ? { $ne: excludeUserId } : {}), + $exists: true, + }, + }; + + return this.countDocuments(query); + } + findOneByRoomIdAndUserId(rid: string, uid: string, options: FindOptions = {}): Promise { const query = { rid, diff --git a/packages/rest-typings/src/v1/chat.ts b/packages/rest-typings/src/v1/chat.ts index c40865bd73bb8..8d2d4f11d4bc2 100644 --- a/packages/rest-typings/src/v1/chat.ts +++ b/packages/rest-typings/src/v1/chat.ts @@ -574,6 +574,23 @@ const ChatGetMessageReadReceiptsSchema = { export const isChatGetMessageReadReceiptsProps = ajv.compile(ChatGetMessageReadReceiptsSchema); +type ChatGetMessageReadCount = { + messageId: IMessage['_id']; +}; + +const ChatGetMessageReadCountSchema = { + type: 'object', + properties: { + messageId: { + type: 'string', + }, + }, + required: ['messageId'], + additionalProperties: false, +}; + +export const isChatGetMessageReadCountProps = ajv.compile(ChatGetMessageReadCountSchema); + type GetStarredMessages = { roomId: IRoom['_id']; count?: number; @@ -1054,6 +1071,9 @@ export type ChatEndpoints = { '/v1/chat.getMessageReadReceipts': { GET: (params: ChatGetMessageReadReceipts) => { receipts: IReadReceiptWithUser[] }; }; + '/v1/chat.getMessageReadCount': { + GET: (params: ChatGetMessageReadCount) => { readCount: number }; + }; '/v1/chat.getStarredMessages': { GET: (params: GetStarredMessages) => { messages: IMessage[];