diff --git a/imports/message-read-receipt/server/dbIndexes.js b/imports/message-read-receipt/server/dbIndexes.js new file mode 100644 index 00000000000..b88d4021a7b --- /dev/null +++ b/imports/message-read-receipt/server/dbIndexes.js @@ -0,0 +1,5 @@ +RocketChat.models.Messages.tryEnsureIndex({ + unread: 1 +}, { + sparse: true +}); diff --git a/imports/message-read-receipt/server/index.js b/imports/message-read-receipt/server/index.js new file mode 100644 index 00000000000..b0a36ad00ee --- /dev/null +++ b/imports/message-read-receipt/server/index.js @@ -0,0 +1,2 @@ +import './dbIndexes'; +import './settings'; diff --git a/imports/message-read-receipt/server/lib/ReadReceipt.js b/imports/message-read-receipt/server/lib/ReadReceipt.js new file mode 100644 index 00000000000..fe033a8e872 --- /dev/null +++ b/imports/message-read-receipt/server/lib/ReadReceipt.js @@ -0,0 +1,55 @@ +import { Random } from 'meteor/random'; +import ModelReadReceipts from '../models/ReadReceipts'; + +const rawReadReceipts = ModelReadReceipts.model.rawCollection(); + +// @TODO create a debounced function by roomId, so multiple calls to same roomId runs only once + +export const ReadReceipt = { + markMessagesAsRead(roomId, userId, userLastSeen) { + if (!RocketChat.settings.get('Message_Read_Receipt_Enabled')) { + return; + } + + const room = RocketChat.models.Rooms.findOneById(roomId, { fields: { 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 (userLastSeen > room.lm) { + return; + } + + const firstSubscription = RocketChat.models.Subscriptions.getMinimumLastSeenByRoomId(roomId); + // console.log('userLastSeen ->', userLastSeen); + // console.log('firstSubscription ->', firstSubscription); + // console.log('room ->', room); + + // last time room was read is already past room's last message, so does nothing everybody have this room already + // if (firstSubscription.ls > room.lm) { + // console.log('already read by everyone'); + + // return; + // } + + // @TODO maybe store firstSubscription in room object so we don't need to call the above update method + // if firstSubscription on room didn't change + + if (RocketChat.settings.get('Message_Read_Receipt_Store_Users')) { + const receipts = RocketChat.models.Messages.findUnreadMessagesByRoomAndDate(roomId, userLastSeen).map(message => { + return { + _id: Random.id(), + roomId, + userId, + messageId: message._id + }; + }); + + try { + rawReadReceipts.insertMany(receipts); + } catch (e) { + console.error('Error inserting read receipts per user'); + } + } + + RocketChat.models.Messages.setAsRead(roomId, firstSubscription.ls); + } +}; diff --git a/imports/message-read-receipt/server/models/ReadReceipts.js b/imports/message-read-receipt/server/models/ReadReceipts.js new file mode 100644 index 00000000000..b5031aa3a8d --- /dev/null +++ b/imports/message-read-receipt/server/models/ReadReceipts.js @@ -0,0 +1,15 @@ +class ModelReadReceipts extends RocketChat.models._Base { + constructor() { + super(...arguments); + + this.tryEnsureIndex({ + roomId: 1, + userId: 1, + messageId: 1 + }, { + unique: 1 + }); + } +} + +export default new ModelReadReceipts('message_read_receipt', true); diff --git a/imports/message-read-receipt/server/settings.js b/imports/message-read-receipt/server/settings.js new file mode 100644 index 00000000000..94f49ecfd73 --- /dev/null +++ b/imports/message-read-receipt/server/settings.js @@ -0,0 +1,12 @@ +RocketChat.settings.add('Message_Read_Receipt_Enabled', false, { + group: 'Message', + type: 'boolean', + public: true +}); + +RocketChat.settings.add('Message_Read_Receipt_Store_Users', false, { + group: 'Message', + type: 'boolean', + public: false, + enableQuery: { _id: 'Message_Read_Receipt_Enabled', value: true } +}); diff --git a/imports/startup/server/index.js b/imports/startup/server/index.js new file mode 100644 index 00000000000..261b86e752d --- /dev/null +++ b/imports/startup/server/index.js @@ -0,0 +1 @@ +import '../../message-read-receipt/server'; diff --git a/packages/rocketchat-lib/server/functions/sendMessage.js b/packages/rocketchat-lib/server/functions/sendMessage.js index 46b44edd90c..af100e1f547 100644 --- a/packages/rocketchat-lib/server/functions/sendMessage.js +++ b/packages/rocketchat-lib/server/functions/sendMessage.js @@ -31,6 +31,10 @@ RocketChat.sendMessage = function(user, message, room, upsert = false) { }); } } + + // @TODO test if setting is enabled? + message.unread = true; + message = RocketChat.callbacks.run('beforeSaveMessage', message); if (message) { // Avoid saving sandstormSessionId to the database diff --git a/packages/rocketchat-lib/server/models/Messages.js b/packages/rocketchat-lib/server/models/Messages.js index 90f8bf06876..f74a0cefa1e 100644 --- a/packages/rocketchat-lib/server/models/Messages.js +++ b/packages/rocketchat-lib/server/models/Messages.js @@ -622,4 +622,30 @@ RocketChat.models.Messages = new class extends RocketChat.models._Base { getMessageByFileId(fileID) { return this.findOne({ 'file._id': fileID }); } + + setAsRead(rid, until) { + return this.update({ + rid, + unread: true, + ts: { $lt: until } + }, { + $unset: { + unread: 1 + } + }, { + multi: true + }); + } + + findUnreadMessagesByRoomAndDate(rid, after) { + return this.find({ + unread: true, + rid, + ts: { $gt: after } + }, { + fields: { + _id: 1 + } + }); + } }; diff --git a/packages/rocketchat-lib/server/models/Subscriptions.js b/packages/rocketchat-lib/server/models/Subscriptions.js index 4c59e26add7..a2c92b0aae7 100644 --- a/packages/rocketchat-lib/server/models/Subscriptions.js +++ b/packages/rocketchat-lib/server/models/Subscriptions.js @@ -182,6 +182,19 @@ class ModelSubscriptions extends RocketChat.models._Base { return this.find(query, { fields: { unread: 1 } }); } + getMinimumLastSeenByRoomId(rid) { + return this.db.findOne({ + rid + }, { + sort: { + ls: 1 + }, + fields: { + ls: 1 + } + }); + } + // UPDATE archiveByRoomId(roomId) { const query = diff --git a/packages/rocketchat-theme/client/imports/components/messages.css b/packages/rocketchat-theme/client/imports/components/messages.css index 16e1b298698..56228e89aa2 100644 --- a/packages/rocketchat-theme/client/imports/components/messages.css +++ b/packages/rocketchat-theme/client/imports/components/messages.css @@ -77,6 +77,14 @@ left: 0; } } + + &.temp .read-receipt { + color: #999; + } + + & .read-receipt.read { + color: blue; + } } .messages-box .rc-popover__list { diff --git a/packages/rocketchat-theme/client/imports/general/base.css b/packages/rocketchat-theme/client/imports/general/base.css index 1a62424e1c1..bb35fc58eac 100644 --- a/packages/rocketchat-theme/client/imports/general/base.css +++ b/packages/rocketchat-theme/client/imports/general/base.css @@ -123,6 +123,7 @@ button { vertical-align: -0.15em; + fill: currentColor; } .ps-scrollbar-y-rail { diff --git a/packages/rocketchat-ui-master/public/icons.svg b/packages/rocketchat-ui-master/public/icons.svg index 3bf31b7d5ca..2105c2fb1df 100644 --- a/packages/rocketchat-ui-master/public/icons.svg +++ b/packages/rocketchat-ui-master/public/icons.svg @@ -80,6 +80,7 @@ + diff --git a/packages/rocketchat-ui-message/client/message.html b/packages/rocketchat-ui-message/client/message.html index 9beba25aa67..d5ccdb88f43 100644 --- a/packages/rocketchat-ui-message/client/message.html +++ b/packages/rocketchat-ui-message/client/message.html @@ -114,5 +114,10 @@ + {{#with readReceipt}} +
+ {{> icon icon="check" }} +
+ {{/with}} diff --git a/packages/rocketchat-ui-message/client/message.js b/packages/rocketchat-ui-message/client/message.js index 8938fa77214..c24d6b55c10 100644 --- a/packages/rocketchat-ui-message/client/message.js +++ b/packages/rocketchat-ui-message/client/message.js @@ -284,6 +284,15 @@ Template.message.helpers({ }, isSnippet() { return this.actionContext === 'snippeted'; + }, + readReceipt() { + if (!RocketChat.settings.get('Message_Read_Receipt_Enabled')) { + return; + } + + return { + readByEveryone: !this.unread && 'read' + }; } }); diff --git a/server/main.js b/server/main.js new file mode 100644 index 00000000000..282492e67e2 --- /dev/null +++ b/server/main.js @@ -0,0 +1 @@ +import '/imports/startup/server'; diff --git a/server/methods/readMessages.js b/server/methods/readMessages.js index d40e92ab289..cd3e635da2e 100644 --- a/server/methods/readMessages.js +++ b/server/methods/readMessages.js @@ -1,13 +1,26 @@ +import { ReadReceipt } from '../../imports/message-read-receipt/server/lib/ReadReceipt'; + Meteor.methods({ readMessages(rid) { check(rid, String); - if (!Meteor.userId()) { + const userId = Meteor.userId(); + + if (!userId) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'readMessages' }); } - return RocketChat.models.Subscriptions.setAsReadByRoomIdAndUserId(rid, Meteor.userId()); + const userSubscription = RocketChat.models.Subscriptions.findOneByRoomIdAndUserId(rid, userId); + + // this prevents cache from updating object reference/pointer + const { ls: lastSeen } = userSubscription; + + RocketChat.models.Subscriptions.setAsReadByRoomIdAndUserId(rid, userId); + + Meteor.defer(() => { + ReadReceipt.markMessagesAsRead(rid, userId, lastSeen); + }); } });