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:
27
apps/meteor/server/lib/messaging/Message.ts
Normal file
27
apps/meteor/server/lib/messaging/Message.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { IMessage } from '@rocket.chat/core-typings';
|
||||
import { MessageTypes } from '@rocket.chat/message-types';
|
||||
import { escapeHTML } from '@rocket.chat/tools';
|
||||
import { Accounts } from 'meteor/accounts-base';
|
||||
|
||||
import { trim } from '../../../lib/utils/stringUtils';
|
||||
import { settings } from '../../settings';
|
||||
import { i18n } from '../i18n';
|
||||
|
||||
export const Message = {
|
||||
parse(msg: IMessage, language: string) {
|
||||
const messageType = MessageTypes.getType(msg);
|
||||
if (messageType) {
|
||||
return messageType.text(i18n.getFixedT(language || Accounts.storageLocation.getItem('userLanguage') || 'en'), msg);
|
||||
}
|
||||
if (msg.u && msg.u.username === settings.get('Chatops_Username')) {
|
||||
msg.html = msg.msg;
|
||||
return msg.html;
|
||||
}
|
||||
msg.html = msg.msg;
|
||||
if (trim(msg.html) !== '') {
|
||||
msg.html = escapeHTML(msg.html);
|
||||
}
|
||||
msg.html = msg.html.replace(/\n/gm, '<br/>');
|
||||
return msg.html;
|
||||
},
|
||||
};
|
||||
14
apps/meteor/server/lib/messaging/discussions/permissions.ts
Normal file
14
apps/meteor/server/lib/messaging/discussions/permissions.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Permissions } from '@rocket.chat/models';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
Meteor.startup(async () => {
|
||||
// Add permissions for discussion
|
||||
const permissions = [
|
||||
{ _id: 'start-discussion', roles: ['admin', 'user', 'guest', 'app'] },
|
||||
{ _id: 'start-discussion-other-user', roles: ['admin', 'user', 'owner', 'app'] },
|
||||
];
|
||||
|
||||
for (const permission of permissions) {
|
||||
await Permissions.create(permission._id, permission.roles);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { IRoom, MessageTypesValues } from '@rocket.chat/core-typings';
|
||||
import { Messages } from '@rocket.chat/models';
|
||||
|
||||
import { settings } from '../../../settings/cached';
|
||||
import { notifyOnMessageChange } from '../../notifyListener';
|
||||
|
||||
type DiscussionRoom = Pick<IRoom, '_id' | 'msgs' | 'lm' | 'sysMes'>;
|
||||
|
||||
/**
|
||||
* Both the system messages hidden globally and the ones hidden on the room itself are
|
||||
* filtered out from the room history, so the count has to consider both of them.
|
||||
* Type `rm` is filtered out because it's already discounted when the message is deleted.
|
||||
*/
|
||||
const getHiddenTypesToDiscount = (room: DiscussionRoom): MessageTypesValues[] => {
|
||||
const globalHiddenTypes = settings.get<MessageTypesValues[]>('Hide_System_Messages');
|
||||
const globallyHiddenTypes = Array.isArray(globalHiddenTypes) ? globalHiddenTypes : [];
|
||||
const roomHiddenTypes = Array.isArray(room.sysMes) ? room.sysMes : [];
|
||||
|
||||
return [...new Set([...globallyHiddenTypes, ...roomHiddenTypes])]
|
||||
.flatMap<MessageTypesValues>((type) =>
|
||||
// `mute_unmute` is a single option covering two different message types
|
||||
type === 'mute_unmute' ? ['user-muted', 'user-unmuted'] : [type],
|
||||
)
|
||||
.filter((type) => type !== 'rm');
|
||||
};
|
||||
|
||||
const getDiscussionMessagesCount = async (room: DiscussionRoom): Promise<number> => {
|
||||
const hiddenMessageTypes = getHiddenTypesToDiscount(room);
|
||||
|
||||
if (!hiddenMessageTypes.length) {
|
||||
return room.msgs;
|
||||
}
|
||||
|
||||
const hiddenMessagesCount = await Messages.countVisibleByRoomIdContainingTypes(room._id, hiddenMessageTypes);
|
||||
|
||||
return Math.max(room.msgs - hiddenMessagesCount, 0);
|
||||
};
|
||||
|
||||
/**
|
||||
* Copies the current metadata of a discussion (messages count and last message timestamp) to the
|
||||
* message which links to it on the parent room, and notifies the change to the clients.
|
||||
*/
|
||||
export const updateAndNotifyParentRoomWithParentMessage = async (room: DiscussionRoom): Promise<void> => {
|
||||
room.msgs = await getDiscussionMessagesCount(room);
|
||||
|
||||
const parentMessage = await Messages.refreshDiscussionMetadata(room);
|
||||
if (!parentMessage) {
|
||||
return;
|
||||
}
|
||||
|
||||
void notifyOnMessageChange({
|
||||
id: parentMessage._id,
|
||||
data: parentMessage,
|
||||
});
|
||||
};
|
||||
28
apps/meteor/server/lib/messaging/emoji.ts
Normal file
28
apps/meteor/server/lib/messaging/emoji.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { EmojiPackages } from '../../../app/emoji/lib/rocketchat';
|
||||
|
||||
export const emoji: EmojiPackages = {
|
||||
packages: {
|
||||
base: {
|
||||
emojiCategories: [{ key: 'recent', i18n: 'Frequently_Used' }],
|
||||
categoryIndex: 0,
|
||||
emojisByCategory: {
|
||||
recent: [],
|
||||
},
|
||||
toneList: {},
|
||||
render: (message: string) => message,
|
||||
renderPicker(emojiToRender) {
|
||||
if (!emoji.list[emojiToRender]) {
|
||||
return;
|
||||
}
|
||||
const correctPackage = emoji.list[emojiToRender].emojiPackage;
|
||||
if (!correctPackage) {
|
||||
return;
|
||||
}
|
||||
|
||||
return emoji.packages[correctPackage]?.renderPicker(emojiToRender);
|
||||
},
|
||||
},
|
||||
},
|
||||
/** @type {Record<string, unknown>} */
|
||||
list: {},
|
||||
};
|
||||
10
apps/meteor/server/lib/messaging/getHiddenSystemMessages.ts
Normal file
10
apps/meteor/server/lib/messaging/getHiddenSystemMessages.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { MessageTypesValues, IRoom } from '@rocket.chat/core-typings';
|
||||
|
||||
export const getHiddenSystemMessages = (room: Pick<IRoom, 'sysMes'>, hiddenSystemMessages: MessageTypesValues[]): MessageTypesValues[] => {
|
||||
const hiddenTypes = hiddenSystemMessages.reduce((array, value): MessageTypesValues[] => {
|
||||
const newValue: MessageTypesValues[] = value === 'mute_unmute' ? ['user-muted', 'user-unmuted'] : [value];
|
||||
return [...array, ...newValue];
|
||||
}, [] as MessageTypesValues[]);
|
||||
|
||||
return Array.isArray(room?.sysMes) ? room.sysMes : hiddenTypes;
|
||||
};
|
||||
21
apps/meteor/server/lib/messaging/markdown.ts
Normal file
21
apps/meteor/server/lib/messaging/markdown.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { Tracker } from 'meteor/tracker';
|
||||
|
||||
import { createMarkdownMessageRenderer, createMarkdownNotificationRenderer } from '../../../app/markdown/lib/markdown';
|
||||
import { callbacks } from '../callbacks';
|
||||
|
||||
export { Markdown } from '../../../app/markdown/lib/markdown';
|
||||
|
||||
Meteor.startup(() => {
|
||||
Tracker.autorun(() => {
|
||||
const options = {
|
||||
rootUrl: Meteor.absoluteUrl(),
|
||||
};
|
||||
|
||||
const renderMessage = createMarkdownMessageRenderer(options);
|
||||
callbacks.add('renderMessage', renderMessage, callbacks.priority.HIGH, 'markdown');
|
||||
});
|
||||
|
||||
const renderNotification = createMarkdownNotificationRenderer();
|
||||
callbacks.add('renderNotification', renderNotification, callbacks.priority.HIGH, 'filter-markdown');
|
||||
});
|
||||
128
apps/meteor/server/lib/messaging/mentions/Mentions.ts
Normal file
128
apps/meteor/server/lib/messaging/mentions/Mentions.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
/*
|
||||
* Mentions is a named function that will process Mentions
|
||||
* @param {Object} message - The message object
|
||||
*/
|
||||
import { isE2EEMessage, type IMessage, type IRoom, type IUser } from '@rocket.chat/core-typings';
|
||||
|
||||
import { type MentionsParserArgs, MentionsParser } from '../../../../app/mentions/lib/MentionsParser';
|
||||
import { extractMentionsFromMessageAST } from '../../messages/extractMentionsFromMessageAST';
|
||||
|
||||
type MentionsServerArgs = MentionsParserArgs & {
|
||||
messageMaxAll: () => number;
|
||||
getChannels: (c: string[]) => Promise<Pick<IRoom, '_id' | 'name' | 'fname' | 'federated'>[]>;
|
||||
getUsers: (u: string[]) => Promise<{ type: 'team' | 'user'; _id: string; username?: string; name?: string }[]>;
|
||||
getUser: (u: string) => Promise<IUser | null>;
|
||||
getTotalChannelMembers: (rid: string) => Promise<number>;
|
||||
onMaxRoomMembersExceeded: ({ sender, rid }: { sender: IMessage['u']; rid: string }) => Promise<void>;
|
||||
};
|
||||
|
||||
export class MentionsServer extends MentionsParser {
|
||||
messageMaxAll: MentionsServerArgs['messageMaxAll'];
|
||||
|
||||
getChannels: MentionsServerArgs['getChannels'];
|
||||
|
||||
getUsers: MentionsServerArgs['getUsers'];
|
||||
|
||||
getUser: MentionsServerArgs['getUser'];
|
||||
|
||||
getTotalChannelMembers: MentionsServerArgs['getTotalChannelMembers'];
|
||||
|
||||
onMaxRoomMembersExceeded: MentionsServerArgs['onMaxRoomMembersExceeded'];
|
||||
|
||||
constructor(args: MentionsServerArgs) {
|
||||
super(args);
|
||||
|
||||
this.messageMaxAll = args.messageMaxAll;
|
||||
this.getChannels = args.getChannels;
|
||||
this.getUsers = args.getUsers;
|
||||
this.getUser = args.getUser;
|
||||
this.getTotalChannelMembers = args.getTotalChannelMembers;
|
||||
this.onMaxRoomMembersExceeded =
|
||||
args.onMaxRoomMembersExceeded ||
|
||||
(() => {
|
||||
/* do nothing */
|
||||
});
|
||||
}
|
||||
|
||||
async getUsersByMentions(message: IMessage): Promise<IMessage['mentions']> {
|
||||
const { msg, rid, u: sender, e2eMentions }: Pick<IMessage, 'msg' | 'rid' | 'u' | 't' | 'e2eMentions'> = message;
|
||||
|
||||
const mentions =
|
||||
isE2EEMessage(message) && e2eMentions?.e2eUserMentions && e2eMentions?.e2eUserMentions.length > 0
|
||||
? e2eMentions?.e2eUserMentions
|
||||
: this.getUserMentions(msg);
|
||||
|
||||
return this.convertMentionsToUsers(mentions, rid, sender);
|
||||
}
|
||||
|
||||
async convertMentionsToUsers(mentions: string[], rid: string, sender: IMessage['u']): Promise<IMessage['mentions']> {
|
||||
const mentionsAll: { _id: string; username: string }[] = [];
|
||||
const userMentions = new Set<string>();
|
||||
|
||||
// A message may mention both @all and @here; fetch the member count at
|
||||
// most once per message instead of once per mention.
|
||||
let totalChannelMembers: number | undefined;
|
||||
|
||||
for (const m of mentions) {
|
||||
let mention: string;
|
||||
if (m.includes(':')) {
|
||||
mention = m.trim();
|
||||
} else if (m.startsWith('@')) {
|
||||
mention = m.substring(1);
|
||||
} else {
|
||||
mention = m;
|
||||
}
|
||||
if (mention !== 'all' && mention !== 'here') {
|
||||
userMentions.add(mention);
|
||||
continue;
|
||||
}
|
||||
if (this.messageMaxAll() > 0) {
|
||||
totalChannelMembers ??= await this.getTotalChannelMembers(rid);
|
||||
if (totalChannelMembers > this.messageMaxAll()) {
|
||||
await this.onMaxRoomMembersExceeded({ sender, rid });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
mentionsAll.push({
|
||||
_id: mention,
|
||||
username: mention,
|
||||
});
|
||||
}
|
||||
|
||||
return [...mentionsAll, ...(userMentions.size ? await this.getUsers(Array.from(userMentions)) : [])];
|
||||
}
|
||||
|
||||
async getChannelbyMentions(message: IMessage) {
|
||||
const { msg, e2eMentions }: Pick<IMessage, 'msg' | 't' | 'e2eMentions'> = message;
|
||||
|
||||
const channels =
|
||||
isE2EEMessage(message) && e2eMentions?.e2eChannelMentions && e2eMentions?.e2eChannelMentions.length > 0
|
||||
? e2eMentions?.e2eChannelMentions
|
||||
: this.getChannelMentions(msg);
|
||||
return this.convertMentionsToChannels(channels);
|
||||
}
|
||||
|
||||
async convertMentionsToChannels(channels: string[]): Promise<Pick<IRoom, '_id' | 'name' | 'fname' | 'federated'>[]> {
|
||||
// Most messages don't mention any channel; skip the (empty) database
|
||||
// query entirely in that case. An empty $in matches nothing anyway.
|
||||
if (channels.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.getChannels(channels.map((c) => (c.startsWith('#') ? c.substring(1) : c)));
|
||||
}
|
||||
|
||||
async execute(message: IMessage) {
|
||||
if (message.md) {
|
||||
const { mentions, channels } = extractMentionsFromMessageAST(message.md);
|
||||
message.mentions = await this.convertMentionsToUsers(mentions, message.rid, message.u);
|
||||
message.channels = await this.convertMentionsToChannels(channels);
|
||||
return message;
|
||||
}
|
||||
|
||||
message.mentions = await this.getUsersByMentions(message);
|
||||
message.channels = await this.getChannelbyMentions(message);
|
||||
|
||||
return message;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Team } from '@rocket.chat/core-services';
|
||||
import type { MessageMention } from '@rocket.chat/core-typings';
|
||||
|
||||
import { settings } from '../../../settings';
|
||||
import { callbacks } from '../../callbacks';
|
||||
|
||||
const beforeGetMentions = async (mentionIds: string[], teamMentions: MessageMention[]): Promise<string[]> => {
|
||||
if (!teamMentions.length) {
|
||||
return mentionIds;
|
||||
}
|
||||
|
||||
const teamsIds = teamMentions.map(({ _id }) => _id);
|
||||
const members = await Team.getMembersByTeamIds(teamsIds, { projection: { userId: 1 } });
|
||||
return [...new Set([...mentionIds, ...members.map(({ userId }) => userId)])];
|
||||
};
|
||||
|
||||
settings.watch<boolean>('Troubleshoot_Disable_Teams_Mention', (value) => {
|
||||
if (value) {
|
||||
callbacks.remove('beforeGetMentions', 'before-get-mentions-get-teams');
|
||||
} else {
|
||||
callbacks.add('beforeGetMentions', beforeGetMentions, callbacks.priority.MEDIUM, 'before-get-mentions-get-teams');
|
||||
}
|
||||
});
|
||||
3
apps/meteor/server/lib/messaging/msgStream.ts
Normal file
3
apps/meteor/server/lib/messaging/msgStream.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import notifications from '../notifications/core/lib/Notifications';
|
||||
|
||||
export const msgStream = notifications.streamRoomMessage;
|
||||
228
apps/meteor/server/lib/messaging/pins/pinMessage.ts
Normal file
228
apps/meteor/server/lib/messaging/pins/pinMessage.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import { Apps, AppEvents } from '@rocket.chat/apps';
|
||||
import { Message } from '@rocket.chat/core-services';
|
||||
import { isQuoteAttachment, isRegisterUser } from '@rocket.chat/core-typings';
|
||||
import type { IMessage, MessageAttachment, MessageQuoteAttachment } from '@rocket.chat/core-typings';
|
||||
import type { ServerMethods } from '@rocket.chat/ddp-client';
|
||||
import { Messages, Rooms, Subscriptions, Users } from '@rocket.chat/models';
|
||||
import { isTruthy } from '@rocket.chat/tools';
|
||||
import { check } from 'meteor/check';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import { settings } from '../../../settings';
|
||||
import { canAccessRoomAsync, roomAccessAttributes } from '../../authorization';
|
||||
import { hasPermissionAsync } from '../../authorization/hasPermission';
|
||||
import { methodDeprecationLogger } from '../../deprecationWarningLogger';
|
||||
import { isTheLastMessage } from '../../messages/isTheLastMessage';
|
||||
import { notifyOnRoomChangedById, notifyOnMessageChange } from '../../notifyListener';
|
||||
import { getUserAvatarURL } from '../../utils/getUserAvatarURL';
|
||||
|
||||
const recursiveRemove = (msg: MessageAttachment, deep = 1) => {
|
||||
if (!msg || !isQuoteAttachment(msg)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (deep > (settings.get<number>('Message_QuoteChainLimit') ?? 0)) {
|
||||
delete msg.attachments;
|
||||
return msg;
|
||||
}
|
||||
|
||||
msg.attachments = Array.isArray(msg.attachments)
|
||||
? msg.attachments.map((nestedMsg) => recursiveRemove(nestedMsg, deep + 1)).filter(isTruthy)
|
||||
: undefined;
|
||||
|
||||
return msg;
|
||||
};
|
||||
|
||||
const shouldAdd = (attachments: MessageAttachment[], attachment: MessageQuoteAttachment) =>
|
||||
!attachments.some((_attachment) => isQuoteAttachment(_attachment) && _attachment.message_link === attachment.message_link);
|
||||
|
||||
declare module '@rocket.chat/ddp-client' {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
interface ServerMethods {
|
||||
pinMessage(message: IMessage, pinnedAt?: Date): IMessage | null;
|
||||
unpinMessage(message: IMessage): boolean;
|
||||
}
|
||||
}
|
||||
|
||||
export async function pinMessage(message: IMessage, userId: string, pinnedAt?: Date) {
|
||||
let originalMessage = await Messages.findOneById(message._id);
|
||||
if (!originalMessage?.rid) {
|
||||
throw new Meteor.Error('error-invalid-message', 'Message you are pinning was not found', {
|
||||
method: 'pinMessage',
|
||||
action: 'Message_pinning',
|
||||
});
|
||||
}
|
||||
|
||||
if (!settings.get('Message_AllowPinning')) {
|
||||
throw new Meteor.Error('error-action-not-allowed', 'Message pinning not allowed', {
|
||||
method: 'pinMessage',
|
||||
action: 'Message_pinning',
|
||||
});
|
||||
}
|
||||
|
||||
if (!(await hasPermissionAsync(userId, 'pin-message', originalMessage.rid))) {
|
||||
throw new Meteor.Error('not-authorized', 'Not Authorized', { method: 'pinMessage' });
|
||||
}
|
||||
|
||||
const room = await Rooms.findOneById(originalMessage.rid);
|
||||
if (!room) {
|
||||
throw new Meteor.Error('not-authorized', 'Not Authorized', { method: 'pinMessage' });
|
||||
}
|
||||
|
||||
if (!(await canAccessRoomAsync(room, { _id: userId }))) {
|
||||
throw new Meteor.Error('not-authorized', 'Not Authorized', { method: 'pinMessage' });
|
||||
}
|
||||
|
||||
if (originalMessage.pinned) {
|
||||
return originalMessage;
|
||||
}
|
||||
|
||||
const me = await Users.findOneById(userId);
|
||||
if (!me) {
|
||||
throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'pinMessage' });
|
||||
}
|
||||
|
||||
originalMessage.pinned = true;
|
||||
originalMessage.pinnedAt = pinnedAt || new Date();
|
||||
originalMessage.pinnedBy = {
|
||||
_id: userId,
|
||||
username: me.username,
|
||||
};
|
||||
|
||||
originalMessage = await Message.beforeSave({ message: originalMessage, room, user: me });
|
||||
|
||||
await Messages.setPinnedByIdAndUserId(originalMessage._id, originalMessage.pinnedBy, originalMessage.pinned);
|
||||
if (isTheLastMessage(room, originalMessage)) {
|
||||
await Rooms.setLastMessagePinned(room._id, originalMessage.pinnedBy, originalMessage.pinned);
|
||||
}
|
||||
|
||||
const attachments: MessageAttachment[] = [];
|
||||
|
||||
if (Array.isArray(originalMessage.attachments)) {
|
||||
originalMessage.attachments.forEach((attachment) => {
|
||||
if (!isQuoteAttachment(attachment) || shouldAdd(attachments, attachment)) {
|
||||
attachments.push(attachment);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// App IPostMessagePinned event hook
|
||||
await Apps.self?.triggerEvent(AppEvents.IPostMessagePinned, originalMessage, me, originalMessage.pinned);
|
||||
|
||||
const pinMessageType = originalMessage.t === 'e2e' ? 'message_pinned_e2e' : 'message_pinned';
|
||||
|
||||
return Message.saveSystemMessage(pinMessageType, originalMessage.rid, '', me, {
|
||||
attachments: [
|
||||
{
|
||||
text: originalMessage.msg,
|
||||
author_name: originalMessage.u.username,
|
||||
author_icon: getUserAvatarURL(originalMessage.u.username),
|
||||
...(originalMessage.content && { content: originalMessage.content }),
|
||||
ts: originalMessage.ts,
|
||||
attachments: attachments.map(recursiveRemove),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
export const unpinMessage = async (userId: string, message: IMessage) => {
|
||||
if (!settings.get('Message_AllowPinning')) {
|
||||
throw new Meteor.Error('error-action-not-allowed', 'Message pinning not allowed', {
|
||||
method: 'unpinMessage',
|
||||
action: 'Message_pinning',
|
||||
});
|
||||
}
|
||||
|
||||
let originalMessage = await Messages.findOneById(message._id);
|
||||
if (originalMessage?._id == null) {
|
||||
throw new Meteor.Error('error-invalid-message', 'Message you are unpinning was not found', {
|
||||
method: 'unpinMessage',
|
||||
action: 'Message_pinning',
|
||||
});
|
||||
}
|
||||
|
||||
const subscription = await Subscriptions.findOneByRoomIdAndUserId(originalMessage.rid, userId, { projection: { _id: 1 } });
|
||||
if (!subscription) {
|
||||
// If it's a valid message but on a room that the user is not subscribed to, report that the message was not found.
|
||||
throw new Meteor.Error('error-invalid-message', 'Message you are unpinning was not found', {
|
||||
method: 'unpinMessage',
|
||||
action: 'Message_pinning',
|
||||
});
|
||||
}
|
||||
|
||||
if (!(await hasPermissionAsync(userId, 'pin-message', originalMessage.rid))) {
|
||||
throw new Meteor.Error('not-authorized', 'Not Authorized', { method: 'unpinMessage' });
|
||||
}
|
||||
|
||||
const me = await Users.findOneById(userId);
|
||||
if (!me) {
|
||||
throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'unpinMessage' });
|
||||
}
|
||||
|
||||
// If we keep history of edits, insert a new message to store history information
|
||||
if (settings.get('Message_KeepHistory') && isRegisterUser(me)) {
|
||||
await Messages.cloneAndSaveAsHistoryById(originalMessage._id, me);
|
||||
}
|
||||
|
||||
originalMessage.pinned = false;
|
||||
originalMessage.pinnedBy = {
|
||||
_id: userId,
|
||||
username: me.username,
|
||||
};
|
||||
|
||||
const room = await Rooms.findOneById(originalMessage.rid, { projection: { ...roomAccessAttributes, lastMessage: 1 } });
|
||||
if (!room) {
|
||||
throw new Meteor.Error('not-authorized', 'Not Authorized', { method: 'unpinMessage' });
|
||||
}
|
||||
|
||||
if (!(await canAccessRoomAsync(room, { _id: userId }))) {
|
||||
throw new Meteor.Error('not-authorized', 'Not Authorized', { method: 'unpinMessage' });
|
||||
}
|
||||
|
||||
originalMessage = await Message.beforeSave({ message: originalMessage, room, user: me });
|
||||
|
||||
if (isTheLastMessage(room, message)) {
|
||||
await Rooms.setLastMessagePinned(room._id, originalMessage.pinnedBy, originalMessage.pinned);
|
||||
void notifyOnRoomChangedById(room._id);
|
||||
}
|
||||
|
||||
// App IPostMessagePinned event hook
|
||||
await Apps.self?.triggerEvent(AppEvents.IPostMessagePinned, originalMessage, me, originalMessage.pinned);
|
||||
|
||||
await Messages.setPinnedByIdAndUserId(originalMessage._id, originalMessage.pinnedBy, originalMessage.pinned);
|
||||
void notifyOnMessageChange({
|
||||
id: message._id,
|
||||
});
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
Meteor.methods<ServerMethods>({
|
||||
async pinMessage(message, pinnedAt) {
|
||||
methodDeprecationLogger.method('pinMessage', '9.0.0', '/v1/chat.pinMessage');
|
||||
check(message._id, String);
|
||||
|
||||
const userId = Meteor.userId();
|
||||
if (!userId) {
|
||||
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
|
||||
method: 'pinMessage',
|
||||
});
|
||||
}
|
||||
|
||||
return pinMessage(message, userId, pinnedAt);
|
||||
},
|
||||
async unpinMessage(message) {
|
||||
methodDeprecationLogger.method('unpinMessage', '9.0.0', '/v1/chat.unPinMessage');
|
||||
check(message._id, String);
|
||||
|
||||
const userId = Meteor.userId();
|
||||
|
||||
if (!userId) {
|
||||
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
|
||||
method: 'unpinMessage',
|
||||
});
|
||||
}
|
||||
|
||||
return unpinMessage(userId, message);
|
||||
},
|
||||
});
|
||||
150
apps/meteor/server/lib/messaging/reactions/setReaction.ts
Normal file
150
apps/meteor/server/lib/messaging/reactions/setReaction.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { Apps, AppEvents } from '@rocket.chat/apps';
|
||||
import { Message } from '@rocket.chat/core-services';
|
||||
import type { IMessage, IRoom, IUser } from '@rocket.chat/core-typings';
|
||||
import { Messages, EmojiCustom, Rooms, Users } from '@rocket.chat/models';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import { canAccessRoomAsync } from '../../authorization';
|
||||
import { hasPermissionAsync } from '../../authorization/hasPermission';
|
||||
import { callbacks } from '../../callbacks';
|
||||
import { i18n } from '../../i18n';
|
||||
import { isTheLastMessage } from '../../messages/isTheLastMessage';
|
||||
import { notifyOnMessageChange } from '../../notifyListener';
|
||||
import { emoji } from '../emoji';
|
||||
|
||||
export const removeUserReaction = (message: IMessage, reaction: string, username: string) => {
|
||||
if (!message.reactions) {
|
||||
return message;
|
||||
}
|
||||
|
||||
const idx = message.reactions[reaction].usernames.indexOf(username);
|
||||
|
||||
// user not found in reaction array
|
||||
if (idx === -1) {
|
||||
return message;
|
||||
}
|
||||
|
||||
message.reactions[reaction].usernames.splice(idx, 1);
|
||||
if (!message.reactions[reaction].usernames.length) {
|
||||
delete message.reactions[reaction];
|
||||
}
|
||||
return message;
|
||||
};
|
||||
|
||||
export async function setReaction(room: IRoom, user: IUser, message: IMessage, reaction: string, userAlreadyReacted?: boolean) {
|
||||
await Message.beforeReacted(message, room);
|
||||
|
||||
if (Array.isArray(room.muted) && room.muted.includes(user.username as string)) {
|
||||
throw new Meteor.Error('error-not-allowed', i18n.t('You_have_been_muted', { lng: user.language }), {
|
||||
rid: room._id,
|
||||
});
|
||||
}
|
||||
|
||||
if (room.ro === true && !room.reactWhenReadOnly && !(await hasPermissionAsync(user, 'post-readonly', room._id))) {
|
||||
// Unless the user was manually unmuted
|
||||
if (!(room.unmuted || []).includes(user.username as string)) {
|
||||
throw new Error("You can't send messages because the room is readonly.");
|
||||
}
|
||||
}
|
||||
|
||||
let isReacted;
|
||||
if (userAlreadyReacted) {
|
||||
const oldMessage = JSON.parse(JSON.stringify(message));
|
||||
removeUserReaction(message, reaction, user.username as string);
|
||||
if (Object.keys(message.reactions || {}).length === 0) {
|
||||
delete message.reactions;
|
||||
await Messages.unsetReactions(message._id);
|
||||
if (isTheLastMessage(room, message)) {
|
||||
await Rooms.unsetReactionsInLastMessage(room._id);
|
||||
}
|
||||
} else {
|
||||
await Messages.setReactions(message._id, message.reactions);
|
||||
if (isTheLastMessage(room, message)) {
|
||||
await Rooms.setReactionsInLastMessage(room._id, message.reactions);
|
||||
}
|
||||
}
|
||||
void callbacks.run('afterUnsetReaction', message, { user, reaction, shouldReact: false, oldMessage, room });
|
||||
|
||||
isReacted = false;
|
||||
} else {
|
||||
if (!message.reactions) {
|
||||
message.reactions = {};
|
||||
}
|
||||
if (!message.reactions[reaction]) {
|
||||
message.reactions[reaction] = {
|
||||
usernames: [],
|
||||
};
|
||||
}
|
||||
message.reactions[reaction].usernames.push(user.username as string);
|
||||
await Messages.setReactions(message._id, message.reactions);
|
||||
if (isTheLastMessage(room, message)) {
|
||||
await Rooms.setReactionsInLastMessage(room._id, message.reactions);
|
||||
}
|
||||
|
||||
void callbacks.run('afterSetReaction', message, { user, reaction, shouldReact: true, room });
|
||||
|
||||
isReacted = true;
|
||||
}
|
||||
|
||||
void Apps.self?.triggerEvent(AppEvents.IPostMessageReacted, message, user, reaction, isReacted);
|
||||
|
||||
void notifyOnMessageChange({
|
||||
id: message._id,
|
||||
});
|
||||
}
|
||||
|
||||
export async function executeSetReaction(
|
||||
userId: string,
|
||||
reaction: string,
|
||||
messageParam: IMessage['_id'] | IMessage,
|
||||
shouldReact?: boolean,
|
||||
) {
|
||||
// Check if the emoji is valid before proceeding
|
||||
const reactionWithoutColons = reaction.replace(/:/g, '');
|
||||
reaction = `:${reactionWithoutColons}:`;
|
||||
|
||||
if (!emoji.list[reaction] && (await EmojiCustom.countByNameOrAlias(reactionWithoutColons)) === 0) {
|
||||
throw new Meteor.Error('error-not-allowed', 'Invalid emoji provided.', {
|
||||
method: 'setReaction',
|
||||
});
|
||||
}
|
||||
|
||||
const user = await Users.findOneById(userId);
|
||||
if (!user) {
|
||||
throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'setReaction' });
|
||||
}
|
||||
|
||||
const message = typeof messageParam === 'string' ? await Messages.findOneById(messageParam) : messageParam;
|
||||
if (!message) {
|
||||
throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'setReaction' });
|
||||
}
|
||||
|
||||
const userAlreadyReacted = Boolean(message.reactions?.[reaction]?.usernames?.includes(user.username as string));
|
||||
|
||||
// When shouldReact was not informed, toggle the reaction.
|
||||
if (shouldReact === undefined) {
|
||||
shouldReact = !userAlreadyReacted;
|
||||
}
|
||||
|
||||
if (userAlreadyReacted === shouldReact) {
|
||||
return;
|
||||
}
|
||||
|
||||
const room = await Rooms.findOneById(message.rid);
|
||||
if (!room) {
|
||||
throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'setReaction' });
|
||||
}
|
||||
|
||||
if (!(await canAccessRoomAsync(room, user))) {
|
||||
throw new Meteor.Error('not-authorized', 'Not Authorized', { method: 'setReaction' });
|
||||
}
|
||||
|
||||
return setReaction(room, user, message, reaction, userAlreadyReacted);
|
||||
}
|
||||
|
||||
declare module '@rocket.chat/ddp-client' {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
interface ServerMethods {
|
||||
setReaction(reaction: string, messageId: IMessage['_id'], shouldReact?: boolean): boolean | undefined;
|
||||
}
|
||||
}
|
||||
77
apps/meteor/server/lib/messaging/stars/starMessage.ts
Normal file
77
apps/meteor/server/lib/messaging/stars/starMessage.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { Apps, AppEvents } from '@rocket.chat/apps';
|
||||
import type { IMessage, IUser } from '@rocket.chat/core-typings';
|
||||
import type { ServerMethods } from '@rocket.chat/ddp-client';
|
||||
import { Messages, Subscriptions, Rooms } from '@rocket.chat/models';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import { settings } from '../../../settings';
|
||||
import { canAccessRoomAsync, roomAccessAttributes } from '../../authorization';
|
||||
import { methodDeprecationLogger } from '../../deprecationWarningLogger';
|
||||
import { isTheLastMessage } from '../../messages/isTheLastMessage';
|
||||
import { notifyOnRoomChangedById, notifyOnMessageChange } from '../../notifyListener';
|
||||
|
||||
declare module '@rocket.chat/ddp-client' {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
interface ServerMethods {
|
||||
starMessage(message: Pick<IMessage, 'rid' | '_id'> & { starred: boolean }): boolean;
|
||||
}
|
||||
}
|
||||
|
||||
export const starMessage = async (user: IUser, message: Pick<IMessage, 'rid' | '_id'> & { starred: boolean }): Promise<boolean> => {
|
||||
if (!settings.get('Message_AllowStarring')) {
|
||||
throw new Meteor.Error('error-action-not-allowed', 'Message starring not allowed', {
|
||||
method: 'starMessage',
|
||||
action: 'Message_starring',
|
||||
});
|
||||
}
|
||||
|
||||
const subscription = await Subscriptions.findOneByRoomIdAndUserId(message.rid, user._id, {
|
||||
projection: { _id: 1 },
|
||||
});
|
||||
if (!subscription) {
|
||||
return false;
|
||||
}
|
||||
if (!(await Messages.findOneByRoomIdAndMessageId(message.rid, message._id))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const room = await Rooms.findOneById(message.rid, { projection: { ...roomAccessAttributes, lastMessage: 1 } });
|
||||
|
||||
if (!room) {
|
||||
throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'starMessage' });
|
||||
}
|
||||
|
||||
if (!(await canAccessRoomAsync(room, { _id: user._id }))) {
|
||||
throw new Meteor.Error('not-authorized', 'Not Authorized', { method: 'starMessage' });
|
||||
}
|
||||
|
||||
if (isTheLastMessage(room, message)) {
|
||||
await Rooms.updateLastMessageStar(room._id, user._id, message.starred);
|
||||
void notifyOnRoomChangedById(room._id);
|
||||
}
|
||||
|
||||
await Apps.self?.triggerEvent(AppEvents.IPostMessageStarred, message, user, message.starred);
|
||||
|
||||
await Messages.updateUserStarById(message._id, user._id, message.starred);
|
||||
|
||||
void notifyOnMessageChange({
|
||||
id: message._id,
|
||||
});
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
Meteor.methods<ServerMethods>({
|
||||
async starMessage(message) {
|
||||
methodDeprecationLogger.method('starMessage', '9.0.0', '/v1/chat.starMessage');
|
||||
const user = (await Meteor.userAsync()) as IUser;
|
||||
|
||||
if (!user) {
|
||||
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
|
||||
method: 'starMessage',
|
||||
});
|
||||
}
|
||||
|
||||
return starMessage(user, message);
|
||||
},
|
||||
});
|
||||
17
apps/meteor/server/lib/messaging/threads/README.md
Normal file
17
apps/meteor/server/lib/messaging/threads/README.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# Threads
|
||||
|
||||
|
||||
# TODO
|
||||
|
||||
* Tests
|
||||
* reply to message and check if thread is created
|
||||
* reply to a msg reply and see if thead id updated
|
||||
* remove the unique reply of a thread and see if the thread is removed
|
||||
* remove some message from a thread and see if tlm is updated
|
||||
* open threads list
|
||||
* start a thread sending a reply, the thread should appear on the list
|
||||
* remove a thread, the thread should be removed from the list
|
||||
* open a thread
|
||||
* send a new reply, the message should appear
|
||||
* delete a reply, the message should be removed
|
||||
* remove the thread, the panel should close
|
||||
97
apps/meteor/server/lib/messaging/threads/functions.ts
Normal file
97
apps/meteor/server/lib/messaging/threads/functions.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import type { IMessage, IRoom, IUser } from '@rocket.chat/core-typings';
|
||||
import { isEditedMessage } from '@rocket.chat/core-typings';
|
||||
import { Messages, Subscriptions, NotificationQueue } from '@rocket.chat/models';
|
||||
|
||||
import { getMentions, getUserIdsFromHighlights } from '../../../hooks/messages/notifyUsersOnMessage';
|
||||
import { callbacks } from '../../callbacks';
|
||||
import { notifyOnSubscriptionChangedByRoomIdAndUserIds, notifyOnSubscriptionChangedByRoomIdAndUserId } from '../../notifyListener';
|
||||
|
||||
export async function reply({ tmid }: { tmid?: string }, message: IMessage, parentMessage: IMessage, followers: string[]) {
|
||||
if (!tmid || isEditedMessage(message)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { rid, ts, u } = message;
|
||||
|
||||
const { toAll, toHere, mentionIds } = await getMentions(message);
|
||||
|
||||
const addToReplies = [
|
||||
...new Set([
|
||||
...followers,
|
||||
...mentionIds,
|
||||
...(Array.isArray(parentMessage.replies) && parentMessage.replies.length ? [u._id] : [parentMessage.u._id, u._id]),
|
||||
]),
|
||||
];
|
||||
|
||||
await Messages.updateRepliesByThreadId(tmid, addToReplies, ts);
|
||||
|
||||
const [highlightsUids, threadFollowers] = await Promise.all([
|
||||
getUserIdsFromHighlights(rid, message),
|
||||
Messages.getThreadFollowsByThreadId(tmid),
|
||||
]);
|
||||
|
||||
const threadFollowersUids = threadFollowers?.filter((userId) => userId !== u._id && !mentionIds.includes(userId)) || [];
|
||||
|
||||
// Notify everyone involved in the thread
|
||||
const notifyOptions = toAll || toHere ? { groupMention: true } : {};
|
||||
|
||||
// Notify message mentioned users and highlights
|
||||
const mentionedUsers = [...new Set([...mentionIds, ...highlightsUids])];
|
||||
|
||||
const promises = [Subscriptions.addUnreadThreadByRoomIdAndUserIds(rid, threadFollowersUids, tmid, notifyOptions)];
|
||||
|
||||
if (mentionedUsers.length) {
|
||||
promises.push(Subscriptions.addUnreadThreadByRoomIdAndUserIds(rid, mentionedUsers, tmid, { userMention: true }));
|
||||
}
|
||||
|
||||
if (highlightsUids.length) {
|
||||
promises.push(
|
||||
Subscriptions.setAlertForRoomIdAndUserIds(rid, highlightsUids),
|
||||
Subscriptions.setOpenForRoomIdAndUserIds(rid, highlightsUids),
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.allSettled(promises);
|
||||
|
||||
void notifyOnSubscriptionChangedByRoomIdAndUserIds(rid, [...threadFollowersUids, ...mentionedUsers, ...highlightsUids]);
|
||||
}
|
||||
|
||||
export async function follow({ tmid, uid }: { tmid: string; uid: string }) {
|
||||
if (!tmid || !uid) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await Messages.addThreadFollowerByThreadId(tmid, uid);
|
||||
}
|
||||
|
||||
export async function unfollow({ tmid, rid, uid }: { tmid: string; rid: string; uid: string }) {
|
||||
if (!tmid || !uid) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const removeUnreadThreadResponse = await Subscriptions.removeUnreadThreadByRoomIdAndUserId(rid, uid, tmid);
|
||||
if (removeUnreadThreadResponse.modifiedCount) {
|
||||
void notifyOnSubscriptionChangedByRoomIdAndUserId(rid, uid);
|
||||
}
|
||||
|
||||
await Messages.removeThreadFollowerByThreadId(tmid, uid);
|
||||
}
|
||||
|
||||
export const readThread = async ({ user, room, tmid }: { user: IUser; room: IRoom; tmid: string }) => {
|
||||
const sub = await Subscriptions.findOneByRoomIdAndUserId(room._id, user._id, { projection: { tunread: 1 } });
|
||||
if (!sub) {
|
||||
return;
|
||||
}
|
||||
|
||||
// if the thread being marked as read is the last one unread also clear the unread subscription flag
|
||||
const clearAlert = sub.tunread && sub.tunread?.length <= 1 && sub.tunread.includes(tmid);
|
||||
|
||||
const removeUnreadThreadResponse = await Subscriptions.removeUnreadThreadByRoomIdAndUserId(room._id, user._id, tmid, clearAlert);
|
||||
if (removeUnreadThreadResponse.modifiedCount) {
|
||||
void notifyOnSubscriptionChangedByRoomIdAndUserId(room._id, user._id);
|
||||
}
|
||||
|
||||
await NotificationQueue.clearQueueByUserId(user._id);
|
||||
|
||||
callbacks.runAsync('afterReadMessages', room, { uid: user._id, tmid });
|
||||
};
|
||||
4
apps/meteor/server/lib/messaging/unread/logger.ts
Normal file
4
apps/meteor/server/lib/messaging/unread/logger.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { Logger } from '@rocket.chat/logger';
|
||||
|
||||
const logger = new Logger('MessageMarkAsUnread');
|
||||
export default logger;
|
||||
95
apps/meteor/server/lib/messaging/unread/unreadMessages.ts
Normal file
95
apps/meteor/server/lib/messaging/unread/unreadMessages.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import type { IMessage, IRoom } from '@rocket.chat/core-typings';
|
||||
import type { ServerMethods } from '@rocket.chat/ddp-client';
|
||||
import { Messages, Subscriptions } from '@rocket.chat/models';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import logger from './logger';
|
||||
import { methodDeprecationLogger } from '../../deprecationWarningLogger';
|
||||
import { notifyOnSubscriptionChangedByRoomIdAndUserId } from '../../notifyListener';
|
||||
|
||||
declare module '@rocket.chat/ddp-client' {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
interface ServerMethods {
|
||||
unreadMessages(firstUnreadMessage?: Pick<IMessage, '_id'>, room?: IRoom['_id']): void;
|
||||
}
|
||||
}
|
||||
|
||||
export const unreadMessages = async (userId: string, firstUnreadMessage?: Pick<IMessage, '_id'>, room?: IRoom['_id']): Promise<void> => {
|
||||
if (room && typeof room === 'string') {
|
||||
const lastMessage = (
|
||||
await Messages.findVisibleByRoomId(room, {
|
||||
limit: 1,
|
||||
sort: { ts: -1 },
|
||||
}).toArray()
|
||||
)[0];
|
||||
|
||||
if (!lastMessage) {
|
||||
throw new Meteor.Error('error-no-message-for-unread', 'There are no messages to mark unread', {
|
||||
method: 'unreadMessages',
|
||||
action: 'Unread_messages',
|
||||
});
|
||||
}
|
||||
|
||||
const setAsUnreadResponse = await Subscriptions.setAsUnreadByRoomIdAndUserId(lastMessage.rid, userId, lastMessage.ts);
|
||||
if (setAsUnreadResponse.modifiedCount) {
|
||||
void notifyOnSubscriptionChangedByRoomIdAndUserId(lastMessage.rid, userId);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof firstUnreadMessage?._id !== 'string') {
|
||||
throw new Meteor.Error('error-action-not-allowed', 'Not allowed', {
|
||||
method: 'unreadMessages',
|
||||
action: 'Unread_messages',
|
||||
});
|
||||
}
|
||||
|
||||
const originalMessage = await Messages.findOneById(firstUnreadMessage._id, {
|
||||
projection: {
|
||||
u: 1,
|
||||
rid: 1,
|
||||
ts: 1,
|
||||
},
|
||||
});
|
||||
if (!originalMessage || userId === originalMessage.u._id) {
|
||||
throw new Meteor.Error('error-action-not-allowed', 'Not allowed', {
|
||||
method: 'unreadMessages',
|
||||
action: 'Unread_messages',
|
||||
});
|
||||
}
|
||||
const lastSeen = (await Subscriptions.findOneByRoomIdAndUserId(originalMessage.rid, userId))?.ls;
|
||||
if (!lastSeen) {
|
||||
throw new Meteor.Error('error-subscription-not-found', 'Subscription not found', {
|
||||
method: 'unreadMessages',
|
||||
action: 'Unread_messages',
|
||||
});
|
||||
}
|
||||
|
||||
if (originalMessage.ts >= lastSeen) {
|
||||
return logger.debug('Provided message is already marked as unread');
|
||||
}
|
||||
|
||||
logger.debug({
|
||||
msg: 'Updating unread message as the first unread',
|
||||
timestamp: originalMessage.ts,
|
||||
});
|
||||
const setAsUnreadResponse = await Subscriptions.setAsUnreadByRoomIdAndUserId(originalMessage.rid, userId, originalMessage.ts);
|
||||
if (setAsUnreadResponse.modifiedCount) {
|
||||
void notifyOnSubscriptionChangedByRoomIdAndUserId(originalMessage.rid, userId);
|
||||
}
|
||||
};
|
||||
|
||||
Meteor.methods<ServerMethods>({
|
||||
async unreadMessages(firstUnreadMessage, room) {
|
||||
methodDeprecationLogger.method('unreadMessages', '9.0.0', '/v1/subscriptions.unread');
|
||||
const userId = Meteor.userId();
|
||||
if (!userId) {
|
||||
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
|
||||
method: 'unreadMessages',
|
||||
});
|
||||
}
|
||||
|
||||
return unreadMessages(userId, firstUnreadMessage, room);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { ajv } from '@rocket.chat/rest-typings';
|
||||
import mem from 'mem';
|
||||
|
||||
const customFieldsValidate = mem(
|
||||
(customFieldsSetting: string) => {
|
||||
const schema = JSON.parse(customFieldsSetting);
|
||||
|
||||
if (schema.type && schema.type !== 'object') {
|
||||
throw new Error('Invalid custom fields config');
|
||||
}
|
||||
|
||||
return ajv.compile({
|
||||
...schema,
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
});
|
||||
},
|
||||
{ maxAge: 1000 * 60 },
|
||||
);
|
||||
|
||||
export const validateCustomMessageFields = ({
|
||||
customFields,
|
||||
messageCustomFieldsEnabled,
|
||||
messageCustomFields,
|
||||
}: {
|
||||
customFields: Record<string, any>;
|
||||
messageCustomFieldsEnabled: boolean;
|
||||
messageCustomFields: string;
|
||||
}) => {
|
||||
// get the json schema for the custom fields of the message and validate it using ajv
|
||||
// if the validation fails, throw an error
|
||||
// if there are no custom fields, the message object remains unchanged
|
||||
|
||||
if (messageCustomFieldsEnabled !== true) {
|
||||
throw new Error('Custom fields not enabled');
|
||||
}
|
||||
|
||||
const validate = customFieldsValidate(messageCustomFields);
|
||||
if (!validate(customFields)) {
|
||||
throw new Error('Invalid custom fields');
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user