Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/hot-lies-divide.md
Original file line number Diff line number Diff line change
@@ -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.
23 changes: 19 additions & 4 deletions apps/meteor/app/lib/server/functions/setUserActiveStatus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,17 @@ import { settings } from '../../../settings/server';
import {
notifyOnRoomChangedById,
notifyOnRoomChangedByUserDM,
notifyOnSubscriptionChangedByNameAndRoomType,
notifyOnSubscriptionChangedByUserId,
notifyOnUserChange,
} from '../lib/notifyListener';

async function getArchivedRoomIdsForUser(userId: string): Promise<string[]> {
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
Expand Down Expand Up @@ -112,10 +119,18 @@ 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 notifyOnSubscriptionChangedByUserId(userId);
}
}

if (user.username && active === true && !user.active) {
const archivedRoomIds = await getArchivedRoomIdsForUser(userId);
const { modifiedCount } = await Subscriptions.unarchiveByUsernameExcludingRoomIds(user.username, archivedRoomIds);
if (modifiedCount) {
void notifyOnSubscriptionChangedByNameAndRoomType({ t: 'd', name: user.username });
void notifyOnSubscriptionChangedByUserId(userId);
}
}

Expand Down
5 changes: 3 additions & 2 deletions apps/meteor/app/lib/server/functions/unarchiveRoom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ import { notifyOnRoomChangedById, notifyOnSubscriptionChangedByRoomId } from '..
export const unarchiveRoom = async function (rid: string, user: IMessage['u']): Promise<void> {
await Rooms.unarchiveById(rid);

const unarchiveResponse = await Subscriptions.unarchiveByRoomId(rid);
if (unarchiveResponse.modifiedCount) {
const hasUnarchived = await Subscriptions.unarchiveByRoomId(rid);

if (hasUnarchived) {
void notifyOnSubscriptionChangedByRoomId(rid);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
110 changes: 110 additions & 0 deletions apps/meteor/tests/e2e/read-receipts-deactivated-users.spec.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
});
Loading
Loading