fix: desktop notifications not respecting privacy settings (#36156)

pull/36206/head
Abhinav Kumar 7 months ago committed by GitHub
parent 3779de0e8c
commit d2ac7f0988
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 5
      .changeset/silent-dolphins-argue.md
  2. 1
      apps/meteor/.mocharc.js
  3. 231
      apps/meteor/app/lib/server/functions/notifications/desktop.spec.ts
  4. 9
      apps/meteor/app/lib/server/functions/notifications/desktop.ts
  5. 3
      apps/meteor/app/lib/server/lib/sendNotificationsOnMessage.ts
  6. 22
      apps/meteor/client/hooks/notification/useNotification.ts
  7. 3
      apps/meteor/definition/IRoomTypeConfig.ts
  8. 97
      apps/meteor/server/lib/rooms/buildNotificationDetails.spec.ts
  9. 64
      apps/meteor/server/lib/rooms/buildNotificationDetails.ts
  10. 21
      apps/meteor/server/lib/rooms/roomCoordinator.ts
  11. 30
      apps/meteor/server/lib/rooms/roomTypes/direct.ts
  12. 16
      apps/meteor/server/lib/rooms/roomTypes/livechat.ts
  13. 15
      apps/meteor/server/lib/rooms/roomTypes/voip.ts

@ -0,0 +1,5 @@
---
'@rocket.chat/meteor': patch
---
Fixes an issue where the `Show Message in Notification` and `Show Channel/Group/Username in Notification` setting was ignored in desktop notifications. Also ensures users are redirected to the correct room on interacting with the notifications.

@ -41,5 +41,6 @@ module.exports = {
'tests/unit/server/**/*.spec.ts',
'app/api/server/lib/**/*.spec.ts',
'app/file-upload/server/**/*.spec.ts',
'app/lib/server/functions/notifications/**/*.spec.ts',
],
};

@ -0,0 +1,231 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { UserStatus } from '@rocket.chat/core-typings';
import { expect } from 'chai';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
const broadcastStub = sinon.stub();
const settingsGetStub = sinon.stub();
const { buildNotificationDetails } = proxyquire.noCallThru().load('../../../../../server/lib/rooms/buildNotificationDetails.ts', {
'../../../app/settings/server': { settings: { get: settingsGetStub } },
});
const { roomCoordinator } = proxyquire.noCallThru().load('../../../../../server/lib/rooms/roomCoordinator.ts', {
'../../../app/settings/server': { settings: { get: settingsGetStub } },
'./buildNotificationDetails': { buildNotificationDetails },
});
['public', 'private', 'voip', 'livechat'].forEach((type) => {
proxyquire.noCallThru().load(`../../../../../server/lib/rooms/roomTypes/${type}.ts`, {
'../../../../app/settings/server': { settings: { get: settingsGetStub } },
'../roomCoordinator': { roomCoordinator },
'../buildNotificationDetails': { buildNotificationDetails },
});
});
proxyquire.noCallThru().load('../../../../../server/lib/rooms/roomTypes/direct.ts', {
'../../../../app/settings/server': { settings: { get: settingsGetStub } },
'../roomCoordinator': { roomCoordinator },
'../buildNotificationDetails': { buildNotificationDetails },
'meteor/meteor': { Meteor: { userId: () => 'user123' } },
'@rocket.chat/models': {
Subscription: {
findOneByRoomIdAndUserId: () => ({ name: 'general', fname: 'general' }),
},
},
});
const { notifyDesktopUser } = proxyquire.noCallThru().load('./desktop', {
'../../../../settings/server': { settings: { get: settingsGetStub } },
'../../../../metrics/server': {
metrics: {
notificationsSent: { inc: sinon.stub() },
},
},
'@rocket.chat/core-services': { api: { broadcast: broadcastStub } },
'../../lib/sendNotificationsOnMessage': {},
'../../../../../server/lib/rooms/roomCoordinator': { roomCoordinator },
});
const fakeUserId = 'user123';
const createTestData = (t: IRoom['t'] = 'c', showPushMessage = false, showUserOrRoomName = false, groupDM = false) => {
const sender = { _id: 'sender123', name: 'Alice', username: 'alice' };
let uids: string[] | undefined;
if (t === 'd') {
uids = groupDM ? ['sender123', 'user123', 'otherUser123'] : ['sender123', 'user123'];
}
const room: Partial<IRoom> = {
t,
_id: 'room123',
msgs: 0,
_updatedAt: new Date(),
u: sender,
usersCount: uids ? uids.length : 2,
fname: uids?.length === 2 ? sender.name : 'general',
name: uids?.length === 2 ? sender.username : 'general',
uids,
};
const message = {
_id: 'msg123',
rid: 'room123',
tmid: null,
u: sender,
msg: 'Fake message here',
};
const receiver = {
_id: 'user123',
language: 'en',
username: 'receiver-username',
emails: [{ address: 'receiver@example.com', verified: true }],
active: true,
status: UserStatus.OFFLINE,
statusConnection: 'offline',
};
let expectedTitle: string | undefined;
if (showUserOrRoomName) {
switch (t) {
case 'c':
case 'p':
expectedTitle = `#${room.name}`;
break;
case 'l':
case 'v':
expectedTitle = `[Omnichannel] ${room.name}`;
break;
case 'd':
expectedTitle = room.name;
break;
}
}
let expectedNotificationMessage: string;
if (!showPushMessage) {
expectedNotificationMessage = 'You have a new message';
} else if (!showUserOrRoomName) {
// No prefix if showUserOrRoomName is false
expectedNotificationMessage = message.msg;
} else if (t === 'd' && uids && uids.length > 2) {
expectedNotificationMessage = `${sender.username}: ${message.msg}`;
} else {
switch (t) {
case 'c':
case 'p':
expectedNotificationMessage = `${sender.username}: ${message.msg}`;
break;
case 'l':
case 'v':
case 'd':
expectedNotificationMessage = message.msg;
break;
}
}
return {
room: room as IRoom,
user: sender,
message,
receiver,
expectedTitle,
expectedNotificationMessage,
};
};
describe('notifyDesktopUser privacy settings across all room types', () => {
const roomTypes: Array<{ t: IRoom['t']; isGroupDM?: boolean }> = [
{ t: 'c' },
{ t: 'p' },
{ t: 'l' },
{ t: 'v' },
{ t: 'd', isGroupDM: false },
{ t: 'd', isGroupDM: true },
];
afterEach(() => {
broadcastStub.resetHistory();
settingsGetStub.resetHistory();
});
roomTypes.forEach(({ t, isGroupDM = false }) => {
let roomLabel: string;
if (t === 'c') {
roomLabel = 'channel';
} else if (t === 'p') {
roomLabel = 'private';
} else if (t === 'l') {
roomLabel = 'livechat';
} else if (t === 'v') {
roomLabel = 'voip';
} else if (t === 'd' && isGroupDM) {
roomLabel = 'direct (group DM)';
} else {
roomLabel = 'direct (1:1 DM)';
}
describe(`when room type is "${roomLabel}"`, () => {
[
{ showPushMessage: false, showUserOrRoomName: true },
{ showPushMessage: true, showUserOrRoomName: false },
{ showPushMessage: false, showUserOrRoomName: false },
{ showPushMessage: true, showUserOrRoomName: true },
].forEach(({ showPushMessage, showUserOrRoomName }) => {
const label = `Push_show_message=${
showPushMessage ? 'true' : 'false'
} and Push_show_username_room=${showUserOrRoomName ? 'true' : 'false'}`;
it(`should handle settings: ${label}`, async () => {
const { room, user, message, receiver, expectedTitle, expectedNotificationMessage } = createTestData(
t,
showPushMessage,
showUserOrRoomName,
isGroupDM,
);
settingsGetStub.withArgs('Push_show_message').returns(showPushMessage);
settingsGetStub.withArgs('Push_show_username_room').returns(showUserOrRoomName);
settingsGetStub.withArgs('UI_Use_Real_Name').returns(false);
const duration = 4000;
const notificationMessage = message.msg;
await notifyDesktopUser({
userId: fakeUserId,
user,
room,
message,
duration,
notificationMessage,
receiver,
});
expect(broadcastStub.calledOnce).to.be.true;
const [eventName, targetUserId, payload] = broadcastStub.firstCall.args as [string, string, any];
expect(eventName).to.equal('notify.desktop');
expect(targetUserId).to.equal(fakeUserId);
expect(payload.text).to.equal(expectedNotificationMessage);
if (showPushMessage) {
expect(payload.payload.message?.msg).to.equal(message.msg);
} else {
expect(!!payload.payload.message?.msg).to.equal(false);
}
if (showUserOrRoomName) {
expect(payload.title).to.equal(expectedTitle);
expect(payload.payload.name).to.equal(room.name);
} else {
expect(!!payload.title).to.equal(false, `Found title to be ${payload.title} when expected falsy`);
expect(!!payload.payload.name).to.equal(false, `Found name to be ${payload.name} when expected falsy`);
}
});
});
});
});
});

@ -4,6 +4,7 @@ import type { IMessage, IRoom, IUser, AtLeast } from '@rocket.chat/core-typings'
import { roomCoordinator } from '../../../../../server/lib/rooms/roomCoordinator';
import { metrics } from '../../../../metrics/server';
import { settings } from '../../../../settings/server';
import type { SubscriptionAggregation } from '../../lib/sendNotificationsOnMessage';
/**
* Send notification to user
@ -22,6 +23,7 @@ export async function notifyDesktopUser({
room,
duration,
notificationMessage,
receiver,
}: {
userId: string;
user: AtLeast<IUser, '_id' | 'name' | 'username'>;
@ -29,10 +31,13 @@ export async function notifyDesktopUser({
room: IRoom;
duration?: number;
notificationMessage: string;
receiver?: SubscriptionAggregation['receiver'][number];
}): Promise<void> {
const { title, text, name } = await roomCoordinator
.getRoomDirectives(room.t)
.getNotificationDetails(room, user, notificationMessage, userId);
.getNotificationDetails(room, user, notificationMessage, userId, receiver?.language);
const showPushMessage = settings.get<boolean>('Push_show_message');
const payload = {
title: title || '',
@ -51,7 +56,7 @@ export async function notifyDesktopUser({
sender: message.u,
type: room.t,
message: {
msg: 'msg' in message ? message.msg : '',
msg: 'msg' in message && showPushMessage ? message.msg : '',
...('t' in message && {
t: message.t,
}),

@ -24,7 +24,7 @@ import { getEmailData, shouldNotifyEmail } from '../functions/notifications/emai
import { messageContainsHighlight } from '../functions/notifications/messageContainsHighlight';
import { getPushData, shouldNotifyMobile } from '../functions/notifications/mobile';
type SubscriptionAggregation = {
export type SubscriptionAggregation = {
receiver: [Pick<IUser, 'active' | 'emails' | 'language' | 'status' | 'statusConnection' | 'username' | 'settings'> | null];
} & Pick<
ISubscription,
@ -134,6 +134,7 @@ export const sendNotification = async ({
user: sender,
message,
room,
receiver,
});
}

@ -22,7 +22,7 @@ export const useNotification = () => {
return;
}
const { rid } = notification.payload;
const { rid, name, _id: msgId } = notification.payload;
if (!rid) {
return;
}
@ -63,56 +63,54 @@ export const useNotification = () => {
n.close();
window.focus();
if (!notification.payload._id || !notification.payload.rid || !notification.payload.name) {
return;
}
const jump = msgId && { jump: msgId };
switch (notification.payload?.type) {
case 'd':
router.navigate({
pattern: '/direct/:rid/:tab?/:context?',
params: {
rid: notification.payload.rid,
rid,
...(notification.payload.tmid && {
tab: 'thread',
context: notification.payload.tmid,
}),
},
search: { ...router.getSearchParameters(), jump: notification.payload._id },
search: { ...router.getSearchParameters(), ...jump },
});
break;
case 'c':
return router.navigate({
pattern: '/channel/:name/:tab?/:context?',
params: {
name: notification.payload.name,
name: name || rid,
...(notification.payload.tmid && {
tab: 'thread',
context: notification.payload.tmid,
}),
},
search: { ...router.getSearchParameters(), jump: notification.payload._id },
search: { ...router.getSearchParameters(), ...jump },
});
case 'p':
return router.navigate({
pattern: '/group/:name/:tab?/:context?',
params: {
name: notification.payload.name,
name: name || rid,
...(notification.payload.tmid && {
tab: 'thread',
context: notification.payload.tmid,
}),
},
search: { ...router.getSearchParameters(), jump: notification.payload._id },
search: { ...router.getSearchParameters(), ...jump },
});
case 'l':
return router.navigate({
pattern: '/live/:id/:tab?/:context?',
params: {
id: notification.payload.rid,
id: rid,
tab: 'room-info',
},
search: { ...router.getSearchParameters(), jump: notification.payload._id },
search: { ...router.getSearchParameters(), ...jump },
});
}
};

@ -105,7 +105,8 @@ export interface IRoomTypeServerDirectives {
sender: AtLeast<IUser, '_id' | 'name' | 'username'>,
notificationMessage: string,
userId: string,
) => Promise<{ title: string | undefined; text: string; name: string | undefined }>;
language?: string,
) => Promise<{ title?: string; text: string; name?: string }>;
getMsgSender: (message: IMessage) => Promise<IUser | null>;
includeInRoomSearch: () => boolean;
getReadReceiptsExtraData: (message: IMessage) => Partial<ReadReceipt>;

@ -0,0 +1,97 @@
import { expect } from 'chai';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
const getUserDisplayNameStub = sinon.stub();
const i18nTranslateStub = sinon.stub();
const settingsGetStub = sinon.stub();
const { buildNotificationDetails } = proxyquire.noCallThru().load('./buildNotificationDetails.ts', {
'../../../app/settings/server': {
settings: { get: settingsGetStub },
},
'@rocket.chat/core-typings': {
getUserDisplayName: getUserDisplayNameStub,
},
'../i18n': {
i18n: { t: i18nTranslateStub },
},
});
describe('buildNotificationDetails', () => {
const room = { name: 'general' };
const sender = { _id: 'id1', name: 'Alice', username: 'alice' };
beforeEach(() => {
settingsGetStub.reset();
getUserDisplayNameStub.reset();
i18nTranslateStub.reset();
});
const languageFallback = 'You_have_a_new_message';
const testCases = [
{ showPushMessage: true, showName: true, expectPrefix: true },
{ showPushMessage: true, showName: false, expectPrefix: false },
{ showPushMessage: false, showName: true, expectPrefix: false },
{ showPushMessage: false, showName: false, expectPrefix: false },
];
testCases.forEach(({ showPushMessage, showName, expectPrefix }) => {
const label = `Push_show_message=${showPushMessage}, Push_show_username_room=${showName}`;
it(`should return correct fields when ${label}`, () => {
settingsGetStub.withArgs('Push_show_message').returns(showPushMessage);
settingsGetStub.withArgs('Push_show_username_room').returns(showName);
settingsGetStub.withArgs('UI_Use_Real_Name').returns(false);
settingsGetStub.withArgs('Language').returns('en');
const expectedMessage = 'Test message';
const expectedTitle = 'Some Room Title';
const senderDisplayName = 'Alice';
getUserDisplayNameStub.returns(senderDisplayName);
i18nTranslateStub.withArgs('You_have_a_new_message', { lng: 'en' }).returns(languageFallback);
const result = buildNotificationDetails({
expectedNotificationMessage: expectedMessage,
expectedTitle,
room,
sender,
language: undefined,
senderNameExpectedInMessage: true,
});
const shouldShowTitle = showName;
const shouldPrefix = showPushMessage && showName && expectPrefix;
expect(result.title).to.equal(shouldShowTitle ? expectedTitle : undefined);
expect(result.name).to.equal(shouldShowTitle ? room.name : undefined);
if (!showPushMessage) {
expect(result.text).to.equal(languageFallback);
} else if (shouldPrefix) {
expect(result.text).to.equal(`${senderDisplayName}: ${expectedMessage}`);
} else {
expect(result.text).to.equal(expectedMessage);
}
});
});
it('should respect provided language if supplied', () => {
settingsGetStub.withArgs('Push_show_message').returns(false);
settingsGetStub.withArgs('Push_show_username_room').returns(false);
i18nTranslateStub.withArgs('You_have_a_new_message', { lng: 'fr' }).returns('Vous avez un nouveau message');
const result = buildNotificationDetails({
expectedNotificationMessage: 'ignored',
room,
sender,
language: 'fr',
senderNameExpectedInMessage: false,
});
expect(result.text).to.equal('Vous avez un nouveau message');
});
});

@ -0,0 +1,64 @@
import { getUserDisplayName } from '@rocket.chat/core-typings';
import type { AtLeast, IRoom, IUser } from '@rocket.chat/core-typings';
import { settings } from '../../../app/settings/server';
import { i18n } from '../i18n';
/**
* Options for building notification details.
*/
export type BuildNotificationDetailsOptions = {
/** The message to display for the notification. */
expectedNotificationMessage: string;
/** The precomputed title for the notification. */
expectedTitle?: string;
/** The room object with at least a name property. */
room: AtLeast<IRoom, 'name'>;
/** The user sending the notification. */
sender: AtLeast<IUser, '_id' | 'name' | 'username'>;
/** Optional language code used for translation (defaults to server setting or 'en'). */
language?: string;
/** Whether to prefix the message with senderName: (defaults to false). */
senderNameExpectedInMessage?: boolean;
};
/**
* Builds the notification payload fields (title, text, name) based on settings and provided expectations.
*
* @param options - Precomputed values and context for the notification.
* @returns An object containing the notification title, text, and user/room label.
*/
export function buildNotificationDetails({
expectedNotificationMessage,
expectedTitle,
room,
sender,
language,
senderNameExpectedInMessage = false,
}: BuildNotificationDetailsOptions): { title?: string; text: string; name?: string } {
const showPushMessage = settings.get<boolean>('Push_show_message');
const showUserOrRoomName = settings.get<boolean>('Push_show_username_room');
let text: string;
if (showPushMessage) {
if (senderNameExpectedInMessage && showUserOrRoomName) {
const useRealName = settings.get<boolean>('UI_Use_Real_Name');
const senderName = getUserDisplayName(sender.name, sender.username, useRealName);
text = `${senderName}: ${expectedNotificationMessage}`;
} else {
text = expectedNotificationMessage;
}
} else {
const lng = language || settings.get('Language') || 'en';
text = i18n.t('You_have_a_new_message', { lng });
}
let title: string | undefined;
let name: string | undefined;
if (showUserOrRoomName) {
name = room.name;
title = expectedTitle;
}
return { title, text, name };
}

@ -1,8 +1,7 @@
import { getUserDisplayName } from '@rocket.chat/core-typings';
import type { IRoom, RoomType, IUser, IMessage, ReadReceipt, ValueOf, AtLeast } from '@rocket.chat/core-typings';
import { Users } from '@rocket.chat/models';
import { settings } from '../../../app/settings/server';
import { buildNotificationDetails } from './buildNotificationDetails';
import type { IRoomTypeConfig, IRoomTypeServerDirectives, RoomSettingsEnum, RoomMemberActions } from '../../../definition/IRoomTypeConfig';
import { RoomCoordinator } from '../../../lib/rooms/coordinator';
@ -41,14 +40,16 @@ class RoomCoordinatorServer extends RoomCoordinator {
sender: AtLeast<IUser, '_id' | 'name' | 'username'>,
notificationMessage: string,
userId: string,
): Promise<{ title: string | undefined; text: string; name: string | undefined }> {
const title = `#${await this.roomName(room, userId)}`;
const useRealName = settings.get<boolean>('UI_Use_Real_Name');
const senderName = getUserDisplayName(sender.name, sender.username, useRealName);
const text = `${senderName}: ${notificationMessage}`;
return { title, text, name: room.name };
language?: string,
) {
return buildNotificationDetails({
expectedNotificationMessage: notificationMessage,
sender,
senderNameExpectedInMessage: true,
language,
expectedTitle: `#${await this.roomName(room, userId)}`,
room,
});
},
getMsgSender(message: IMessage): Promise<IUser | null> {
return Users.findOneById(message.u._id);

@ -1,5 +1,5 @@
import type { AtLeast } from '@rocket.chat/core-typings';
import { isRoomFederated } from '@rocket.chat/core-typings';
import { getUserDisplayName, isRoomFederated } from '@rocket.chat/core-typings';
import { Subscriptions } from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';
@ -8,6 +8,7 @@ import type { IRoomTypeServerDirectives } from '../../../../definition/IRoomType
import { RoomSettingsEnum, RoomMemberActions } from '../../../../definition/IRoomTypeConfig';
import { getDirectMessageRoomType } from '../../../../lib/rooms/roomTypes/direct';
import { Federation } from '../../../services/federation/Federation';
import { buildNotificationDetails } from '../buildNotificationDetails';
import { roomCoordinator } from '../roomCoordinator';
const DirectMessageRoomType = getDirectMessageRoomType(roomCoordinator);
@ -91,24 +92,19 @@ roomCoordinator.add(DirectMessageRoomType, {
return (room?.uids?.length || 0) > 2;
},
async getNotificationDetails(room, sender, notificationMessage, userId) {
async getNotificationDetails(room, sender, notificationMessage, userId, language) {
const useRealName = settings.get<boolean>('UI_Use_Real_Name');
const displayRoomName = await this.roomName(room, userId);
if (this.isGroupChat(room)) {
return {
title: displayRoomName,
text: `${(useRealName && sender.name) || sender.username}: ${notificationMessage}`,
name: room.name || displayRoomName,
};
}
return {
title: (useRealName && sender.name) || sender.username,
text: notificationMessage,
name: room.name || displayRoomName,
};
const senderDisplayName = getUserDisplayName(sender.name, sender.username, useRealName);
return buildNotificationDetails({
expectedNotificationMessage: notificationMessage,
room,
sender,
expectedTitle: this.isGroupChat(room) ? displayRoomName : senderDisplayName,
language,
senderNameExpectedInMessage: this.isGroupChat(room),
});
},
includeInDashboard() {

@ -5,6 +5,7 @@ import { LivechatVisitors, LivechatRooms } from '@rocket.chat/models';
import { RoomSettingsEnum, RoomMemberActions } from '../../../../definition/IRoomTypeConfig';
import type { IRoomTypeServerDirectives } from '../../../../definition/IRoomTypeConfig';
import { getLivechatRoomType } from '../../../../lib/rooms/roomTypes/livechat';
import { buildNotificationDetails } from '../buildNotificationDetails';
import { roomCoordinator } from '../roomCoordinator';
const LivechatRoomType = getLivechatRoomType(roomCoordinator);
@ -31,12 +32,15 @@ roomCoordinator.add(LivechatRoomType, {
return token && rid && !!(await LivechatRooms.findOneByIdAndVisitorToken(rid, token));
},
async getNotificationDetails(room, _sender, notificationMessage, userId) {
const roomName = await this.roomName(room, userId);
const title = `[Omnichannel] ${roomName}`;
const text = notificationMessage;
return { title, text, name: roomName };
async getNotificationDetails(room, sender, notificationMessage, userId, language) {
return buildNotificationDetails({
expectedNotificationMessage: notificationMessage,
sender,
room,
expectedTitle: `[Omnichannel] ${await this.roomName(room, userId)}`,
language,
senderNameExpectedInMessage: false,
});
},
async getMsgSender(message) {

@ -3,6 +3,7 @@ import { Users } from '@rocket.chat/models';
import type { IRoomTypeServerDirectives } from '../../../../definition/IRoomTypeConfig';
import { getVoipRoomType } from '../../../../lib/rooms/roomTypes/voip';
import { buildNotificationDetails } from '../buildNotificationDetails';
import { roomCoordinator } from '../roomCoordinator';
const VoipRoomType = getVoipRoomType(roomCoordinator);
@ -12,11 +13,15 @@ roomCoordinator.add(VoipRoomType, {
return room.name || room.fname || (room as any).label;
},
async getNotificationDetails(room, _sender, notificationMessage, userId) {
const title = `[Omnichannel] ${this.roomName(room, userId)}`;
const text = notificationMessage;
return { title, text, name: room.name };
async getNotificationDetails(room, sender, notificationMessage, userId, language) {
return buildNotificationDetails({
expectedNotificationMessage: notificationMessage,
room,
sender,
expectedTitle: `[Omnichannel] ${await this.roomName(room, userId)}`,
language,
senderNameExpectedInMessage: false,
});
},
async getMsgSender(message) {

Loading…
Cancel
Save