From aad915d84100c2d645db4e05963d2cd5b75dbeda Mon Sep 17 00:00:00 2001 From: Abhinav Kumar Date: Tue, 24 Feb 2026 17:43:00 +0530 Subject: [PATCH 1/4] fix: read receipts not turning blue when all active users have read Signed-off-by: Abhinav Kumar --- .../server/functions/setUserActiveStatus.ts | 21 +- .../lib/message-read-receipt/ReadReceipt.ts | 2 +- .../read-receipts-deactivated-users.spec.ts | 110 ++++++++ .../functions/setUserActiveStatus.spec.ts | 239 ++++++++++++++++++ .../model-typings/src/models/IRoomsModel.ts | 1 + .../src/models/ISubscriptionsModel.ts | 4 +- packages/models/src/models/Rooms.ts | 9 + packages/models/src/models/Subscriptions.ts | 43 +++- 8 files changed, 410 insertions(+), 19 deletions(-) create mode 100644 apps/meteor/tests/e2e/read-receipts-deactivated-users.spec.ts create mode 100644 apps/meteor/tests/unit/app/lib/server/functions/setUserActiveStatus.spec.ts diff --git a/apps/meteor/app/lib/server/functions/setUserActiveStatus.ts b/apps/meteor/app/lib/server/functions/setUserActiveStatus.ts index 8ebfe0c7449bc..4727ab5df4b6a 100644 --- a/apps/meteor/app/lib/server/functions/setUserActiveStatus.ts +++ b/apps/meteor/app/lib/server/functions/setUserActiveStatus.ts @@ -15,7 +15,7 @@ import { settings } from '../../../settings/server'; import { notifyOnRoomChangedById, notifyOnRoomChangedByUserDM, - notifyOnSubscriptionChangedByNameAndRoomType, + notifyOnSubscriptionChangedByUserId, notifyOnUserChange, } from '../lib/notifyListener'; @@ -112,10 +112,23 @@ export async function setUserActiveStatus( await callbacks.run('afterDeactivateUser', user); } - if (user.username) { - const { modifiedCount } = await Subscriptions.setArchivedByUsername(user.username, !active); + if (user.username && active === false) { + const { modifiedCount } = await Subscriptions.setArchivedByUsername(user.username, true); if (modifiedCount) { - void notifyOnSubscriptionChangedByNameAndRoomType({ t: 'd', name: user.username }); + void notifyOnSubscriptionChangedByUserId(userId); + } + } + + if (user.username && active === true && !user.active) { + // When reactivating, only unarchive subscriptions to non-archived rooms + const subscriptions = await Subscriptions.findByUserId(userId, { projection: { rid: 1 } }).toArray(); + const roomIds = subscriptions.map((sub) => sub.rid); + const archivedRooms = await Rooms.findArchivedByRoomIds(roomIds, { projection: { _id: 1 } }).toArray(); + const archivedRoomIds = archivedRooms.map((room) => room._id); + + const { modifiedCount } = await Subscriptions.unarchiveByUsernameExcludingRoomIds(user.username, archivedRoomIds); + if (modifiedCount) { + void notifyOnSubscriptionChangedByUserId(userId); } } diff --git a/apps/meteor/ee/server/lib/message-read-receipt/ReadReceipt.ts b/apps/meteor/ee/server/lib/message-read-receipt/ReadReceipt.ts index 8be6bc78ab1b8..62122cab7c3ed 100644 --- a/apps/meteor/ee/server/lib/message-read-receipt/ReadReceipt.ts +++ b/apps/meteor/ee/server/lib/message-read-receipt/ReadReceipt.ts @@ -72,7 +72,7 @@ class ReadReceiptClass { } // mark message as read if the sender is the only one in the room - const isUserAlone = (await Subscriptions.countByRoomIdAndNotUserId(roomId, userId)) === 0; + const isUserAlone = (await Subscriptions.countUnarchivedByRoomIdAndNotUserId(roomId, userId)) === 0; if (isUserAlone) { const result = await Messages.setAsReadById(message._id); if (result.modifiedCount > 0) { diff --git a/apps/meteor/tests/e2e/read-receipts-deactivated-users.spec.ts b/apps/meteor/tests/e2e/read-receipts-deactivated-users.spec.ts new file mode 100644 index 0000000000000..07aaf8e9e1c40 --- /dev/null +++ b/apps/meteor/tests/e2e/read-receipts-deactivated-users.spec.ts @@ -0,0 +1,110 @@ +import type { Page } from '@playwright/test'; + +import { IS_EE } from './config/constants'; +import { createAuxContext } from './fixtures/createAuxContext'; +import { Users } from './fixtures/userStates'; +import { HomeChannel } from './page-objects'; +import { createTargetChannel, deleteChannel, setSettingValueById } from './utils'; +import { expect, test } from './utils/test'; + +test.use({ storageState: Users.admin.state }); + +test.describe.serial('read-receipts-deactivated-users', () => { + let poHomeChannel: HomeChannel; + let targetChannel: string; + let user1Context: { page: Page; poHomeChannel: HomeChannel } | undefined; + let user2Context: { page: Page; poHomeChannel: HomeChannel } | undefined; + + test.skip(!IS_EE, 'Enterprise Only'); + + test.beforeAll(async ({ api }) => { + targetChannel = await createTargetChannel(api, { members: ['user1', 'user2'] }); + await setSettingValueById(api, 'Message_Read_Receipt_Enabled', true); + await setSettingValueById(api, 'Message_Read_Receipt_Store_Users', true); + }); + + test.afterAll(async ({ api }) => { + await setSettingValueById(api, 'Message_Read_Receipt_Enabled', false); + await setSettingValueById(api, 'Message_Read_Receipt_Store_Users', false); + + await api.post('/users.setActiveStatus', { userId: Users.user1.data._id, activeStatus: true }); + await api.post('/users.setActiveStatus', { userId: Users.user2.data._id, activeStatus: true }); + + await deleteChannel(api, targetChannel); + }); + + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeChannel(page); + await page.goto('/home'); + }); + + test.afterEach(async () => { + if (user1Context) { + await user1Context.page.close(); + } + if (user2Context) { + await user2Context.page.close(); + } + user1Context = undefined; + user2Context = undefined; + }); + + test('should correctly handle read receipts as users are deactivated', async ({ browser, api, page }) => { + const { page: page1 } = await createAuxContext(browser, Users.user1); + const user1Ctx = { page: page1, poHomeChannel: new HomeChannel(page1) }; + user1Context = user1Ctx; + + const { page: page2 } = await createAuxContext(browser, Users.user2); + const user2Ctx = { page: page2, poHomeChannel: new HomeChannel(page2) }; + user2Context = user2Ctx; + + await poHomeChannel.navbar.openChat(targetChannel); + await user1Ctx.poHomeChannel.navbar.openChat(targetChannel); + await user2Ctx.poHomeChannel.navbar.openChat(targetChannel); + + await test.step('when all users are active', async () => { + await poHomeChannel.content.sendMessage('Message 1: All three users active'); + + await expect(user1Ctx.poHomeChannel.content.lastUserMessage).toBeVisible(); + await expect(user2Ctx.poHomeChannel.content.lastUserMessage).toBeVisible(); + + await expect(poHomeChannel.content.lastUserMessage.getByRole('status', { name: 'Message viewed' })).toBeVisible(); + + await poHomeChannel.content.openLastMessageMenu(); + await page.locator('role=menuitem[name="Read receipts"]').click(); + await expect(page.getByRole('dialog').getByRole('listitem')).toHaveCount(3); + await page.getByRole('button', { name: 'Close' }).click(); + }); + + await test.step('when some users are deactivated', async () => { + await api.post('/users.setActiveStatus', { userId: Users.user1.data._id, activeStatus: false }); + + await poHomeChannel.content.sendMessage('Message 2: User1 deactivated, two active users'); + + await expect(user2Ctx.poHomeChannel.content.lastUserMessage).toBeVisible(); + + await expect(poHomeChannel.content.lastUserMessage.getByRole('status', { name: 'Message viewed' })).toBeVisible(); + + await poHomeChannel.content.openLastMessageMenu(); + await page.locator('role=menuitem[name="Read receipts"]').click(); + await expect(page.getByRole('dialog').getByRole('listitem')).toHaveCount(2); + await page.getByRole('button', { name: 'Close' }).click(); + }); + + await test.step('when only one user remains active (user alone in room)', async () => { + await api.post('/users.setActiveStatus', { userId: Users.user2.data._id, activeStatus: false }); + + await poHomeChannel.content.sendMessage('Message 3: Only admin active'); + + await expect(poHomeChannel.content.lastUserMessage.getByRole('status', { name: 'Message viewed' })).toBeVisible(); + + await poHomeChannel.content.openLastMessageMenu(); + await page.locator('role=menuitem[name="Read receipts"]').click(); + await expect(page.getByRole('dialog').getByRole('listitem')).toHaveCount(1); + await page.getByRole('button', { name: 'Close' }).click(); + }); + + await api.post('/users.setActiveStatus', { userId: Users.user1.data._id, activeStatus: true }); + await api.post('/users.setActiveStatus', { userId: Users.user2.data._id, activeStatus: true }); + }); +}); diff --git a/apps/meteor/tests/unit/app/lib/server/functions/setUserActiveStatus.spec.ts b/apps/meteor/tests/unit/app/lib/server/functions/setUserActiveStatus.spec.ts new file mode 100644 index 0000000000000..b0e47efb47e55 --- /dev/null +++ b/apps/meteor/tests/unit/app/lib/server/functions/setUserActiveStatus.spec.ts @@ -0,0 +1,239 @@ +import { expect } from 'chai'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +describe('setUserActiveStatus', () => { + const userId = 'test-user-id'; + const username = 'testuser'; + + const stubs = { + Users: { + findOneById: sinon.stub(), + setUserActive: sinon.stub(), + findOneAdmin: sinon.stub(), + countActiveUsersInRoles: sinon.stub(), + unsetLoginTokens: sinon.stub(), + unsetReason: sinon.stub(), + findActiveByUserIds: sinon.stub(), + }, + Subscriptions: { + setArchivedByUsername: sinon.stub(), + findByUserId: sinon.stub(), + unarchiveByUsernameExcludingRoomIds: sinon.stub(), + }, + Rooms: { + findArchivedByRoomIds: sinon.stub(), + setDmReadOnlyByUserId: sinon.stub(), + getDirectConversationsByUserId: sinon.stub(), + }, + check: sinon.stub(), + callbacks: { + run: sinon.stub(), + }, + settings: { + get: sinon.stub(), + }, + notifyOnUserChange: sinon.stub(), + notifyOnSubscriptionChangedByUserId: sinon.stub(), + notifyOnRoomChangedByUserDM: sinon.stub(), + notifyOnRoomChangedById: sinon.stub(), + getSubscribedRoomsForUserWithDetails: sinon.stub(), + shouldRemoveOrChangeOwner: sinon.stub(), + getUserSingleOwnedRooms: sinon.stub(), + closeOmnichannelConversations: sinon.stub(), + relinquishRoomOwnerships: sinon.stub(), + Mailer: { + sendNoWrap: sinon.stub(), + }, + Accounts: { + emailTemplates: { + userActivated: { + subject: sinon.stub(), + html: sinon.stub(), + }, + }, + }, + isUserFederated: sinon.stub(), + }; + + const { setUserActiveStatus } = proxyquire.noCallThru().load('../../../../../../app/lib/server/functions/setUserActiveStatus', { + 'meteor/check': { check: stubs.check }, + 'meteor/meteor': { Meteor: { Error } }, + 'meteor/accounts-base': { Accounts: stubs.Accounts }, + '@rocket.chat/core-typings': { isUserFederated: stubs.isUserFederated, isDirectMessageRoom: sinon.stub() }, + '@rocket.chat/models': { Users: stubs.Users, Subscriptions: stubs.Subscriptions, Rooms: stubs.Rooms }, + './closeOmnichannelConversations': { closeOmnichannelConversations: stubs.closeOmnichannelConversations }, + './getRoomsWithSingleOwner': { + shouldRemoveOrChangeOwner: stubs.shouldRemoveOrChangeOwner, + getSubscribedRoomsForUserWithDetails: stubs.getSubscribedRoomsForUserWithDetails, + }, + './getUserSingleOwnedRooms': { getUserSingleOwnedRooms: stubs.getUserSingleOwnedRooms }, + './relinquishRoomOwnerships': { relinquishRoomOwnerships: stubs.relinquishRoomOwnerships }, + '../../../../server/lib/callbacks': { callbacks: stubs.callbacks }, + '../../../mailer/server/api': stubs.Mailer, + '../../../settings/server': { settings: stubs.settings }, + '../lib/notifyListener': { + notifyOnRoomChangedById: stubs.notifyOnRoomChangedById, + notifyOnRoomChangedByUserDM: stubs.notifyOnRoomChangedByUserDM, + notifyOnSubscriptionChangedByUserId: stubs.notifyOnSubscriptionChangedByUserId, + notifyOnUserChange: stubs.notifyOnUserChange, + }, + }); + + beforeEach(() => { + stubs.Users.findOneById.resolves({ _id: userId, username, active: true }); + stubs.Users.setUserActive.resolves(); + stubs.Users.unsetLoginTokens.resolves(); + stubs.Users.unsetReason.resolves(); + stubs.isUserFederated.returns(false); + stubs.Users.findOneAdmin.resolves(null); + stubs.Users.countActiveUsersInRoles.resolves(2); + stubs.Users.findActiveByUserIds.returns({ toArray: sinon.stub().resolves([]) }); + stubs.getSubscribedRoomsForUserWithDetails.resolves([]); + stubs.shouldRemoveOrChangeOwner.returns(false); + stubs.closeOmnichannelConversations.resolves(); + stubs.relinquishRoomOwnerships.resolves(); + stubs.callbacks.run.resolves(); + stubs.settings.get.returns(false); + stubs.Rooms.setDmReadOnlyByUserId.resolves({ modifiedCount: 0 }); + stubs.Rooms.getDirectConversationsByUserId.returns({ toArray: sinon.stub().resolves([]) }); + stubs.notifyOnRoomChangedById.returns(undefined); + stubs.notifyOnUserChange.returns(undefined); + stubs.notifyOnRoomChangedByUserDM.returns(undefined); + stubs.notifyOnSubscriptionChangedByUserId.returns(undefined); + }); + + afterEach(() => { + Object.values(stubs).forEach((stub) => { + if (typeof stub === 'object' && stub !== null) { + Object.values(stub).forEach((method) => { + if (typeof method?.reset === 'function') { + method.reset(); + } + }); + } else if (typeof stub?.reset === 'function') { + stub.reset(); + } + }); + }); + + describe('Subscription archiving on deactivation', () => { + it('should archive all user subscriptions when user is deactivated', async () => { + stubs.Subscriptions.setArchivedByUsername.resolves({ modifiedCount: 5 }); + + await setUserActiveStatus(userId, false); + + expect(stubs.Subscriptions.setArchivedByUsername.calledOnce).to.be.true; + expect(stubs.Subscriptions.setArchivedByUsername.calledWith(username, true)).to.be.true; + expect(stubs.notifyOnSubscriptionChangedByUserId.calledWith(userId)).to.be.true; + }); + + it('should not notify if no subscriptions were modified', async () => { + stubs.Subscriptions.setArchivedByUsername.resolves({ modifiedCount: 0 }); + + await setUserActiveStatus(userId, false); + + expect(stubs.Subscriptions.setArchivedByUsername.calledOnce).to.be.true; + expect(stubs.notifyOnSubscriptionChangedByUserId.called).to.be.false; + }); + }); + + describe('Subscription unarchiving on reactivation', () => { + beforeEach(() => { + stubs.Users.findOneById.resolves({ _id: userId, username, active: false }); + }); + + it('should unarchive subscriptions excluding archived rooms when user is reactivated', async () => { + const subscriptions = [{ rid: 'room1' }, { rid: 'room2' }, { rid: 'room3' }]; + const archivedRooms = [{ _id: 'room2' }]; + + stubs.Subscriptions.findByUserId.returns({ + toArray: sinon.stub().resolves(subscriptions), + }); + stubs.Rooms.findArchivedByRoomIds.returns({ + toArray: sinon.stub().resolves(archivedRooms), + }); + stubs.Subscriptions.unarchiveByUsernameExcludingRoomIds.resolves({ modifiedCount: 2 }); + + await setUserActiveStatus(userId, true); + + expect(stubs.Subscriptions.findByUserId.calledWith(userId, { projection: { rid: 1 } })).to.be.true; + expect(stubs.Rooms.findArchivedByRoomIds.calledWith(['room1', 'room2', 'room3'], { projection: { _id: 1 } })).to.be.true; + expect(stubs.Subscriptions.unarchiveByUsernameExcludingRoomIds.calledWith(username, ['room2'])).to.be.true; + expect(stubs.notifyOnSubscriptionChangedByUserId.calledWith(userId)).to.be.true; + }); + + it('should unarchive all subscriptions if no rooms are archived', async () => { + const subscriptions = [{ rid: 'room1' }, { rid: 'room2' }]; + + stubs.Subscriptions.findByUserId.returns({ + toArray: sinon.stub().resolves(subscriptions), + }); + stubs.Rooms.findArchivedByRoomIds.returns({ + toArray: sinon.stub().resolves([]), + }); + stubs.Subscriptions.unarchiveByUsernameExcludingRoomIds.resolves({ modifiedCount: 2 }); + + await setUserActiveStatus(userId, true); + + expect(stubs.Subscriptions.unarchiveByUsernameExcludingRoomIds.calledWith(username, [])).to.be.true; + expect(stubs.notifyOnSubscriptionChangedByUserId.calledWith(userId)).to.be.true; + }); + + it('should not notify if no subscriptions were modified during reactivation', async () => { + stubs.Subscriptions.findByUserId.returns({ + toArray: sinon.stub().resolves([{ rid: 'room1' }]), + }); + stubs.Rooms.findArchivedByRoomIds.returns({ + toArray: sinon.stub().resolves([]), + }); + stubs.Subscriptions.unarchiveByUsernameExcludingRoomIds.resolves({ modifiedCount: 0 }); + + await setUserActiveStatus(userId, true); + + expect(stubs.Subscriptions.unarchiveByUsernameExcludingRoomIds.calledOnce).to.be.true; + expect(stubs.notifyOnSubscriptionChangedByUserId.called).to.be.false; + }); + }); + + describe('User without username', () => { + it('should not archive subscriptions for user without username', async () => { + stubs.Users.findOneById.resolves({ _id: userId, username: undefined, active: true }); + + await setUserActiveStatus(userId, false); + + expect(stubs.Subscriptions.setArchivedByUsername.called).to.be.false; + }); + + it('should not unarchive subscriptions for user without username', async () => { + stubs.Users.findOneById.resolves({ _id: userId, username: undefined, active: false }); + + await setUserActiveStatus(userId, true); + + expect(stubs.Subscriptions.findByUserId.called).to.be.false; + expect(stubs.Subscriptions.unarchiveByUsernameExcludingRoomIds.called).to.be.false; + }); + }); + + describe('Error handling and validation', () => { + it('should return false if user is not found', async () => { + stubs.Users.findOneById.resolves(null); + + const result = await setUserActiveStatus(userId, false); + + expect(result).to.be.false; + expect(stubs.Subscriptions.setArchivedByUsername.called).to.be.false; + }); + + it('should throw error for federated users', async () => { + stubs.isUserFederated.returns(true); + + try { + await setUserActiveStatus(userId, false); + expect.fail('Should have thrown an error'); + } catch (error: any) { + expect(error.message).to.equal('error-user-is-federated'); + } + }); + }); +}); diff --git a/packages/model-typings/src/models/IRoomsModel.ts b/packages/model-typings/src/models/IRoomsModel.ts index 2639f6adcb3a7..b297d0163a25f 100644 --- a/packages/model-typings/src/models/IRoomsModel.ts +++ b/packages/model-typings/src/models/IRoomsModel.ts @@ -261,6 +261,7 @@ export interface IRoomsModel extends IBaseModel { addImportIds(rid: string, importIds: string[]): Promise; archiveById(rid: string): Promise; unarchiveById(rid: string): Promise; + findArchivedByRoomIds(roomIds: IRoom['_id'][], options?: FindOptions): FindCursor; setNameById(rid: string, name: string, fname: string): Promise; setIncMsgCountAndSetLastMessageUpdateQuery( inc: number, diff --git a/packages/model-typings/src/models/ISubscriptionsModel.ts b/packages/model-typings/src/models/ISubscriptionsModel.ts index 27a8e2dadc709..ae0a31a452398 100644 --- a/packages/model-typings/src/models/ISubscriptionsModel.ts +++ b/packages/model-typings/src/models/ISubscriptionsModel.ts @@ -38,6 +38,8 @@ export interface ISubscriptionsModel extends IBaseModel { countUnarchivedByRoomId(rid: string): Promise; + countUnarchivedByRoomIdAndNotUserId(rid: string, uid: string): Promise; + isUserInRole(uid: IUser['_id'], roleId: IRole['_id'], rid?: IRoom['_id']): Promise; setAsReadByRoomIdAndUserId( @@ -241,6 +243,7 @@ export interface ISubscriptionsModel extends IBaseModel { archiveByRoomId(roomId: string): Promise; unarchiveByRoomId(roomId: string): Promise; + unarchiveByUsernameExcludingRoomIds(username: string, excludeRoomIds: string[]): Promise; updateNameAndAlertByRoomId(roomId: string, name: string, fname: string): Promise; findByRoomIdWhenUsernameExists(rid: string, options?: FindOptions): FindCursor; setCustomFieldsDirectMessagesByUserId(userId: string, fields: Record): Promise; @@ -331,7 +334,6 @@ export interface ISubscriptionsModel extends IBaseModel { countByRoomId(roomId: string, options?: CountDocumentsOptions): Promise; countByUserIdExceptType(userId: string, typeException: ISubscription['t']): Promise; openByRoomIdAndUserId(roomId: string, userId: string): Promise; - countByRoomIdAndNotUserId(rid: string, uid: string): Promise; countByRoomIdWhenUsernameExists(rid: string): Promise; setE2EKeyByUserIdAndRoomId(userId: string, rid: string, key: string): Promise>; countUsersInRoles(roles: IRole['_id'][], rid: IRoom['_id'] | undefined): Promise; diff --git a/packages/models/src/models/Rooms.ts b/packages/models/src/models/Rooms.ts index 9bcac18cf72fe..a81cba82d05fc 100644 --- a/packages/models/src/models/Rooms.ts +++ b/packages/models/src/models/Rooms.ts @@ -1597,6 +1597,15 @@ export class RoomsRaw extends BaseRaw implements IRoomsModel { return this.updateOne(query, update); } + findArchivedByRoomIds(roomIds: IRoom['_id'][], options?: FindOptions): FindCursor { + const query: Filter = { + _id: { $in: roomIds }, + archived: true, + }; + + return this.find(query, options || {}); + } + setNameById(_id: IRoom['_id'], name: IRoom['name'], fname: IRoom['fname']): Promise { const query: Filter = { _id }; diff --git a/packages/models/src/models/Subscriptions.ts b/packages/models/src/models/Subscriptions.ts index f85013fd4bce4..9480c77b59bfe 100644 --- a/packages/models/src/models/Subscriptions.ts +++ b/packages/models/src/models/Subscriptions.ts @@ -67,6 +67,7 @@ export class SubscriptionsRaw extends BaseRaw implements ISubscri { key: { rid: 1, ls: 1 } }, { key: { 'u._id': 1, 'autotranslate': 1 } }, { key: { 'v._id': 1, 'open': 1 } }, + { key: { rid: 1, archived: 1, ls: 1 } }, ]; } @@ -135,17 +136,6 @@ export class SubscriptionsRaw extends BaseRaw implements ISubscri return this.find(query, options); } - countByRoomIdAndNotUserId(rid: string, uid: string): Promise { - const query = { - rid, - 'u._id': { - $ne: uid, - }, - }; - - return this.countDocuments(query); - } - findByLivechatRoomIdAndNotUserId(roomId: string, userId: string, options: FindOptions = {}): FindCursor { const query = { 'rid': roomId, @@ -176,6 +166,17 @@ export class SubscriptionsRaw extends BaseRaw implements ISubscri return this.countDocuments(query); } + countUnarchivedByRoomIdAndNotUserId(rid: string, uid: string): Promise { + const query = { + rid, + 'archived': { $ne: true }, + 'u._id': { + $ne: uid, + }, + }; + return this.countDocuments(query); + } + async isUserInRole(uid: IUser['_id'], roleId: IRole['_id'], rid?: IRoom['_id']): Promise { if (rid == null) { return false; @@ -1283,6 +1284,7 @@ export class SubscriptionsRaw extends BaseRaw implements ISubscri return this.findOne( { rid, + archived: { $ne: true }, }, { sort: { @@ -1324,6 +1326,22 @@ export class SubscriptionsRaw extends BaseRaw implements ISubscri return this.updateMany(query, update); } + unarchiveByUsernameExcludingRoomIds(username: string, excludeRoomIds: string[]): Promise { + const query: Filter = { + 'u.username': username, + 'rid': { $nin: excludeRoomIds }, + 'archived': true, + }; + + const update: UpdateFilter = { + $set: { + archived: false, + }, + }; + + return this.updateMany(query, update); + } + hideByRoomIdAndUserId(roomId: string, userId: string): Promise { const query = { 'rid': roomId, @@ -1736,8 +1754,7 @@ export class SubscriptionsRaw extends BaseRaw implements ISubscri setArchivedByUsername(username: string, archived: boolean): Promise { const query: Filter = { - t: 'd', - name: username, + 'u.username': username, }; const update: UpdateFilter = { From f24d4ea792ca02c56f6c1ec24384598a94cbf569 Mon Sep 17 00:00:00 2001 From: Abhinav Kumar Date: Tue, 24 Feb 2026 17:44:45 +0530 Subject: [PATCH 2/4] added changeset Signed-off-by: Abhinav Kumar --- .changeset/hot-lies-divide.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/hot-lies-divide.md diff --git a/.changeset/hot-lies-divide.md b/.changeset/hot-lies-divide.md new file mode 100644 index 0000000000000..89b596fdba55a --- /dev/null +++ b/.changeset/hot-lies-divide.md @@ -0,0 +1,7 @@ +--- +'@rocket.chat/model-typings': patch +'@rocket.chat/models': patch +'@rocket.chat/meteor': patch +--- + +Fixes an issue where messages appeared as unread even when all active users had read them. Read receipts now correctly ignore deactivated users. From f6f0360bdec9c34d17fa2e9946e7f047c3a93c21 Mon Sep 17 00:00:00 2001 From: Abhinav Kumar Date: Mon, 2 Mar 2026 20:54:14 +0530 Subject: [PATCH 3/4] unarchive subscription of only active users Signed-off-by: Abhinav Kumar --- .../app/lib/server/functions/unarchiveRoom.ts | 5 +- .../src/models/ISubscriptionsModel.ts | 2 +- packages/models/src/models/Subscriptions.ts | 50 +++++++++++++++---- 3 files changed, 44 insertions(+), 13 deletions(-) diff --git a/apps/meteor/app/lib/server/functions/unarchiveRoom.ts b/apps/meteor/app/lib/server/functions/unarchiveRoom.ts index 699f9c3701b1c..635b976c9bb36 100644 --- a/apps/meteor/app/lib/server/functions/unarchiveRoom.ts +++ b/apps/meteor/app/lib/server/functions/unarchiveRoom.ts @@ -7,8 +7,9 @@ import { notifyOnRoomChangedById, notifyOnSubscriptionChangedByRoomId } from '.. export const unarchiveRoom = async function (rid: string, user: IMessage['u']): Promise { await Rooms.unarchiveById(rid); - const unarchiveResponse = await Subscriptions.unarchiveByRoomId(rid); - if (unarchiveResponse.modifiedCount) { + const hasUnarchived = await Subscriptions.unarchiveByRoomId(rid); + + if (hasUnarchived) { void notifyOnSubscriptionChangedByRoomId(rid); } diff --git a/packages/model-typings/src/models/ISubscriptionsModel.ts b/packages/model-typings/src/models/ISubscriptionsModel.ts index ae0a31a452398..21acc7200f651 100644 --- a/packages/model-typings/src/models/ISubscriptionsModel.ts +++ b/packages/model-typings/src/models/ISubscriptionsModel.ts @@ -242,7 +242,7 @@ export interface ISubscriptionsModel extends IBaseModel { findUnreadByUserId(userId: string): FindCursor; archiveByRoomId(roomId: string): Promise; - unarchiveByRoomId(roomId: string): Promise; + unarchiveByRoomId(roomId: string): Promise; unarchiveByUsernameExcludingRoomIds(username: string, excludeRoomIds: string[]): Promise; updateNameAndAlertByRoomId(roomId: string, name: string, fname: string): Promise; findByRoomIdWhenUsernameExists(rid: string, options?: FindOptions): FindCursor; diff --git a/packages/models/src/models/Subscriptions.ts b/packages/models/src/models/Subscriptions.ts index 9480c77b59bfe..8651712614419 100644 --- a/packages/models/src/models/Subscriptions.ts +++ b/packages/models/src/models/Subscriptions.ts @@ -1312,18 +1312,48 @@ export class SubscriptionsRaw extends BaseRaw implements ISubscri return this.updateMany(query, update); } - unarchiveByRoomId(roomId: string): Promise { - const query = { rid: roomId }; + async unarchiveByRoomId(roomId: string): Promise { + const hasArchived = await this.col.countDocuments({ rid: roomId, archived: true }, { limit: 1 }); + if (!hasArchived) { + return false; + } - const update: UpdateFilter = { - $set: { - alert: false, - open: true, - archived: false, - }, - }; + await this.col + .aggregate( + [ + { $match: { rid: roomId, archived: true } }, + { $project: { '_id': 1, 'u._id': 1 } }, + { + $lookup: { + from: Users.getCollectionName(), + localField: 'u._id', + foreignField: '_id', + as: '_user', + pipeline: [{ $project: { active: 1 } }], + }, + }, + { $match: { '_user.active': true } }, + { + $project: { + _id: 1, + archived: { $literal: false }, + open: { $literal: true }, + alert: { $literal: false }, + }, + }, + { + $merge: { + into: this.getCollectionName(), + whenMatched: 'merge', + whenNotMatched: 'discard', + }, + }, + ], + { allowDiskUse: true }, + ) + .toArray(); - return this.updateMany(query, update); + return true; } unarchiveByUsernameExcludingRoomIds(username: string, excludeRoomIds: string[]): Promise { From 6b40a462e1a9c553621c5c907b17d9b71c5a8821 Mon Sep 17 00:00:00 2001 From: Abhinav Kumar Date: Mon, 2 Mar 2026 21:17:38 +0530 Subject: [PATCH 4/4] minor change Signed-off-by: Abhinav Kumar --- .../lib/server/functions/setUserActiveStatus.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/apps/meteor/app/lib/server/functions/setUserActiveStatus.ts b/apps/meteor/app/lib/server/functions/setUserActiveStatus.ts index 4727ab5df4b6a..b48b711d4ee1e 100644 --- a/apps/meteor/app/lib/server/functions/setUserActiveStatus.ts +++ b/apps/meteor/app/lib/server/functions/setUserActiveStatus.ts @@ -19,6 +19,13 @@ import { notifyOnUserChange, } from '../lib/notifyListener'; +async function getArchivedRoomIdsForUser(userId: string): Promise { + const subscriptions = await Subscriptions.findByUserId(userId, { projection: { rid: 1 } }).toArray(); + const roomIds = subscriptions.map((sub) => sub.rid); + const archivedRooms = await Rooms.findArchivedByRoomIds(roomIds, { projection: { _id: 1 } }).toArray(); + return archivedRooms.map((room) => room._id); +} + async function reactivateDirectConversations(userId: string) { // since both users can be deactivated at the same time, we should just reactivate rooms if both users are active // for that, we need to fetch the direct messages, fetch the users involved and then the ids of rooms we can reactivate @@ -120,12 +127,7 @@ export async function setUserActiveStatus( } if (user.username && active === true && !user.active) { - // When reactivating, only unarchive subscriptions to non-archived rooms - const subscriptions = await Subscriptions.findByUserId(userId, { projection: { rid: 1 } }).toArray(); - const roomIds = subscriptions.map((sub) => sub.rid); - const archivedRooms = await Rooms.findArchivedByRoomIds(roomIds, { projection: { _id: 1 } }).toArray(); - const archivedRoomIds = archivedRooms.map((room) => room._id); - + const archivedRoomIds = await getArchivedRoomIdsForUser(userId); const { modifiedCount } = await Subscriptions.unarchiveByUsernameExcludingRoomIds(user.username, archivedRoomIds); if (modifiedCount) { void notifyOnSubscriptionChangedByUserId(userId);