Chore: Restrict `ChatMessages API` - Phase 2 (#27457)

pull/27425/head
Tasso Evangelista 3 years ago committed by GitHub
parent af3ec69236
commit 6163275cd7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 71
      apps/meteor/app/threads/client/flextab/dropzone.ts
  2. 6
      apps/meteor/app/threads/client/flextab/thread.html
  3. 166
      apps/meteor/app/threads/client/flextab/thread.ts
  4. 9
      apps/meteor/app/threads/client/threads.css
  5. 11
      apps/meteor/app/ui-master/client/body.js
  6. 3
      apps/meteor/app/ui-message/client/message.html
  7. 4
      apps/meteor/app/ui-message/client/message.js
  8. 2
      apps/meteor/app/ui-message/client/messageBox/messageBox.html
  9. 329
      apps/meteor/app/ui-message/client/messageBox/messageBox.ts
  10. 14
      apps/meteor/app/ui-message/client/messageBox/messageBoxActions.ts
  11. 10
      apps/meteor/app/ui-utils/client/lib/MessageAction.ts
  12. 34
      apps/meteor/app/ui-utils/client/lib/messageActionDefault.ts
  13. 11
      apps/meteor/app/ui-utils/client/lib/messageBox.ts
  14. 12
      apps/meteor/app/ui-utils/client/lib/popover.js
  15. 15
      apps/meteor/app/ui-utils/client/lib/readMessages.ts
  16. 4
      apps/meteor/app/ui-utils/lib/MessageTypes.ts
  17. 10
      apps/meteor/app/ui-vrecord/client/VRecDialog.js
  18. 25
      apps/meteor/app/ui-vrecord/client/vrecord.js
  19. 2
      apps/meteor/app/ui/client/index.ts
  20. 823
      apps/meteor/app/ui/client/lib/ChatMessages.ts
  21. 245
      apps/meteor/app/ui/client/lib/fileUpload.ts
  22. 4
      apps/meteor/app/ui/client/views/app/lib/CommonRoomTemplateInstance.ts
  23. 62
      apps/meteor/app/ui/client/views/app/lib/getCommonRoomEvents.ts
  24. 15
      apps/meteor/app/webdav/client/startup/messageBoxActions.js
  25. 1
      apps/meteor/client/.eslintrc.json
  26. 84
      apps/meteor/client/components/message/Attachments/ActionAttachmentButton.tsx
  27. 6
      apps/meteor/client/components/message/Attachments/ActionAttachtment.tsx
  28. 26
      apps/meteor/client/components/message/Attachments/Attachments.tsx
  29. 97
      apps/meteor/client/lib/chats/ChatAPI.ts
  30. 6
      apps/meteor/client/lib/chats/Upload.ts
  31. 262
      apps/meteor/client/lib/chats/data.ts
  32. 32
      apps/meteor/client/lib/chats/flows/processMessageEditing.ts
  33. 26
      apps/meteor/client/lib/chats/flows/processSetReaction.ts
  34. 95
      apps/meteor/client/lib/chats/flows/processSlashCommand.ts
  35. 63
      apps/meteor/client/lib/chats/flows/processTooLongMessage.ts
  36. 65
      apps/meteor/client/lib/chats/flows/requestMessageDeletion.ts
  37. 91
      apps/meteor/client/lib/chats/flows/sendMessage.ts
  38. 55
      apps/meteor/client/lib/chats/flows/uploadFiles.ts
  39. 151
      apps/meteor/client/lib/chats/uploads.ts
  40. 18
      apps/meteor/client/lib/utils/prependReplies.ts
  41. 2
      apps/meteor/client/startup/afterLogoutCleanUp/index.ts
  42. 8
      apps/meteor/client/startup/afterLogoutCleanUp/messageBoxState.ts
  43. 13
      apps/meteor/client/startup/afterLogoutCleanUp/purgeAllDrafts.ts
  44. 2
      apps/meteor/client/startup/enterRoom/index.ts
  45. 27
      apps/meteor/client/startup/enterRoom/restoreReplies.ts
  46. 17
      apps/meteor/client/startup/enterRoom/wipeFailedUploads.ts
  47. 11
      apps/meteor/client/views/composer/AudioMessageRecorder/AudioMessageRecorder.tsx
  48. 109
      apps/meteor/client/views/room/MessageList/MessageList.tsx
  49. 10
      apps/meteor/client/views/room/MessageList/components/Toolbox/MessageActionMenu.tsx
  50. 7
      apps/meteor/client/views/room/MessageList/components/Toolbox/Toolbox.tsx
  51. 78
      apps/meteor/client/views/room/Room/Room.tsx
  52. 6
      apps/meteor/client/views/room/components/body/ErroredUploadProgressIndicator.tsx
  53. 75
      apps/meteor/client/views/room/components/body/RoomBody.tsx
  54. 6
      apps/meteor/client/views/room/components/body/UploadProgressIndicator.tsx
  55. 93
      apps/meteor/client/views/room/components/body/composer/ComposerMessage.tsx
  56. 27
      apps/meteor/client/views/room/components/body/useChatMessages.ts
  57. 14
      apps/meteor/client/views/room/components/body/useFileUploadDropTarget.ts
  58. 9
      apps/meteor/client/views/room/contexts/ChatContext.ts
  59. 23
      apps/meteor/client/views/room/contextualBar/Threads/ThreadList.tsx
  60. 29
      apps/meteor/client/views/room/providers/ChatProvider.tsx
  61. 4
      apps/meteor/client/views/room/providers/MessageProvider.tsx
  62. 33
      apps/meteor/client/views/room/threads/ThreadComponent.tsx
  63. 12
      apps/meteor/client/views/room/webdav/WebdavFilePickerModal/WebdavFilePickerModal.tsx
  64. 12
      apps/meteor/ee/client/omnichannel/components/contextualBar/CannedResponse/index.tsx
  65. 2
      packages/core-typings/src/SlashCommands/index.ts
  66. 2
      packages/rest-typings/src/v1/statistics.ts
  67. 3
      packages/ui-contexts/src/ServerContext/methods.ts

@ -2,11 +2,10 @@ import type { IRoom } from '@rocket.chat/core-typings';
import moment from 'moment';
import _ from 'underscore';
import { Users } from '../../../../../models/client';
import { roomCoordinator } from '../../../../../../client/lib/rooms/roomCoordinator';
import { settings } from '../../../../../settings/client';
import { RoomManager } from '../../../../../ui-utils/client';
import { ChatMessages } from '../../../lib/ChatMessages';
import { Users } from '../../../models/client';
import { roomCoordinator } from '../../../../client/lib/rooms/roomCoordinator';
import { settings } from '../../../settings/client';
import type { ThreadTemplateInstance } from './thread';
const userCanDrop = (rid: IRoom['_id']) =>
!roomCoordinator.readOnly(rid, Users.findOne({ _id: Meteor.userId() }, { fields: { username: 1 } }));
@ -23,7 +22,7 @@ async function createFileFromUrl(url: string): Promise<File> {
const metadata = {
type: data.type,
};
const { mime } = await import('../../../../../utils/lib/mimeTypes');
const { mime } = await import('../../../utils/lib/mimeTypes');
const file = new File(
[data],
`File - ${moment().format(settings.get('Message_TimeAndDateFormat'))}.${mime.extension(data.type)}`,
@ -32,19 +31,6 @@ async function createFileFromUrl(url: string): Promise<File> {
return file;
}
function addToInput(text: string): void {
const input = RoomManager.openedRoom ? ChatMessages.get({ rid: RoomManager.openedRoom })?.input : undefined;
if (!input) {
return;
}
const initText = input.value.slice(0, input.selectionStart ?? undefined);
const finalText = input.value.slice(input.selectionEnd ?? undefined, input.value.length);
input.value = initText + text + finalText;
$(input).change().trigger('input');
}
export const dropzoneHelpers = {
dragAndDrop(): string | undefined {
return settings.get('FileUpload_Enabled') ? 'dropzone--disabled' : undefined;
@ -54,8 +40,8 @@ export const dropzoneHelpers = {
return settings.get('FileUpload_Enabled') ? 'dropzone-overlay--enabled' : 'dropzone-overlay--disabled';
},
dragAndDropLabel(this: { _id: IRoom['_id']; rid: IRoom['_id'] }): string {
if (!userCanDrop(this._id)) {
dragAndDropLabel(this: ThreadTemplateInstance['data']): string {
if (!userCanDrop(this.rid)) {
return 'error-not-allowed';
}
@ -68,14 +54,14 @@ export const dropzoneHelpers = {
};
export const dropzoneEvents = {
'dragenter .dropzone'(this: { _id: IRoom['_id'] }, e: JQuery.DragEnterEvent) {
'dragenter .dropzone'(this: ThreadTemplateInstance['data'], e: JQuery.DragEnterEvent) {
const types = e.originalEvent?.dataTransfer?.types;
if (
types &&
types.length > 0 &&
_.some(types, (type) => type.indexOf('text/') === -1 || type.indexOf('text/uri-list') !== -1 || type.indexOf('text/plain') !== -1) &&
userCanDrop(this._id)
userCanDrop(this.rid)
) {
e.currentTarget.classList.add('over');
}
@ -105,24 +91,13 @@ export const dropzoneEvents = {
event.stopPropagation();
},
async 'dropped .dropzone-overlay'(
this: { _id: IRoom['_id']; rid: IRoom['_id'] },
event: JQuery.DropEvent,
instance: Blaze.TemplateInstance & {
onFile?: (
filesToUpload: {
file: File;
name: string;
}[],
) => void;
},
) {
async 'dropped .dropzone-overlay'(this: ThreadTemplateInstance['data'], event: JQuery.DropEvent, instance: ThreadTemplateInstance) {
event.currentTarget.parentNode.classList.remove('over');
event.stopPropagation();
event.preventDefault();
if (!userCanDrop(this._id) || !settings.get('FileUpload_Enabled')) {
if (!userCanDrop(this.rid) || !settings.get('FileUpload_Enabled')) {
return false;
}
@ -147,23 +122,23 @@ export const dropzoneEvents = {
const file = await createFileFromUrl(imgURL);
if (typeof file === 'string') {
return addToInput(file);
instance.onTextDrop?.(file);
return;
}
files = [file];
}
if (dataTransfer.types.includes('text/plain') && !dataTransfer.types.includes('text/x-moz-url')) {
return addToInput(transferData?.trim());
instance.onTextDrop?.(transferData.trim());
return;
}
}
const { mime } = await import('../../../../../utils/lib/mimeTypes');
const filesToUpload = Array.from(files).map((file) => {
Object.defineProperty(file, 'type', { value: mime.lookup(file.name) });
return {
file,
name: file.name,
};
});
return instance.onFile?.(filesToUpload);
const { mime } = await import('../../../utils/lib/mimeTypes');
instance.onFileDrop?.(
Array.from(files).map((file) => {
Object.defineProperty(file, 'type', { value: mime.lookup(file.name) });
return file;
}),
);
},
};

@ -3,17 +3,17 @@
<div class="dropzone-overlay {{isDropzoneDisabled}} background-transparent-darkest color-content-background-color">{{_ dragAndDropLabel}}</div>
<div class="thread-list js-scroll-thread">
<ul class="thread">
{{#with messageContext}}
{{#with _messageContext}}
{{#if isLoading}}
<li class="load-more">
{{> loading}}
</li>
{{else}}
{{#if mainMessage }}
{{> message groupable=false hideRoles=true msg=mainMessage room=room subscription=subscription settings=settings customClass="thread-message" templatePrefix='thread-' customClass="thread-main" u=u ignored=false shouldCollapseReplies=true}}
{{> message groupable=false hideRoles=true msg=mainMessage room=room subscription=subscription settings=settings templatePrefix='thread-' customClass=customClassMain u=u ignored=false shouldCollapseReplies=true chatContext=chatContext messageContext=messageContext}}
{{/if}}
{{#each msg in messages}}
{{> message hideRoles=true msg=msg room=room shouldCollapseReplies=true subscription=subscription settings=settings templatePrefix='thread-' u=u context="threads"}}
{{> message hideRoles=true msg=msg room=room shouldCollapseReplies=true subscription=subscription settings=settings templatePrefix='thread-' customClass=(customClass msg) u=u context="threads" chatContext=chatContext messageContext=messageContext}}
{{/each}}
{{/if}}
{{/with}}

@ -6,34 +6,44 @@ import { Session } from 'meteor/session';
import { ReactiveDict } from 'meteor/reactive-dict';
import { Tracker } from 'meteor/tracker';
import { FlowRouter } from 'meteor/kadira:flow-router';
import type { IMessage, IEditedMessage, ISubscription } from '@rocket.chat/core-typings';
import type { IMessage, IEditedMessage, ISubscription, IRoom } from '@rocket.chat/core-typings';
import type { ContextType } from 'react';
import { ChatMessages } from '../../../ui/client';
import { callWithErrorHandling } from '../../../../client/lib/utils/callWithErrorHandling';
import { messageContext } from '../../../ui-utils/client/lib/messageContext';
import { upsertMessageBulk } from '../../../ui-utils/client/lib/RoomHistoryManager';
import { Messages } from '../../../models/client';
import type { FileUploadProp } from '../../../ui/client/lib/fileUpload';
import { fileUpload } from '../../../ui/client/lib/fileUpload';
import { dropzoneEvents, dropzoneHelpers } from '../../../ui/client/views/app/lib/dropzone';
import { dropzoneEvents, dropzoneHelpers } from './dropzone';
import { getUserPreference } from '../../../utils/client';
import { settings } from '../../../settings/client';
import { callbacks } from '../../../../lib/callbacks';
import { getCommonRoomEvents } from '../../../ui/client/views/app/lib/getCommonRoomEvents';
import { keyCodes } from '../../../../client/lib/utils/keyCodes';
import './thread.html';
import type { MessageBoxTemplateInstance } from '../../../ui-message/client/messageBox/messageBox';
import type { MessageContext } from '../../../../client/views/room/contexts/MessageContext';
import type { ChatContext } from '../../../../client/views/room/contexts/ChatContext';
import type MessageHighlightContext from '../../../../client/views/room/MessageList/contexts/MessageHighlightContext';
type ThreadTemplateInstance = Blaze.TemplateInstance<{
export type ThreadTemplateInstance = Blaze.TemplateInstance<{
mainMessage: IMessage;
subscription: ISubscription;
jump: unknown;
following: boolean;
rid: IRoom['_id'];
tabBar: {
openRoomInfo: (username: string) => void;
};
chatContext: ContextType<typeof ChatContext>;
messageContext: ContextType<typeof MessageContext>;
messageHighlightContext: () => ContextType<typeof MessageHighlightContext>;
}> & {
firstNode: HTMLElement;
wrapper?: HTMLElement;
Threads: Mongo.Collection<Omit<IMessage, '_id'>, IMessage> & {
direct: Mongo.Collection<Omit<IMessage, '_id'>, IMessage>;
queries: unknown[];
};
threadsObserve?: Meteor.LiveQueryHandle;
chatMessages: ChatMessages;
callbackRemove?: () => void;
state: ReactiveDict<{
rid: string;
@ -41,13 +51,15 @@ type ThreadTemplateInstance = Blaze.TemplateInstance<{
loading?: boolean;
sendToChannel: boolean;
jump?: string | null;
editingMID?: IMessage['_id'];
}>;
closeThread: () => void;
loadMore: () => Promise<void>;
atBottom?: boolean;
sendToBottom: () => void;
sendToBottomIfNecessary: () => void;
onFile: (files: FileUploadProp) => void;
onFileDrop: (files: File[]) => void;
onTextDrop: (text: string) => void;
lastJump?: string;
};
@ -87,7 +99,15 @@ Template.thread.helpers({
return Threads.find({ tmid, _id: { $ne: tmid } }, { sort });
},
messageContext(this: { mainMessage: IMessage }) {
customClass(msg: IMessage) {
const { state } = Template.instance() as ThreadTemplateInstance;
return msg._id === state.get('editingMID') ? 'editing' : '';
},
customClassMain() {
const { state } = Template.instance() as ThreadTemplateInstance;
return ['thread-main', state.get('tmid') === state.get('editingMID') ? 'editing' : ''].filter(Boolean).join(' ');
},
_messageContext(this: { mainMessage: IMessage }) {
const result = messageContext.call(this, { rid: this.mainMessage.rid });
return {
...result,
@ -98,26 +118,34 @@ Template.thread.helpers({
},
};
},
messageBoxData() {
messageBoxData(): MessageBoxTemplateInstance['data'] {
const instance = Template.instance() as ThreadTemplateInstance;
const {
mainMessage: { rid, _id: tmid },
subscription,
chatContext,
} = Template.currentData() as ThreadTemplateInstance['data'];
if (!chatContext) {
throw new Error('chatContext is not defined');
}
const showFormattingTips = settings.get('Message_ShowFormattingTips');
const alsoSendPreferenceState = getUserPreference(Meteor.userId(), 'alsoSendThreadToChannel');
return {
chatMessagesInstance: instance.chatMessages,
chatContext,
showFormattingTips,
tshow: instance.state.get('sendToChannel'),
subscription,
rid,
tmid,
onSend: (
onSend: async (
_event: Event,
params: {
{
value: text,
tshow,
}: {
value: string;
tshow?: boolean;
},
@ -126,24 +154,19 @@ Template.thread.helpers({
if (alsoSendPreferenceState === 'default') {
instance.state.set('sendToChannel', false);
}
return instance.chatMessages?.send(params);
},
onKeyUp: (
event: KeyboardEvent,
params: {
rid: string;
tmid?: string | undefined;
},
) => instance.chatMessages?.keyup(event, params),
onKeyDown: (event: KeyboardEvent) => {
const result = instance.chatMessages?.keydown(event);
const { which: keyCode } = event;
const input = event.target as HTMLTextAreaElement | null;
if (keyCode === keyCodes.ESCAPE && !result && !input?.value.trim()) {
instance.closeThread();
}
await chatContext.flows.sendMessage({
text,
tshow,
});
},
onEscape: () => {
instance.closeThread();
},
onNavigateToPreviousMessage: () => chatContext.messageEditing.toPreviousMessage(),
onNavigateToNextMessage: () => chatContext.messageEditing.toNextMessage(),
onUploadFiles: (files: readonly File[]) => {
return chatContext.flows.uploadFiles(files);
},
};
},
@ -159,6 +182,16 @@ Template.thread.helpers({
onChange: () => instance.state.set('sendToChannel', !checked),
};
},
// TODO: remove this
chatContext() {
const { chatContext } = (Template.instance() as ThreadTemplateInstance).data;
return () => chatContext;
},
// TODO: remove this
messageContext() {
const { messageContext } = (Template.instance() as ThreadTemplateInstance).data;
return () => messageContext;
},
});
Template.thread.onCreated(async function (this: ThreadTemplateInstance) {
@ -200,14 +233,13 @@ Template.thread.onCreated(async function (this: ThreadTemplateInstance) {
const messages = await callWithErrorHandling('getThreadMessages', { tmid });
upsertMessageBulk({ msgs: messages }, this.Threads);
upsertMessageBulk({ msgs: messages }, Messages);
Tracker.afterFlush(() => {
this.state.set('loading', false);
});
};
this.chatMessages = new ChatMessages({ rid: mainMessage.rid, tmid: mainMessage._id }, this.Threads);
this.closeThread = () => {
const {
route,
@ -227,16 +259,13 @@ Template.thread.onRendered(function (this: ThreadTemplateInstance) {
}
this.atBottom = true;
const wrapper = this.find('.js-scroll-thread');
const input = this.find('.js-input-message') as HTMLTextAreaElement;
this.chatMessages.initializeWrapper(wrapper);
this.chatMessages.initializeInput(input);
this.wrapper = this.find('.js-scroll-thread');
this.sendToBottom = _.throttle(() => {
this.atBottom = true;
wrapper.scrollTop = wrapper.scrollHeight;
if (this.wrapper) {
this.wrapper.scrollTop = this.wrapper.scrollHeight;
}
}, 300);
this.sendToBottomIfNecessary = () => {
@ -252,23 +281,29 @@ Template.thread.onRendered(function (this: ThreadTemplateInstance) {
const observer = new ResizeObserver(this.sendToBottomIfNecessary);
observer.observe(list);
this.onFile = (filesToUpload) => {
const { input } = this.chatMessages;
this.onTextDrop = (droppedText: string) => {
const composer = this.data.chatContext?.composer;
if (!input) {
throw new Error('Could not find input element');
if (!composer) {
return;
}
const { text, selection } = composer;
const initText = text.slice(0, selection.start ?? undefined);
const finalText = text.slice(selection.end ?? undefined, text.length);
composer.setText(initText + droppedText + finalText);
};
this.onFileDrop = (files) => {
const rid = this.state.get('rid');
if (!rid) {
throw new Error('No rid found');
}
fileUpload(filesToUpload, input, {
rid,
tmid: this.state.get('tmid'),
});
this.data.chatContext?.flows.uploadFiles(files);
};
this.autorun(() => {
@ -320,27 +355,6 @@ Template.thread.onRendered(function (this: ThreadTemplateInstance) {
this.loadMore();
});
this.autorun(() => {
const rid = this.state.get('rid');
const tmid = this.state.get('tmid');
const input = this.find('.js-input-message') as HTMLTextAreaElement | null;
if (!input) {
throw new Error('Could not find input element');
}
this.chatMessages.initializeInput(input);
setTimeout(() => {
if (window.matchMedia('screen and (min-device-width: 500px)').matches) {
input.focus();
}
}, 200);
if (rid && tmid) {
ChatMessages.set({ rid, tmid }, this.chatMessages);
}
});
this.autorun(() => {
FlowRouter.watchPathChange();
const jump = FlowRouter.getQueryParam('jump');
@ -385,18 +399,18 @@ Template.thread.onRendered(function (this: ThreadTemplateInstance) {
this.closeThread();
}
});
this.autorun(() => {
const { messageHighlightContext } = Template.currentData() as ThreadTemplateInstance['data'];
this.state.set('editingMID', messageHighlightContext()?.highlightMessageId);
});
});
Template.thread.onDestroyed(function (this: ThreadTemplateInstance) {
const { Threads, threadsObserve, callbackRemove, state } = this;
const { Threads, threadsObserve, callbackRemove } = this;
Threads.remove({});
threadsObserve?.stop();
callbackRemove?.();
const tmid = state.get('tmid');
const rid = state.get('rid');
if (rid && tmid) {
ChatMessages.delete({ rid, tmid });
}
});

@ -8,15 +8,6 @@
display: block;
}
.message.thread-message {
padding-top: 16px;
padding-bottom: 8px;
}
.thread-message + .thread-message {
border-top: 1px solid var(--color-gray-light);
}
.thread-empty {
padding: calc(2 * var(--default-padding));
}

@ -6,16 +6,17 @@ import { Session } from 'meteor/session';
import { Template } from 'meteor/templating';
import { APIClient, t } from '../../utils/client';
import { ChatMessages } from '../../ui/client';
import { popover, RoomManager } from '../../ui-utils';
import { popover } from '../../ui-utils/client';
import { settings } from '../../settings';
import { ChatSubscription } from '../../models/client';
import './body.html';
import { imperativeModal } from '../../../client/lib/imperativeModal';
import GenericModal from '../../../client/components/GenericModal';
import { fireGlobalEvent } from '../../../client/lib/utils/fireGlobalEvent';
import { isLayoutEmbedded } from '../../../client/lib/utils/isLayoutEmbedded';
import { dispatchToastMessage } from '../../../client/lib/toast';
import { refocusComposer } from '../../ui-message/client/messageBox/messageBox.ts';
import './body.html';
Template.body.onRendered(function () {
new Clipboard('.clipboard');
@ -75,6 +76,7 @@ Template.body.onRendered(function () {
popover.close();
return;
}
if (!((e.keyCode > 45 && e.keyCode < 91) || e.keyCode === 8)) {
return;
}
@ -82,6 +84,7 @@ Template.body.onRendered(function () {
if (/input|textarea|select/i.test(target.tagName)) {
return;
}
if (target.id === 'pswp') {
return;
}
@ -92,7 +95,7 @@ Template.body.onRendered(function () {
return;
}
ChatMessages.get({ rid: RoomManager.openedRoom })?.input.focus();
refocusComposer();
});
const handleMessageLinkClick = (event) => {

@ -132,7 +132,7 @@
{{/each}}
{{/if}}
{{#if hasAttachments}}
{{> reactAttachments attachments=msg.attachments file=msg.file }}
{{> reactAttachments attachments=msg.attachments file=msg.file messageContext=messageContext chatContext=chatContext }}
{{/if}}
@ -143,7 +143,6 @@
{{/with}}
{{#unless hideMessageActions}}
<div class="message-actions">
<div class="message-actions__buttons">
{{#each action in messageActions 'message'}}
<button class="message-actions__button" data-qa-type="message-action-menu" data-message-action="{{action.id}}" title="{{_ action.label}}">

@ -13,16 +13,16 @@ import { normalizeThreadTitle } from '../../threads/client/lib/normalizeThreadTi
import { MessageTypes, MessageAction } from '../../ui-utils/client';
import { RoomRoles, UserRoles, Roles, Rooms } from '../../models/client';
import { Markdown } from '../../markdown/client';
import { t } from '../../utils';
import { t } from '../../utils/client';
import { AutoTranslate } from '../../autotranslate/client';
import { renderMentions } from '../../mentions/client/client';
import { renderMessageBody } from '../../../client/lib/utils/renderMessageBody';
import { settings } from '../../settings/client';
import { formatTime } from '../../../client/lib/utils/formatTime';
import { formatDate } from '../../../client/lib/utils/formatDate';
import { roomCoordinator } from '../../../client/lib/rooms/roomCoordinator';
import './messageThread';
import './message.html';
import { roomCoordinator } from '../../../client/lib/rooms/roomCoordinator';
const renderBody = (msg, settings) => {
const searchedText = msg.searchedText ? msg.searchedText : '';

@ -32,7 +32,7 @@
{{> Template.dynamic template=customAction.template data=customAction.data }}
{{ else }}
{{#if canSend}}
{{> AudioMessageRecorder rid=rid tmid=tmid }}
{{> AudioMessageRecorder rid=rid tmid=tmid chatContext=chatContext}}
<span class="rc-message-box__action-menu js-action-menu" data-desktop aria-haspopup="true" data-qa-id="menu-more-actions">
{{#if actions}}
<span class="rc-message-box__icon">

@ -8,14 +8,16 @@ import moment from 'moment';
import type { IMessage, IRoom, ISubscription } from '@rocket.chat/core-typings';
import { isRoomFederated } from '@rocket.chat/core-typings';
import type { Blaze } from 'meteor/blaze';
import type { ContextType } from 'react';
import { Emitter } from '@rocket.chat/emitter';
import $ from 'jquery';
import { setupAutogrow } from './messageBoxAutogrow';
import { formattingButtons, applyFormatting } from './messageBoxFormatting';
import { EmojiPicker } from '../../../emoji/client';
import { Users, ChatRoom } from '../../../models/client';
import { settings } from '../../../settings/client';
import type { ChatMessages } from '../../../ui/client';
import { fileUpload, KonchatNotification } from '../../../ui/client';
import { UserAction, USER_ACTIVITIES, KonchatNotification } from '../../../ui/client';
import { messageBox, popover } from '../../../ui-utils/client';
import { t, getUserPreference } from '../../../utils/client';
import { getImageExtensionFromMime } from '../../../../lib/getImageExtensionFromMime';
@ -23,24 +25,150 @@ import { keyCodes } from '../../../../client/lib/utils/keyCodes';
import { isRTL } from '../../../../client/lib/utils/isRTL';
import { call } from '../../../../client/lib/utils/call';
import { roomCoordinator } from '../../../../client/lib/rooms/roomCoordinator';
import type { ChatContext } from '../../../../client/views/room/contexts/ChatContext';
import { withDebouncing } from '../../../../lib/utils/highOrderFunctions';
import type { ComposerAPI } from '../../../../client/lib/chats/ChatAPI';
import './messageBoxActions';
import './messageBoxReplyPreview.ts';
import './userActionIndicator.ts';
import './messageBox.html';
type MessageBoxTemplateInstance = Blaze.TemplateInstance<{
const createComposerAPI = (input: HTMLTextAreaElement, storageID: string): ComposerAPI => {
const emitter = new Emitter<{ quotedMessagesUpdate: void }>();
let _quotedMessages: IMessage[] = [];
const persist = withDebouncing({ wait: 1000 })(() => {
if (input.value) {
Meteor._localStorage.setItem(storageID, input.value);
return;
}
Meteor._localStorage.removeItem(storageID);
});
const notifyQuotedMessagesUpdate = (): void => {
emitter.emit('quotedMessagesUpdate');
};
input.value = Meteor._localStorage.getItem(storageID) ?? '';
input.addEventListener('input', persist);
const release = (): void => {
input.removeEventListener('input', persist);
};
const setText = (
text: string,
{
selection,
}: {
selection?:
| { readonly start?: number; readonly end?: number }
| ((previous: { readonly start: number; readonly end: number }) => { readonly start?: number; readonly end?: number });
} = {},
): void => {
input.value = text;
if (typeof selection === 'function') {
selection = selection({ start: input.selectionStart, end: input.selectionEnd });
}
if (selection) {
input.setSelectionRange(selection.start ?? 0, selection.end ?? text.length);
}
persist();
$(input).trigger('change').trigger('input');
};
const clear = (): void => {
setText('');
};
const focus = (): void => {
input.focus();
};
const replyWith = async (text: string): Promise<void> => {
if (input) {
input.value = text;
input.focus();
}
};
const quoteMessage = async (message: IMessage): Promise<void> => {
_quotedMessages = [..._quotedMessages.filter((_message) => _message._id !== message._id), message];
notifyQuotedMessagesUpdate();
input.focus();
};
const dismissQuotedMessage = async (mid: IMessage['_id']): Promise<void> => {
_quotedMessages = _quotedMessages.filter((message) => message._id !== mid);
notifyQuotedMessagesUpdate();
};
const dismissAllQuotedMessages = async (): Promise<void> => {
_quotedMessages = [];
notifyQuotedMessagesUpdate();
};
const quotedMessages = {
get: () => _quotedMessages,
subscribe: (callback: () => void) => emitter.on('quotedMessagesUpdate', callback),
};
const setEditingMode = (editing: boolean): void => {
if (editing) {
input.parentElement?.classList.add('editing');
} else {
input.parentElement?.classList.remove('editing');
}
};
return {
release,
get text(): string {
return input.value;
},
get selection(): { start: number; end: number } {
return {
start: input.selectionStart,
end: input.selectionEnd,
};
},
setText,
clear,
focus,
replyWith,
quoteMessage,
dismissQuotedMessage,
dismissAllQuotedMessages,
quotedMessages,
setEditingMode,
};
};
export type MessageBoxTemplateInstance = Blaze.TemplateInstance<{
rid: IRoom['_id'];
tmid: IMessage['_id'];
onSend: (
tmid?: IMessage['_id'];
onSend?: (
event: Event,
params: {
value: string;
tshow?: boolean;
},
) => Promise<void>;
tshow: IMessage['tshow'];
subscription: ISubscription & IRoom;
chatMessagesInstance: ChatMessages;
onResize?: () => void;
onEscape?: () => void;
onNavigateToPreviousMessage?: () => void;
onNavigateToNextMessage?: () => void;
onUploadFiles?: (files: readonly File[]) => void;
tshow?: IMessage['tshow'];
subscription?: ISubscription;
showFormattingTips: boolean;
isEmbedded?: boolean;
chatContext: ContextType<typeof ChatContext>;
}> & {
state: ReactiveDict<{
mustJoinWithCode?: boolean;
@ -67,10 +195,16 @@ type MessageBoxTemplateInstance = Blaze.TemplateInstance<{
sendIconDisabled: ReactiveVar<boolean>;
};
let lastFocusedInput: HTMLTextAreaElement | undefined = undefined;
export const refocusComposer = () => {
(lastFocusedInput ?? document.querySelector<HTMLTextAreaElement>('.js-input-message'))?.focus();
};
Template.messageBox.onCreated(function (this: MessageBoxTemplateInstance) {
this.state = new ReactiveDict();
this.popupConfig = new ReactiveVar(null);
this.replyMessageData = new ReactiveVar(null);
this.replyMessageData = new ReactiveVar(this.data.chatContext?.composer?.quotedMessages.get() ?? []);
this.isMicrophoneDenied = new ReactiveVar(true);
this.isSendIconVisible = new ReactiveVar(false);
@ -121,6 +255,8 @@ Template.messageBox.onCreated(function (this: MessageBoxTemplateInstance) {
const { value } = input;
this.set('');
UserAction.stop(this.data.rid, USER_ACTIVITIES.USER_TYPING, { tmid: this.data.tmid });
onSend?.call(this.data, event, { value, tshow }).then(() => {
autogrow?.update();
input.focus();
@ -131,12 +267,6 @@ Template.messageBox.onCreated(function (this: MessageBoxTemplateInstance) {
Template.messageBox.onRendered(function (this: MessageBoxTemplateInstance) {
let inputSetup = false;
const { chatMessagesInstance } = this.data;
chatMessagesInstance.quotedMessages.subscribe(() => {
this.replyMessageData.set(chatMessagesInstance.quotedMessages.get());
});
this.autorun(() => {
const { rid, subscription } = Template.currentData() as MessageBoxTemplateInstance['data'];
const room = Session.get(`roomData${rid}`);
@ -171,7 +301,9 @@ Template.messageBox.onRendered(function (this: MessageBoxTemplateInstance) {
});
this.autorun(() => {
const { rid, tmid, onInputChanged, onResize } = Template.currentData();
const { rid, tmid, onResize, chatContext } = Template.currentData() as MessageBoxTemplateInstance['data'];
let unsubscribeToQuotedMessages: (() => void) | undefined;
Tracker.afterFlush(() => {
const input = this.find('.js-input-message') as HTMLTextAreaElement;
@ -181,7 +313,23 @@ Template.messageBox.onRendered(function (this: MessageBoxTemplateInstance) {
}
this.input = input;
onInputChanged?.(input);
if (chatContext) {
const storageID = `${rid}${tmid ? `-${tmid}` : ''}`;
chatContext.setComposerAPI(createComposerAPI(input, storageID));
}
setTimeout(() => {
if (window.matchMedia('screen and (min-device-width: 500px)').matches) {
input.focus();
}
}, 200);
unsubscribeToQuotedMessages?.();
unsubscribeToQuotedMessages = chatContext?.composer?.quotedMessages.subscribe(() => {
this.replyMessageData.set(chatContext?.composer?.quotedMessages.get() ?? []);
});
if (input && rid) {
this.popupConfig.set({
@ -203,12 +351,18 @@ Template.messageBox.onRendered(function (this: MessageBoxTemplateInstance) {
}
const shadow = this.find('.js-input-message-shadow');
this.autogrow = setupAutogrow(input, shadow, onResize);
this.autogrow = onResize ? setupAutogrow(input, shadow, onResize) : null;
});
});
});
Template.messageBox.onDestroyed(function (this: MessageBoxTemplateInstance) {
UserAction.cancel(this.data.rid);
if (lastFocusedInput === this.input) {
lastFocusedInput = undefined;
}
if (!this.autogrow) {
return;
}
@ -219,7 +373,7 @@ Template.messageBox.onDestroyed(function (this: MessageBoxTemplateInstance) {
Template.messageBox.helpers({
isAnonymousOrMustJoinWithCode() {
const instance = Template.instance() as MessageBoxTemplateInstance;
const { rid } = Template.currentData();
const { rid } = Template.currentData() as MessageBoxTemplateInstance['data'];
if (!rid) {
return false;
}
@ -227,7 +381,7 @@ Template.messageBox.helpers({
return isAnonymous || instance.state.get('mustJoinWithCode');
},
isWritable() {
const { rid, subscription } = Template.currentData();
const { rid, subscription } = Template.currentData() as MessageBoxTemplateInstance['data'];
if (!rid) {
return true;
}
@ -257,8 +411,8 @@ Template.messageBox.helpers({
return (Template.instance() as MessageBoxTemplateInstance).replyMessageData.get();
},
onDismissReply() {
const { chatMessagesInstance } = (Template.instance() as MessageBoxTemplateInstance).data;
return (mid: IMessage['_id']) => chatMessagesInstance.quotedMessages.remove(mid);
const { chatContext } = (Template.instance() as MessageBoxTemplateInstance).data;
return (mid: IMessage['_id']) => chatContext?.composer?.dismissQuotedMessage(mid);
},
isEmojiEnabled() {
return getUserPreference(Meteor.userId(), 'useEmojis');
@ -396,10 +550,15 @@ Template.messageBox.events({
input.selectionEnd = caretPos + emojiValue.length;
});
},
'focus .js-input-message'() {
'focus .js-input-message'(event: JQuery.FocusEvent) {
KonchatNotification.removeRoomNotification(this.rid);
lastFocusedInput = event.currentTarget;
},
'keydown .js-input-message'(event: JQuery.KeyDownEvent, instance: MessageBoxTemplateInstance) {
'keydown .js-input-message'(
this: MessageBoxTemplateInstance['data'],
event: JQuery.KeyDownEvent<HTMLTextAreaElement>,
instance: MessageBoxTemplateInstance,
) {
const { originalEvent } = event;
if (!originalEvent) {
throw new Error('Event is not an original event');
@ -413,21 +572,86 @@ Template.messageBox.events({
return;
}
const { rid, tmid, onKeyDown } = this;
onKeyDown?.call(this, event, { rid, tmid });
const { chatContext } = this;
const { currentTarget: input } = event;
switch (event.key) {
case 'Escape': {
const currentEditing = chatContext?.currentEditing;
if (currentEditing) {
event.preventDefault();
event.stopPropagation();
currentEditing.reset().then((reset) => {
if (!reset) {
currentEditing?.cancel();
}
});
return;
}
if (!input.value.trim()) this.onEscape?.();
return;
}
case 'ArrowUp': {
if (event.shiftKey) {
return;
}
if (input.selectionEnd === 0) {
event.preventDefault();
event.stopPropagation();
this.onNavigateToPreviousMessage?.();
if (event.altKey) {
input.setSelectionRange(0, 0);
}
}
return;
}
case 'ArrowDown': {
if (event.shiftKey) {
return;
}
if (input.selectionEnd === input.value.length) {
event.preventDefault();
event.stopPropagation();
this.onNavigateToNextMessage?.();
if (event.altKey) {
input.setSelectionRange(input.value.length, input.value.length);
}
}
}
}
},
'keyup .js-input-message'(event: JQuery.KeyUpEvent) {
const { rid, tmid, onKeyUp } = this;
onKeyUp?.call(this, event, { rid, tmid });
'keyup .js-input-message'(this: MessageBoxTemplateInstance['data'], event: JQuery.KeyUpEvent<HTMLTextAreaElement>) {
const { rid, tmid } = this;
const { currentTarget: input, which: keyCode } = event;
if (!Object.values<number>(keyCodes).includes(keyCode)) {
if (input?.value.trim()) {
UserAction.start(rid, USER_ACTIVITIES.USER_TYPING, { tmid });
} else {
UserAction.stop(rid, USER_ACTIVITIES.USER_TYPING, { tmid });
}
}
},
'paste .js-input-message'(event: JQuery.TriggeredEvent, instance: MessageBoxTemplateInstance) {
'paste .js-input-message'(event: JQuery.TriggeredEvent<HTMLTextAreaElement>, instance: MessageBoxTemplateInstance) {
const originalEvent = event.originalEvent as ClipboardEvent | undefined;
if (!originalEvent) {
throw new Error('Event is not an original event');
}
const { rid, tmid } = this;
const { input, autogrow } = instance;
const { autogrow } = instance;
setTimeout(() => autogrow?.update(), 50);
@ -454,26 +678,24 @@ Template.messageBox.events({
const extension = imageExtension ? `.${imageExtension}` : '';
return {
file: fileItem,
name: `Clipboard - ${moment().format(settings.get('Message_TimeAndDateFormat'))}${extension}`,
};
Object.defineProperty(fileItem, 'name', {
writable: true,
value: `Clipboard - ${moment().format(settings.get('Message_TimeAndDateFormat'))}${extension}`,
});
return fileItem;
})
.filter(
(
file,
): file is {
file: File;
name: string;
} => Boolean(file),
);
.filter((file): file is File => !!file);
if (files.length) {
event.preventDefault();
fileUpload(files, input, { rid, tmid });
instance.data.onUploadFiles?.(files);
}
},
'input .js-input-message'(event: JQuery.TriggeredEvent, instance: MessageBoxTemplateInstance) {
'input .js-input-message'(
this: MessageBoxTemplateInstance['data'],
_event: JQuery.TriggeredEvent<HTMLTextAreaElement>,
instance: MessageBoxTemplateInstance,
) {
const { input } = instance;
if (!input) {
return;
@ -484,11 +706,12 @@ Template.messageBox.events({
if (input.value.length > 0) {
input.dir = isRTL(input.value) ? 'rtl' : 'ltr';
}
const { rid, tmid, onValueChanged } = this;
onValueChanged?.call(this, event, { rid, tmid });
},
'propertychange .js-input-message'(event: JQuery.TriggeredEvent, instance: MessageBoxTemplateInstance) {
'propertychange .js-input-message'(
this: MessageBoxTemplateInstance['data'],
event: JQuery.TriggeredEvent<HTMLTextAreaElement>,
instance: MessageBoxTemplateInstance,
) {
const originalEvent = event.originalEvent as { propertyName: string } | undefined;
if (!originalEvent) {
throw new Error('Event is not an original event');
@ -508,9 +731,6 @@ Template.messageBox.events({
if (input.value.length > 0) {
input.dir = isRTL(input.value) ? 'rtl' : 'ltr';
}
const { rid, tmid, onValueChanged } = this;
onValueChanged?.call(this, event, { rid, tmid });
},
async 'click .js-send'(event: JQuery.ClickEvent, instance: MessageBoxTemplateInstance) {
instance.send(event as unknown as Event);
@ -545,6 +765,7 @@ Template.messageBox.events({
tmid: this.tmid,
prid: this.subscription.prid,
messageBox: instance.firstNode,
chat: instance.data.chatContext,
},
activeElement: event.currentTarget,
};
@ -561,12 +782,14 @@ Template.messageBox.events({
actions
.filter(({ action }) => !!action)
.forEach(({ action }) => {
console.log(instance.data);
action.call(null, {
rid: this.rid,
tmid: this.tmid,
messageBox: instance.firstNode as HTMLElement,
prid: this.subscription.prid,
event: event as unknown as Event,
chat: instance.data.chatContext,
});
});
},

@ -5,7 +5,6 @@ import { isRoomFederated } from '@rocket.chat/core-typings';
import { VRecDialog } from '../../../ui-vrecord/client';
import { messageBox } from '../../../ui-utils/client';
import { fileUpload } from '../../../ui/client';
import { settings } from '../../../settings/client';
import { imperativeModal } from '../../../../client/lib/imperativeModal';
import ShareLocationModal from '../../../../client/views/room/ShareLocation/ShareLocationModal';
@ -22,14 +21,16 @@ messageBox.actions.add('Create_new', 'Video_message', {
(!settings.get('FileUpload_MediaTypeBlackList') || !settings.get('FileUpload_MediaTypeBlackList').match(/video\/webm|video\/\*/i)) &&
(!settings.get('FileUpload_MediaTypeWhiteList') || settings.get('FileUpload_MediaTypeWhiteList').match(/video\/webm|video\/\*/i)) &&
window.MediaRecorder.isTypeSupported('video/webm; codecs=vp8,opus'),
action: ({ rid, tmid, messageBox }) => (VRecDialog.opened ? VRecDialog.close() : VRecDialog.open(messageBox, { rid, tmid })),
action: ({ rid, tmid, messageBox, chat }) => {
VRecDialog.opened ? VRecDialog.close() : VRecDialog.open(messageBox, { rid, tmid, chat });
},
});
messageBox.actions.add('Add_files_from', 'Computer', {
id: 'file-upload',
icon: 'computer',
condition: () => settings.get('FileUpload_Enabled'),
action({ rid, tmid, event, messageBox }) {
action({ event, chat }) {
event.preventDefault();
const $input = $(document.createElement('input'));
$input.css('display', 'none');
@ -47,13 +48,10 @@ messageBox.actions.add('Add_files_from', 'Computer', {
Object.defineProperty(file, 'type', {
value: mime.lookup(file.name),
});
return {
file,
name: file.name,
};
return file;
});
fileUpload(filesToUpload, $('.js-input-message', messageBox).get(0) as HTMLTextAreaElement, { rid, tmid });
chat?.flows.uploadFiles(filesToUpload);
$input.remove();
});

@ -1,4 +1,4 @@
import type { ComponentProps } from 'react';
import type { ComponentProps, ContextType } from 'react';
import _ from 'underscore';
import mem from 'mem';
import { Meteor } from 'meteor/meteor';
@ -11,6 +11,7 @@ import type { TranslationKey } from '@rocket.chat/ui-contexts';
import { Messages, Rooms, Subscriptions } from '../../../models/client';
import { roomCoordinator } from '../../../../client/lib/rooms/roomCoordinator';
import type { ToolboxContextValue } from '../../../../client/views/room/contexts/ToolboxContext';
import type { ChatContext } from '../../../../client/views/room/contexts/ChatContext';
const call = (method: string, ...args: any[]): Promise<any> =>
new Promise((resolve, reject) => {
@ -64,7 +65,12 @@ export type MessageActionConfig = {
context?: MessageActionContext[];
action: (
e: Pick<Event, 'preventDefault' | 'stopPropagation'>,
{ message, tabbar, room }: { message?: IMessage; tabbar: ToolboxContextValue; room?: IRoom },
{
message,
tabbar,
room,
chat,
}: { message?: IMessage; tabbar: ToolboxContextValue; room?: IRoom; chat: ContextType<typeof ChatContext> },
) => any;
condition?: (props: MessageActionConditionProps) => boolean;
};

@ -2,7 +2,6 @@ import { FlowRouter } from 'meteor/kadira:flow-router';
import moment from 'moment';
import { Meteor } from 'meteor/meteor';
import { TAPi18n } from 'meteor/rocketchat:tap-i18n';
import { Session } from 'meteor/session';
import type { IMessage } from '@rocket.chat/core-typings';
import { isRoomFederated } from '@rocket.chat/core-typings';
@ -17,7 +16,6 @@ import ReportMessageModal from '../../../../client/views/room/modals/ReportMessa
import CreateDiscussion from '../../../../client/components/CreateDiscussion/CreateDiscussion';
import { canDeleteMessage } from '../../../../client/lib/utils/canDeleteMessage';
import { dispatchToastMessage } from '../../../../client/lib/toast';
import type { ChatMessages } from '../../../ui/client';
export const addMessageToList = (messagesList: IMessage[], message: IMessage): IMessage[] => {
// checks if the message is not already on the list
@ -29,14 +27,6 @@ export const addMessageToList = (messagesList: IMessage[], message: IMessage): I
};
Meteor.startup(async function () {
const { ChatMessages } = await import('../../../ui/client');
const getChatMessagesFrom = (msg: IMessage): ChatMessages | undefined => {
const { rid = Session.get('openedRoom'), tmid = msg._id } = msg;
return ChatMessages.get({ rid, tmid }) ?? ChatMessages.get({ rid });
};
MessageAction.addButton({
id: 'reply-directly',
icon: 'reply-directly',
@ -81,17 +71,9 @@ Meteor.startup(async function () {
label: 'Quote',
context: ['message', 'message-mobile', 'threads', 'federated'],
action(_, props) {
const { message = messageArgs(this).msg } = props;
const chatMessagesInstance = getChatMessagesFrom(message);
const input = chatMessagesInstance?.input;
if (!input) {
return;
}
const { message = messageArgs(this).msg, chat } = props;
const $input = $(input);
$input.focus().data('mention-user', false).trigger('dataChange');
chatMessagesInstance.quotedMessages.add(message);
chat?.composer?.quoteMessage(message);
},
condition({ subscription, room }) {
if (subscription == null) {
@ -151,12 +133,8 @@ Meteor.startup(async function () {
label: 'Edit',
context: ['message', 'message-mobile', 'threads', 'federated'],
action(_, props) {
const { message = messageArgs(this).msg } = props;
const element = document.getElementById(message.tmid ? `thread-${message._id}` : message._id);
if (!element) {
throw new Error('Message not found');
}
getChatMessagesFrom(message)?.edit(element);
const { message = messageArgs(this).msg, chat } = props;
chat?.messageEditing.editMessage(message);
},
condition({ message, subscription, settings, room }) {
if (subscription == null) {
@ -196,8 +174,8 @@ Meteor.startup(async function () {
context: ['message', 'message-mobile', 'threads', 'federated'],
color: 'alert',
action(_, props) {
const { message = messageArgs(this).msg } = props;
getChatMessagesFrom(message)?.confirmDeleteMsg(message);
const { message = messageArgs(this).msg, chat } = props;
chat?.flows.requestMessageDeletion(message);
},
condition({ message, subscription, room }) {
if (!subscription) {

@ -1,10 +1,19 @@
import type { IMessage, IRoom } from '@rocket.chat/core-typings';
import type { ContextType } from 'react';
import type { ChatContext } from '../../../../client/views/room/contexts/ChatContext';
type MessageBoxAction = {
label: string;
id: string;
icon?: string;
action: (params: { rid: IRoom['_id']; tmid?: IMessage['_id']; event: Event; messageBox: HTMLElement }) => void;
action: (params: {
rid: IRoom['_id'];
tmid?: IMessage['_id'];
event: Event;
messageBox: HTMLElement;
chat: ContextType<typeof ChatContext>;
}) => void;
condition?: () => boolean;
};

@ -5,7 +5,6 @@ import _ from 'underscore';
import { Meteor } from 'meteor/meteor';
import { messageBox } from './messageBox.ts';
import { MessageAction } from './MessageAction';
import { isRTLScriptLanguage } from '../../../../client/lib/utils/isRTLScriptLanguage';
export const popover = {
@ -178,17 +177,6 @@ Template.popover.events({
});
popover.close();
},
'click [data-qa-type="message-action"]'(e, t) {
const button = MessageAction.getButtonById(e.currentTarget.dataset.id);
if ((button != null ? button.action : undefined) != null) {
e.stopPropagation();
e.preventDefault();
const { tabBar, rid } = t.data.instance;
button.action.call(t.data.data, e, { tabBar, rid });
popover.close();
return false;
}
},
});
Template.popover.helpers({

@ -2,6 +2,7 @@ import { Meteor } from 'meteor/meteor';
import { Session } from 'meteor/session';
import { Emitter } from '@rocket.chat/emitter';
import type { IRoom } from '@rocket.chat/core-typings';
import $ from 'jquery';
import { RoomHistoryManager } from './RoomHistoryManager';
import { RoomManager } from './RoomManager';
@ -94,22 +95,22 @@ export class ReadMessage extends Emitter {
}
public refreshUnreadMark(rid: IRoom['_id']) {
if (rid == null) {
if (!rid) {
return;
}
const subscription = ChatSubscription.findOne({ rid }, { reactive: false });
if (subscription == null) {
if (!subscription) {
return;
}
const room = RoomManager.openedRooms[subscription.t + subscription.name];
if (room == null) {
if (!room) {
return;
}
if (!subscription.alert && subscription.unread === 0) {
$('.message.first-unread').removeClass('first-unread');
document.querySelector('.message.first-unread')?.classList.remove('first-unread');
room.unreadSince.set(undefined);
return;
}
@ -129,7 +130,7 @@ export class ReadMessage extends Emitter {
) as { ts: Date } | undefined;
const { unreadNotLoaded } = RoomHistoryManager.getRoom(rid);
if (lastReadRecord == null && unreadNotLoaded.get() === 0) {
if (!lastReadRecord && unreadNotLoaded.get() === 0) {
lastReadRecord = { ts: new Date(0) };
}
@ -158,8 +159,8 @@ export class ReadMessage extends Emitter {
if (firstUnreadRecord) {
room.unreadFirstId = firstUnreadRecord._id;
$('.message.first-unread').removeClass('first-unread');
$(`.message#${firstUnreadRecord._id}`).addClass('first-unread');
document.querySelector('.message.first-unread')?.classList.remove('first-unread');
document.querySelector(`.message#${firstUnreadRecord._id}`)?.classList.add('first-unread');
}
}
}

@ -25,11 +25,11 @@ class MessageTypesClass {
return options;
}
getType(message: IMessage): MessageType | undefined {
getType(message: Pick<IMessage, 't'>): MessageType | undefined {
return message.t && this.types.get(message.t);
}
isSystemMessage(message: IMessage): boolean {
isSystemMessage(message: Pick<IMessage, 't'>): boolean {
const type = this.getType(message);
return Boolean(type?.system);
}

@ -14,7 +14,11 @@ export const VRecDialog = new (class {
this.dialogView = Blaze.render(Template.vrecDialog, document.body);
}
open(source, { rid, tmid }) {
/**
* @param {HTMLElement} source
* @param {{ rid: import('@rocket.chat/core-typings').IRoom['_id']; tmid?: import('@rocket.chat/core-typings').IMessage['_id']; chat: import('react').ContextType<typeof import('../../../client/views/room/contexts/ChatContext').ChatContext> }} options
*/
open(source, { rid, tmid, chat }) {
if (!this.dialogView) {
this.init();
}
@ -27,7 +31,7 @@ export const VRecDialog = new (class {
this.dialogView.templateInstance().update({
rid,
tmid,
input: source.querySelector('.js-input-message'),
chat,
});
this.source = source;
@ -42,7 +46,7 @@ export const VRecDialog = new (class {
close() {
$('.vrec-dialog').removeClass('show');
this.opened = false;
if (this.video != null) {
if (this.video) {
return VideoRecorder.stop();
}
}

@ -4,7 +4,16 @@ import { ReactiveVar } from 'meteor/reactive-var';
import _ from 'underscore';
import { VRecDialog } from './VRecDialog';
import { VideoRecorder, fileUpload, UserAction, USER_ACTIVITIES } from '../../ui/client';
import { VideoRecorder, UserAction, USER_ACTIVITIES } from '../../ui/client';
/**
* @typedef {import('meteor/blaze').Blaze.TemplateInstance<{}> & {
* rid: ReactiveVar<string>,
* tmid: ReactiveVar<string | undefined>;
* time: ReactiveVar<string>;
* chat?: import('../../../client/lib/chats/ChatAPI').ChatAPI;
* }} VRecDialogTemplateInstance
*/
Template.vrecDialog.helpers({
recordIcon() {
@ -73,13 +82,16 @@ Template.vrecDialog.events({
);
}
},
/**
* @param {JQuery.ClickEvent} e
* @param {VRecDialogTemplateInstance} instance
*/
'click .vrec-dialog .ok'(e, instance) {
const [rid, tmid, input] = [instance.rid.get(), instance.tmid.get(), instance.input.get()];
const [rid, tmid] = [instance.rid.get(), instance.tmid.get()];
const cb = (blob) => {
const fileName = `${TAPi18n.__('Video record')}.webm`;
const file = new File([blob], fileName, { type: 'video/webm' });
fileUpload([{ file, type: 'video/webm', name: fileName }], input, { rid, tmid });
instance.chat?.flows.uploadFiles([file]);
VRecDialog.close();
};
VideoRecorder.stop(cb);
@ -94,12 +106,11 @@ Template.vrecDialog.onCreated(function () {
this.rid = new ReactiveVar();
this.tmid = new ReactiveVar();
this.input = new ReactiveVar();
this.time = new ReactiveVar('');
this.update = ({ rid, tmid, input }) => {
this.update = ({ rid, tmid, chat }) => {
this.rid.set(rid);
this.tmid.set(tmid);
this.input.set(input);
this.chat = chat;
};
this.setPosition = function (dialog, source, anchor = 'left') {

@ -14,8 +14,6 @@ import './components/popupList.html';
import './components/popupList';
import './components/selectDropdown.html';
export { ChatMessages } from './lib/ChatMessages';
export { fileUpload } from './lib/fileUpload';
export { UserAction, USER_ACTIVITIES } from './lib/UserAction';
export { KonchatNotification } from './lib/notification';
export { Login, Button } from './lib/rocket';

@ -1,755 +1,200 @@
import { Emitter } from '@rocket.chat/emitter';
import { escapeHTML } from '@rocket.chat/string-helpers';
import $ from 'jquery';
import { Meteor } from 'meteor/meteor';
import { Random } from 'meteor/random';
import { Session } from 'meteor/session';
import { TAPi18n } from 'meteor/rocketchat:tap-i18n';
import moment from 'moment';
import type { IMessage, IRoom } from '@rocket.chat/core-typings';
import type { Mongo } from 'meteor/mongo';
import { KonchatNotification } from './notification';
import { fileUpload } from './fileUpload';
import { t, slashCommands, APIClient } from '../../../utils/client';
import { messageProperties, MessageTypes, readMessage } from '../../../ui-utils/client';
import { settings } from '../../../settings/client';
import { hasAtLeastOnePermission } from '../../../authorization/client';
import { Rooms, ChatMessage, ChatSubscription } from '../../../models/client';
import { emoji } from '../../../emoji/client';
import { generateTriggerId } from '../../../ui-message/client/ActionManager';
import { imperativeModal } from '../../../../client/lib/imperativeModal';
import GenericModal from '../../../../client/components/GenericModal';
import { prependReplies } from '../../../../client/lib/utils/prependReplies';
import { callWithErrorHandling } from '../../../../client/lib/utils/callWithErrorHandling';
import { dispatchToastMessage } from '../../../../client/lib/toast';
import { onClientBeforeSendMessage } from '../../../../client/lib/onClientBeforeSendMessage';
import { createUploadsAPI } from '../../../../client/lib/chats/uploads';
import {
setHighlightMessage,
clearHighlightMessage,
} from '../../../../client/views/room/MessageList/providers/messageHighlightSubscription';
import { UserAction, USER_ACTIVITIES } from './UserAction';
import { keyCodes } from '../../../../client/lib/utils/keyCodes';
import { withDebouncing } from '../../../../lib/utils/highOrderFunctions';
class QuotedMessages {
private emitter = new Emitter<{ update: void }>();
private messages: IMessage[] = [];
public get(): IMessage[] {
return this.messages;
}
public add(message: IMessage): void {
this.messages = [...this.messages.filter((_message) => _message._id !== message._id), message];
this.emitter.emit('update');
}
public remove(mid: IMessage['_id']): void {
this.messages = this.messages.filter((message) => message._id !== mid);
this.emitter.emit('update');
}
public clear(): void {
this.messages = [];
this.emitter.emit('update');
}
public subscribe(callback: () => void): () => void {
return this.emitter.on('update', callback);
}
}
class ComposerState {
private emitter = new Emitter<{ update: void }>();
private key: string;
private state: string | undefined;
public constructor(id: string) {
this.key = `messagebox_${id}`;
this.state = Meteor._localStorage.getItem(this.key) ?? undefined;
}
public get() {
return this.state;
}
private persist = withDebouncing({ wait: 1000 })(() => {
if (this.state) {
Meteor._localStorage.setItem(this.key, this.state);
return;
}
Meteor._localStorage.removeItem(this.key);
});
public update(value: string | undefined) {
this.state = value;
this.persist();
this.emitter.emit('update');
}
public subscribe(callback: () => void): () => void {
return this.emitter.on('update', callback);
}
public static purgeAll() {
Object.keys(Meteor._localStorage)
.filter((key) => key.indexOf('messagebox_') === 0)
.forEach((key) => Meteor._localStorage.removeItem(key));
}
}
type ChatMessagesParams =
| { rid: IRoom['_id']; tmid?: IMessage['_id']; input?: never }
| { rid?: never; tmid?: never; input: HTMLInputElement | HTMLTextAreaElement };
export class ChatMessages {
public quotedMessages: QuotedMessages = new QuotedMessages();
public composerState: ComposerState;
private editing: {
element?: HTMLElement;
id?: string;
saved?: string;
savedCursor?: number;
} = {};
private records: Record<
IMessage['_id'],
| {
draft: string;
}
| undefined
> = {};
public wrapper: HTMLElement | undefined;
public input: HTMLTextAreaElement | undefined;
public constructor(
private params: { rid: IRoom['_id']; tmid?: IMessage['_id'] },
private collection: Mongo.Collection<Omit<IMessage, '_id'>, IMessage> = ChatMessage,
) {
this.quotedMessages.subscribe(() => {
if (this.input) $(this.input).trigger('dataChange');
});
this.composerState = new ComposerState(params.rid + (params.tmid ? `-${params.tmid}` : ''));
}
private setDraftAndUpdateInput(value: string | undefined) {
this.composerState.update(value);
if (value === undefined) return;
if (!this.input) return;
this.input.value = value;
$(this.input).trigger('change').trigger('input');
}
public initializeWrapper(wrapper: HTMLElement) {
this.wrapper = wrapper;
}
public initializeInput(input: HTMLTextAreaElement) {
this.input = input;
this.setDraftAndUpdateInput(this.composerState.get());
}
private recordInputAsDraft() {
const { input } = this;
if (!input) {
return;
}
const { id } = this.editing;
if (!id) {
return;
}
const message = this.collection.findOne(id);
if (!message) {
throw new Error('Message not found');
}
const draft = input.value;
if (draft === message.msg) {
this.clearCurrentDraft();
return;
}
const record = this.records[id] || { draft };
record.draft = draft;
this.records[id] = record;
}
private clearCurrentDraft() {
const { id } = this.editing;
if (!id) {
return;
}
const hasValue = this.records[id];
delete this.records[id];
return !!hasValue;
}
private resetToDraft(id: string) {
const { input } = this;
if (!input) {
return;
}
const message = this.collection.findOne(id);
if (!message) {
throw new Error('Message not found');
}
const oldValue = input.value;
this.setDraftAndUpdateInput(message.msg);
return oldValue !== message.msg;
}
private toPrevMessage() {
const { element } = this.editing;
if (!element) {
const messages = Array.from(this.wrapper?.querySelectorAll('[data-own="true"]') ?? []);
const message = messages.pop();
return message && this.edit(message as HTMLElement, false);
}
for (let previous = element.previousElementSibling; previous; previous = previous.previousElementSibling) {
if (previous.matches('[data-own="true"]')) {
return this.edit(previous as HTMLElement, false);
}
}
this.clearEditing();
}
private toNextMessage() {
const { element } = this.editing;
if (element) {
let next;
for (next = element.nextElementSibling; next; next = next.nextElementSibling) {
if (next.matches('[data-own="true"]')) {
break;
}
import type { ChatAPI, ComposerAPI, DataAPI, UploadsAPI } from '../../../../client/lib/chats/ChatAPI';
import { createDataAPI } from '../../../../client/lib/chats/data';
import { uploadFiles } from '../../../../client/lib/chats/flows/uploadFiles';
import { processSlashCommand } from '../../../../client/lib/chats/flows/processSlashCommand';
import { requestMessageDeletion } from '../../../../client/lib/chats/flows/requestMessageDeletion';
import { processMessageEditing } from '../../../../client/lib/chats/flows/processMessageEditing';
import { processTooLongMessage } from '../../../../client/lib/chats/flows/processTooLongMessage';
import { processSetReaction } from '../../../../client/lib/chats/flows/processSetReaction';
import { sendMessage } from '../../../../client/lib/chats/flows/sendMessage';
export class ChatMessages implements ChatAPI {
private currentEditingMID?: string;
public messageEditing: ChatAPI['messageEditing'] = {
toPreviousMessage: async () => {
if (!this.composer) {
return;
}
next ? this.edit(next as HTMLElement, true) : this.clearEditing();
} else {
this.clearEditing();
}
}
public edit(element: HTMLElement, isEditingTheNextOne?: boolean) {
const message = this.collection.findOne(element.dataset.id);
if (!message) {
throw new Error('Message not found');
}
const hasPermission = hasAtLeastOnePermission('edit-message', message.rid);
const editAllowed = settings.get('Message_AllowEditing');
const editOwn = message?.u && message.u._id === Meteor.userId();
if (!hasPermission && (!editAllowed || !editOwn)) {
return;
}
if (MessageTypes.isSystemMessage(message)) {
return;
}
if (!this.currentEditing) {
const lastMessage = await this.data.findLastOwnMessage();
const blockEditInMinutes = settings.get('Message_AllowEditing_BlockEditInMinutes');
if (blockEditInMinutes && blockEditInMinutes !== 0) {
let msgTs;
if (message.ts) {
msgTs = moment(message.ts);
}
if (msgTs) {
const currentTsDiff = moment().diff(msgTs, 'minutes');
if (currentTsDiff > blockEditInMinutes) {
return;
if (lastMessage) {
await this.data.saveDraft(undefined, this.composer.text);
await this.messageEditing.editMessage(lastMessage);
}
}
}
const draft = this.records[message._id];
let msg = draft?.draft;
msg = msg || message.msg;
this.clearEditing();
const { input } = this;
if (!input) {
return;
}
this.editing.element = element;
this.editing.id = message._id;
input.parentElement?.classList.add('editing');
element.classList.add('editing');
setHighlightMessage(message._id);
if (message.attachments?.[0].description) {
this.setDraftAndUpdateInput(message.attachments[0].description);
} else if (msg) {
this.setDraftAndUpdateInput(msg);
}
const cursorPosition = isEditingTheNextOne ? 0 : input.value.length;
input.focus();
input.setSelectionRange(cursorPosition, cursorPosition);
}
private clearEditing() {
const { input } = this;
if (!input) {
return;
}
if (!this.editing.element) {
this.editing.saved = this.input?.value;
this.editing.savedCursor = this.input?.selectionEnd;
return;
}
this.recordInputAsDraft();
input.parentElement?.classList.remove('editing');
this.editing.element.classList.remove('editing');
delete this.editing.id;
delete this.editing.element;
clearHighlightMessage();
this.setDraftAndUpdateInput(this.editing.saved || '');
const cursorPosition = this.editing.savedCursor ? this.editing.savedCursor : input.value.length;
input.setSelectionRange(cursorPosition, cursorPosition);
}
public async send({ value, tshow }: { value: string; tshow?: boolean }) {
const { rid } = this.params;
let { tmid } = this.params;
if (!rid) {
throw new Error('Room ID is required');
}
const threadsEnabled = settings.get('Threads_enabled');
UserAction.stop(rid, USER_ACTIVITIES.USER_TYPING, { tmid });
if (!ChatSubscription.findOne({ rid })) {
await callWithErrorHandling('joinRoom', rid);
}
if (!this.input) {
throw new Error('Input is not defined');
}
let msg = value.trim();
if (msg) {
const mention = $(this.input).data('mention-user') ?? false;
const replies = this.quotedMessages.get();
if (!mention || !threadsEnabled) {
msg = await prependReplies(msg, replies, mention);
}
if (mention && threadsEnabled && replies.length) {
tmid = replies[0]._id;
return;
}
}
// don't add tmid or tshow if the message isn't part of a thread (it can happen if editing the main message of a thread)
const originalMessage = this.collection.findOne({ _id: this.editing.id }, { fields: { tmid: 1 }, reactive: false });
if (originalMessage && tmid && !originalMessage.tmid) {
tmid = undefined;
tshow = undefined;
}
const currentMessage = await this.data.findMessageByID(this.currentEditing.mid);
const previousMessage = currentMessage ? await this.data.findPreviousOwnMessage(currentMessage) : undefined;
if (msg) {
readMessage.readNow(rid);
readMessage.refreshUnreadMark(rid);
const message = (await onClientBeforeSendMessage({
_id: Random.id(),
rid,
tshow,
tmid,
msg,
})) as IMessage;
try {
await this.processMessageSend(message);
this.quotedMessages.clear();
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
if (previousMessage) {
await this.messageEditing.editMessage(previousMessage);
return;
}
return;
}
if (this.editing.id) {
const message = this.collection.findOne(this.editing.id);
if (!message) {
throw new Error('Message not found');
this.composer.setText((await this.data.getDraft(undefined)) ?? '');
await this.currentEditing.stop();
},
toNextMessage: async () => {
if (!this.composer || !this.currentEditing) {
return;
}
try {
if (message.attachments && message.attachments?.length > 0) {
await this.processMessageEditing({ _id: this.editing.id, rid, msg: '' } as IMessage);
return;
}
const currentMessage = await this.data.findMessageByID(this.currentEditing.mid);
const nextMessage = currentMessage ? await this.data.findNextOwnMessage(currentMessage) : undefined;
this.resetToDraft(this.editing.id);
await this.confirmDeleteMsg(message);
if (nextMessage) {
this.messageEditing.editMessage(nextMessage, { cursorAtStart: true });
return;
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
}
}
}
private async processMessageSend(message: IMessage) {
if (await this.processSetReaction(message)) {
return;
}
await this.currentEditing.stop();
this.composer.setText((await this.data.getDraft(undefined)) ?? '');
},
editMessage: async (message: IMessage, { cursorAtStart = false }: { cursorAtStart?: boolean } = {}) => {
const text = (await this.data.getDraft(message._id)) || message.attachments?.[0].description || message.msg;
const cursorPosition = cursorAtStart ? 0 : text.length;
this.clearCurrentDraft();
this.currentEditing?.stop();
if (await this.processTooLongMessage(message)) {
return;
}
if (this.editing.id && (await this.processMessageEditing({ ...message, _id: this.editing.id }))) {
return;
}
KonchatNotification.removeRoomNotification(message.rid);
if (await this.processSlashCommand(message)) {
return;
}
await callWithErrorHandling('sendMessage', message);
}
private async processSetReaction({ rid, tmid, msg }: Pick<IMessage, 'msg' | 'rid' | 'tmid'>) {
if (msg.slice(0, 2) !== '+:') {
return false;
}
const reaction = msg.slice(1).trim();
if (!emoji.list[reaction]) {
return false;
}
if (!this.composer || !(await this.data.canUpdateMessage(message))) {
return;
}
const lastMessage = this.collection.findOne({ rid, tmid }, { fields: { ts: 1 }, sort: { ts: -1 } });
if (!lastMessage) {
throw new Error('Message not found');
}
await callWithErrorHandling('setReaction', reaction, lastMessage._id);
return true;
}
this.currentEditingMID = message._id;
setHighlightMessage(message._id);
this.composer?.setEditingMode(true);
private async processTooLongMessage({ msg, rid, tmid }: Pick<IMessage, 'msg' | 'rid' | 'tmid'>) {
const adjustedMessage = messageProperties.messageWithoutEmojiShortnames(msg);
if (messageProperties.length(adjustedMessage) <= settings.get('Message_MaxAllowedSize') && msg) {
return false;
}
this.composer.setText(text, { selection: { start: cursorPosition, end: cursorPosition } });
this.composer?.focus();
},
};
if (!settings.get('FileUpload_Enabled') || !settings.get('Message_AllowConvertLongMessagesToAttachment') || this.editing.id) {
throw new Error(t('Message_too_long'));
}
public composer: ComposerAPI | undefined;
const { input } = this;
const user = Meteor.user();
public readonly data: DataAPI;
if (!input || !user) {
throw new Error('Input or user is not defined');
}
public readonly uploads: UploadsAPI;
const onConfirm = () => {
const contentType = 'text/plain';
const messageBlob = new Blob([msg], { type: contentType });
const fileName = `${user.username} - ${new Date()}.txt`;
const file = new File([messageBlob], fileName, {
type: contentType,
lastModified: Date.now(),
});
fileUpload([{ file, name: fileName }], input, { rid, tmid });
imperativeModal.close();
};
public readonly flows: ChatAPI['flows'];
const onClose = () => {
this.setDraftAndUpdateInput(msg);
imperativeModal.close();
public constructor(private params: { rid: IRoom['_id']; tmid?: IMessage['_id'] }) {
this.data = createDataAPI({ rid: params.rid, tmid: params.tmid });
this.uploads = createUploadsAPI({ rid: params.rid, tmid: params.tmid });
this.flows = {
uploadFiles: uploadFiles.bind(null, this),
sendMessage: sendMessage.bind(this, this),
processSlashCommand: processSlashCommand.bind(null, this),
processTooLongMessage: processTooLongMessage.bind(null, this),
processMessageEditing: processMessageEditing.bind(null, this),
processSetReaction: processSetReaction.bind(null, this),
requestMessageDeletion: requestMessageDeletion.bind(this, this),
};
imperativeModal.open({
component: GenericModal,
props: {
title: t('Message_too_long'),
children: t('Send_it_as_attachment_instead_question'),
onConfirm,
onClose,
onCancel: onClose,
variant: 'warning',
},
});
return true;
}
private async processMessageEditing(message: IMessage) {
if (!message._id) {
return false;
}
public setComposerAPI(composer: ComposerAPI): void {
this.composer?.release();
this.composer = composer;
}
if (MessageTypes.isSystemMessage(message)) {
return false;
public get currentEditing() {
if (!this.composer || !this.currentEditingMID) {
return undefined;
}
this.clearEditing();
await callWithErrorHandling('updateMessage', message);
return true;
}
public async processSlashCommand(msgObject: IMessage) {
if (msgObject.msg[0] === '/') {
const match = msgObject.msg.match(/^\/([^\s]+)/m);
if (match) {
const command = match[1];
if (slashCommands.commands[command]) {
const commandOptions = slashCommands.commands[command];
const param = msgObject.msg.replace(/^\/([^\s]+)/m, '');
if (!commandOptions.permission || hasAtLeastOnePermission(commandOptions.permission, Session.get('openedRoom'))) {
if (commandOptions.clientOnly) {
commandOptions.callback?.(command, param, msgObject);
} else {
APIClient.post('/v1/statistics.telemetry', { params: [{ eventName: 'slashCommandsStats', timestamp: Date.now(), command }] });
const triggerId = generateTriggerId(slashCommands.commands[command].appId);
Meteor.call('slashCommand', { cmd: command, params: param, msg: msgObject, triggerId }, (err: Error, result: never) => {
typeof commandOptions.result === 'function' &&
commandOptions.result(err, result, {
cmd: command,
params: param,
msg: msgObject,
});
});
}
return true;
}
return {
mid: this.currentEditingMID,
reset: async (): Promise<boolean> => {
if (!this.composer || !this.currentEditingMID) {
return false;
}
if (!settings.get('Message_AllowUnrecognizedSlashCommand')) {
console.error(TAPi18n.__('No_such_command', { command: escapeHTML(command) }));
const invalidCommandMsg = {
_id: Random.id(),
rid: msgObject.rid,
ts: new Date(),
msg: TAPi18n.__('No_such_command', { command: escapeHTML(command) }),
u: {
_id: 'rocket.cat',
username: 'rocket.cat',
name: 'Rocket.Cat',
},
private: true,
};
this.collection.upsert({ _id: invalidCommandMsg._id }, { $set: invalidCommandMsg });
const message = await this.data.findMessageByID(this.currentEditingMID);
if (this.composer.text !== message?.msg) {
this.composer.setText(message?.msg ?? '');
return true;
}
}
}
return false;
}
public async confirmDeleteMsg(message: IMessage) {
if (MessageTypes.isSystemMessage(message)) {
return;
}
const room =
message.drid &&
Rooms.findOne({
_id: message.drid,
prid: { $exists: true },
});
await new Promise<void>((resolve) => {
const onConfirm = () => {
if (this.editing.id === message._id) {
this.clearEditing();
}
this.deleteMsg(message);
this.input?.focus();
resolve();
imperativeModal.close();
dispatchToastMessage({ type: 'success', message: t('Your_entry_has_been_deleted') });
};
const onCloseModal = () => {
imperativeModal.close();
if (this.editing.id === message._id) {
this.clearEditing();
return false;
},
stop: async (): Promise<void> => {
if (!this.composer || !this.currentEditingMID) {
return;
}
this.input?.focus();
resolve();
};
imperativeModal.open({
component: GenericModal,
props: {
title: t('Are_you_sure'),
children: room ? t('The_message_is_a_discussion_you_will_not_be_able_to_recover') : t('You_will_not_be_able_to_recover'),
variant: 'danger',
confirmText: t('Yes_delete_it'),
onConfirm,
onClose: onCloseModal,
onCancel: onCloseModal,
},
});
});
}
public async deleteMsg({ _id, rid, ts }: Pick<IMessage, '_id' | 'rid' | 'ts'>) {
const forceDelete = hasAtLeastOnePermission('force-delete-message', rid);
const blockDeleteInMinutes = settings.get('Message_AllowDeleting_BlockDeleteInMinutes');
if (blockDeleteInMinutes && forceDelete === false) {
const msgTs = moment(ts);
const currentTsDiff = moment().diff(msgTs, 'minutes');
if (currentTsDiff > blockDeleteInMinutes) {
dispatchToastMessage({ type: 'error', message: t('Message_deleting_blocked') });
return;
}
}
await callWithErrorHandling('deleteMessage', { _id });
}
public keydown(event: KeyboardEvent) {
const input = event.currentTarget as HTMLTextAreaElement;
const keyCode = event.which;
const { id } = this.editing;
if (keyCode === keyCodes.ESCAPE && this.editing.element) {
event.preventDefault();
event.stopPropagation();
if (!id || !this.resetToDraft(id)) {
this.clearCurrentDraft();
this.clearEditing();
return true;
}
return;
}
if (keyCode === keyCodes.ARROW_UP || keyCode === keyCodes.ARROW_DOWN) {
if (event.shiftKey) {
return;
}
const message = await this.data.findMessageByID(this.currentEditingMID);
const draft = this.composer.text;
const cursorPosition = input.selectionEnd;
if (keyCode === keyCodes.ARROW_UP) {
if (cursorPosition === 0) {
this.toPrevMessage();
} else if (!event.altKey) {
return;
if (draft === message?.msg) {
await this.data.discardDraft(this.currentEditingMID);
} else {
await this.data.saveDraft(this.currentEditingMID, (await this.data.getDraft(this.currentEditingMID)) || draft);
}
if (event.altKey) {
this.input?.setSelectionRange(0, 0);
}
} else {
if (cursorPosition === input.value.length) {
this.toNextMessage();
} else if (!event.altKey) {
this.composer.setEditingMode(false);
this.currentEditingMID = undefined;
clearHighlightMessage();
},
cancel: async (): Promise<void> => {
if (!this.currentEditingMID) {
return;
}
if (event.altKey) {
this.input?.setSelectionRange(this.input.value.length, this.input.value.length);
}
}
event.preventDefault();
event.stopPropagation();
}
}
public keyup(event: KeyboardEvent, { rid, tmid }: { rid: IRoom['_id']; tmid?: IMessage['_id'] }) {
const input = event.currentTarget as HTMLTextAreaElement;
const keyCode = event.which;
if (!Object.values<number>(keyCodes).includes(keyCode)) {
if (input?.value.trim()) {
UserAction.start(rid, USER_ACTIVITIES.USER_TYPING, { tmid });
} else {
UserAction.stop(rid, USER_ACTIVITIES.USER_TYPING, { tmid });
}
}
this.setDraftAndUpdateInput(input.value);
await this.data.discardDraft(this.currentEditingMID);
this.currentEditing?.stop();
},
};
}
public onDestroyed(rid: IRoom['_id'], tmid?: IMessage['_id']) {
UserAction.cancel(rid);
// TODO: check why we need too many ?. here :(
if (this.input?.parentElement?.classList.contains('editing') === true) {
if (!tmid) {
this.clearCurrentDraft();
this.clearEditing();
private release() {
this.composer?.release();
if (this.currentEditing) {
if (!this.params.tmid) {
this.currentEditing.cancel();
}
this.setDraftAndUpdateInput('');
this.composer?.clear();
}
}
private static instances: Record<string, ChatMessages> = {};
private static getID({ rid, tmid, input }: ChatMessagesParams): string {
if (input) {
const id = Object.entries(this.instances).find(([, instance]) => instance.input === input)?.[0];
if (!id) throw new Error('ChatMessages: input not found');
return id;
}
private static refs = new Map<string, { instance: ChatMessages; count: number }>();
private static getID({ rid, tmid }: { rid: IRoom['_id']; tmid?: IMessage['_id'] }): string {
return `${rid}${tmid ? `-${tmid}` : ''}`;
}
public static get(params: ChatMessagesParams): ChatMessages | undefined {
return this.instances[this.getID(params)];
}
public static hold({ rid, tmid }: { rid: IRoom['_id']; tmid?: IMessage['_id'] }) {
const id = this.getID({ rid, tmid });
const ref = this.refs.get(id) ?? { instance: new ChatMessages({ rid, tmid }), count: 0 };
ref.count++;
this.refs.set(id, ref);
public static set(params: ChatMessagesParams, instance: ChatMessages) {
this.instances[this.getID(params)] = instance;
return ref.instance;
}
public static delete({ rid, tmid }: { rid: IRoom['_id']; tmid?: IMessage['_id'] }) {
public static release({ rid, tmid }: { rid: IRoom['_id']; tmid?: IMessage['_id'] }) {
const id = this.getID({ rid, tmid });
this.instances[id].onDestroyed(rid, tmid);
delete this.instances[id];
}
public static purgeAllDrafts() {
ComposerState.purgeAll();
const ref = this.refs.get(id);
if (!ref) {
return;
}
ref.count--;
if (ref.count === 0) {
this.refs.delete(id);
ref.instance.release();
}
}
}

@ -1,245 +0,0 @@
import { Tracker } from 'meteor/tracker';
import { Session } from 'meteor/session';
import { Random } from 'meteor/random';
import { Meteor } from 'meteor/meteor';
import { isRoomFederated } from '@rocket.chat/core-typings';
import { settings } from '../../../settings/client';
import { UserAction, USER_ACTIVITIES } from './UserAction';
import { fileUploadIsValidContentType, APIClient } from '../../../utils/client';
import { imperativeModal } from '../../../../client/lib/imperativeModal';
import FileUploadModal from '../../../../client/views/room/modals/FileUploadModal';
import { prependReplies } from '../../../../client/lib/utils/prependReplies';
import { ChatMessages } from './ChatMessages';
import { getErrorMessage } from '../../../../client/lib/errorHandling';
import { Rooms } from '../../../models/client';
export type Uploading = {
id: string;
name: string;
percentage: number;
error?: Error;
};
declare module 'meteor/session' {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Session {
function get(key: 'uploading'): Uploading[];
function set(key: 'uploading', param: Uploading[]): void;
}
}
Session.setDefault('uploading', []);
export const uploadFileWithMessage = async (
rid: string,
{
description,
msg,
file,
}: {
file: File;
description?: string;
msg?: string;
},
tmid?: string,
): Promise<void> => {
const uploads = Session.get('uploading');
const upload = {
id: Random.id(),
name: file.name,
percentage: 0,
};
uploads.push(upload);
Session.set('uploading', uploads);
try {
await new Promise((resolve, reject) => {
const xhr = APIClient.upload(
`/v1/rooms.upload/${rid}`,
{
msg,
tmid,
file,
description,
},
{
load: (event) => {
return resolve(event);
},
progress: (event) => {
if (!event.lengthComputable) {
return;
}
const progress = (event.loaded / event.total) * 100;
if (progress === 100) {
return;
}
const uploads = Session.get('uploading');
uploads
.filter((u) => u.id === upload.id)
.forEach((u) => {
u.percentage = Math.round(progress) || 0;
});
Session.set('uploading', uploads);
},
error: (error) => {
const uploads = Session.get('uploading');
uploads
.filter((u) => u.id === upload.id)
.forEach((u) => {
u.error = new Error(xhr.responseText);
u.percentage = 0;
});
Session.set('uploading', uploads);
reject(error);
},
},
);
if (Session.get('uploading').length) {
UserAction.performContinuously(rid, USER_ACTIVITIES.USER_UPLOADING, { tmid });
}
Tracker.autorun((computation) => {
const isCanceling = Session.get(`uploading-cancel-${upload.id}`);
if (!isCanceling) {
return;
}
computation.stop();
Session.delete(`uploading-cancel-${upload.id}`);
xhr.abort();
const uploads = Session.get('uploading');
Session.set(
'uploading',
uploads.filter((u) => u.id !== upload.id),
);
});
});
const uploads = Session.get('uploading');
Session.set(
'uploading',
uploads.filter((u) => u.id !== upload.id),
);
if (!Session.get('uploading').length) {
UserAction.stop(rid, USER_ACTIVITIES.USER_UPLOADING, { tmid });
}
} catch (error: unknown) {
const uploads = Session.get('uploading');
uploads
.filter((u) => u.id === upload.id)
.forEach((u) => {
u.error = new Error(getErrorMessage(error));
u.percentage = 0;
});
if (!uploads.length) {
UserAction.stop(rid, USER_ACTIVITIES.USER_UPLOADING, { tmid });
}
Session.set('uploading', uploads);
}
};
type SingleOrArray<T> = T | T[];
/* @deprecated */
export type FileUploadProp = SingleOrArray<{
file: File;
name: string;
}>;
/* @deprecated */
export const fileUpload = async (
f: FileUploadProp,
input: HTMLInputElement | HTMLTextAreaElement | undefined,
{
rid,
tmid,
}: {
rid: string;
tmid?: string;
},
): Promise<void> => {
if (!f) {
throw new Error('No files to upload');
}
const threadsEnabled = settings.get('Threads_enabled');
const files = Array.isArray(f) ? f : [f];
const chatMessagesInstance = input ? ChatMessages.get({ input }) : undefined;
const replies = chatMessagesInstance?.quotedMessages.get() ?? [];
const mention = input ? $(input).data('mention-user') : false;
let msg = '';
if (!mention || !threadsEnabled) {
msg = await prependReplies('', replies, mention);
}
if (mention && threadsEnabled && replies.length) {
tmid = replies[0]._id;
}
const key = ['messagebox', rid, tmid].filter(Boolean).join('_');
const messageBoxText = Meteor._localStorage.getItem(key) || '';
const room = Rooms.findOne({ _id: rid });
const uploadNextFile = (): void => {
const file = files.pop();
if (!file) {
chatMessagesInstance?.quotedMessages.clear();
return;
}
imperativeModal.open({
component: FileUploadModal,
props: {
file: file.file,
fileName: file.name,
fileDescription: messageBoxText,
showDescription: room && !isRoomFederated(room),
onClose: (): void => {
imperativeModal.close();
uploadNextFile();
},
onSubmit: (fileName: string, description?: string): void => {
Object.defineProperty(file.file, 'name', {
writable: true,
value: fileName,
});
uploadFileWithMessage(
rid,
{
description,
msg,
file: file.file,
},
tmid,
);
const localStorageKey = ['messagebox', rid, tmid].filter(Boolean).join('_');
const input = ChatMessages.get({ rid, tmid })?.input;
if (input) {
input.value = '';
$(input).trigger('input');
}
Meteor._localStorage.removeItem(localStorageKey);
imperativeModal.close();
uploadNextFile();
},
invalidContentType: Boolean(file.file.type && !fileUploadIsValidContentType(file.file.type)),
},
});
};
uploadNextFile();
};

@ -1,8 +1,12 @@
import type { ContextType } from 'react';
import type { ToolboxContextValue } from '../../../../../../client/views/room/contexts/ToolboxContext';
import type { ChatContext } from '../../../../../../client/views/room/contexts/ChatContext';
export type CommonRoomTemplateInstance = {
data: {
rid: string;
tabBar: ToolboxContextValue;
chatContext: ContextType<typeof ChatContext>;
};
};

@ -1,8 +1,6 @@
import Clipboard from 'clipboard';
import { Meteor } from 'meteor/meteor';
import { Random } from 'meteor/random';
import { FlowRouter } from 'meteor/kadira:flow-router';
import type { IMessage } from '@rocket.chat/core-typings';
import { isRoomFederated } from '@rocket.chat/core-typings';
import { popover, MessageAction } from '../../../../../ui-utils/client';
@ -10,13 +8,11 @@ import { callWithErrorHandling } from '../../../../../../client/lib/utils/callWi
import { isURL } from '../../../../../../lib/utils/isURL';
import { openUserCard } from '../../../lib/UserCard';
import { messageArgs } from '../../../../../../client/lib/utils/messageArgs';
import { ChatMessage, Rooms, Messages } from '../../../../../models/client';
import { ChatMessage, Rooms } from '../../../../../models/client';
import { t } from '../../../../../utils/client';
import { ChatMessages } from '../../../lib/ChatMessages';
import { EmojiEvents } from '../../../../../reactions/client/init';
import { fireGlobalEvent } from '../../../../../../client/lib/utils/fireGlobalEvent';
import { isLayoutEmbedded } from '../../../../../../client/lib/utils/isLayoutEmbedded';
import { onClientBeforeSendMessage } from '../../../../../../client/lib/onClientBeforeSendMessage';
import { goToRoomById } from '../../../../../../client/lib/utils/goToRoomById';
import { mountPopover } from './mountPopover';
import type { CommonRoomTemplateInstance } from './CommonRoomTemplateInstance';
@ -107,7 +103,7 @@ function handleMessageActionButtonClick(event: JQuery.ClickEvent, template: Comm
const button = MessageAction.getButtonById(event.currentTarget.dataset.messageAction);
const messageElement = event.target.closest('.message') as HTMLElement;
const dataContext = Blaze.getData(messageElement);
button?.action.call(dataContext, event, { tabbar: tabBar });
button?.action.call(dataContext, event, { tabbar: tabBar, chat: template.data.chatContext });
}
function handleFollowThreadButtonClick(event: JQuery.ClickEvent) {
@ -190,55 +186,6 @@ function handleOpenUserCardButtonClick(event: JQuery.ClickEvent, template: Commo
}
}
function handleRespondWithMessageActionButtonClick(event: JQuery.ClickEvent, template: CommonRoomTemplateInstance) {
const { rid } = template.data;
const msg = event.currentTarget.value;
if (!msg) {
return;
}
const input = ChatMessages.get({ rid })?.input;
if (input) {
input.value = msg;
input.focus();
}
}
function handleRespondWithQuotedMessageActionButtonClick(event: JQuery.ClickEvent, template: CommonRoomTemplateInstance) {
const { rid } = template.data;
const { id: msgId } = event.currentTarget;
const chatMessagesInstance = ChatMessages.get({ rid });
const input = chatMessagesInstance?.input;
if (!msgId || !input) {
return;
}
const message = Messages.findOne({ _id: msgId });
chatMessagesInstance.quotedMessages.add(message);
$(input)?.trigger('focus').data('mention-user', false).trigger('dataChange');
}
async function handleSendMessageActionButtonClick(event: JQuery.ClickEvent, template: CommonRoomTemplateInstance) {
const { rid } = template.data;
const msg = event.currentTarget.value;
let msgObject = { _id: Random.id(), rid, msg } as IMessage;
if (!msg) {
return;
}
msgObject = (await onClientBeforeSendMessage(msgObject)) as IMessage;
const _chatMessages = ChatMessages.get({ rid });
if (_chatMessages && (await _chatMessages.processSlashCommand(msgObject))) {
return;
}
await callWithErrorHandling('sendMessage', msgObject);
}
function handleMessageActionMenuClick(event: JQuery.ClickEvent, template: CommonRoomTemplateInstance) {
const { rid, tabBar } = template.data;
const messageElement = event.target.closest('.message') as HTMLElement;
@ -255,7 +202,7 @@ function handleMessageActionMenuClick(event: JQuery.ClickEvent, template: Common
type: 'message-action',
id: item.id,
modifier: item.color,
action: () => item.action(event, { tabbar: tabBar, message, room }),
action: () => item.action(event, { tabbar: tabBar, message, room, chat: template.data.chatContext }),
}));
const itemsBelowDivider = ['delete-message', 'report-message'];
@ -340,9 +287,6 @@ export const getCommonRoomEvents = (useLegacyMessageTemplate = true) => ({
'click .js-open-thread': handleOpenThreadButtonClick,
'click .image-to-download': handleDownloadImageButtonClick,
'click .user-card-message': handleOpenUserCardButtonClick,
'click .js-actionButton-respondWithMessage': handleRespondWithMessageActionButtonClick,
'click .js-actionButton-respondWithQuotedMessage': handleRespondWithQuotedMessageActionButtonClick,
'click .js-actionButton-sendMessage': handleSendMessageActionButtonClick,
'click .message-actions__menu': handleMessageActionMenuClick,
...(useLegacyMessageTemplate && { 'click .mention-link': handleMentionLinkClick }),
});

@ -1,8 +1,8 @@
import { Meteor } from 'meteor/meteor';
import { Tracker } from 'meteor/tracker';
import { settings } from '../../../settings';
import { messageBox } from '../../../ui-utils';
import { settings } from '../../../settings/client';
import { messageBox } from '../../../ui-utils/client';
import { WebdavAccounts } from '../../../models/client';
import { imperativeModal } from '../../../../client/lib/imperativeModal';
import { getWebdavServerName } from '../../../../client/lib/getWebdavServerName';
@ -36,10 +36,17 @@ Meteor.startup(function () {
id: `webdav-upload-${account._id.toLowerCase()}`,
icon: 'cloud-plus',
condition: () => settings.get('Webdav_Integration_Enabled'),
action({ rid }) {
action({ chat }) {
imperativeModal.open({
component: WebdavFilePickerModal,
props: { rid, onClose: imperativeModal.close, account },
props: {
onUpload: async (file, description) =>
chat.uploads.send(file, {
description,
}),
onClose: imperativeModal.close,
account,
},
});
},
});

@ -160,6 +160,7 @@
]
}
],
"no-empty-function": "off",
"no-extra-parens": "off",
"no-redeclare": "off",
"no-spaced-func": "off",

@ -0,0 +1,84 @@
import { IMessage, MessageAttachmentAction } from '@rocket.chat/core-typings';
import { Button } from '@rocket.chat/fuselage';
import { useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import { useMutation, UseMutationOptions, UseMutationResult } from '@tanstack/react-query';
import React, { ReactElement, ReactNode } from 'react';
import { useChat } from '../../../views/room/contexts/ChatContext';
type ProcessingType = Exclude<MessageAttachmentAction['actions'][number]['msg_processing_type'], undefined>;
type UsePerfomActionMutationParams = {
processingType: ProcessingType;
msg?: string;
mid?: IMessage['_id'];
};
type ActionAttachmentButtonProps = {
children: ReactNode;
mid?: IMessage['_id'];
msg?: string;
processingType: ProcessingType;
};
const usePerformActionMutation = (
options?: Omit<UseMutationOptions<void, Error, UsePerfomActionMutationParams>, 'mutationFn'>,
): UseMutationResult<void, Error, UsePerfomActionMutationParams> => {
const chat = useChat();
return useMutation(async ({ processingType, msg, mid }) => {
if (!chat) {
return;
}
switch (processingType) {
case 'sendMessage':
if (!msg) return;
await chat.flows.sendMessage({ text: msg });
return;
case 'respondWithMessage':
if (!msg) return;
await chat.composer?.replyWith(msg);
return;
case 'respondWithQuotedMessage':
if (!mid) return;
const message = await chat.data.getMessageByID(mid);
await chat.composer?.quoteMessage(message);
}
}, options);
};
const ActionAttachmentButton = ({ children, processingType, msg, mid }: ActionAttachmentButtonProps): ReactElement => {
const dispatchToastMessage = useToastMessageDispatch();
const performActionMutation = usePerformActionMutation({
onError: (error) => {
console.error(error);
dispatchToastMessage({ type: 'error', message: error });
},
});
return (
<Button
small
value={msg}
id={mid}
disabled={performActionMutation.isLoading}
onClick={(event): void => {
event.preventDefault();
performActionMutation.mutate({
processingType,
msg,
mid,
});
}}
>
{children}
</Button>
);
};
export default ActionAttachmentButton;

@ -2,6 +2,8 @@ import { MessageAttachmentAction } from '@rocket.chat/core-typings';
import { Box, Button, ButtonGroup } from '@rocket.chat/fuselage';
import React, { FC } from 'react';
import ActionAttachmentButton from './ActionAttachmentButton';
export const ActionAttachment: FC<MessageAttachmentAction> = ({ actions }) => (
<ButtonGroup mb='x4' {...({ small: true } as any)}>
{actions
@ -19,9 +21,9 @@ export const ActionAttachment: FC<MessageAttachmentAction> = ({ actions }) => (
);
}
return (
<Button className={`js-actionButton-${processingType}`} key={index} small value={msg} id={msgId}>
<ActionAttachmentButton key={index} processingType={processingType} msg={msg} mid={msgId}>
{content}
</Button>
</ActionAttachmentButton>
);
})}
</ButtonGroup>

@ -1,23 +1,33 @@
import { FileProp, MessageAttachmentBase } from '@rocket.chat/core-typings';
import React, { ReactElement } from 'react';
import React, { ContextType, ReactElement, useContext } from 'react';
import { ChatContext } from '../../../views/room/contexts/ChatContext';
import { MessageContext } from '../../../views/room/contexts/MessageContext';
import { useBlockRendered } from '../hooks/useBlockRendered';
import Item from './Item';
type AttachmentsProps = {
file?: FileProp;
attachments: MessageAttachmentBase[];
chatContext?: ContextType<typeof ChatContext>; // TODO: Remove this prop when threads are implemented as a component
messageContext?: ContextType<typeof MessageContext>; // TODO: Remove this prop when threads are implemented as a component
};
const Attachments = ({ attachments, file }: AttachmentsProps): ReactElement => {
const Attachments = ({ attachments, file, chatContext, messageContext }: AttachmentsProps): ReactElement => {
const { className, ref } = useBlockRendered<HTMLDivElement>();
const outerChatContext = useContext(ChatContext); // TODO: Remove this hook when threads are implemented as a component
const outerMessageContext = useContext(MessageContext); // TODO: Remove this hack when threads are implemented as a component
return (
<>
<div className={className} ref={ref} />
{attachments?.map((attachment, index) => (
<Item key={index} file={file} attachment={attachment} />
))}
</>
<ChatContext.Provider value={chatContext ?? outerChatContext}>
{/* TODO: Remove this hack when threads are implemented as a component */}
<MessageContext.Provider value={messageContext ?? outerMessageContext}>
<div className={className} ref={ref} />
{attachments?.map((attachment, index) => (
<Item key={index} file={file} attachment={attachment} />
))}
</MessageContext.Provider>
</ChatContext.Provider>
);
};

@ -0,0 +1,97 @@
import { IMessage, IRoom } from '@rocket.chat/core-typings';
import { Upload } from './Upload';
export type ComposerAPI = {
release(): void;
readonly text: string;
readonly selection: { readonly start: number; readonly end: number };
setText(
text: string,
options?: {
selection?:
| { readonly start?: number; readonly end?: number }
| ((previous: { readonly start: number; readonly end: number }) => { readonly start?: number; readonly end?: number });
},
): void;
clear(): void;
focus(): void;
replyWith(text: string): Promise<void>;
quoteMessage(message: IMessage): Promise<void>;
dismissQuotedMessage(mid: IMessage['_id']): Promise<void>;
dismissAllQuotedMessages(): Promise<void>;
readonly quotedMessages: {
get(): IMessage[];
subscribe(callback: () => void): () => void;
};
setEditingMode(editing: boolean): void;
};
export type DataAPI = {
composeMessage(
text: string,
options: { sendToChannel?: boolean; quotedMessages: IMessage[]; originalMessage?: IMessage },
): Promise<IMessage>;
findMessageByID(mid: IMessage['_id']): Promise<IMessage | undefined>;
getMessageByID(mid: IMessage['_id']): Promise<IMessage>;
findLastMessage(): Promise<IMessage | undefined>;
getLastMessage(): Promise<IMessage>;
findLastOwnMessage(): Promise<IMessage | undefined>;
getLastOwnMessage(): Promise<IMessage>;
findPreviousOwnMessage(message: IMessage): Promise<IMessage | undefined>;
getPreviousOwnMessage(message: IMessage): Promise<IMessage>;
findNextOwnMessage(message: IMessage): Promise<IMessage | undefined>;
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>;
canDeleteMessage(message: IMessage): Promise<boolean>;
deleteMessage(mid: IMessage['_id']): Promise<void>;
getDraft(mid: IMessage['_id'] | undefined): Promise<string | undefined>;
discardDraft(mid: IMessage['_id'] | undefined): Promise<void>;
saveDraft(mid: IMessage['_id'] | undefined, text: string): Promise<void>;
findRoom(): Promise<IRoom | undefined>;
getRoom(): Promise<IRoom>;
isSubscribedToRoom(): Promise<boolean>;
joinRoom(): Promise<void>;
markRoomAsRead(): Promise<void>;
findDiscussionByID(drid: IRoom['_id']): Promise<IRoom | undefined>;
getDiscussionByID(drid: IRoom['_id']): Promise<IRoom>;
};
export type UploadsAPI = {
get(): readonly Upload[];
subscribe(callback: () => void): () => void;
wipeFailedOnes(): void;
cancel(id: Upload['id']): void;
send(file: File, { description, msg }: { description?: string; msg?: string }): Promise<void>;
};
export type ChatAPI = {
readonly composer?: ComposerAPI;
readonly setComposerAPI: (composer: ComposerAPI) => void;
readonly data: DataAPI;
readonly uploads: UploadsAPI;
readonly messageEditing: {
toPreviousMessage(): Promise<void>;
toNextMessage(): Promise<void>;
editMessage(message: IMessage, options?: { cursorAtStart?: boolean }): Promise<void>;
};
readonly currentEditing:
| {
readonly mid: IMessage['_id'];
reset(): Promise<boolean>;
stop(): Promise<void>;
cancel(): Promise<void>;
}
| undefined;
readonly flows: {
readonly uploadFiles: (files: readonly File[]) => Promise<void>;
readonly sendMessage: ({ text, tshow }: { text: string; tshow?: boolean }) => Promise<void>;
readonly processSlashCommand: (message: IMessage) => Promise<boolean>;
readonly processTooLongMessage: (message: IMessage) => Promise<boolean>;
readonly processMessageEditing: (message: Pick<IMessage, '_id' | 't'> & Partial<Omit<IMessage, '_id' | 't'>>) => Promise<boolean>;
readonly processSetReaction: (message: Pick<IMessage, 'msg'>) => Promise<boolean>;
readonly requestMessageDeletion: (message: IMessage) => Promise<void>;
};
};

@ -0,0 +1,6 @@
export type Upload = {
readonly id: string;
readonly name: string;
readonly percentage: number;
readonly error?: Error;
};

@ -0,0 +1,262 @@
import type { IMessage, IRoom, ISubscription } from '@rocket.chat/core-typings';
import type { Mongo } from 'meteor/mongo';
import moment from 'moment';
import { hasAtLeastOnePermission } from '../../../app/authorization/client';
import { Messages, Rooms, Subscriptions } from '../../../app/models/client';
import { settings } from '../../../app/settings/client';
import { readMessage, MessageTypes } from '../../../app/ui-utils/client';
import { getRandomId } from '../../../lib/random';
import { onClientBeforeSendMessage } from '../onClientBeforeSendMessage';
import { call } from '../utils/call';
import { prependReplies } from '../utils/prependReplies';
import { DataAPI } from './ChatAPI';
const messagesCollection = Messages as Mongo.Collection<IMessage>;
const roomsCollection = Rooms as Mongo.Collection<IRoom>;
const subscriptionsCollection = Subscriptions as Mongo.Collection<ISubscription>;
export const createDataAPI = ({ rid, tmid }: { rid: IRoom['_id']; tmid: IMessage['_id'] | undefined }): DataAPI => {
const composeMessage = async (
text: string,
{ sendToChannel, quotedMessages, originalMessage }: { sendToChannel?: boolean; quotedMessages: IMessage[]; originalMessage?: IMessage },
): Promise<IMessage> => {
const msg = await prependReplies(text, quotedMessages);
const effectiveRID = originalMessage?.rid ?? rid;
const effectiveTMID = originalMessage ? originalMessage.tmid : tmid;
return (await onClientBeforeSendMessage({
_id: originalMessage?._id ?? getRandomId(),
rid: effectiveRID,
...(effectiveTMID && {
tmid: effectiveTMID,
...(sendToChannel && { tshow: sendToChannel }),
}),
msg,
})) as IMessage;
};
const findMessageByID = async (mid: IMessage['_id']): Promise<IMessage | undefined> =>
messagesCollection.findOne({ _id: mid, _hidden: { $ne: true } }, { reactive: false }) ?? call('getSingleMessage', mid);
const getMessageByID = async (mid: IMessage['_id']): Promise<IMessage> => {
const message = await findMessageByID(mid);
if (!message) {
throw new Error('Message not found');
}
return message;
};
const findLastMessage = async (): Promise<IMessage | undefined> =>
messagesCollection.findOne({ rid, tmid: tmid ?? { $exists: false }, _hidden: { $ne: true } }, { sort: { ts: -1 }, reactive: false });
const getLastMessage = async (): Promise<IMessage> => {
const message = await findLastMessage();
if (!message) {
throw new Error('Message not found');
}
return message;
};
const findLastOwnMessage = async (): Promise<IMessage | undefined> => {
const uid = Meteor.userId();
if (!uid) {
return undefined;
}
return messagesCollection.findOne(
{ rid, 'tmid': tmid ?? { $exists: false }, 'u._id': uid, '_hidden': { $ne: true } },
{ sort: { ts: -1 }, reactive: false },
);
};
const getLastOwnMessage = async (): Promise<IMessage> => {
const message = await findLastOwnMessage();
if (!message) {
throw new Error('Message not found');
}
return message;
};
const findPreviousOwnMessage = async (message: IMessage): Promise<IMessage | undefined> => {
const uid = Meteor.userId();
if (!uid) {
return undefined;
}
return messagesCollection.findOne(
{ rid, 'tmid': tmid ?? { $exists: false }, 'u._id': uid, '_hidden': { $ne: true }, 'ts': { $lt: message.ts } },
{ sort: { ts: -1 }, reactive: false },
);
};
const getPreviousOwnMessage = async (message: IMessage): Promise<IMessage> => {
const previousMessage = await findPreviousOwnMessage(message);
if (!previousMessage) {
throw new Error('Message not found');
}
return previousMessage;
};
const findNextOwnMessage = async (message: IMessage): Promise<IMessage | undefined> => {
const uid = Meteor.userId();
if (!uid) {
return undefined;
}
return messagesCollection.findOne(
{ rid, 'tmid': tmid ?? { $exists: false }, 'u._id': uid, '_hidden': { $ne: true }, 'ts': { $gt: message.ts } },
{ sort: { ts: 1 }, reactive: false },
);
};
const getNextOwnMessage = async (message: IMessage): Promise<IMessage> => {
const nextMessage = await findNextOwnMessage(message);
if (!nextMessage) {
throw new Error('Message not found');
}
return nextMessage;
};
const pushEphemeralMessage = async (message: Omit<IMessage, 'rid' | 'tmid'>): Promise<void> => {
messagesCollection.upsert({ _id: message._id }, { $set: { ...message, rid, ...(tmid && { tmid }) } });
};
const canUpdateMessage = async (message: IMessage): Promise<boolean> => {
if (MessageTypes.isSystemMessage(message)) {
return false;
}
const hasPermission = hasAtLeastOnePermission('edit-message', message.rid);
const editAllowed = (settings.get('Message_AllowEditing') as boolean | undefined) ?? false;
const editOwn = message?.u && message.u._id === Meteor.userId();
if (!hasPermission && (!editAllowed || !editOwn)) {
return false;
}
const blockEditInMinutes = settings.get('Message_AllowEditing_BlockEditInMinutes') as number | undefined;
const elapsedMinutes = moment().diff(message.ts, 'minutes');
if (elapsedMinutes && blockEditInMinutes && elapsedMinutes > blockEditInMinutes) {
return false;
}
return true;
};
const updateMessage = async (message: Pick<IMessage, '_id' | 't'> & Partial<Omit<IMessage, '_id' | 't'>>): Promise<void> =>
call('updateMessage', message);
const canDeleteMessage = async (message: IMessage): Promise<boolean> => {
if (MessageTypes.isSystemMessage(message)) {
return false;
}
const hasPermission = hasAtLeastOnePermission('force-delete-message', message.rid);
if (!hasPermission) {
return false;
}
const blockDeleteInMinutes = settings.get('Message_AllowDeleting_BlockDeleteInMinutes') as number | undefined;
const elapsedMinutes = moment().diff(message.ts, 'minutes');
if (elapsedMinutes && blockDeleteInMinutes && elapsedMinutes > blockDeleteInMinutes) {
return false;
}
return true;
};
const deleteMessage = async (mid: IMessage['_id']): Promise<void> => {
await call('deleteMessage', { _id: mid });
};
const drafts = new Map<IMessage['_id'] | undefined, string>();
const getDraft = async (mid: IMessage['_id'] | undefined): Promise<string | undefined> => drafts.get(mid);
const discardDraft = async (mid: IMessage['_id'] | undefined): Promise<void> => {
drafts.delete(mid);
};
const saveDraft = async (mid: IMessage['_id'] | undefined, draft: string): Promise<void> => {
drafts.set(mid, draft);
};
const findRoom = async (): Promise<IRoom | undefined> => roomsCollection.findOne({ _id: rid }, { reactive: false });
const getRoom = async (): Promise<IRoom> => {
const room = await findRoom();
if (!room) {
throw new Error('Room not found');
}
return room;
};
const isSubscribedToRoom = async (): Promise<boolean> => !!subscriptionsCollection.findOne({ rid }, { reactive: false });
const joinRoom = async (): Promise<void> => call('joinRoom', rid);
const markRoomAsRead = async (): Promise<void> => {
readMessage.readNow(rid);
readMessage.refreshUnreadMark(rid);
};
const findDiscussionByID = async (drid: IRoom['_id']): Promise<IRoom | undefined> =>
roomsCollection.findOne({ _id: drid, prid: { $exists: true } }, { reactive: false });
const getDiscussionByID = async (drid: IRoom['_id']): Promise<IRoom> => {
const discussion = await findDiscussionByID(drid);
if (!discussion) {
throw new Error('Discussion not found');
}
return discussion;
};
return {
composeMessage,
findMessageByID,
getMessageByID,
findLastMessage,
getLastMessage,
findLastOwnMessage,
getLastOwnMessage,
findPreviousOwnMessage,
getPreviousOwnMessage,
findNextOwnMessage,
getNextOwnMessage,
pushEphemeralMessage,
canUpdateMessage,
updateMessage,
canDeleteMessage,
deleteMessage,
getDraft,
saveDraft,
discardDraft,
findRoom,
getRoom,
isSubscribedToRoom,
joinRoom,
markRoomAsRead,
findDiscussionByID,
getDiscussionByID,
};
};

@ -0,0 +1,32 @@
import { IMessage } from '@rocket.chat/core-typings';
import { MessageTypes } from '../../../../app/ui-utils/client';
import { dispatchToastMessage } from '../../toast';
import { ChatAPI } from '../ChatAPI';
export const processMessageEditing = async (
chat: ChatAPI,
message: Pick<IMessage, '_id' | 't'> & Partial<Omit<IMessage, '_id' | 't'>>,
): Promise<boolean> => {
if (!chat.currentEditing) {
return false;
}
if (MessageTypes.isSystemMessage(message)) {
return false;
}
if (!message.msg && !message.attachments?.length) {
return false;
}
try {
await chat.data.updateMessage({ ...message, _id: chat.currentEditing.mid });
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
}
chat.currentEditing.stop();
return true;
};

@ -0,0 +1,26 @@
import { IMessage } from '@rocket.chat/core-typings';
import { emoji } from '../../../../app/emoji/client';
import { callWithErrorHandling } from '../../utils/callWithErrorHandling';
import { ChatAPI } from '../ChatAPI';
export const processSetReaction = async (chat: ChatAPI, { msg }: Pick<IMessage, 'msg'>): Promise<boolean> => {
const match = msg.trim().match(/^\+(:.*?:)$/m);
if (!match) {
return false;
}
const [, reaction] = match;
if (!emoji.list[reaction]) {
return false;
}
const lastMessage = await chat.data.findLastMessage();
if (!lastMessage) {
return false;
}
await callWithErrorHandling('setReaction', reaction, lastMessage._id);
return true;
};

@ -0,0 +1,95 @@
import type { IMessage, SlashCommand } from '@rocket.chat/core-typings';
import { escapeHTML } from '@rocket.chat/string-helpers';
import { hasAtLeastOnePermission } from '../../../../app/authorization/client';
import { settings } from '../../../../app/settings/client';
import { generateTriggerId } from '../../../../app/ui-message/client/ActionManager';
import { slashCommands, APIClient, t } from '../../../../app/utils/client';
import { getRandomId } from '../../../../lib/random';
import { call } from '../../utils/call';
import type { ChatAPI } from '../ChatAPI';
const parse = (msg: string): { command: string; params: string } | { command: SlashCommand; params: string } | undefined => {
const match = msg.match(/^\/([^\s]+)(.*)/m);
if (!match) {
return undefined;
}
const [, cmd, params] = match;
const command = slashCommands.commands[cmd];
if (!command) {
return { command: cmd, params };
}
return { command, params };
};
const warnUnrecognizedSlashCommand = async (chat: ChatAPI, command: string): Promise<void> => {
console.error(t('No_such_command', { command: escapeHTML(command) }));
await chat.data.pushEphemeralMessage({
_id: getRandomId(),
ts: new Date(),
msg: t('No_such_command', { command: escapeHTML(command) }),
u: {
_id: 'rocket.cat',
username: 'rocket.cat',
name: 'Rocket.Cat',
},
private: true,
_updatedAt: new Date(),
});
};
export const processSlashCommand = async (chat: ChatAPI, message: IMessage): Promise<boolean> => {
const match = parse(message.msg);
if (!match) {
return false;
}
const { command, params } = match;
if (typeof command === 'string') {
if (!settings.get('Message_AllowUnrecognizedSlashCommand')) {
await warnUnrecognizedSlashCommand(chat, command);
return true;
}
return false;
}
const { permission, clientOnly, callback: handleOnClient, result: handleResult, appId, command: commandName } = command;
if (permission && !hasAtLeastOnePermission(permission)) {
return false;
}
if (clientOnly) {
handleOnClient?.(commandName, params, message);
return true;
}
await APIClient.post('/v1/statistics.telemetry', {
params: [{ eventName: 'slashCommandsStats', timestamp: Date.now(), command: commandName }],
});
const triggerId = generateTriggerId(appId);
const data = {
cmd: commandName,
params,
msg: message,
} as const;
try {
const result = await call('slashCommand', { cmd: commandName, params, msg: message, triggerId });
handleResult?.(undefined, result, data);
} catch (error: unknown) {
handleResult?.(error, undefined, data);
}
return true;
};

@ -0,0 +1,63 @@
import { IMessage } from '@rocket.chat/core-typings';
import { settings } from '../../../../app/settings/client';
import { messageProperties } from '../../../../app/ui-utils/client';
import { t } from '../../../../app/utils/client';
import GenericModal from '../../../components/GenericModal';
import { imperativeModal } from '../../imperativeModal';
import { dispatchToastMessage } from '../../toast';
import { ChatAPI } from '../ChatAPI';
export const processTooLongMessage = async (chat: ChatAPI, { msg }: Pick<IMessage, 'msg'>): Promise<boolean> => {
const adjustedMessage = messageProperties.messageWithoutEmojiShortnames(msg);
const maxAllowedSize = settings.get('Message_MaxAllowedSize');
if (messageProperties.length(adjustedMessage) <= maxAllowedSize) {
return false;
}
const fileUploadsEnabled = settings.get('FileUpload_Enabled');
const convertLongMessagesToAttachment = settings.get('Message_AllowConvertLongMessagesToAttachment');
if (chat.currentEditing || !fileUploadsEnabled || !convertLongMessagesToAttachment) {
dispatchToastMessage({ type: 'error', message: new Error(t('Message_too_long')) });
chat.composer?.setText(msg);
return true;
}
await new Promise<void>((resolve) => {
const onConfirm = async (): Promise<void> => {
const contentType = 'text/plain';
const messageBlob = new Blob([msg], { type: contentType });
const fileName = `${Meteor.user()?.username ?? 'anonymous'} - ${new Date()}.txt`; // TODO: proper naming and formatting
const file = new File([messageBlob], fileName, {
type: contentType,
lastModified: Date.now(),
});
await chat.flows.uploadFiles([file]);
imperativeModal.close();
resolve();
};
const onClose = (): void => {
chat.composer?.setText(msg);
imperativeModal.close();
resolve();
};
imperativeModal.open({
component: GenericModal,
props: {
title: t('Message_too_long'),
children: t('Send_it_as_attachment_instead_question'),
onConfirm,
onClose,
onCancel: onClose,
variant: 'warning',
},
});
});
return true;
};

@ -0,0 +1,65 @@
import { IMessage } from '@rocket.chat/core-typings';
import { t } from '../../../../app/utils/client';
import GenericModal from '../../../components/GenericModal';
import { imperativeModal } from '../../imperativeModal';
import { dispatchToastMessage } from '../../toast';
import { ChatAPI } from '../ChatAPI';
export const requestMessageDeletion = async (chat: ChatAPI, message: IMessage): Promise<void> => {
if (!(await chat.data.canDeleteMessage(message))) {
dispatchToastMessage({ type: 'error', message: t('Message_deleting_blocked') });
return;
}
const room = message.drid ? await chat.data.getDiscussionByID(message.drid) : undefined;
await new Promise<void>((resolve, reject) => {
const onConfirm = async (): Promise<void> => {
try {
if (!(await chat.data.canDeleteMessage(message))) {
dispatchToastMessage({ type: 'error', message: t('Message_deleting_blocked') });
return;
}
await chat.data.deleteMessage(message._id);
imperativeModal.close();
if (chat.currentEditing?.mid === message._id) {
chat.currentEditing.stop();
}
chat.composer?.focus();
dispatchToastMessage({ type: 'success', message: t('Your_entry_has_been_deleted') });
resolve();
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
reject(error);
}
};
const onCloseModal = async (): Promise<void> => {
imperativeModal.close();
if (chat.currentEditing?.mid === message._id) {
chat.currentEditing.stop();
}
chat.composer?.focus();
resolve();
};
imperativeModal.open({
component: GenericModal,
props: {
title: t('Are_you_sure'),
children: room ? t('The_message_is_a_discussion_you_will_not_be_able_to_recover') : t('You_will_not_be_able_to_recover'),
variant: 'danger',
confirmText: t('Yes_delete_it'),
onConfirm,
onClose: onCloseModal,
onCancel: onCloseModal,
},
});
});
};

@ -0,0 +1,91 @@
import { IMessage } from '@rocket.chat/core-typings';
import { KonchatNotification } from '../../../../app/ui/client';
import { t } from '../../../../app/utils/client';
import { dispatchToastMessage } from '../../toast';
import { call } from '../../utils/call';
import { ChatAPI } from '../ChatAPI';
import { processMessageEditing } from './processMessageEditing';
import { processSetReaction } from './processSetReaction';
import { processSlashCommand } from './processSlashCommand';
import { processTooLongMessage } from './processTooLongMessage';
const process = async (chat: ChatAPI, message: IMessage): Promise<void> => {
KonchatNotification.removeRoomNotification(message.rid);
if (await processSetReaction(chat, message)) {
return;
}
if (await processTooLongMessage(chat, message)) {
return;
}
if (await processMessageEditing(chat, message)) {
return;
}
if (await processSlashCommand(chat, message)) {
return;
}
await call('sendMessage', message);
};
export const sendMessage = async (chat: ChatAPI, { text, tshow }: { text: string; tshow?: boolean }): Promise<void> => {
if (!(await chat.data.isSubscribedToRoom())) {
try {
await chat.data.joinRoom();
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
return;
}
}
await chat.data.markRoomAsRead();
text = text.trim();
if (!text && !chat.currentEditing) {
// Nothing to do
return;
}
if (text) {
const message = await chat.data.composeMessage(text, {
sendToChannel: tshow,
quotedMessages: chat.composer?.quotedMessages.get() ?? [],
originalMessage: chat.currentEditing ? await chat.data.findMessageByID(chat.currentEditing.mid) : undefined,
});
try {
await process(chat, message);
chat.composer?.dismissAllQuotedMessages();
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
}
return;
}
if (chat.currentEditing) {
const originalMessage = await chat.data.findMessageByID(chat.currentEditing.mid);
if (!originalMessage) {
dispatchToastMessage({ type: 'warning', message: t('Message_not_found') });
return;
}
try {
if (await chat.flows.processMessageEditing({ ...originalMessage, msg: '' })) {
chat.currentEditing.stop();
return;
}
await chat.currentEditing?.reset();
await chat.flows.requestMessageDeletion(originalMessage);
return;
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
}
}
};

@ -0,0 +1,55 @@
import { isRoomFederated } from '@rocket.chat/core-typings';
import { fileUploadIsValidContentType } from '../../../../app/utils/client';
import FileUploadModal from '../../../views/room/modals/FileUploadModal';
import { imperativeModal } from '../../imperativeModal';
import { prependReplies } from '../../utils/prependReplies';
import { ChatAPI } from '../ChatAPI';
export const uploadFiles = async (chat: ChatAPI, files: readonly File[]): Promise<void> => {
const replies = chat.composer?.quotedMessages.get() ?? [];
const msg = await prependReplies('', replies);
const room = await chat.data.getRoom();
const queue = [...files];
const uploadNextFile = (): void => {
const file = queue.pop();
if (!file) {
chat.composer?.dismissAllQuotedMessages();
return;
}
imperativeModal.open({
component: FileUploadModal,
props: {
file,
fileName: file.name,
fileDescription: chat.composer?.text ?? '',
showDescription: room && !isRoomFederated(room),
onClose: (): void => {
imperativeModal.close();
uploadNextFile();
},
onSubmit: (fileName: string, description?: string): void => {
Object.defineProperty(file, 'name', {
writable: true,
value: fileName,
});
chat.uploads.send(file, {
description,
msg,
});
chat.composer?.clear();
imperativeModal.close();
uploadNextFile();
},
invalidContentType: Boolean(file.type && !fileUploadIsValidContentType(file.type)),
},
});
};
uploadNextFile();
};

@ -0,0 +1,151 @@
import { IMessage, IRoom } from '@rocket.chat/core-typings';
import { Emitter } from '@rocket.chat/emitter';
import { UserAction, USER_ACTIVITIES } from '../../../app/ui/client/lib/UserAction';
import { APIClient } from '../../../app/utils/client';
import { getRandomId } from '../../../lib/random';
import { getErrorMessage } from '../errorHandling';
import { UploadsAPI } from './ChatAPI';
import type { Upload } from './Upload';
let uploads: readonly Upload[] = [];
const emitter = new Emitter<{ update: void; [x: `cancelling-${Upload['id']}`]: void }>();
const updateUploads = (update: (uploads: readonly Upload[]) => readonly Upload[]): void => {
uploads = update(uploads);
emitter.emit('update');
};
const get = (): readonly Upload[] => uploads;
const subscribe = (callback: () => void): (() => void) => emitter.on('update', callback);
const cancel = (id: Upload['id']): void => {
emitter.emit(`cancelling-${id}`);
};
const wipeFailedOnes = (): void => {
updateUploads((uploads) => uploads.filter((upload) => !upload.error));
};
const send = async (
file: File,
{
description,
msg,
rid,
tmid,
}: {
description?: string;
msg?: string;
rid: string;
tmid?: string;
},
): Promise<void> => {
const id = getRandomId();
updateUploads((uploads) => [
...uploads,
{
id,
name: file.name,
percentage: 0,
},
]);
try {
await new Promise((resolve, reject) => {
const xhr = APIClient.upload(
`/v1/rooms.upload/${rid}`,
{
msg,
tmid,
file,
description,
},
{
load: (event) => {
resolve(event);
},
progress: (event) => {
if (!event.lengthComputable) {
return;
}
const progress = (event.loaded / event.total) * 100;
if (progress === 100) {
return;
}
updateUploads((uploads) =>
uploads.map((upload) => {
if (upload.id !== id) {
return upload;
}
return {
...upload,
percentage: Math.round(progress) || 0,
};
}),
);
},
error: (event) => {
updateUploads((uploads) =>
uploads.map((upload) => {
if (upload.id !== id) {
return upload;
}
return {
...upload,
percentage: 0,
error: new Error(xhr.responseText),
};
}),
);
reject(event);
},
},
);
if (uploads.length) {
UserAction.performContinuously(rid, USER_ACTIVITIES.USER_UPLOADING, { tmid });
}
emitter.once(`cancelling-${id}`, () => {
xhr.abort();
updateUploads((uploads) => uploads.filter((upload) => upload.id !== id));
});
});
updateUploads((uploads) => uploads.filter((upload) => upload.id !== id));
} catch (error: unknown) {
updateUploads((uploads) =>
uploads.map((upload) => {
if (upload.id !== id) {
return upload;
}
return {
...upload,
percentage: 0,
error: new Error(getErrorMessage(error)),
};
}),
);
} finally {
if (!uploads.length) {
UserAction.stop(rid, USER_ACTIVITIES.USER_UPLOADING, { tmid });
}
}
};
export const createUploadsAPI = ({ rid, tmid }: { rid: IRoom['_id']; tmid?: IMessage['_id'] }): UploadsAPI => ({
get,
subscribe,
wipeFailedOnes,
cancel,
send: (file: File, { description, msg }: { description?: string; msg?: string }): Promise<void> =>
send(file, { description, msg, rid, tmid }),
});

@ -1,23 +1,13 @@
import type { IMessage, IRoom } from '@rocket.chat/core-typings';
import { Meteor } from 'meteor/meteor';
import type { IMessage } from '@rocket.chat/core-typings';
import { Rooms, Users } from '../../../app/models/client';
import { MessageAction } from '../../../app/ui-utils/client/lib/MessageAction';
export const prependReplies = async (msg: string, replies: IMessage[] = [], mention = false): Promise<string> => {
const { username } = Users.findOne({ _id: Meteor.userId() }, { fields: { username: 1 } });
export const prependReplies = async (msg: string, replies: IMessage[] = []): Promise<string> => {
const chunks = await Promise.all(
replies.map(async ({ _id, rid, u }) => {
replies.map(async ({ _id }) => {
const permalink = await MessageAction.getPermaLink(_id);
const room: IRoom | null = Rooms.findOne(rid, { fields: { t: 1 } });
let chunk = `[ ](${permalink})`;
if (room?.t === 'd' && u.username !== username && mention) {
chunk += ` @${u.username}`;
}
return chunk;
return `[ ](${permalink})`;
}),
);

@ -1,3 +1,3 @@
import './customScriptOnLogout';
import './messageBoxState';
import './purgeAllDrafts';
import './roomManager';

@ -1,8 +0,0 @@
import { Meteor } from 'meteor/meteor';
import { ChatMessages } from '../../../app/ui/client';
import { callbacks } from '../../../lib/callbacks';
Meteor.startup(() => {
callbacks.add('afterLogoutCleanUp', ChatMessages.purgeAllDrafts, callbacks.priority.MEDIUM, 'chatMessages-after-logout-cleanup');
});

@ -0,0 +1,13 @@
import { Meteor } from 'meteor/meteor';
import { callbacks } from '../../../lib/callbacks';
Meteor.startup(() => {
const purgeAllDrafts = (): void => {
Object.keys(Meteor._localStorage)
.filter((key) => key.indexOf('messagebox_') === 0)
.forEach((key) => Meteor._localStorage.removeItem(key));
};
callbacks.add('afterLogoutCleanUp', purgeAllDrafts, callbacks.priority.MEDIUM, 'chatMessages-after-logout-cleanup');
});

@ -1,3 +1 @@
import './wipeFailedUploads';
import './readMessages';
import './restoreReplies';

@ -1,27 +0,0 @@
import { IMessage, ISubscription } from '@rocket.chat/core-typings';
import { FlowRouter } from 'meteor/kadira:flow-router';
import { Messages } from '../../../app/models/client';
import { ChatMessages } from '../../../app/ui/client';
import { callbacks } from '../../../lib/callbacks';
import { callWithErrorHandling } from '../../lib/utils/callWithErrorHandling';
callbacks.add('enter-room', async (sub?: ISubscription) => {
if (!sub) {
return;
}
const mid = FlowRouter.getQueryParam('reply');
if (!mid) {
return;
}
const getSingleMessage = (mid: IMessage['_id']): Promise<IMessage> => callWithErrorHandling('getSingleMessage', mid);
const message = (Messages as Mongo.Collection<IMessage>).findOne(mid) ?? (await getSingleMessage(mid));
if (!message) {
return;
}
ChatMessages.get({ rid: sub.rid })?.quotedMessages.add(message);
});

@ -1,17 +0,0 @@
import { Session } from 'meteor/session';
import type { Uploading } from '../../../app/ui/client/lib/fileUpload';
import { callbacks } from '../../../lib/callbacks';
function wipeFailedUploads(): void {
const uploads: Uploading[] = Session.get('uploading');
if (uploads) {
Session.set(
'uploading',
uploads.filter((upload) => !upload.error),
);
}
}
callbacks.add('enter-room', wipeFailedUploads);

@ -4,16 +4,19 @@ import { useMutableCallback } from '@rocket.chat/fuselage-hooks';
import { useSetting, useTranslation } from '@rocket.chat/ui-contexts';
import React, { ReactElement, useEffect, useMemo, useState } from 'react';
import { AudioRecorder, fileUpload, UserAction, USER_ACTIVITIES } from '../../../../app/ui/client';
import { AudioRecorder, UserAction, USER_ACTIVITIES } from '../../../../app/ui/client';
import { ChatAPI } from '../../../lib/chats/ChatAPI';
import { useChat } from '../../room/contexts/ChatContext';
const audioRecorder = new AudioRecorder();
type AudioMessageRecorderProps = {
rid: IRoom['_id'];
tmid: IMessage['_id'];
chatContext?: ChatAPI; // TODO: remove this when the composer is migrated to React
};
const AudioMessageRecorder = ({ rid, tmid }: AudioMessageRecorderProps): ReactElement | null => {
const AudioMessageRecorder = ({ rid, tmid, chatContext }: AudioMessageRecorderProps): ReactElement | null => {
const t = useTranslation();
const [state, setState] = useState<'idle' | 'loading' | 'recording'>('idle');
@ -138,6 +141,8 @@ const AudioMessageRecorder = ({ rid, tmid }: AudioMessageRecorderProps): ReactEl
await stopRecording();
});
const chat = useChat() ?? chatContext;
const handleDoneButtonClick = useMutableCallback(async () => {
setState('loading');
@ -146,7 +151,7 @@ const AudioMessageRecorder = ({ rid, tmid }: AudioMessageRecorderProps): ReactEl
const fileName = `${t('Audio_record')}.mp3`;
const file = new File([blob], fileName, { type: 'audio/mpeg' });
await fileUpload([{ file, name: fileName }], undefined, { rid, tmid });
await chat?.flows.uploadFiles([file]);
});
if (!isAllowed) {

@ -5,7 +5,7 @@ import React, { Fragment, memo, ReactElement } from 'react';
import { MessageTypes } from '../../../../app/ui-utils/client';
import { useFormatDate } from '../../../hooks/useFormatDate';
import { MessageProvider } from '../providers/MessageProvider';
import MessageProvider from '../providers/MessageProvider';
import { SelectedMessagesProvider } from '../providers/SelectedMessagesProvider';
import Message from './components/Message';
import MessageSystem from './components/MessageSystem';
@ -15,7 +15,6 @@ import { isMessageFirstUnread } from './lib/isMessageFirstUnread';
import { isMessageNewDay } from './lib/isMessageNewDay';
import { isMessageSequential } from './lib/isMessageSequential';
import { isOwnUserMessage } from './lib/isOwnUserMessage';
import MessageHighlightProvider from './providers/MessageHighlightProvider';
import { MessageListProvider } from './providers/MessageListProvider';
type MessageListProps = {
@ -34,69 +33,67 @@ export const MessageList = ({ rid }: MessageListProps): ReactElement => {
<MessageListProvider rid={rid}>
<MessageProvider rid={rid} broadcast={isBroadcast}>
<SelectedMessagesProvider>
<MessageHighlightProvider>
{messages.map((message, index, arr) => {
const previous = arr[index - 1];
{messages.map((message, index, arr) => {
const previous = arr[index - 1];
const isSequential = isMessageSequential(message, previous, messageGroupingPeriod);
const isSequential = isMessageSequential(message, previous, messageGroupingPeriod);
const isNewDay = isMessageNewDay(message, previous);
const isFirstUnread = isMessageFirstUnread(subscription, message, previous);
const isUserOwnMessage = isOwnUserMessage(message, subscription);
const shouldShowDivider = isNewDay || isFirstUnread;
const isNewDay = isMessageNewDay(message, previous);
const isFirstUnread = isMessageFirstUnread(subscription, message, previous);
const isUserOwnMessage = isOwnUserMessage(message, subscription);
const shouldShowDivider = isNewDay || isFirstUnread;
const shouldShowAsSequential = isSequential && !isNewDay;
const shouldShowAsSequential = isSequential && !isNewDay;
const isSystemMessage = MessageTypes.isSystemMessage(message);
const shouldShowMessage = !isThreadMessage(message) && !isSystemMessage;
const isSystemMessage = MessageTypes.isSystemMessage(message);
const shouldShowMessage = !isThreadMessage(message) && !isSystemMessage;
const unread = Boolean(subscription?.tunread?.includes(message._id));
const mention = Boolean(subscription?.tunreadUser?.includes(message._id));
const all = Boolean(subscription?.tunreadGroup?.includes(message._id));
const unread = Boolean(subscription?.tunread?.includes(message._id));
const mention = Boolean(subscription?.tunreadUser?.includes(message._id));
const all = Boolean(subscription?.tunreadGroup?.includes(message._id));
return (
<Fragment key={message._id}>
{shouldShowDivider && (
<MessageDivider unreadLabel={isFirstUnread ? t('Unread_Messages').toLowerCase() : undefined}>
{isNewDay && format(message.ts)}
</MessageDivider>
)}
return (
<Fragment key={message._id}>
{shouldShowDivider && (
<MessageDivider unreadLabel={isFirstUnread ? t('Unread_Messages').toLowerCase() : undefined}>
{isNewDay && format(message.ts)}
</MessageDivider>
)}
{shouldShowMessage && (
<Message
id={message._id}
data-id={message._id}
data-system-message={Boolean(message.t)}
data-mid={message._id}
data-unread={isFirstUnread}
data-sequential={isSequential}
data-own={isUserOwnMessage}
data-qa-type='message'
sequential={shouldShowAsSequential}
message={message}
unread={unread}
mention={mention}
all={all}
/>
)}
{shouldShowMessage && (
<Message
id={message._id}
data-id={message._id}
data-system-message={Boolean(message.t)}
data-mid={message._id}
data-unread={isFirstUnread}
data-sequential={isSequential}
data-own={isUserOwnMessage}
data-qa-type='message'
sequential={shouldShowAsSequential}
message={message}
unread={unread}
mention={mention}
all={all}
/>
)}
{isThreadMessage(message) && (
<ThreadMessagePreview
data-system-message={Boolean(message.t)}
data-mid={message._id}
data-tmid={message.tmid}
data-unread={isFirstUnread}
data-sequential={isSequential}
sequential={shouldShowAsSequential}
message={message as IThreadMessage}
/>
)}
{isThreadMessage(message) && (
<ThreadMessagePreview
data-system-message={Boolean(message.t)}
data-mid={message._id}
data-tmid={message.tmid}
data-unread={isFirstUnread}
data-sequential={isSequential}
sequential={shouldShowAsSequential}
message={message as IThreadMessage}
/>
)}
{isSystemMessage && <MessageSystem message={message} />}
</Fragment>
);
})}
</MessageHighlightProvider>
{isSystemMessage && <MessageSystem message={message} />}
</Fragment>
);
})}
</SelectedMessagesProvider>
</MessageProvider>
</MessageListProvider>

@ -1,14 +1,16 @@
import { MessageToolboxItem, Option } from '@rocket.chat/fuselage';
import { useTranslation } from '@rocket.chat/ui-contexts';
import React, { FC, useState, Fragment, useRef, ComponentProps } from 'react';
import React, { FC, useState, Fragment, useRef, ComponentProps, UIEvent } from 'react';
import { MessageActionConfig } from '../../../../../../app/ui-utils/client/lib/MessageAction';
import { ToolboxDropdown } from './ToolboxDropdown';
type MessageActionConfigOption = Omit<MessageActionConfig, 'condition' | 'context' | 'order'>;
type MessageActionConfigOption = Omit<MessageActionConfig, 'condition' | 'context' | 'order' | 'action'> & {
action: (event: UIEvent) => void;
};
export const MessageActionMenu: FC<{
options: MessageActionConfig[];
options: MessageActionConfigOption[];
}> = ({ options, ...rest }) => {
const ref = useRef(null);
@ -51,7 +53,7 @@ export const MessageActionMenu: FC<{
id={option.id}
icon={option.icon as ComponentProps<typeof Option>['icon']}
label={t(option.label)}
onClick={option.action as any}
onClick={option.action}
data-qa-type='message-action'
data-qa-id={option.id}
/>

@ -4,6 +4,7 @@ import { useUser, useUserSubscription, useSettings, useTranslation } from '@rock
import React, { FC, memo, useMemo } from 'react';
import { MessageAction, MessageActionContext } from '../../../../../../app/ui-utils/client/lib/MessageAction';
import { useChat } from '../../../contexts/ChatContext';
import { useRoom } from '../../../contexts/RoomContext';
import { useToolboxContext } from '../../../contexts/ToolboxContext';
import { useIsSelecting } from '../../contexts/SelectedMessagesContext';
@ -40,6 +41,8 @@ export const Toolbox: FC<{ message: IMessage }> = ({ message }) => {
const isSelecting = useIsSelecting();
const chat = useChat();
if (isSelecting) {
return null;
}
@ -50,7 +53,7 @@ export const Toolbox: FC<{ message: IMessage }> = ({ message }) => {
<MessageToolboxItem
onClick={(e): void => {
e.stopPropagation();
action.action(e, { message, tabbar: toolbox, room });
action.action(e, { message, tabbar: toolbox, room, chat });
}}
key={action.id}
icon={action.icon}
@ -65,7 +68,7 @@ export const Toolbox: FC<{ message: IMessage }> = ({ message }) => {
...action,
action: (e): void => {
e.stopPropagation();
action.action(e, { message, tabbar: toolbox, room });
action.action(e, { message, tabbar: toolbox, room, chat });
},
}))}
data-qa-type='message-action-menu-options'

@ -4,6 +4,7 @@ import { ErrorBoundary } from 'react-error-boundary';
import VerticalBarSkeleton from '../../../components/VerticalBar/VerticalBarSkeleton';
import Header from '../Header';
import MessageHighlightProvider from '../MessageList/providers/MessageHighlightProvider';
import VerticalBarOldActions from '../components/VerticalBarOldActions';
import RoomBody from '../components/body/RoomBody';
import { useRoom } from '../contexts/RoomContext';
@ -11,6 +12,7 @@ import { useTab, useToolboxContext } from '../contexts/ToolboxContext';
import AppsContextualBar from '../contextualBar/Apps';
import { useAppsContextualBar } from '../hooks/useAppsContextualBar';
import RoomLayout from '../layout/RoomLayout';
import ChatProvider from '../providers/ChatProvider';
import { SelectedMessagesProvider } from '../providers/SelectedMessagesProvider';
const Room = (): ReactElement => {
@ -24,42 +26,46 @@ const Room = (): ReactElement => {
const appsContextualBarContext = useAppsContextualBar();
return (
<RoomLayout
aria-label={t('Channel')}
data-qa-rc-room={room._id}
header={<Header room={room} />}
body={<RoomBody />}
aside={
(tab && (
<ErrorBoundary fallback={null}>
<SelectedMessagesProvider>
{typeof tab.template === 'string' && (
<VerticalBarOldActions {...tab} name={tab.template} tabBar={toolbox} rid={room._id} _id={room._id} />
)}
{typeof tab.template !== 'string' && typeof tab.template !== 'undefined' && (
<Suspense fallback={<VerticalBarSkeleton />}>
{createElement(tab.template, { tabBar: toolbox, _id: room._id, rid: room._id, teamId: room.teamId })}
</Suspense>
)}
</SelectedMessagesProvider>
</ErrorBoundary>
)) ||
(appsContextualBarContext && (
<ErrorBoundary fallback={null}>
<SelectedMessagesProvider>
<Suspense fallback={<VerticalBarSkeleton />}>
<AppsContextualBar
viewId={appsContextualBarContext.viewId}
roomId={appsContextualBarContext.roomId}
payload={appsContextualBarContext.payload}
appId={appsContextualBarContext.appId}
/>
</Suspense>
</SelectedMessagesProvider>
</ErrorBoundary>
))
}
/>
<ChatProvider>
<MessageHighlightProvider>
<RoomLayout
aria-label={t('Channel')}
data-qa-rc-room={room._id}
header={<Header room={room} />}
body={<RoomBody />}
aside={
(tab && (
<ErrorBoundary fallback={null}>
<SelectedMessagesProvider>
{typeof tab.template === 'string' && (
<VerticalBarOldActions {...tab} name={tab.template} tabBar={toolbox} rid={room._id} _id={room._id} />
)}
{typeof tab.template !== 'string' && typeof tab.template !== 'undefined' && (
<Suspense fallback={<VerticalBarSkeleton />}>
{createElement(tab.template, { tabBar: toolbox, _id: room._id, rid: room._id, teamId: room.teamId })}
</Suspense>
)}
</SelectedMessagesProvider>
</ErrorBoundary>
)) ||
(appsContextualBarContext && (
<ErrorBoundary fallback={null}>
<SelectedMessagesProvider>
<Suspense fallback={<VerticalBarSkeleton />}>
<AppsContextualBar
viewId={appsContextualBarContext.viewId}
roomId={appsContextualBarContext.roomId}
payload={appsContextualBarContext.payload}
appId={appsContextualBarContext.appId}
/>
</Suspense>
</SelectedMessagesProvider>
</ErrorBoundary>
))
}
/>
</MessageHighlightProvider>
</ChatProvider>
);
};

@ -1,12 +1,12 @@
import { useTranslation } from '@rocket.chat/ui-contexts';
import React, { ReactElement, useCallback } from 'react';
import { Uploading } from '../../../../../app/ui/client/lib/fileUpload';
import { Upload } from '../../../../lib/chats/Upload';
type ErroredUploadProgressIndicatorProps = {
id: Uploading['id'];
id: Upload['id'];
error: string;
onClose?: (id: Uploading['id']) => void;
onClose?: (id: Upload['id']) => void;
};
const ErroredUploadProgressIndicator = ({ id, error, onClose }: ErroredUploadProgressIndicatorProps): ReactElement => {

@ -11,11 +11,11 @@ import {
useUserPreference,
} from '@rocket.chat/ui-contexts';
import React, { memo, ReactElement, UIEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useSyncExternalStore } from 'use-sync-external-store/shim';
import { ChatMessage } from '../../../../../app/models/client';
import { readMessage, RoomHistoryManager } from '../../../../../app/ui-utils/client';
import { openUserCard } from '../../../../../app/ui/client/lib/UserCard';
import { Uploading } from '../../../../../app/ui/client/lib/fileUpload';
import { CommonRoomTemplateInstance } from '../../../../../app/ui/client/views/app/lib/CommonRoomTemplateInstance';
import { getCommonRoomEvents } from '../../../../../app/ui/client/views/app/lib/getCommonRoomEvents';
import { isAtBottom } from '../../../../../app/ui/client/views/app/lib/scrolling';
@ -25,10 +25,12 @@ import { withDebouncing, withThrottling } from '../../../../../lib/utils/highOrd
import { useEmbeddedLayout } from '../../../../hooks/useEmbeddedLayout';
import { useReactiveQuery } from '../../../../hooks/useReactiveQuery';
import { RoomManager as NewRoomManager } from '../../../../lib/RoomManager';
import { Upload } from '../../../../lib/chats/Upload';
import { roomCoordinator } from '../../../../lib/rooms/roomCoordinator';
import Announcement from '../../Announcement';
import { MessageList } from '../../MessageList/MessageList';
import MessageListErrorBoundary from '../../MessageList/MessageListErrorBoundary';
import { useChat } from '../../contexts/ChatContext';
import { useRoom, useRoomSubscription, useRoomMessages } from '../../contexts/RoomContext';
import { useToolboxContext } from '../../contexts/ToolboxContext';
import DropTargetOverlay from './DropTargetOverlay';
@ -42,7 +44,6 @@ import RoomForeword from './RoomForeword';
import UnreadMessagesIndicator from './UnreadMessagesIndicator';
import UploadProgressIndicator from './UploadProgressIndicator';
import ComposerContainer from './composer/ComposerContainer';
import { useChatMessages } from './useChatMessages';
import { useFileUploadDropTarget } from './useFileUploadDropTarget';
import { useRetentionPolicy } from './useRetentionPolicy';
import { useUnreadMessages } from './useUnreadMessages';
@ -71,7 +72,12 @@ const RoomBody = (): ReactElement => {
const atBottomRef = useRef(!useQueryStringParameter('msg'));
const lastScrollTopRef = useRef(0);
const chatMessagesInstance = useChatMessages(room._id, wrapperRef);
const chat = useChat();
if (!chat) {
throw new Error('No ChatContext provided');
}
const [fileUploadTriggerProps, fileUploadOverlayProps] = useFileUploadDropTarget(room);
const _isAtBottom = useCallback((scrollThreshold = 0) => {
@ -108,8 +114,8 @@ const RoomBody = (): ReactElement => {
const handleNewMessageButtonClick = useCallback(() => {
atBottomRef.current = true;
sendToBottomIfNecessary();
chatMessagesInstance.input?.focus();
}, [chatMessagesInstance, sendToBottomIfNecessary]);
chat.composer?.focus();
}, [chat, sendToBottomIfNecessary]);
const handleJumpToRecentButtonClick = useCallback(() => {
atBottomRef.current = true;
@ -119,7 +125,7 @@ const RoomBody = (): ReactElement => {
const [unread, setUnreadCount] = useUnreadMessages(room);
const uploading = useSession('uploading') as Uploading[];
const uploads = useSyncExternalStore(chat.uploads.subscribe, chat.uploads.get);
const messageViewMode = useMemo(() => {
const modes = ['', 'cozy', 'compact'] as const;
@ -202,9 +208,12 @@ const RoomBody = (): ReactElement => {
readMessage.readNow(room._id);
}, [room._id]);
const handleUploadProgressClose = useCallback((id: Uploading['id']) => {
Session.set(`uploading-cancel-${id}`, true);
}, []);
const handleUploadProgressClose = useCallback(
(id: Upload['id']) => {
chat.uploads.cancel(id);
},
[chat],
);
const retentionPolicy = useRetentionPolicy(room);
@ -348,7 +357,7 @@ const RoomBody = (): ReactElement => {
event,
selector,
listener: (e: JQuery.TriggeredEvent<HTMLUListElement, undefined>) =>
handler.call(null, e, { data: { rid: room._id, tabBar: toolbox } }),
handler.call(null, e, { data: { rid: room._id, tabBar: toolbox, chatContext: chat } }),
};
});
@ -361,7 +370,7 @@ const RoomBody = (): ReactElement => {
$(messageList).off(event, selector, listener);
}
};
}, [room._id, sendToBottomIfNecessary, toolbox, useLegacyMessageTemplate]);
}, [chat, room._id, sendToBottomIfNecessary, toolbox, useLegacyMessageTemplate]);
useEffect(() => {
const wrapper = wrapperRef.current;
@ -522,6 +531,41 @@ const RoomBody = (): ReactElement => {
sendToBottomIfNecessary();
}, [sendToBottomIfNecessary]);
const handleNavigateToPreviousMessage = useCallback((): void => {
chat.messageEditing.toPreviousMessage();
}, [chat.messageEditing]);
const handleNavigateToNextMessage = useCallback((): void => {
chat.messageEditing.toNextMessage();
}, [chat.messageEditing]);
const handleUploadFiles = useCallback(
(files: readonly File[]): void => {
chat.flows.uploadFiles(files);
},
[chat],
);
const replyMID = useQueryStringParameter('reply');
useEffect(() => {
if (!replyMID) {
return;
}
chat.data.getMessageByID(replyMID).then((message) => {
if (!message) {
return;
}
chat.composer?.quoteMessage(message);
});
}, [chat.data, chat.composer, replyMID]);
useEffect(() => {
chat.uploads.wipeFailedOnes();
}, [chat]);
return (
<>
{!isLayoutEmbedded && room.announcement && <Announcement announcement={room.announcement} announcementDetails={undefined} />}
@ -535,7 +579,7 @@ const RoomBody = (): ReactElement => {
<div className='messages-container-wrapper'>
<div className='messages-container-main' {...fileUploadTriggerProps}>
<DropTargetOverlay {...fileUploadOverlayProps} />
<div className={['container-bars', (unread || uploading.length) && 'show'].filter(isTruthy).join(' ')}>
<div className={['container-bars', (unread || uploads.length) && 'show'].filter(isTruthy).join(' ')}>
{unread ? (
<UnreadMessagesIndicator
count={unread.count}
@ -544,7 +588,7 @@ const RoomBody = (): ReactElement => {
onMarkAsReadButtonClick={handleMarkAsReadButtonClick}
/>
) : null}
{uploading.map((upload) => (
{uploads.map((upload) => (
<UploadProgressIndicator
key={upload.id}
id={upload.id}
@ -612,8 +656,11 @@ const RoomBody = (): ReactElement => {
<ComposerContainer
rid={room._id}
subscription={subscription}
chatMessagesInstance={chatMessagesInstance}
chatMessagesInstance={chat}
onResize={handleComposerResize}
onNavigateToPreviousMessage={handleNavigateToPreviousMessage}
onNavigateToNextMessage={handleNavigateToNextMessage}
onUploadFiles={handleUploadFiles}
/>
</div>
</div>

@ -1,15 +1,15 @@
import { useTranslation } from '@rocket.chat/ui-contexts';
import React, { ReactElement, useCallback } from 'react';
import { Uploading } from '../../../../../app/ui/client/lib/fileUpload';
import { Upload } from '../../../../lib/chats/Upload';
import ErroredUploadProgressIndicator from './ErroredUploadProgressIndicator';
type UploadProgressIndicatorProps = {
id: Uploading['id'];
id: Upload['id'];
name: string;
percentage: number;
error?: string;
onClose?: (id: Uploading['id']) => void;
onClose?: (id: Upload['id']) => void;
};
const UploadProgressIndicator = ({ id, name, percentage, error, onClose }: UploadProgressIndicatorProps): ReactElement => {

@ -1,36 +1,54 @@
import { IRoom, ISubscription } from '@rocket.chat/core-typings';
import { useSetting } from '@rocket.chat/ui-contexts';
import { useSetting, useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import { Blaze } from 'meteor/blaze';
import { ReactiveVar } from 'meteor/reactive-var';
import { Template } from 'meteor/templating';
import React, { memo, ReactElement, useCallback, useEffect, useRef } from 'react';
import React, { ContextType, memo, ReactElement, useCallback, useEffect, useRef } from 'react';
import type { MessageBoxTemplateInstance } from '../../../../../../app/ui-message/client/messageBox/messageBox';
import { RoomManager } from '../../../../../../app/ui-utils/client';
import { ChatMessages } from '../../../../../../app/ui/client';
import { useEmbeddedLayout } from '../../../../../hooks/useEmbeddedLayout';
import { useReactiveValue } from '../../../../../hooks/useReactiveValue';
import ComposerSkeleton from '../../../Room/ComposerSkeleton';
import { ChatContext } from '../../../contexts/ChatContext';
export type ComposerMessageProps = {
rid: IRoom['_id'];
subscription?: ISubscription;
chatMessagesInstance: ChatMessages;
chatMessagesInstance: ContextType<typeof ChatContext>;
onResize?: () => void;
onEscape?: () => void;
onNavigateToNextMessage?: () => void;
onNavigateToPreviousMessage?: () => void;
onUploadFiles?: (files: readonly File[]) => void;
};
const ComposerMessage = ({ rid, subscription, chatMessagesInstance, onResize }: ComposerMessageProps): ReactElement => {
const ComposerMessage = ({
rid,
subscription,
chatMessagesInstance,
onResize,
onEscape,
onNavigateToNextMessage,
onNavigateToPreviousMessage,
onUploadFiles,
}: ComposerMessageProps): ReactElement => {
const isLayoutEmbedded = useEmbeddedLayout();
const showFormattingTips = useSetting('Message_ShowFormattingTips') as boolean;
const messageBoxViewRef = useRef<Blaze.View>();
const messageBoxViewDataRef = useRef(
new ReactiveVar({
new ReactiveVar<MessageBoxTemplateInstance['data']>({
rid,
subscription,
isEmbedded: isLayoutEmbedded,
showFormattingTips: showFormattingTips && !isLayoutEmbedded,
onResize,
chatMessagesInstance,
onEscape,
onNavigateToNextMessage,
onNavigateToPreviousMessage,
onUploadFiles,
chatContext: chatMessagesInstance,
}),
);
@ -41,44 +59,53 @@ const ComposerMessage = ({ rid, subscription, chatMessagesInstance, onResize }:
isEmbedded: isLayoutEmbedded,
showFormattingTips: showFormattingTips && !isLayoutEmbedded,
onResize,
chatMessagesInstance,
onEscape,
onNavigateToNextMessage,
onNavigateToPreviousMessage,
onUploadFiles,
chatContext: chatMessagesInstance,
});
}, [isLayoutEmbedded, onResize, rid, showFormattingTips, subscription, chatMessagesInstance]);
}, [
isLayoutEmbedded,
onResize,
rid,
showFormattingTips,
subscription,
chatMessagesInstance,
onEscape,
onNavigateToNextMessage,
onNavigateToPreviousMessage,
onUploadFiles,
]);
const dispatchToastMessage = useToastMessageDispatch();
const footerRef = useCallback(
(footer: HTMLElement | null) => {
if (footer) {
messageBoxViewRef.current = Blaze.renderWithData(
Template.messageBox,
() => ({
(): MessageBoxTemplateInstance['data'] => ({
...messageBoxViewDataRef.current.get(),
onInputChanged: (input: HTMLTextAreaElement): void => {
chatMessagesInstance.initializeInput(input);
setTimeout(() => {
if (window.matchMedia('screen and (min-device-width: 500px)').matches) {
input.focus();
}
}, 200);
},
onKeyUp: (
event: KeyboardEvent,
onSend: async (
_event: Event,
{
rid,
tmid,
value: text,
tshow,
}: {
rid: string;
tmid?: string | undefined;
},
) => chatMessagesInstance.keyup(event, { rid, tmid }),
onKeyDown: (event: KeyboardEvent) => chatMessagesInstance.keydown(event),
onSend: (
_event: Event,
params: {
value: string;
tshow?: boolean;
},
) => chatMessagesInstance.send(params),
): Promise<void> => {
try {
await chatMessagesInstance?.flows.sendMessage({
text,
tshow,
});
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
}
},
}),
footer,
);
@ -90,7 +117,7 @@ const ComposerMessage = ({ rid, subscription, chatMessagesInstance, onResize }:
messageBoxViewRef.current = undefined;
}
},
[chatMessagesInstance],
[chatMessagesInstance, dispatchToastMessage],
);
const publicationReady = useReactiveValue(useCallback(() => RoomManager.getOpenedRoomByRid(rid)?.streamActive ?? false, [rid]));

@ -1,27 +0,0 @@
import { IRoom } from '@rocket.chat/core-typings';
import { RefObject, useEffect, useMemo } from 'react';
import { ChatMessages } from '../../../../../app/ui/client';
export const useChatMessages = (rid: IRoom['_id'], wrapperRef: RefObject<HTMLElement | null>): ChatMessages => {
const chatMessagesInstance = useMemo(() => {
const instance = ChatMessages.get({ rid }) ?? new ChatMessages({ rid });
ChatMessages.set({ rid }, instance);
return instance;
}, [rid]);
useEffect(() => {
const wrapper = wrapperRef.current;
if (!wrapper) {
return;
}
chatMessagesInstance.initializeWrapper(wrapper);
return (): void => {
chatMessagesInstance.onDestroyed?.(rid);
};
}, [chatMessagesInstance, rid, wrapperRef]);
return chatMessagesInstance;
};

@ -4,9 +4,9 @@ import { useSetting, useTranslation } from '@rocket.chat/ui-contexts';
import React, { ReactNode, useCallback, useMemo } from 'react';
import { Users } from '../../../../../app/models/client';
import { ChatMessages, fileUpload } from '../../../../../app/ui/client';
import { useReactiveValue } from '../../../../hooks/useReactiveValue';
import { roomCoordinator } from '../../../../lib/rooms/roomCoordinator';
import { useChat } from '../../contexts/ChatContext';
import { useDropTarget } from './useDropTarget';
export const useFileUploadDropTarget = (
@ -34,21 +34,17 @@ export const useFileUploadDropTarget = (
),
);
const onFileDrop = useMutableCallback(async (files: File[]) => {
const input = ChatMessages.get({ rid: room._id })?.input;
if (!input) return;
const chat = useChat();
const onFileDrop = useMutableCallback(async (files: File[]) => {
const { mime } = await import('../../../../../app/utils/lib/mimeTypes');
const uploads = Array.from(files).map((file) => {
Object.defineProperty(file, 'type', { value: mime.lookup(file.name) });
return {
file,
name: file.name,
};
return file;
});
fileUpload(uploads, input, { rid: room._id });
chat?.flows.uploadFiles(uploads);
});
const allOverlayProps = useMemo(() => {

@ -0,0 +1,9 @@
import { createContext, useContext } from 'react';
import { ChatAPI } from '../../../lib/chats/ChatAPI';
type ChatContextValue = ChatAPI | undefined;
export const ChatContext = createContext<ChatContextValue>(undefined);
export const useChat = (): ChatContextValue => useContext(ChatContext);

@ -1,13 +1,22 @@
import type { IMessage, IRoom, IUser } from '@rocket.chat/core-typings';
import { Box, Icon, TextInput, Select, Margins, Callout, Throbber } from '@rocket.chat/fuselage';
import { useResizeObserver, useMutableCallback, useAutoFocus } from '@rocket.chat/fuselage-hooks';
import { useRoute, useCurrentRoute, useQueryStringParameter, useSetting, useTranslation } from '@rocket.chat/ui-contexts';
import {
useRoute,
useCurrentRoute,
useQueryStringParameter,
useSetting,
useTranslation,
useUserSubscription,
} from '@rocket.chat/ui-contexts';
import React, { FC, useMemo } from 'react';
import { Virtuoso } from 'react-virtuoso';
import ScrollableContentWrapper from '../../../../components/ScrollableContentWrapper';
import VerticalBar from '../../../../components/VerticalBar';
import { useTabContext } from '../../contexts/ToolboxContext';
import ChatProvider from '../../providers/ChatProvider';
import MessageProvider from '../../providers/MessageProvider';
import ThreadComponent from '../../threads/ThreadComponent';
import ThreadRow from './ThreadRow';
import { withData } from './withData';
@ -36,7 +45,9 @@ export type ThreadListProps = {
loadMoreItems: (min: number, max: number) => void;
};
export const ThreadList: FC<ThreadListProps> = function ThreadList({
const subscriptionFields = {};
const ThreadList: FC<ThreadListProps> = function ThreadList({
total = 10,
threads = [],
room,
@ -53,6 +64,8 @@ export const ThreadList: FC<ThreadListProps> = function ThreadList({
userId = '',
setText,
}) {
const subscription = useUserSubscription(room._id, subscriptionFields);
const showRealNames = Boolean(useSetting('UI_Use_Real_Name'));
const t = useTranslation();
@ -172,7 +185,11 @@ export const ThreadList: FC<ThreadListProps> = function ThreadList({
{typeof mid === 'string' && (
<VerticalBar.InnerContent>
<ThreadComponent onClickBack={onClick} mid={mid} jump={jump} room={room} />
<ChatProvider tmid={mid}>
<MessageProvider rid={room._id} broadcast={subscription?.broadcast ?? false}>
<ThreadComponent onClickBack={onClick} mid={mid} jump={jump} room={room} />
</MessageProvider>
</ChatProvider>
</VerticalBar.InnerContent>
)}
</>

@ -0,0 +1,29 @@
import React, { ReactElement, ReactNode, useEffect, useMemo } from 'react';
import { ChatMessages } from '../../../../app/ui/client/lib/ChatMessages';
import { ChatContext } from '../contexts/ChatContext';
import { useRoom } from '../contexts/RoomContext';
type ChatProviderProps = {
children: ReactNode;
tmid?: string;
};
const ChatProvider = ({ children, tmid }: ChatProviderProps): ReactElement => {
const { _id: rid } = useRoom();
const chatMessages = useMemo(() => ChatMessages.hold({ rid, tmid }), [rid, tmid]);
useEffect(
() => (): void => {
ChatMessages.release({ rid, tmid });
},
[rid, tmid],
);
const value = useMemo(() => chatMessages, [chatMessages]);
return <ChatContext.Provider value={value}>{children}</ChatContext.Provider>;
};
export default ChatProvider;

@ -12,7 +12,7 @@ import { goToRoomById } from '../../../lib/utils/goToRoomById';
import { MessageContext } from '../contexts/MessageContext';
import { useTabBarOpen } from '../contexts/ToolboxContext';
export const MessageProvider = memo(function MessageProvider({
const MessageProvider = memo(function MessageProvider({
rid,
broadcast,
children,
@ -119,3 +119,5 @@ export const MessageProvider = memo(function MessageProvider({
return <MessageContext.Provider value={context}>{children}</MessageContext.Provider>;
});
export default MessageProvider;

@ -2,14 +2,18 @@ import type { IMessage, IRoom } from '@rocket.chat/core-typings';
import { useLocalStorage } from '@rocket.chat/fuselage-hooks';
import { useToastMessageDispatch, useRoute, useUserId, useUserSubscription, useEndpoint } from '@rocket.chat/ui-contexts';
import { Blaze } from 'meteor/blaze';
import { ReactiveVar } from 'meteor/reactive-var';
import { Template } from 'meteor/templating';
import { Tracker } from 'meteor/tracker';
import React, { useEffect, useRef, useState, useCallback, useMemo, FC } from 'react';
import React, { useEffect, useRef, useState, useCallback, useMemo, FC, useContext } from 'react';
import { ChatMessage } from '../../../../app/models/client';
import { normalizeThreadTitle } from '../../../../app/threads/client/lib/normalizeThreadTitle';
import { roomCoordinator } from '../../../lib/rooms/roomCoordinator';
import { mapMessageFromApi } from '../../../lib/utils/mapMessageFromApi';
import MessageHighlightContext from '../MessageList/contexts/MessageHighlightContext';
import { ChatContext } from '../contexts/ChatContext';
import { MessageContext } from '../contexts/MessageContext';
import { useTabBarOpenUserInfo } from '../contexts/ToolboxContext';
import ThreadSkeleton from './ThreadSkeleton';
import ThreadView from './ThreadView';
@ -98,6 +102,15 @@ const ThreadComponent: FC<{
channelRoute.push(room.t === 'd' ? { rid: room._id } : { name: room.name || room._id });
}, [channelRoute, room._id, room.t, room.name]);
const chatContext = useContext(ChatContext);
const messageContext = useContext(MessageContext);
const messageHighlightContext = useContext(MessageHighlightContext);
const { current: messageHighlightContextReactiveVar } = useRef(new ReactiveVar(messageHighlightContext));
useEffect(() => {
messageHighlightContextReactiveVar.set(messageHighlightContext);
}, [messageHighlightContext, messageHighlightContextReactiveVar]);
const [viewData, setViewData] = useState(() => ({
mainMessage: threadMessage,
jump,
@ -105,6 +118,9 @@ const ThreadComponent: FC<{
subscription,
rid: room._id,
tabBar: { openRoomInfo },
chatContext,
messageContext,
messageHighlightContext: () => messageHighlightContextReactiveVar.get(),
}));
useEffect(() => {
@ -120,9 +136,22 @@ const ThreadComponent: FC<{
subscription,
rid: room._id,
tabBar: { openRoomInfo },
chatContext,
messageContext,
messageHighlightContext: () => messageHighlightContextReactiveVar.get(),
};
});
}, [following, jump, openRoomInfo, room._id, subscription, threadMessage]);
}, [
chatContext,
following,
jump,
messageContext,
messageHighlightContextReactiveVar,
openRoomInfo,
room._id,
subscription,
threadMessage,
]);
useEffect(() => {
if (!ref.current || !viewData.mainMessage) {

@ -1,10 +1,9 @@
import { IWebdavNode, IWebdavAccountIntegration, IRoom } from '@rocket.chat/core-typings';
import { IWebdavNode, IWebdavAccountIntegration } from '@rocket.chat/core-typings';
import { Modal, Box, IconButton, Select, SelectOption } from '@rocket.chat/fuselage';
import { useMutableCallback, useDebouncedValue } from '@rocket.chat/fuselage-hooks';
import { useMethod, useToastMessageDispatch, useTranslation, useSetModal } from '@rocket.chat/ui-contexts';
import React, { useState, ReactElement, useEffect, useCallback, MouseEvent } from 'react';
import { uploadFileWithMessage } from '../../../../../app/ui/client/lib/fileUpload';
import { fileUploadIsValidContentType } from '../../../../../app/utils/client';
import FilterByText from '../../../../components/FilterByText';
import { useSort } from '../../../../components/GenericTable/hooks/useSort';
@ -17,12 +16,12 @@ import { sortWebdavNodes } from './lib/sortWebdavNodes';
export type WebdavSortOptions = 'name' | 'size' | 'dataModified';
type WebdavFilePickerModalProps = {
rid: IRoom['_id'];
onUpload: (file: File, description?: string) => Promise<void>;
onClose: () => void;
account: IWebdavAccountIntegration;
};
const WebdavFilePickerModal = ({ rid, onClose, account }: WebdavFilePickerModalProps): ReactElement => {
const WebdavFilePickerModal = ({ onUpload, onClose, account }: WebdavFilePickerModalProps): ReactElement => {
const t = useTranslation();
const setModal = useSetModal();
const getWebdavFilePreview = useMethod('getWebdavFilePreview');
@ -128,10 +127,7 @@ const WebdavFilePickerModal = ({ rid, onClose, account }: WebdavFilePickerModalP
const uploadFile = async (file: File, description?: string): Promise<void> => {
try {
await uploadFileWithMessage(rid, {
description,
file,
});
await onUpload?.(file, description);
} catch (error) {
return dispatchToastMessage({ type: 'error', message: error });
} finally {

@ -2,9 +2,9 @@ import { useDebouncedValue, useLocalStorage, useMutableCallback } from '@rocket.
import { useSetModal, useCurrentRoute, useRoute } from '@rocket.chat/ui-contexts';
import React, { FC, memo, MouseEvent, useCallback, useMemo, useState } from 'react';
import { ChatMessages } from '../../../../../../app/ui/client';
import { useRecordList } from '../../../../../../client/hooks/lists/useRecordList';
import { AsyncStatePhase } from '../../../../../../client/lib/asyncState';
import { useChat } from '../../../../../../client/views/room/contexts/ChatContext';
import { useRoom } from '../../../../../../client/views/room/contexts/RoomContext';
import { useCannedResponseFilterOptions } from '../../../hooks/useCannedResponseFilterOptions';
import { useCannedResponseList } from '../../../hooks/useCannedResponseList';
@ -43,16 +43,14 @@ export const WrapCannedResponseList: FC<{ tabBar: any }> = ({ tabBar }) => {
});
});
const composer = useChat()?.composer;
const onClickUse = (e: MouseEvent<HTMLOrSVGElement>, text: string): void => {
e.preventDefault();
e.stopPropagation();
const input = ChatMessages.get({ rid: room._id })?.input;
if (input) {
input.value = text;
input.focus();
}
composer?.setText(text);
composer?.focus();
};
const onClickCreate = (): void => {

@ -43,7 +43,7 @@ export type SlashCommand<T extends string = string> = {
description: SlashCommandOptions['description'];
permission: SlashCommandOptions['permission'];
clientOnly?: SlashCommandOptions['clientOnly'];
result?: (err: unknown, result: never, data: { cmd: string; params: string; msg: IMessage }) => void;
result?: (err: unknown, result: unknown, data: { cmd: string; params: string; msg: IMessage }) => void;
providesPreview: boolean;
previewer?: SlashCommandPreviewer;
previewCallback?: SlashCommandPreviewCallback;

@ -18,7 +18,7 @@ type Param = {
timestamp?: number;
} & (OTREnded | SlashCommand | SettingsCounter);
export type TelemetryPayload = {
type TelemetryPayload = {
params: Param[];
};

@ -209,6 +209,7 @@ export interface ServerMethods {
'setUsername': (...args: any[]) => any;
'setUserPassword': (...args: any[]) => any;
'setUserStatus': (statusType: IUser['status'], statusText: IUser['statusText']) => void;
'slashCommand': (params: { cmd: string; params: string; msg: IMessage; triggerId: string }) => unknown;
'toggleFavorite': (...args: any[]) => any;
'unblockUser': (...args: any[]) => any;
'unfollowMessage': UnfollowMessageMethod;
@ -216,7 +217,7 @@ export interface ServerMethods {
'unreadMessages': (...args: any[]) => any;
'unsetAsset': (...args: any[]) => any;
'updateIncomingIntegration': (...args: any[]) => any;
'updateMessage': (message: IMessage) => void;
'updateMessage': (message: Pick<IMessage, '_id'> & Partial<Omit<IMessage, '_id'>>) => void;
'updateOAuthApp': (...args: any[]) => any;
'updateOutgoingIntegration': (...args: any[]) => any;
'uploadCustomSound': (...args: any[]) => any;

Loading…
Cancel
Save