feat: Preview only selected URLs when sending messages (#30011)

pull/30183/head^2
Matheus Barbosa Silva 2 years ago committed by GitHub
parent a1efdbb042
commit 2db32f0d4a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 6
      .changeset/pretty-bees-give.md
  2. 6
      apps/meteor/app/api/server/v1/chat.ts
  3. 2
      apps/meteor/app/e2e/client/rocketchat.e2e.ts
  4. 13
      apps/meteor/app/lib/server/functions/parseUrlsInMessage.ts
  5. 13
      apps/meteor/app/lib/server/functions/sendMessage.js
  6. 13
      apps/meteor/app/lib/server/functions/updateMessage.ts
  7. 10
      apps/meteor/app/lib/server/methods/sendMessage.ts
  8. 151
      apps/meteor/app/lib/server/methods/updateMessage.ts
  9. 3
      apps/meteor/app/oembed/server/jumpToMessage.ts
  10. 14
      apps/meteor/app/oembed/server/server.ts
  11. 9
      apps/meteor/client/lib/chats/ChatAPI.ts
  12. 3
      apps/meteor/client/lib/chats/data.ts
  13. 3
      apps/meteor/client/lib/chats/flows/processMessageEditing.ts
  14. 15
      apps/meteor/client/lib/chats/flows/sendMessage.ts
  15. 2
      apps/meteor/client/views/room/body/RoomBody.tsx
  16. 4
      apps/meteor/client/views/room/composer/ComposerMessage.tsx
  17. 5
      apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx
  18. 4
      apps/meteor/server/services/messages/service.ts
  19. 120
      apps/meteor/tests/end-to-end/api/05-chat.js
  20. 16
      packages/rest-typings/src/v1/chat.ts

@ -0,0 +1,6 @@
---
"@rocket.chat/meteor": minor
"@rocket.chat/rest-typings": minor
---
Add option to select what URL previews should be generated for each message.

@ -14,6 +14,7 @@ import { hasPermissionAsync } from '../../../authorization/server/functions/hasP
import { deleteMessageValidatingPermission } from '../../../lib/server/functions/deleteMessage';
import { processWebhookMessage } from '../../../lib/server/functions/processWebhookMessage';
import { executeSendMessage } from '../../../lib/server/methods/sendMessage';
import { executeUpdateMessage } from '../../../lib/server/methods/updateMessage';
import { executeSetReaction } from '../../../reactions/server/setReaction';
import { settings } from '../../../settings/server';
import { normalizeMessagesForUser } from '../../../utils/server/lib/normalizeMessagesForUser';
@ -215,7 +216,7 @@ API.v1.addRoute(
throw new Meteor.Error('error-invalid-params', 'The "message" parameter must be provided.');
}
const sent = await executeSendMessage(this.userId, this.bodyParams.message as Pick<IMessage, 'rid'>);
const sent = await executeSendMessage(this.userId, this.bodyParams.message as Pick<IMessage, 'rid'>, this.bodyParams.previewUrls);
const [message] = await normalizeMessagesForUser([sent], this.userId);
return API.v1.success({
@ -310,6 +311,7 @@ API.v1.addRoute(
roomId: String,
msgId: String,
text: String, // Using text to be consistant with chat.postMessage
previewUrls: Match.Maybe([String]),
}),
);
@ -325,7 +327,7 @@ API.v1.addRoute(
}
// Permission checks are already done in the updateMessage method, so no need to duplicate them
await Meteor.callAsync('updateMessage', { _id: msg._id, msg: this.bodyParams.text, rid: msg.rid });
await executeUpdateMessage(this.userId, { _id: msg._id, msg: this.bodyParams.text, rid: msg.rid }, this.bodyParams.previewUrls);
const updatedMessage = await Messages.findOneById(msg._id);
const [message] = await normalizeMessagesForUser(updatedMessage ? [updatedMessage] : [], this.userId);

@ -466,7 +466,7 @@ class E2E extends Emitter {
await Promise.all(
urls.map(async (url) => {
if (!url.includes(Meteor.absoluteUrl())) {
if (!url.includes(settings.get('Site_Url'))) {
return;
}

@ -1,9 +1,10 @@
import type { IMessage } from '@rocket.chat/core-typings';
import type { IMessage, AtLeast } from '@rocket.chat/core-typings';
import { getMessageUrlRegex } from '../../../../lib/getMessageUrlRegex';
import { Markdown } from '../../../markdown/server';
import { settings } from '../../../settings/server';
export const parseUrlsInMessage = (message: IMessage & { parseUrls?: boolean }): IMessage => {
export const parseUrlsInMessage = (message: AtLeast<IMessage, 'msg'> & { parseUrls?: boolean }, previewUrls?: string[]) => {
if (message.parseUrls === false) {
return message;
}
@ -13,13 +14,15 @@ export const parseUrlsInMessage = (message: IMessage & { parseUrls?: boolean }):
const urls = message.html?.match(getMessageUrlRegex()) || [];
if (urls) {
message.urls = [...new Set(urls)].map((url) => ({ url, meta: {} }));
message.urls = [...new Set(urls)].map((url) => ({
url,
meta: {},
...(previewUrls && !previewUrls.includes(url) && !url.includes(settings.get('Site_Url')) && { ignoreParse: true }),
}));
}
message = Markdown.mountTokensBack(message, false);
message.msg = message.html || message.msg;
delete message.html;
delete message.tokens;
return message;
};

@ -203,7 +203,16 @@ function cleanupMessageObject(message) {
['customClass'].forEach((field) => delete message[field]);
}
export const sendMessage = async function (user, message, room, upsert = false) {
/**
* Validates and sends the message object.
* @param {IUser} user
* @param {AtLeast<IMessage, 'rid'>} message
* @param {IRoom} room
* @param {boolean} [upsert=false]
* @param {string[]} [previewUrls]
* @returns {Promise<IMessage>}
*/
export const sendMessage = async function (user, message, room, upsert = false, previewUrls = undefined) {
if (!user || !message || !room._id) {
return false;
}
@ -236,7 +245,7 @@ export const sendMessage = async function (user, message, room, upsert = false)
cleanupMessageObject(message);
parseUrlsInMessage(message);
parseUrlsInMessage(message, previewUrls);
message = await callbacks.run('beforeSaveMessage', message, room);
if (message) {

@ -1,4 +1,4 @@
import type { IEditedMessage, IMessage, IUser } from '@rocket.chat/core-typings';
import type { IEditedMessage, IMessage, IUser, AtLeast } from '@rocket.chat/core-typings';
import { Messages, Rooms } from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';
@ -7,7 +7,12 @@ import { callbacks } from '../../../../lib/callbacks';
import { settings } from '../../../settings/server';
import { parseUrlsInMessage } from './parseUrlsInMessage';
export const updateMessage = async function (message: IMessage, user: IUser, originalMsg?: IMessage): Promise<void> {
export const updateMessage = async function (
message: AtLeast<IMessage, '_id' | 'rid' | 'msg'>,
user: IUser,
originalMsg?: IMessage,
previewUrls?: string[],
): Promise<void> {
const originalMessage = originalMsg || (await Messages.findOneById(message._id));
// For the Rocket.Chat Apps :)
@ -33,7 +38,7 @@ export const updateMessage = async function (message: IMessage, user: IUser, ori
await Messages.cloneAndSaveAsHistoryById(message._id, user as Required<Pick<IUser, '_id' | 'username' | 'name'>>);
}
Object.assign<IMessage, Omit<IEditedMessage, keyof IMessage>>(message, {
Object.assign<AtLeast<IMessage, '_id' | 'rid' | 'msg'>, Omit<IEditedMessage, keyof IMessage>>(message, {
editedAt: new Date(),
editedBy: {
_id: user._id,
@ -41,7 +46,7 @@ export const updateMessage = async function (message: IMessage, user: IUser, ori
},
});
parseUrlsInMessage(message);
parseUrlsInMessage(message, previewUrls);
message = await callbacks.run('beforeSaveMessage', message);

@ -15,7 +15,7 @@ import { settings } from '../../../settings/server';
import { sendMessage } from '../functions/sendMessage';
import { RateLimiter } from '../lib';
export async function executeSendMessage(uid: IUser['_id'], message: AtLeast<IMessage, 'rid'>) {
export async function executeSendMessage(uid: IUser['_id'], message: AtLeast<IMessage, 'rid'>, previewUrls?: string[]) {
if (message.tshow && !message.tmid) {
throw new Meteor.Error('invalid-params', 'tshow provided but missing tmid', {
method: 'sendMessage',
@ -82,7 +82,7 @@ export async function executeSendMessage(uid: IUser['_id'], message: AtLeast<IMe
const room = await canSendMessageAsync(rid, { uid, username: user.username, type: user.type });
metrics.messagesSent.inc(); // TODO This line needs to be moved to it's proper place. See the comments on: https://github.com/RocketChat/Rocket.Chat/pull/5736
return sendMessage(user, message, room, false);
return sendMessage(user, message, room, false, previewUrls);
} catch (err: any) {
SystemLogger.error({ msg: 'Error sending message:', err });
@ -102,12 +102,12 @@ export async function executeSendMessage(uid: IUser['_id'], message: AtLeast<IMe
declare module '@rocket.chat/ui-contexts' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
sendMessage(message: AtLeast<IMessage, '_id' | 'rid' | 'msg'>): any;
sendMessage(message: AtLeast<IMessage, '_id' | 'rid' | 'msg'>, previewUrls?: string[]): any;
}
}
Meteor.methods<ServerMethods>({
sendMessage(message) {
sendMessage(message, previewUrls) {
check(message, Object);
const uid = Meteor.userId();
@ -118,7 +118,7 @@ Meteor.methods<ServerMethods>({
}
try {
return executeSendMessage(uid, message);
return executeSendMessage(uid, message, previewUrls);
} catch (error: any) {
if ((error.error || error.message) === 'error-not-allowed') {
throw new Meteor.Error(error.error || error.message, error.reason, {

@ -1,4 +1,4 @@
import type { IEditedMessage, IMessage } from '@rocket.chat/core-typings';
import type { IEditedMessage, IMessage, IUser, AtLeast } from '@rocket.chat/core-typings';
import { Messages, Users } from '@rocket.chat/models';
import type { ServerMethods } from '@rocket.chat/ui-contexts';
import { Match, check } from 'meteor/check';
@ -12,97 +12,102 @@ import { updateMessage } from '../functions/updateMessage';
const allowedEditedFields = ['tshow', 'alias', 'attachments', 'avatar', 'emoji', 'msg'];
declare module '@rocket.chat/ui-contexts' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
updateMessage(message: IEditedMessage): void;
export async function executeUpdateMessage(uid: IUser['_id'], message: AtLeast<IMessage, '_id' | 'rid' | 'msg'>, previewUrls?: string[]) {
const originalMessage = await Messages.findOneById(message._id);
if (!originalMessage?._id) {
return;
}
}
Meteor.methods<ServerMethods>({
async updateMessage(message: IEditedMessage) {
check(message, Match.ObjectIncluding({ _id: String }));
Object.entries(message).forEach(([key, value]) => {
if (!allowedEditedFields.includes(key) && value !== originalMessage[key as keyof IMessage]) {
throw new Meteor.Error('error-invalid-update-key', `Cannot update the message ${key}`, {
method: 'updateMessage',
});
}
});
const uid = Meteor.userId();
const msgText = originalMessage?.attachments?.[0]?.description ?? originalMessage.msg;
if (msgText === message.msg && !previewUrls) {
return;
}
if (!uid) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'updateMessage' });
}
if (!!message.tmid && originalMessage._id === message.tmid) {
throw new Meteor.Error('error-message-same-as-tmid', 'Cannot set tmid the same as the _id', {
method: 'updateMessage',
});
}
const originalMessage = await Messages.findOneById(message._id);
if (!originalMessage?._id) {
return;
}
if (!originalMessage.tmid && !!message.tmid) {
throw new Meteor.Error('error-message-change-to-thread', 'Cannot update message to a thread', { method: 'updateMessage' });
}
Object.entries(message).forEach(([key, value]) => {
if (!allowedEditedFields.includes(key) && value !== originalMessage[key as keyof IMessage]) {
throw new Meteor.Error('error-invalid-update-key', `Cannot update the message ${key}`, {
method: 'updateMessage',
});
}
const _hasPermission = await hasPermissionAsync(uid, 'edit-message', message.rid);
const editAllowed = settings.get('Message_AllowEditing');
const editOwn = originalMessage.u && originalMessage.u._id === uid;
if (!_hasPermission && (!editAllowed || !editOwn)) {
throw new Meteor.Error('error-action-not-allowed', 'Message editing not allowed', {
method: 'updateMessage',
action: 'Message_editing',
});
}
const msgText = originalMessage?.attachments?.[0]?.description ?? originalMessage.msg;
if (msgText === message.msg) {
return;
}
const blockEditInMinutes = settings.get('Message_AllowEditing_BlockEditInMinutes');
const bypassBlockTimeLimit = await hasPermissionAsync(uid, 'bypass-time-limit-edit-and-delete');
if (!!message.tmid && originalMessage._id === message.tmid) {
throw new Meteor.Error('error-message-same-as-tmid', 'Cannot set tmid the same as the _id', {
if (!bypassBlockTimeLimit && Match.test(blockEditInMinutes, Number) && blockEditInMinutes !== 0) {
let currentTsDiff = 0;
let msgTs;
if (originalMessage.ts instanceof Date || Match.test(originalMessage.ts, Number)) {
msgTs = moment(originalMessage.ts);
}
if (msgTs) {
currentTsDiff = moment().diff(msgTs, 'minutes');
}
if (currentTsDiff >= blockEditInMinutes) {
throw new Meteor.Error('error-message-editing-blocked', 'Message editing is blocked', {
method: 'updateMessage',
});
}
}
if (!originalMessage.tmid && !!message.tmid) {
throw new Meteor.Error('error-message-change-to-thread', 'Cannot update message to a thread', { method: 'updateMessage' });
}
const user = await Users.findOneById(uid);
if (!user) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'updateMessage' });
}
await canSendMessageAsync(message.rid, { uid: user._id, username: user.username ?? undefined, ...user });
const _hasPermission = await hasPermissionAsync(uid, 'edit-message', message.rid);
const editAllowed = settings.get('Message_AllowEditing');
const editOwn = originalMessage.u && originalMessage.u._id === uid;
// It is possible to have an empty array as the attachments property, so ensure both things exist
if (originalMessage.attachments && originalMessage.attachments.length > 0 && originalMessage.attachments[0].description !== undefined) {
originalMessage.attachments[0].description = message.msg;
message.attachments = originalMessage.attachments;
message.msg = originalMessage.msg;
}
if (!_hasPermission && (!editAllowed || !editOwn)) {
throw new Meteor.Error('error-action-not-allowed', 'Message editing not allowed', {
method: 'updateMessage',
action: 'Message_editing',
});
}
message.u = originalMessage.u;
const blockEditInMinutes = settings.get('Message_AllowEditing_BlockEditInMinutes');
const bypassBlockTimeLimit = await hasPermissionAsync(uid, 'bypass-time-limit-edit-and-delete');
if (!bypassBlockTimeLimit && Match.test(blockEditInMinutes, Number) && blockEditInMinutes !== 0) {
let currentTsDiff = 0;
let msgTs;
if (originalMessage.ts instanceof Date || Match.test(originalMessage.ts, Number)) {
msgTs = moment(originalMessage.ts);
}
if (msgTs) {
currentTsDiff = moment().diff(msgTs, 'minutes');
}
if (currentTsDiff >= blockEditInMinutes) {
throw new Meteor.Error('error-message-editing-blocked', 'Message editing is blocked', {
method: 'updateMessage',
});
}
}
return updateMessage(message, user, originalMessage, previewUrls);
}
const user = await Users.findOneById(uid);
if (!user) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'updateMessage' });
}
await canSendMessageAsync(message.rid, { uid: user._id, username: user.username ?? undefined, ...user });
declare module '@rocket.chat/ui-contexts' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
updateMessage(message: IEditedMessage, previewUrls?: string[]): void;
}
}
// It is possible to have an empty array as the attachments property, so ensure both things exist
if (originalMessage.attachments && originalMessage.attachments.length > 0 && originalMessage.attachments[0].description !== undefined) {
originalMessage.attachments[0].description = message.msg;
message.attachments = originalMessage.attachments;
message.msg = originalMessage.msg;
}
Meteor.methods<ServerMethods>({
async updateMessage(message: IEditedMessage, previewUrls?: string[]) {
check(message, Match.ObjectIncluding({ _id: String }));
check(previewUrls, Match.Maybe([String]));
const uid = Meteor.userId();
message.u = originalMessage.u;
if (!uid) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'updateMessage' });
}
return updateMessage(message, user, originalMessage);
return executeUpdateMessage(uid, message, previewUrls);
},
});

@ -4,7 +4,6 @@ import URL from 'url';
import type { MessageAttachment, IMessage, IOmnichannelRoom } from '@rocket.chat/core-typings';
import { isQuoteAttachment } from '@rocket.chat/core-typings';
import { Messages, Users, Rooms } from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';
import { callbacks } from '../../../lib/callbacks';
import { createQuoteAttachment } from '../../../lib/createQuoteAttachment';
@ -51,7 +50,7 @@ callbacks.add(
for await (const item of msg.urls) {
// if the URL doesn't belong to the current server, skip
if (!item.url.includes(Meteor.absoluteUrl())) {
if (!item.url.includes(settings.get('Site_Url'))) {
continue;
}

@ -18,6 +18,7 @@ import { isURL } from '../../../lib/utils/isURL';
import { settings } from '../../settings/server';
import { Info } from '../../utils/rocketchat.info';
const MAX_EXTERNAL_URL_PREVIEWS = 5;
const log = new Logger('OEmbed');
// Detect encoding
// Priority:
@ -287,16 +288,25 @@ const rocketUrlParser = async function (message: IMessage): Promise<IMessage> {
log.debug('Parsing message URLs');
if (Array.isArray(message.urls)) {
log.debug('URLs found', message.urls.length);
if (
message.attachments ||
message.urls.filter((item) => !item.url.includes(settings.get('Site_Url'))).length > MAX_EXTERNAL_URL_PREVIEWS
) {
log.debug('All URL ignored');
return message;
}
const attachments: MessageAttachment[] = [];
let changed = false;
for await (const item of message.urls) {
if (item.ignoreParse === true) {
log.debug('URL ignored', item.url);
break;
continue;
}
if (!isURL(item.url)) {
break;
continue;
}
const data = await getUrlMetaWithCache(item.url);
if (data != null) {

@ -77,7 +77,7 @@ export type DataAPI = {
getNextOwnMessage(message: IMessage): Promise<IMessage>;
pushEphemeralMessage(message: Omit<IMessage, 'rid' | 'tmid'>): Promise<void>;
canUpdateMessage(message: IMessage): Promise<boolean>;
updateMessage(message: Pick<IMessage, '_id' | 't'> & Partial<Omit<IMessage, '_id' | 't'>>): Promise<void>;
updateMessage(message: Pick<IMessage, '_id' | 't'> & Partial<Omit<IMessage, '_id' | 't'>>, previewUrls?: string[]): Promise<void>;
canDeleteMessage(message: IMessage): Promise<boolean>;
deleteMessage(mid: IMessage['_id']): Promise<void>;
getDraft(mid: IMessage['_id'] | undefined): Promise<string | undefined>;
@ -144,10 +144,13 @@ export type ChatAPI = {
readonly flows: {
readonly uploadFiles: (files: readonly File[]) => Promise<void>;
readonly sendMessage: ({ text, tshow }: { text: string; tshow?: boolean }) => Promise<boolean>;
readonly sendMessage: ({ text, tshow }: { text: string; tshow?: boolean; previewUrls?: string[] }) => Promise<boolean>;
readonly processSlashCommand: (message: IMessage, userId: string | null) => Promise<boolean>;
readonly processTooLongMessage: (message: IMessage) => Promise<boolean>;
readonly processMessageEditing: (message: Pick<IMessage, '_id' | 't'> & Partial<Omit<IMessage, '_id' | 't'>>) => Promise<boolean>;
readonly processMessageEditing: (
message: Pick<IMessage, '_id' | 't'> & Partial<Omit<IMessage, '_id' | 't'>>,
previewUrls?: string[],
) => Promise<boolean>;
readonly processSetReaction: (message: Pick<IMessage, 'msg'>) => Promise<boolean>;
readonly requestMessageDeletion: (message: IMessage) => Promise<void>;
readonly replyBroadcast: (message: IMessage) => Promise<void>;

@ -175,7 +175,8 @@ export const createDataAPI = ({ rid, tmid }: { rid: IRoom['_id']; tmid: IMessage
Messages.upsert({ _id: message._id }, { $set: { ...message, rid, ...(tmid && { tmid }) } });
};
const updateMessage = async (message: IEditedMessage): Promise<void> => sdk.call('updateMessage', message);
const updateMessage = async (message: IEditedMessage, previewUrls?: string[]): Promise<void> =>
sdk.call('updateMessage', message, previewUrls);
const canDeleteMessage = async (message: IMessage): Promise<boolean> => {
const uid = Meteor.userId();

@ -7,6 +7,7 @@ import type { ChatAPI } from '../ChatAPI';
export const processMessageEditing = async (
chat: ChatAPI,
message: Pick<IMessage, '_id' | 't'> & Partial<Omit<IMessage, '_id' | 't'>>,
previewUrls?: string[],
): Promise<boolean> => {
if (!chat.currentEditing) {
return false;
@ -21,7 +22,7 @@ export const processMessageEditing = async (
}
try {
await chat.data.updateMessage({ ...message, _id: chat.currentEditing.mid });
await chat.data.updateMessage({ ...message, _id: chat.currentEditing.mid }, previewUrls);
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
}

@ -10,7 +10,7 @@ import { processSetReaction } from './processSetReaction';
import { processSlashCommand } from './processSlashCommand';
import { processTooLongMessage } from './processTooLongMessage';
const process = async (chat: ChatAPI, message: IMessage): Promise<void> => {
const process = async (chat: ChatAPI, message: IMessage, previewUrls?: string[]): Promise<void> => {
KonchatNotification.removeRoomNotification(message.rid);
if (await processSetReaction(chat, message)) {
@ -21,7 +21,7 @@ const process = async (chat: ChatAPI, message: IMessage): Promise<void> => {
return;
}
if (await processMessageEditing(chat, message)) {
if (await processMessageEditing(chat, message, previewUrls)) {
return;
}
@ -29,10 +29,13 @@ const process = async (chat: ChatAPI, message: IMessage): Promise<void> => {
return;
}
await sdk.call('sendMessage', message);
await sdk.call('sendMessage', message, previewUrls);
};
export const sendMessage = async (chat: ChatAPI, { text, tshow }: { text: string; tshow?: boolean }): Promise<boolean> => {
export const sendMessage = async (
chat: ChatAPI,
{ text, tshow, previewUrls }: { text: string; tshow?: boolean; previewUrls?: string[] },
): Promise<boolean> => {
if (!(await chat.data.isSubscribedToRoom())) {
try {
await chat.data.joinRoom();
@ -60,7 +63,7 @@ export const sendMessage = async (chat: ChatAPI, { text, tshow }: { text: string
});
try {
await process(chat, message);
await process(chat, message, previewUrls);
chat.composer?.dismissAllQuotedMessages();
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
@ -77,7 +80,7 @@ export const sendMessage = async (chat: ChatAPI, { text, tshow }: { text: string
}
try {
if (await chat.flows.processMessageEditing({ ...originalMessage, msg: '' })) {
if (await chat.flows.processMessageEditing({ ...originalMessage, msg: '' }, previewUrls)) {
chat.currentEditing.stop();
return false;
}

@ -632,6 +632,8 @@ const RoomBody = (): ReactElement => {
onNavigateToPreviousMessage={handleNavigateToPreviousMessage}
onNavigateToNextMessage={handleNavigateToNextMessage}
onUploadFiles={handleUploadFiles}
// TODO: send previewUrls param
// previewUrls={}
/>
</RoomComposer>
</div>

@ -16,6 +16,7 @@ export type ComposerMessageProps = {
subscription?: ISubscription;
readOnly?: boolean;
tshow?: boolean;
previewUrls?: string[];
onResize?: () => void;
onEscape?: () => void;
onSend?: () => void;
@ -39,12 +40,13 @@ const ComposerMessage = ({ rid, tmid, readOnly, onSend, ...props }: ComposerMess
}
},
onSend: async ({ value: text, tshow }: { value: string; tshow?: boolean }): Promise<void> => {
onSend: async ({ value: text, tshow, previewUrls }: { value: string; tshow?: boolean; previewUrls?: string[] }): Promise<void> => {
try {
await chat?.action.stop('typing');
const newMessageSent = await chat?.flows.sendMessage({
text,
tshow,
previewUrls,
});
if (newMessageSent) onSend?.();
} catch (error) {

@ -81,7 +81,7 @@ type MessageBoxProps = {
rid: IRoom['_id'];
tmid?: IMessage['_id'];
readOnly: boolean;
onSend?: (params: { value: string; tshow?: boolean }) => Promise<void>;
onSend?: (params: { value: string; tshow?: boolean; previewUrls?: string[] }) => Promise<void>;
onJoin?: () => Promise<void>;
onResize?: () => void;
onTyping?: () => void;
@ -90,6 +90,7 @@ type MessageBoxProps = {
onNavigateToNextMessage?: () => void;
onUploadFiles?: (files: readonly File[]) => void;
tshow?: IMessage['tshow'];
previewUrls?: string[];
subscription?: ISubscription;
showFormattingTips: boolean;
isEmbedded?: boolean;
@ -107,6 +108,7 @@ const MessageBox = ({
onTyping,
readOnly,
tshow,
previewUrls,
}: MessageBoxProps): ReactElement => {
const chat = useChat();
const t = useTranslation();
@ -163,6 +165,7 @@ const MessageBox = ({
onSend?.({
value: text,
tshow,
previewUrls,
});
});

@ -25,8 +25,8 @@ export class MessageService extends ServiceClassInternal implements IMessageServ
return deleteMessage(message, user);
}
async updateMessage(message: IMessage, user: IUser, originalMsg?: IMessage): Promise<void> {
return updateMessage(message, user, originalMsg);
async updateMessage(message: IMessage, user: IUser, originalMsg?: IMessage, previewUrls?: string[]): Promise<void> {
return updateMessage(message, user, originalMsg, previewUrls);
}
async reactToMessage(userId: string, reaction: string, messageId: IMessage['_id'], shouldReact?: boolean): Promise<void> {

@ -824,6 +824,126 @@ describe('[Chat]', function () {
.end(done);
}, 200);
});
it('should not generate previews if an empty array of URL to preview is provided', async () => {
let msgId;
await request
.post(api('chat.sendMessage'))
.set(credentials)
.send({
message: {
rid: 'GENERAL',
msg: 'https://www.youtube.com/watch?v=T2v29gK8fP4',
},
previewUrls: [],
})
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('success', true);
expect(res.body).to.have.property('message').to.have.property('urls').to.be.an('array').that.is.not.empty;
expect(res.body.message.urls[0]).to.have.property('ignoreParse', true);
msgId = res.body.message._id;
});
await request
.get(api('chat.getMessage'))
.set(credentials)
.query({
msgId,
})
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('message').to.have.property('urls').to.be.an('array').that.has.lengthOf(1);
expect(res.body.message.urls[0]).to.have.property('meta').to.deep.equals({});
});
});
it('should generate previews of chosen URL when the previewUrls array is provided', async () => {
let msgId;
await request
.post(api('chat.sendMessage'))
.set(credentials)
.send({
message: {
rid: 'GENERAL',
msg: 'https://www.youtube.com/watch?v=T2v29gK8fP4 https://www.rocket.chat/',
},
previewUrls: ['https://www.rocket.chat/'],
})
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('success', true);
expect(res.body).to.have.property('message').to.have.property('urls').to.be.an('array').that.has.lengthOf(2);
expect(res.body.message.urls[0]).to.have.property('ignoreParse', true);
expect(res.body.message.urls[1]).to.not.have.property('ignoreParse');
msgId = res.body.message._id;
});
await request
.get(api('chat.getMessage'))
.set(credentials)
.query({
msgId,
})
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('message').to.have.property('urls').to.be.an('array').that.has.lengthOf(2);
expect(res.body.message.urls[0]).to.have.property('meta').that.is.an('object').that.is.empty;
expect(res.body.message.urls[1]).to.have.property('meta').that.is.an('object').that.is.not.empty;
});
});
it('should not generate previews if the message contains more than five external URL', async () => {
let msgId;
const urls = [
'https://www.youtube.com/watch?v=no050HN4ojo',
'https://www.youtube.com/watch?v=9iaSd13mqXA',
'https://www.youtube.com/watch?v=MW_qsbgt1KQ',
'https://www.youtube.com/watch?v=hLF1XwH5rd4',
'https://www.youtube.com/watch?v=Eo-F9hRBbTk',
'https://www.youtube.com/watch?v=08ms3W7adFI',
];
await request
.post(api('chat.sendMessage'))
.set(credentials)
.send({
message: {
rid: 'GENERAL',
msg: urls.join(' '),
},
previewUrls: urls,
})
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('success', true);
expect(res.body).to.have.property('message').to.have.property('urls').to.be.an('array').that.has.lengthOf(urls.length);
msgId = res.body.message._id;
});
await request
.get(api('chat.getMessage'))
.set(credentials)
.query({
msgId,
})
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('message').to.have.property('urls').to.be.an('array').that.has.lengthOf(urls.length);
res.body.message.urls.forEach((url) => {
expect(url).to.not.have.property('ignoreParse');
expect(url).to.have.property('meta').that.is.an('object').that.is.empty;
});
});
});
});
describe('Read only channel', () => {

@ -9,6 +9,7 @@ const ajv = new Ajv({
type ChatSendMessage = {
message: Partial<IMessage>;
previewUrls?: string[];
};
const chatSendMessageSchema = {
@ -64,6 +65,13 @@ const chatSendMessageSchema = {
},
},
},
previewUrls: {
type: 'array',
items: {
type: 'string',
},
nullable: true,
},
},
required: ['message'],
additionalProperties: false,
@ -430,6 +438,7 @@ type ChatUpdate = {
roomId: IRoom['_id'];
msgId: string;
text: string;
previewUrls?: string[];
};
const ChatUpdateSchema = {
@ -444,6 +453,13 @@ const ChatUpdateSchema = {
text: {
type: 'string',
},
previewUrls: {
type: 'array',
items: {
type: 'string',
},
nullable: true,
},
},
required: ['roomId', 'msgId', 'text'],
additionalProperties: false,

Loading…
Cancel
Save