snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
This commit is contained in:
61
apps/meteor/server/hooks/messages/afterSaveMessage.ts
Normal file
61
apps/meteor/server/hooks/messages/afterSaveMessage.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Message } from '@rocket.chat/core-services';
|
||||
import type { IMessage, IUser, IRoom } from '@rocket.chat/core-typings';
|
||||
import type { Updater } from '@rocket.chat/models';
|
||||
import { Rooms } from '@rocket.chat/models';
|
||||
|
||||
import { callbacks } from '../../lib/callbacks';
|
||||
import type { SendMessageOptions } from '../../lib/messages/sendMessage';
|
||||
|
||||
export async function afterSaveMessage(
|
||||
message: IMessage,
|
||||
room: IRoom,
|
||||
user: IUser,
|
||||
{
|
||||
roomUpdater,
|
||||
options,
|
||||
}: {
|
||||
roomUpdater?: Updater<IRoom>;
|
||||
options?: SendMessageOptions;
|
||||
} = {},
|
||||
): Promise<IMessage> {
|
||||
const updater = roomUpdater ?? Rooms.getUpdater();
|
||||
|
||||
const data: IMessage = (await callbacks.run('afterSaveMessage', message, {
|
||||
room,
|
||||
user,
|
||||
roomUpdater: updater,
|
||||
options,
|
||||
})) as unknown as IMessage;
|
||||
|
||||
if (!roomUpdater && updater.hasChanges()) {
|
||||
await Rooms.updateFromUpdater({ _id: room._id }, updater);
|
||||
}
|
||||
|
||||
void Message.afterSave({ message: data });
|
||||
|
||||
// TODO: Fix type - callback configuration needs to be updated
|
||||
return data;
|
||||
}
|
||||
|
||||
export function afterSaveMessageAsync(
|
||||
message: IMessage,
|
||||
room: IRoom,
|
||||
user: IUser,
|
||||
{
|
||||
roomUpdater: updater,
|
||||
options,
|
||||
}: {
|
||||
roomUpdater?: Updater<IRoom>;
|
||||
options?: SendMessageOptions;
|
||||
} = {},
|
||||
): void {
|
||||
const roomUpdater = updater ?? Rooms.getUpdater();
|
||||
|
||||
callbacks.runAsync('afterSaveMessage', message, { room, user, roomUpdater, options });
|
||||
|
||||
if (roomUpdater.hasChanges()) {
|
||||
void Rooms.updateFromUpdater({ _id: room._id }, roomUpdater);
|
||||
}
|
||||
|
||||
void Message.afterSave({ message });
|
||||
}
|
||||
140
apps/meteor/server/hooks/messages/mentionUserNotInChannel.ts
Normal file
140
apps/meteor/server/hooks/messages/mentionUserNotInChannel.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { api } from '@rocket.chat/core-services';
|
||||
import type { IMessage } from '@rocket.chat/core-typings';
|
||||
import { isDirectMessageRoom, isEditedMessage, isOmnichannelRoom, isRoomFederated, getUserDisplayName } from '@rocket.chat/core-typings';
|
||||
import { Subscriptions, Users } from '@rocket.chat/models';
|
||||
import { isTruthy } from '@rocket.chat/tools';
|
||||
import type { ActionsBlock } from '@rocket.chat/ui-kit';
|
||||
import moment from 'moment';
|
||||
|
||||
import { hasPermissionAsync } from '../../lib/authorization/hasPermission';
|
||||
import { callbacks } from '../../lib/callbacks';
|
||||
import { i18n } from '../../lib/i18n';
|
||||
import { settings } from '../../settings';
|
||||
|
||||
const APP_ID = 'mention-core';
|
||||
const getBlocks = (mentions: IMessage['mentions'], messageId: string, lng: string | undefined) => {
|
||||
const stringifiedMentions = JSON.stringify(mentions);
|
||||
return {
|
||||
addUsersBlock: {
|
||||
type: 'button',
|
||||
appId: APP_ID,
|
||||
blockId: messageId,
|
||||
value: stringifiedMentions,
|
||||
actionId: 'add-users',
|
||||
text: {
|
||||
type: 'plain_text',
|
||||
text: i18n.t('Add_them', { lng }),
|
||||
},
|
||||
},
|
||||
dismissBlock: {
|
||||
type: 'button',
|
||||
appId: APP_ID,
|
||||
blockId: messageId,
|
||||
value: stringifiedMentions,
|
||||
actionId: 'dismiss',
|
||||
text: {
|
||||
type: 'plain_text',
|
||||
text: i18n.t('Do_nothing', { lng }),
|
||||
},
|
||||
},
|
||||
dmBlock: {
|
||||
type: 'button',
|
||||
appId: APP_ID,
|
||||
value: stringifiedMentions,
|
||||
blockId: messageId,
|
||||
actionId: 'share-message',
|
||||
text: {
|
||||
type: 'plain_text',
|
||||
text: i18n.t('Let_them_know', { lng }),
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
};
|
||||
|
||||
callbacks.add(
|
||||
'afterSaveMessage',
|
||||
async (message, { room }) => {
|
||||
// TODO: check if I need to test this 60 second rule.
|
||||
// If the message was edited, or is older than 60 seconds (imported)
|
||||
// the notifications will be skipped, so we can also skip this validation
|
||||
if (isEditedMessage(message) || (message.ts && Math.abs(moment(message.ts).diff(moment())) > 60000) || !message.mentions) {
|
||||
return message;
|
||||
}
|
||||
|
||||
const mentions = message.mentions.filter(({ _id, type }) => _id !== 'all' && _id !== 'here' && type !== 'team');
|
||||
if (!mentions.length) {
|
||||
return message;
|
||||
}
|
||||
|
||||
if (isDirectMessageRoom(room) || isRoomFederated(room) || isOmnichannelRoom(room)) {
|
||||
return message;
|
||||
}
|
||||
|
||||
const subs = await Subscriptions.findByRoomIdAndUserIds(
|
||||
message.rid,
|
||||
mentions.map(({ _id }) => _id),
|
||||
{ projection: { u: 1 } },
|
||||
).toArray();
|
||||
|
||||
// get all users that are mentioned but not in the channel
|
||||
const mentionsUsersNotInChannel = mentions.filter(({ _id }) => !subs.some((sub) => sub.u._id === _id));
|
||||
|
||||
if (!mentionsUsersNotInChannel.length) {
|
||||
return message;
|
||||
}
|
||||
|
||||
const canAddUsersToThisRoom = await hasPermissionAsync(message.u._id, 'add-user-to-joined-room', message.rid);
|
||||
const canAddToAnyRoom = await (room.t === 'c'
|
||||
? hasPermissionAsync(message.u._id, 'add-user-to-any-c-room')
|
||||
: hasPermissionAsync(message.u._id, 'add-user-to-any-p-room'));
|
||||
const canDMUsers = await hasPermissionAsync(message.u._id, 'create-d'); // TODO: Perhaps check if user has DM with mentioned user (might be too expensive)
|
||||
const canAddUsers = canAddUsersToThisRoom || canAddToAnyRoom;
|
||||
|
||||
const { language } = (await Users.findOneById(message.u._id)) || {};
|
||||
|
||||
const actionBlocks = getBlocks(mentionsUsersNotInChannel, message._id, language);
|
||||
const elements: ActionsBlock['elements'] = [
|
||||
canAddUsers && actionBlocks.addUsersBlock,
|
||||
(canAddUsers || canDMUsers) && actionBlocks.dismissBlock,
|
||||
canDMUsers && actionBlocks.dmBlock,
|
||||
].filter(isTruthy);
|
||||
|
||||
const messageLabel = canAddUsers
|
||||
? 'You_mentioned___mentions__but_theyre_not_in_this_room'
|
||||
: 'You_mentioned___mentions__but_theyre_not_in_this_room_You_can_ask_a_room_admin_to_add_them';
|
||||
|
||||
const useRealName = settings.get<boolean>('UI_Use_Real_Name');
|
||||
|
||||
const usernamesOrNames = mentionsUsersNotInChannel.map(({ username, name }) => `*${getUserDisplayName(name, username, useRealName)}*`);
|
||||
|
||||
const mentionsText = usernamesOrNames.join(', ');
|
||||
|
||||
// TODO: Mentions style
|
||||
void api.broadcast('notify.ephemeralMessage', message.u._id, message.rid, {
|
||||
msg: '',
|
||||
mentions: mentionsUsersNotInChannel,
|
||||
tmid: message.tmid,
|
||||
blocks: [
|
||||
{
|
||||
appId: APP_ID,
|
||||
type: 'section',
|
||||
text: {
|
||||
type: 'mrkdwn',
|
||||
text: i18n.t(messageLabel, { mentions: mentionsText, lng: language }),
|
||||
},
|
||||
} as const,
|
||||
Boolean(elements.length) &&
|
||||
({
|
||||
type: 'actions',
|
||||
appId: APP_ID,
|
||||
elements,
|
||||
} as const),
|
||||
].filter(isTruthy),
|
||||
private: true,
|
||||
});
|
||||
|
||||
return message;
|
||||
},
|
||||
callbacks.priority.LOW,
|
||||
'mention-user-not-in-channel',
|
||||
);
|
||||
231
apps/meteor/server/hooks/messages/notifyUsersOnMessage.ts
Normal file
231
apps/meteor/server/hooks/messages/notifyUsersOnMessage.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import type { IMessage, IRoom, IUser, RoomType } from '@rocket.chat/core-typings';
|
||||
import { isEditedMessage } from '@rocket.chat/core-typings';
|
||||
import type { Updater } from '@rocket.chat/models';
|
||||
import { Subscriptions, Rooms } from '@rocket.chat/models';
|
||||
import moment from 'moment';
|
||||
|
||||
import { callbacks } from '../../lib/callbacks';
|
||||
import { messageContainsHighlight } from '../../lib/notifications/message/messageContainsHighlight';
|
||||
import {
|
||||
notifyOnSubscriptionChanged,
|
||||
notifyOnSubscriptionChangedByRoomIdAndUserId,
|
||||
notifyOnSubscriptionChangedByRoomIdAndUserIds,
|
||||
} from '../../lib/notifyListener';
|
||||
import { settings } from '../../settings';
|
||||
|
||||
export async function getMentions(message: IMessage): Promise<{ toAll: boolean; toHere: boolean; mentionIds: string[] }> {
|
||||
const {
|
||||
mentions,
|
||||
u: { _id: senderId },
|
||||
} = message;
|
||||
|
||||
if (!mentions) {
|
||||
return {
|
||||
toAll: false,
|
||||
toHere: false,
|
||||
mentionIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
const toAll = mentions.some(({ _id }) => _id === 'all');
|
||||
const toHere = mentions.some(({ _id }) => _id === 'here');
|
||||
|
||||
const teamsMentions = mentions.filter((mention) => mention.type === 'team');
|
||||
const filteredMentions = mentions
|
||||
.filter((mention) => !mention.type || mention.type === 'user')
|
||||
.filter(({ _id }) => _id !== senderId && !['all', 'here'].includes(_id))
|
||||
.map(({ _id }) => _id);
|
||||
|
||||
const mentionIds = await callbacks.run('beforeGetMentions', filteredMentions, teamsMentions);
|
||||
|
||||
return {
|
||||
toAll,
|
||||
toHere,
|
||||
mentionIds,
|
||||
};
|
||||
}
|
||||
|
||||
type UnreadCountType = 'all_messages' | 'user_mentions_only' | 'group_mentions_only' | 'user_and_group_mentions_only';
|
||||
|
||||
const getGroupMentions = (roomType: RoomType, unreadCount: Exclude<UnreadCountType, 'user_mentions_only'>): number => {
|
||||
const incUnreadByGroup = ['all_messages', 'group_mentions_only', 'user_and_group_mentions_only'].includes(unreadCount);
|
||||
return roomType === 'd' || roomType === 'l' || incUnreadByGroup ? 1 : 0;
|
||||
};
|
||||
|
||||
const getUserMentions = (roomType: RoomType, unreadCount: Exclude<UnreadCountType, 'group_mentions_only'>): number => {
|
||||
const incUnreadByUser = ['all_messages', 'user_mentions_only', 'user_and_group_mentions_only'].includes(unreadCount);
|
||||
return roomType === 'd' || roomType === 'l' || incUnreadByUser ? 1 : 0;
|
||||
};
|
||||
|
||||
export const getUserIdsFromHighlights = async (rid: IRoom['_id'], message: IMessage): Promise<string[]> => {
|
||||
const highlightOptions = { projection: { 'userHighlights': 1, 'u._id': 1 } };
|
||||
const subs = await Subscriptions.findByRoomWithUserHighlights(rid, highlightOptions).toArray();
|
||||
|
||||
return subs
|
||||
.filter(
|
||||
({ userHighlights, u: { _id: uid } }) => userHighlights && messageContainsHighlight(message, userHighlights) && uid !== message.u._id,
|
||||
)
|
||||
.map(({ u: { _id: uid } }) => uid);
|
||||
};
|
||||
|
||||
const getUnreadSettingCount = (roomType: RoomType): UnreadCountType => {
|
||||
let unreadSetting = 'Unread_Count';
|
||||
switch (roomType) {
|
||||
case 'd': {
|
||||
unreadSetting = 'Unread_Count_DM';
|
||||
break;
|
||||
}
|
||||
case 'l': {
|
||||
unreadSetting = 'Unread_Count_Omni';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return settings.get(unreadSetting);
|
||||
};
|
||||
|
||||
async function updateUsersSubscriptions(message: IMessage, room: IRoom): Promise<void> {
|
||||
if (!room || message.tmid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [mentions, highlightIds] = await Promise.all([getMentions(message), getUserIdsFromHighlights(room._id, message)]);
|
||||
|
||||
const { toAll, toHere, mentionIds } = mentions;
|
||||
const userIds = [...new Set([...mentionIds, ...highlightIds])];
|
||||
const unreadCount = getUnreadSettingCount(room.t);
|
||||
const unreadAllMessages = unreadCount === 'all_messages';
|
||||
|
||||
const userMentionInc = getUserMentions(room.t, unreadCount as Exclude<UnreadCountType, 'group_mentions_only'>);
|
||||
const groupMentionInc = getGroupMentions(room.t, unreadCount as Exclude<UnreadCountType, 'user_mentions_only'>);
|
||||
|
||||
// find all subscriptions that will need to be notified after the update.
|
||||
// we need to use toArray() here and keep results in memory because we'll update the them later
|
||||
const subs = await Subscriptions.findByRoomIdAndNotAlertOrOpenExcludingUserIds({
|
||||
roomId: room._id,
|
||||
uidsExclude: [message.u._id],
|
||||
uidsInclude: userIds,
|
||||
onlyRead: !toAll && !toHere && !unreadAllMessages,
|
||||
}).toArray();
|
||||
|
||||
// Give priority to user mentions over group mentions
|
||||
if (userIds.length) {
|
||||
await Subscriptions.incUserMentionsAndUnreadForRoomIdAndUserIds(room._id, userIds, 1, userMentionInc);
|
||||
} else if (toAll || toHere) {
|
||||
await Subscriptions.incGroupMentionsAndUnreadForRoomIdExcludingUserId(room._id, message.u._id, 1, groupMentionInc);
|
||||
}
|
||||
|
||||
if (!toAll && !toHere && unreadAllMessages) {
|
||||
await Subscriptions.incUnreadForRoomIdExcludingUserIds(room._id, [...userIds, message.u._id], 1);
|
||||
}
|
||||
|
||||
// update subscriptions of other members of the room
|
||||
await Promise.all([
|
||||
Subscriptions.setAlertForRoomIdExcludingUserId(message.rid, message.u._id),
|
||||
Subscriptions.setOpenForRoomIdExcludingUserId(message.rid, message.u._id),
|
||||
]);
|
||||
|
||||
subs.forEach((sub) => {
|
||||
const hasUserMention = userIds.includes(sub.u._id);
|
||||
const shouldIncUnread = hasUserMention || toAll || toHere || unreadAllMessages;
|
||||
void notifyOnSubscriptionChanged(
|
||||
{
|
||||
...sub,
|
||||
alert: true,
|
||||
open: true,
|
||||
...(shouldIncUnread && { unread: sub.unread + 1 }),
|
||||
...(hasUserMention && { userMentions: sub.userMentions + 1 }),
|
||||
...((toAll || toHere) && { groupMentions: sub.groupMentions + 1 }),
|
||||
},
|
||||
'updated',
|
||||
);
|
||||
});
|
||||
|
||||
// update subscription of the message sender
|
||||
const setAsReadResponse = await Subscriptions.setAsReadByRoomIdAndUserId(message.rid, message.u._id);
|
||||
if (setAsReadResponse.modifiedCount) {
|
||||
void notifyOnSubscriptionChangedByRoomIdAndUserId(message.rid, message.u._id);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateThreadUsersSubscriptions(message: IMessage, replies: IUser['_id'][]): Promise<void> {
|
||||
// Don't increase unread counter on thread messages
|
||||
const repliesPlusSender = [...new Set([message.u._id, ...replies])];
|
||||
|
||||
const responses = await Promise.all([
|
||||
Subscriptions.setAlertForRoomIdAndUserIds(message.rid, replies),
|
||||
Subscriptions.setOpenForRoomIdAndUserIds(message.rid, repliesPlusSender),
|
||||
Subscriptions.setLastReplyForRoomIdAndUserIds(message.rid, repliesPlusSender, new Date()),
|
||||
]);
|
||||
|
||||
responses.some((response) => response?.modifiedCount) &&
|
||||
void notifyOnSubscriptionChangedByRoomIdAndUserIds(message.rid, repliesPlusSender);
|
||||
}
|
||||
|
||||
export async function notifyUsersOnMessage(message: IMessage, room: IRoom, roomUpdater: Updater<IRoom>): Promise<IMessage> {
|
||||
// Skips this callback if the message was edited and increments it if the edit was way in the past (aka imported)
|
||||
if (isEditedMessage(message)) {
|
||||
if (Math.abs(moment(message.editedAt).diff(Date.now())) > 60000) {
|
||||
// TODO: Review as I am not sure how else to get around this as the incrementing of the msgs count shouldn't be in this callback
|
||||
Rooms.getIncMsgCountUpdateQuery(1, roomUpdater);
|
||||
return message;
|
||||
}
|
||||
|
||||
// Only updates last message if it was edited (skip rest of callback)
|
||||
if (
|
||||
settings.get('Store_Last_Message') &&
|
||||
(!message.tmid || message.tshow) &&
|
||||
(!room.lastMessage || room.lastMessage._id === message._id)
|
||||
) {
|
||||
Rooms.getLastMessageUpdateQuery(message, roomUpdater);
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
if (message.ts && Math.abs(moment(message.ts).diff(Date.now())) > 60000) {
|
||||
Rooms.getIncMsgCountUpdateQuery(1, roomUpdater);
|
||||
return message;
|
||||
}
|
||||
|
||||
// If message sent ONLY on a thread, skips the rest as it is done on a callback specific to threads
|
||||
if (message.tmid && !message.tshow) {
|
||||
Rooms.getIncMsgCountUpdateQuery(1, roomUpdater);
|
||||
return message;
|
||||
}
|
||||
|
||||
// Update all the room activity tracker fields
|
||||
Rooms.setIncMsgCountAndSetLastMessageUpdateQuery(1, message, !!settings.get('Store_Last_Message'), roomUpdater);
|
||||
await updateUsersSubscriptions(message, room);
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
export async function notifyUsersOnSystemMessage(message: IMessage, room: IRoom): Promise<IMessage> {
|
||||
const roomUpdater = Rooms.getUpdater();
|
||||
Rooms.setIncMsgCountAndSetLastMessageUpdateQuery(1, message, !!settings.get('Store_Last_Message'), roomUpdater);
|
||||
|
||||
if (roomUpdater.hasChanges()) {
|
||||
await Rooms.updateFromUpdater({ _id: room._id }, roomUpdater);
|
||||
}
|
||||
|
||||
// TODO: Rewrite to use just needed calls from the function
|
||||
await updateUsersSubscriptions(message, room);
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
callbacks.add(
|
||||
'afterSaveMessage',
|
||||
async (message, { room, roomUpdater }) => {
|
||||
if (!roomUpdater) {
|
||||
return message;
|
||||
}
|
||||
|
||||
await notifyUsersOnMessage(message, room, roomUpdater);
|
||||
|
||||
return message;
|
||||
},
|
||||
callbacks.priority.MEDIUM,
|
||||
'notifyUsersOnMessage',
|
||||
);
|
||||
95
apps/meteor/server/hooks/messages/processThreads.ts
Normal file
95
apps/meteor/server/hooks/messages/processThreads.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import type { IMessage, IRoom, IUser } from '@rocket.chat/core-typings';
|
||||
import { isEditedMessage } from '@rocket.chat/core-typings';
|
||||
import { Messages } from '@rocket.chat/models';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import { updateThreadUsersSubscriptions, getMentions } from './notifyUsersOnMessage';
|
||||
import { sendMessageNotifications } from './sendNotificationsOnMessage';
|
||||
import { callbacks } from '../../lib/callbacks';
|
||||
import type { SendMessageOptions } from '../../lib/messages/sendMessage';
|
||||
import { reply } from '../../lib/messaging/threads/functions';
|
||||
import { notifyOnMessageChange } from '../../lib/notifyListener';
|
||||
import { settings } from '../../settings';
|
||||
|
||||
async function notifyUsersOnReply(message: IMessage, replies: IUser['_id'][]) {
|
||||
// skips this callback if the message was edited
|
||||
if (isEditedMessage(message)) {
|
||||
return message;
|
||||
}
|
||||
|
||||
await updateThreadUsersSubscriptions(message, replies);
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
async function metaData(message: IMessage, parentMessage: IMessage, followers: string[]) {
|
||||
await reply({ tmid: message.tmid }, message, parentMessage, followers);
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
const notification = async (message: IMessage, room: IRoom, replies: string[]) => {
|
||||
// skips this callback if the message was edited
|
||||
if (isEditedMessage(message)) {
|
||||
return message;
|
||||
}
|
||||
|
||||
// will send a notification to everyone who replied/followed the thread except the owner of the message
|
||||
await sendMessageNotifications(message, room, replies);
|
||||
|
||||
return message;
|
||||
};
|
||||
|
||||
export async function processThreads(message: IMessage, room: IRoom, options?: SendMessageOptions) {
|
||||
if (!message.tmid) {
|
||||
return message;
|
||||
}
|
||||
|
||||
const parentMessage = await Messages.findOneById(message.tmid);
|
||||
if (!parentMessage) {
|
||||
return message;
|
||||
}
|
||||
|
||||
const { mentionIds } = await getMentions(message);
|
||||
|
||||
const replies = [
|
||||
...new Set([
|
||||
...((!parentMessage.tcount ? [parentMessage.u._id] : parentMessage.replies) || []),
|
||||
...(!parentMessage.tcount && room.t === 'd' && room.uids ? room.uids : []),
|
||||
...mentionIds,
|
||||
]),
|
||||
].filter((userId) => userId !== message.u._id);
|
||||
|
||||
await notifyUsersOnReply(message, replies);
|
||||
await metaData(message, parentMessage, replies);
|
||||
|
||||
if (!isEditedMessage(message)) {
|
||||
callbacks.runAsync('afterReadMessages', room, { uid: message.u._id, tmid: message.tmid });
|
||||
}
|
||||
|
||||
if (!options?.skipNotifications) {
|
||||
await notification(message, room, replies);
|
||||
}
|
||||
void notifyOnMessageChange({
|
||||
id: message.tmid,
|
||||
});
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
Meteor.startup(() => {
|
||||
settings.watch<boolean>('Threads_enabled', (value) => {
|
||||
if (!value) {
|
||||
callbacks.remove('afterSaveMessage', 'threads-after-save-message');
|
||||
return;
|
||||
}
|
||||
callbacks.add(
|
||||
'afterSaveMessage',
|
||||
async (message, { room, options }) => {
|
||||
return processThreads(message, room, options);
|
||||
},
|
||||
callbacks.priority.LOW,
|
||||
'threads-after-save-message',
|
||||
);
|
||||
});
|
||||
});
|
||||
134
apps/meteor/server/hooks/messages/propagateDiscussionMetadata.ts
Normal file
134
apps/meteor/server/hooks/messages/propagateDiscussionMetadata.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import type { IRoom } from '@rocket.chat/core-typings';
|
||||
import { Messages, Rooms, VideoConference } from '@rocket.chat/models';
|
||||
import type { Updater } from '@rocket.chat/models';
|
||||
|
||||
import { callbacks } from '../../lib/callbacks';
|
||||
import { updateAndNotifyParentRoomWithParentMessage } from '../../lib/messaging/discussions/updateAndNotifyParentRoomWithParentMessage';
|
||||
import { deleteRoom } from '../../lib/rooms/deleteRoom';
|
||||
|
||||
/**
|
||||
* The messages count and the last message timestamp of the room are only written to the database
|
||||
* once every `afterSaveMessage` callback ran, so the changes staged for the message being saved
|
||||
* have to be applied on top of the stored room, otherwise the discussion metadata would always
|
||||
* be left one message behind.
|
||||
*/
|
||||
const withPendingRoomChanges = (
|
||||
room: Pick<IRoom, '_id' | 'msgs' | 'lm'>,
|
||||
roomUpdater?: Updater<IRoom>,
|
||||
): Pick<IRoom, '_id' | 'msgs' | 'lm'> => {
|
||||
const { $inc, $set } = roomUpdater?.getRawUpdateFilter() ?? {};
|
||||
const pendingMsgs = typeof $inc?.msgs === 'number' ? $inc.msgs : 0;
|
||||
const pendingLm = $set?.lm instanceof Date ? $set.lm : undefined;
|
||||
|
||||
return {
|
||||
...room,
|
||||
msgs: room.msgs + pendingMsgs,
|
||||
lm: pendingLm ?? room.lm,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* We need to propagate the writing of new message in a discussion to the linking
|
||||
* system message
|
||||
*/
|
||||
callbacks.add(
|
||||
'afterSaveMessage',
|
||||
async (message, { room: { _id, prid }, roomUpdater }) => {
|
||||
if (!prid) {
|
||||
return message;
|
||||
}
|
||||
|
||||
const room = await Rooms.findOneById(_id, {
|
||||
projection: {
|
||||
msgs: 1,
|
||||
lm: 1,
|
||||
sysMes: 1,
|
||||
},
|
||||
});
|
||||
|
||||
if (!room) {
|
||||
return message;
|
||||
}
|
||||
|
||||
await updateAndNotifyParentRoomWithParentMessage(withPendingRoomChanges(room, roomUpdater));
|
||||
|
||||
return message;
|
||||
},
|
||||
callbacks.priority.LOW,
|
||||
'PropagateDiscussionMetadata',
|
||||
);
|
||||
|
||||
callbacks.add(
|
||||
'afterDeleteMessage',
|
||||
async (message, { room: { _id, prid } }) => {
|
||||
if (prid) {
|
||||
const room = await Rooms.findOneById(_id, {
|
||||
projection: {
|
||||
msgs: 1,
|
||||
lm: 1,
|
||||
sysMes: 1,
|
||||
},
|
||||
});
|
||||
|
||||
if (room) {
|
||||
await updateAndNotifyParentRoomWithParentMessage(room);
|
||||
}
|
||||
}
|
||||
if (message.drid) {
|
||||
await deleteRoom(message.drid);
|
||||
}
|
||||
return message;
|
||||
},
|
||||
callbacks.priority.LOW,
|
||||
'PropagateDiscussionMetadata',
|
||||
);
|
||||
|
||||
callbacks.add(
|
||||
'afterDeleteRoom',
|
||||
async (rid) => {
|
||||
for await (const { _id } of Rooms.find({ prid: rid }, { projection: { _id: 1 } })) {
|
||||
await deleteRoom(_id);
|
||||
}
|
||||
|
||||
return rid;
|
||||
},
|
||||
callbacks.priority.LOW,
|
||||
'DeleteDiscussionChain',
|
||||
);
|
||||
|
||||
// TODO discussions define new fields
|
||||
callbacks.add(
|
||||
'afterRoomNameChange',
|
||||
async (roomConfig) => {
|
||||
const {
|
||||
room: { _id: rid },
|
||||
name,
|
||||
oldName,
|
||||
} = roomConfig;
|
||||
await Rooms.updateMany({ prid: rid, ...(oldName && { topic: oldName }) }, { $set: { topic: name } });
|
||||
return roomConfig;
|
||||
},
|
||||
callbacks.priority.LOW,
|
||||
'updateTopicDiscussion',
|
||||
);
|
||||
|
||||
callbacks.add(
|
||||
'afterDeleteRoom',
|
||||
async (drid) => {
|
||||
await Messages.updateMany(
|
||||
{ drid },
|
||||
{
|
||||
$unset: {
|
||||
dcount: 1,
|
||||
dlm: 1,
|
||||
drid: 1,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await VideoConference.unsetDiscussionRid(drid);
|
||||
return drid;
|
||||
},
|
||||
callbacks.priority.LOW,
|
||||
'CleanDiscussionMessage',
|
||||
);
|
||||
432
apps/meteor/server/hooks/messages/sendNotificationsOnMessage.ts
Normal file
432
apps/meteor/server/hooks/messages/sendNotificationsOnMessage.ts
Normal file
@@ -0,0 +1,432 @@
|
||||
import {
|
||||
type IMessage,
|
||||
type ISubscription,
|
||||
type IUser,
|
||||
type IRoom,
|
||||
type NotificationItem,
|
||||
isEditedMessage,
|
||||
type AtLeast,
|
||||
} from '@rocket.chat/core-typings';
|
||||
import { Subscriptions, Users } from '@rocket.chat/models';
|
||||
import moment from 'moment';
|
||||
import type { RootFilterOperators } from 'mongodb';
|
||||
|
||||
import { getMentions } from './notifyUsersOnMessage';
|
||||
import { shortnameToUnicode } from '../../../app/emoji-native/lib/shortnameToUnicode';
|
||||
import { hasPermissionAsync } from '../../lib/authorization/hasPermission';
|
||||
import { callbacks } from '../../lib/callbacks';
|
||||
import { parseMessageTextPerUser, replaceMentionedUsernamesWithFullNames } from '../../lib/notifications/message';
|
||||
import { notifyDesktopUser, shouldNotifyDesktop } from '../../lib/notifications/message/desktop';
|
||||
import { getEmailData, shouldNotifyEmail } from '../../lib/notifications/message/email';
|
||||
import { messageContainsHighlight } from '../../lib/notifications/message/messageContainsHighlight';
|
||||
import { getPushData, shouldNotifyMobile } from '../../lib/notifications/message/mobile';
|
||||
import { Notification } from '../../lib/notifications/queue/NotificationQueue';
|
||||
import { roomCoordinator } from '../../lib/rooms/roomCoordinator';
|
||||
import { settings } from '../../settings';
|
||||
|
||||
type SubscriptionAggregation = {
|
||||
receiver: [Pick<IUser, 'active' | 'emails' | 'language' | 'status' | 'statusConnection' | 'username' | 'settings'> | null];
|
||||
} & Pick<
|
||||
ISubscription,
|
||||
| 'desktopNotifications'
|
||||
| 'emailNotifications'
|
||||
| 'mobilePushNotifications'
|
||||
| 'muteGroupMentions'
|
||||
| 'name'
|
||||
| 'rid'
|
||||
| 'userHighlights'
|
||||
| 'u'
|
||||
| 'audioNotificationValue'
|
||||
>;
|
||||
|
||||
type WithRequiredProperty<Type, Key extends keyof Type> = Type & {
|
||||
[Property in Key]-?: Type[Property];
|
||||
};
|
||||
|
||||
export const sendNotification = async ({
|
||||
subscription,
|
||||
sender,
|
||||
hasReplyToThread,
|
||||
hasMentionToAll,
|
||||
hasMentionToHere,
|
||||
message,
|
||||
notificationMessage,
|
||||
room,
|
||||
mentionIds,
|
||||
disableAllMessageNotifications,
|
||||
}: {
|
||||
subscription: SubscriptionAggregation;
|
||||
sender: Pick<IUser, '_id' | 'name' | 'username'>;
|
||||
|
||||
hasReplyToThread: boolean;
|
||||
hasMentionToAll: boolean;
|
||||
hasMentionToHere: boolean;
|
||||
message: AtLeast<IMessage, '_id' | 'u' | 'msg' | 't' | 'attachments'>;
|
||||
notificationMessage: string;
|
||||
room: IRoom;
|
||||
mentionIds: string[];
|
||||
disableAllMessageNotifications: boolean;
|
||||
}) => {
|
||||
if (settings.get<boolean>('Troubleshoot_Disable_Notifications') === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
// don't notify the sender
|
||||
if (subscription.u._id === sender._id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasMentionToUser = mentionIds.includes(subscription.u._id);
|
||||
|
||||
// mute group notifications (@here and @all) if not directly mentioned as well
|
||||
if (!hasMentionToUser && !hasReplyToThread && subscription.muteGroupMentions && (hasMentionToAll || hasMentionToHere)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!subscription.receiver) {
|
||||
subscription.receiver = [
|
||||
await Users.findOneById(subscription.u._id, {
|
||||
projection: {
|
||||
'active': 1,
|
||||
'emails': 1,
|
||||
'language': 1,
|
||||
'status': 1,
|
||||
'statusConnection': 1,
|
||||
'username': 1,
|
||||
'settings.preferences.enableMobileRinging': 1,
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
const [receiver] = subscription.receiver;
|
||||
|
||||
if (!receiver) {
|
||||
throw new Error('receiver not found');
|
||||
}
|
||||
|
||||
const roomType = room.t;
|
||||
// If the user doesn't have permission to view direct messages, don't send notification of direct messages.
|
||||
if (roomType === 'd' && !(await hasPermissionAsync(subscription.u._id, 'view-d-room'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isThread = !!message.tmid && !message.tshow;
|
||||
const isVideoConf = message.t === 'videoconf';
|
||||
|
||||
notificationMessage = await parseMessageTextPerUser(notificationMessage, message, receiver);
|
||||
|
||||
const isHighlighted = messageContainsHighlight(message, subscription.userHighlights);
|
||||
|
||||
const { desktopNotifications, mobilePushNotifications, emailNotifications } = subscription;
|
||||
|
||||
// busy users don't receive desktop notification
|
||||
if (
|
||||
shouldNotifyDesktop({
|
||||
disableAllMessageNotifications,
|
||||
status: receiver.status ?? 'offline',
|
||||
statusConnection: receiver.statusConnection ?? 'offline',
|
||||
desktopNotifications,
|
||||
hasMentionToAll,
|
||||
hasMentionToHere,
|
||||
isHighlighted,
|
||||
hasMentionToUser,
|
||||
hasReplyToThread,
|
||||
roomType,
|
||||
isThread,
|
||||
})
|
||||
) {
|
||||
await notifyDesktopUser({
|
||||
notificationMessage,
|
||||
userId: subscription.u._id,
|
||||
user: sender,
|
||||
message,
|
||||
room,
|
||||
audioNotificationValue: subscription.audioNotificationValue,
|
||||
});
|
||||
}
|
||||
|
||||
const queueItems: NotificationItem[] = [];
|
||||
|
||||
if (
|
||||
shouldNotifyMobile({
|
||||
disableAllMessageNotifications,
|
||||
mobilePushNotifications,
|
||||
hasMentionToAll,
|
||||
isHighlighted,
|
||||
hasMentionToUser,
|
||||
hasReplyToThread,
|
||||
roomType,
|
||||
isThread,
|
||||
isVideoConf,
|
||||
userPreferences: receiver.settings?.preferences,
|
||||
roomUids: room.uids,
|
||||
})
|
||||
) {
|
||||
queueItems.push({
|
||||
type: 'push',
|
||||
data: await getPushData({
|
||||
notificationMessage,
|
||||
room,
|
||||
message,
|
||||
userId: subscription.u._id,
|
||||
senderUsername: sender.username,
|
||||
senderName: sender.name,
|
||||
receiver,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
receiver.emails &&
|
||||
shouldNotifyEmail({
|
||||
disableAllMessageNotifications,
|
||||
statusConnection: receiver.statusConnection,
|
||||
emailNotifications,
|
||||
isHighlighted,
|
||||
hasMentionToUser,
|
||||
hasMentionToAll,
|
||||
hasReplyToThread,
|
||||
roomType,
|
||||
isThread,
|
||||
})
|
||||
) {
|
||||
const messageWithUnicode = message.msg ? shortnameToUnicode(message.msg) : message.msg;
|
||||
const firstAttachment = message.attachments?.[0];
|
||||
|
||||
const convertedFirstAttachment = firstAttachment
|
||||
? {
|
||||
...firstAttachment,
|
||||
description: typeof firstAttachment.description === 'string' ? shortnameToUnicode(firstAttachment.description) : undefined,
|
||||
text: typeof firstAttachment.text === 'string' ? shortnameToUnicode(firstAttachment.text) : undefined,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const attachments = convertedFirstAttachment ? [convertedFirstAttachment, ...(message.attachments?.slice(1) ?? [])] : [];
|
||||
for (const email of receiver.emails) {
|
||||
if (email.verified) {
|
||||
queueItems.push({
|
||||
type: 'email',
|
||||
data: await getEmailData({
|
||||
message: {
|
||||
...message,
|
||||
msg: messageWithUnicode,
|
||||
...(attachments.length > 0 ? { attachments } : {}),
|
||||
},
|
||||
receiver,
|
||||
sender,
|
||||
subscription,
|
||||
room,
|
||||
emailAddress: email.address,
|
||||
hasMentionToUser,
|
||||
}),
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (queueItems.length) {
|
||||
void Notification.scheduleItem({
|
||||
user: receiver,
|
||||
uid: subscription.u._id,
|
||||
rid: room._id,
|
||||
mid: message._id,
|
||||
items: queueItems,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const project = {
|
||||
$project: {
|
||||
'desktopNotifications': 1,
|
||||
'emailNotifications': 1,
|
||||
'mobilePushNotifications': 1,
|
||||
'muteGroupMentions': 1,
|
||||
'name': 1,
|
||||
'rid': 1,
|
||||
'userHighlights': 1,
|
||||
'u._id': 1,
|
||||
'receiver.active': 1,
|
||||
'receiver.emails': 1,
|
||||
'receiver.language': 1,
|
||||
'receiver.status': 1,
|
||||
'receiver.statusConnection': 1,
|
||||
'receiver.username': 1,
|
||||
'receiver.settings.preferences.enableMobileRinging': 1,
|
||||
'audioNotificationValue': 1,
|
||||
},
|
||||
} as const;
|
||||
|
||||
const filter = {
|
||||
$match: {
|
||||
'receiver.active': true,
|
||||
},
|
||||
} as const;
|
||||
|
||||
const lookup = {
|
||||
$lookup: {
|
||||
from: 'users',
|
||||
localField: 'u._id',
|
||||
foreignField: '_id',
|
||||
as: 'receiver',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export async function sendMessageNotifications(message: IMessage, room: IRoom, usersInThread: string[] = []) {
|
||||
if (settings.get<boolean>('Troubleshoot_Disable_Notifications') === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sender = await roomCoordinator.getRoomDirectives(room.t).getMsgSender(message);
|
||||
if (!sender) {
|
||||
return message;
|
||||
}
|
||||
|
||||
const { toAll: hasMentionToAll, toHere: hasMentionToHere, mentionIds } = await getMentions(message);
|
||||
|
||||
const mentionIdsWithoutGroups = [...mentionIds];
|
||||
|
||||
// getMentions removes `all` and `here` from mentionIds so we need to add them back for compatibility
|
||||
if (hasMentionToAll) {
|
||||
mentionIds.push('all');
|
||||
}
|
||||
if (hasMentionToHere) {
|
||||
mentionIds.push('here');
|
||||
}
|
||||
|
||||
// add users in thread to mentions array because they follow the same rules
|
||||
mentionIds.push(...usersInThread);
|
||||
|
||||
let notificationMessage = await callbacks.run('beforeSendMessageNotifications', message.msg);
|
||||
if (mentionIds.length > 0 && settings.get('UI_Use_Real_Name')) {
|
||||
notificationMessage = replaceMentionedUsernamesWithFullNames(message.msg, message.mentions ?? []);
|
||||
}
|
||||
|
||||
// Don't fetch all users if room exceeds max members
|
||||
const maxMembersForNotification = settings.get<number>('Notifications_Max_Room_Members');
|
||||
const roomMembersCount = await Users.countRoomMembers(room._id);
|
||||
const disableAllMessageNotifications = roomMembersCount > maxMembersForNotification && maxMembersForNotification !== 0;
|
||||
|
||||
const query: WithRequiredProperty<RootFilterOperators<ISubscription>, '$or'> = {
|
||||
rid: room._id,
|
||||
ignored: { $ne: sender._id },
|
||||
disableNotifications: { $ne: true },
|
||||
$or: [{ 'userHighlights.0': { $exists: 1 } }, ...(usersInThread.length > 0 ? [{ 'u._id': { $in: usersInThread } }] : [])],
|
||||
} as const;
|
||||
|
||||
(['desktop', 'mobile', 'email'] as const).forEach((kind) => {
|
||||
const notificationField = kind === 'mobile' ? 'mobilePush' : `${kind}Notifications`;
|
||||
|
||||
query.$or.push({
|
||||
[notificationField]: 'all',
|
||||
...(disableAllMessageNotifications ? { [`${kind}PrefOrigin`]: { $ne: 'user' } } : {}),
|
||||
});
|
||||
|
||||
if (disableAllMessageNotifications) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (room.t === 'd') {
|
||||
query.$or.push({
|
||||
[notificationField]: 'mentions',
|
||||
});
|
||||
} else if (mentionIdsWithoutGroups.length > 0) {
|
||||
query.$or.push({
|
||||
[notificationField]: 'mentions',
|
||||
'u._id': { $in: mentionIdsWithoutGroups },
|
||||
});
|
||||
}
|
||||
|
||||
const serverField = kind === 'email' ? 'emailNotificationMode' : `${kind}Notifications`;
|
||||
const serverPreference = settings.get(`Accounts_Default_User_Preferences_${serverField}`);
|
||||
|
||||
if (serverPreference === 'all' || hasMentionToAll || hasMentionToHere || room.t === 'd') {
|
||||
query.$or.push({
|
||||
[notificationField]: { $exists: false },
|
||||
});
|
||||
} else if (serverPreference === 'mentions' && mentionIdsWithoutGroups.length > 0) {
|
||||
query.$or.push({
|
||||
[notificationField]: { $exists: false },
|
||||
'u._id': { $in: mentionIdsWithoutGroups },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// the find below is crucial. All subscription records returned will receive at least one kind of notification.
|
||||
// the query is defined by the server's default values and Notifications_Max_Room_Members setting.
|
||||
|
||||
const subscriptions = await Subscriptions.col.aggregate<SubscriptionAggregation>([{ $match: query }, lookup, filter, project]).toArray();
|
||||
|
||||
subscriptions.forEach(
|
||||
(subscription) =>
|
||||
void sendNotification({
|
||||
subscription,
|
||||
sender,
|
||||
hasMentionToAll,
|
||||
hasMentionToHere,
|
||||
message,
|
||||
notificationMessage,
|
||||
room,
|
||||
mentionIds,
|
||||
disableAllMessageNotifications,
|
||||
hasReplyToThread: usersInThread?.includes(subscription.u._id),
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
sender,
|
||||
hasMentionToAll,
|
||||
hasMentionToHere,
|
||||
notificationMessage,
|
||||
mentionIds,
|
||||
mentionIdsWithoutGroups,
|
||||
};
|
||||
}
|
||||
|
||||
export async function sendAllNotifications(message: IMessage, room: IRoom) {
|
||||
if (settings.get<boolean>('Troubleshoot_Disable_Notifications') === true) {
|
||||
return message;
|
||||
}
|
||||
|
||||
// threads
|
||||
if (message.tmid) {
|
||||
return message;
|
||||
}
|
||||
// skips this callback if the message was edited
|
||||
if (isEditedMessage(message)) {
|
||||
return message;
|
||||
}
|
||||
|
||||
if (message.ts && Math.abs(moment(message.ts).diff(new Date())) > 60000) {
|
||||
return message;
|
||||
}
|
||||
|
||||
if (room?.t == null) {
|
||||
return message;
|
||||
}
|
||||
|
||||
await sendMessageNotifications(message, room);
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
settings.watch('Troubleshoot_Disable_Notifications', (value) => {
|
||||
if (value) {
|
||||
return callbacks.remove('afterSaveMessage', 'sendNotificationsOnMessage');
|
||||
}
|
||||
|
||||
callbacks.add(
|
||||
'afterSaveMessage',
|
||||
(message, { room, options }) => {
|
||||
if (options?.skipNotifications) {
|
||||
return message;
|
||||
}
|
||||
|
||||
return sendAllNotifications(message, room);
|
||||
},
|
||||
callbacks.priority.LOW,
|
||||
'sendNotificationsOnMessage',
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user