diff --git a/apps/meteor/app/ui-message/client/messageBox/createComposerAPI.ts b/apps/meteor/app/ui-message/client/messageBox/createComposerAPI.ts index 189e5cb8239..876a4bea7f0 100644 --- a/apps/meteor/app/ui-message/client/messageBox/createComposerAPI.ts +++ b/apps/meteor/app/ui-message/client/messageBox/createComposerAPI.ts @@ -8,6 +8,7 @@ import { limitQuoteChain } from './limitQuoteChain'; import type { FormattingButton } from './messageBoxFormatting'; import { formattingButtons } from './messageBoxFormatting'; import type { ComposerAPI } from '../../../../client/lib/chats/ChatAPI'; +import { createUploadsAPI } from '../../../../client/lib/chats/uploads'; import { withDebouncing } from '../../../../lib/utils/highOrderFunctions'; export const createComposerAPI = ( @@ -15,6 +16,7 @@ export const createComposerAPI = ( storageID: string, quoteChainLimit: number, composerRef: RefObject, + { rid, tmid }: { rid: string; tmid?: string }, ): ComposerAPI => { const triggerEvent = (input: HTMLTextAreaElement, evt: string): void => { const event = new Event(evt, { bubbles: true }); @@ -351,5 +353,6 @@ export const createComposerAPI = ( formatters, isMicrophoneDenied, setIsMicrophoneDenied, + uploads: createUploadsAPI({ rid, tmid }), }; }; diff --git a/apps/meteor/app/ui/client/lib/ChatMessages.ts b/apps/meteor/app/ui/client/lib/ChatMessages.ts index 583dfb0ddf9..a6febf3fdfe 100644 --- a/apps/meteor/app/ui/client/lib/ChatMessages.ts +++ b/apps/meteor/app/ui/client/lib/ChatMessages.ts @@ -4,7 +4,7 @@ import type { IActionManager } from '@rocket.chat/ui-contexts'; import { CurrentEditingMessage } from './CurrentEditingMessage'; import { UserAction } from './UserAction'; -import type { ChatAPI, ComposerAPI, DataAPI, UploadsAPI } from '../../../../client/lib/chats/ChatAPI'; +import type { ChatAPI, ComposerAPI, DataAPI } from '../../../../client/lib/chats/ChatAPI'; import { createDataAPI } from '../../../../client/lib/chats/data'; import { processMessageEditing } from '../../../../client/lib/chats/flows/processMessageEditing'; import { processMessageUploads } from '../../../../client/lib/chats/flows/processMessageUploads'; @@ -16,7 +16,6 @@ import { requestMessageDeletion } from '../../../../client/lib/chats/flows/reque import { sendMessage } from '../../../../client/lib/chats/flows/sendMessage'; import { uploadFiles } from '../../../../client/lib/chats/flows/uploadFiles'; import { ReadStateManager } from '../../../../client/lib/chats/readStateManager'; -import { createUploadsAPI } from '../../../../client/lib/chats/uploads'; import { setHighlightMessage } from '../../../../client/views/room/MessageList/providers/messageHighlightSubscription'; type DeepWritable = T extends (...args: any) => any @@ -43,10 +42,6 @@ export class ChatMessages implements ChatAPI { public readStateManager: ReadStateManager; - public uploads: UploadsAPI; - - public threadUploads: UploadsAPI; - public ActionManager: any; public emojiPicker: { @@ -124,7 +119,7 @@ export class ChatMessages implements ChatAPI { await this.currentEditingMessage.stop(); }, editMessage: async (message: IMessage, { cursorAtStart = false }: { cursorAtStart?: boolean } = {}) => { - message.tmid ? this.threadUploads.clear() : this.uploads.clear(); + this.composer?.uploads.clear(); const text = (await this.data.getDraft(message._id)) || message.attachments?.[0]?.description || message.msg; await this.currentEditingMessage.stop(); @@ -151,8 +146,6 @@ export class ChatMessages implements ChatAPI { this.tmid = tmid; this.uid = params.uid; this.data = createDataAPI({ rid, tmid }); - this.uploads = createUploadsAPI({ rid }); - this.threadUploads = createUploadsAPI({ rid }); this.ActionManager = params.actionManager; this.currentEditingMessage = new CurrentEditingMessage(this); diff --git a/apps/meteor/client/lib/chats/ChatAPI.ts b/apps/meteor/client/lib/chats/ChatAPI.ts index 2f0594760c7..b7155a71cd3 100644 --- a/apps/meteor/client/lib/chats/ChatAPI.ts +++ b/apps/meteor/client/lib/chats/ChatAPI.ts @@ -65,6 +65,8 @@ export type ComposerAPI = { readonly formatters: Subscribable; readonly composerRef: RefObject; + + readonly uploads: UploadsAPI; }; export type DataAPI = { @@ -125,8 +127,6 @@ export type ChatAPI = { readonly composer?: ComposerAPI; readonly setComposerAPI: (composer?: ComposerAPI) => void; readonly data: DataAPI; - readonly uploads: UploadsAPI; - readonly threadUploads: UploadsAPI; readonly readStateManager: ReadStateManager; readonly messageEditing: { toPreviousMessage(): Promise; @@ -156,15 +156,7 @@ export type ChatAPI = { ActionManager: IActionManager; readonly flows: { - readonly uploadFiles: ({ - files, - uploadsStore, - resetFileInput, - }: { - files: readonly File[]; - uploadsStore: UploadsAPI; - resetFileInput?: () => void; - }) => Promise; + readonly uploadFiles: ({ files, resetFileInput }: { files: readonly File[]; resetFileInput?: () => void }) => Promise; readonly sendMessage: ({ text, tshow, diff --git a/apps/meteor/client/lib/chats/flows/processMessageUploads.ts b/apps/meteor/client/lib/chats/flows/processMessageUploads.ts index 7ea3b8ffff4..2736bd9fbf8 100644 --- a/apps/meteor/client/lib/chats/flows/processMessageUploads.ts +++ b/apps/meteor/client/lib/chats/flows/processMessageUploads.ts @@ -90,7 +90,7 @@ const getEncryptedContent = async (filesToUpload: readonly EncryptedUpload[], e2 }); }; -async function continueSendingMessage(chat: ChatAPI, store: UploadsAPI, message: IMessage) { +async function continueSendingMessage(store: UploadsAPI, message: IMessage) { const { msg, rid, tmid } = message; const e2eRoom = await e2e.getInstanceByRoomId(rid); const shouldConvertSentMessages = await e2eRoom?.shouldConvertSentMessages({ msg }); @@ -144,22 +144,24 @@ async function continueSendingMessage(chat: ChatAPI, store: UploadsAPI, message: store.setProcessingUploads(true); for (const fileToConfirm of confirmFilesQueue) { await sdk.rest.post(`/v1/rooms.mediaConfirm/${rid}/${fileToConfirm._id}`, fileToConfirm.composedMessage); + store.removeUpload(fileToConfirm._id); } - store.clear(); } catch (error: unknown) { dispatchToastMessage({ type: 'error', message: error }); } finally { store.setProcessingUploads(false); - chat.action.stop('uploading'); } return true; } export const processMessageUploads = async (chat: ChatAPI, message: IMessage): Promise => { - const { tmid } = message; + const store = chat.composer?.uploads; + + if (!store) { + return false; + } - const store = tmid ? chat.threadUploads : chat.uploads; const filesToUpload = store.get(); if (filesToUpload.length === 0) { @@ -169,7 +171,7 @@ export const processMessageUploads = async (chat: ChatAPI, message: IMessage): P const failedUploads = filesToUpload.filter((upload) => upload.error); if (!failedUploads.length) { - return continueSendingMessage(chat, store, message); + return continueSendingMessage(store, message); } const allUploadsFailed = failedUploads.length === filesToUpload.length; @@ -197,7 +199,7 @@ export const processMessageUploads = async (chat: ChatAPI, message: IMessage): P onConfirm: () => { imperativeModal.close(); failedUploads.forEach((upload) => store.removeUpload(upload.id)); - resolve(continueSendingMessage(chat, store, message)); + resolve(continueSendingMessage(store, message)); }, onCancel: () => { imperativeModal.close(); diff --git a/apps/meteor/client/lib/chats/flows/processTooLongMessage.ts b/apps/meteor/client/lib/chats/flows/processTooLongMessage.ts index efce035a4cd..41911f180e1 100644 --- a/apps/meteor/client/lib/chats/flows/processTooLongMessage.ts +++ b/apps/meteor/client/lib/chats/flows/processTooLongMessage.ts @@ -7,7 +7,7 @@ import { dispatchToastMessage } from '../../toast'; import { getUser } from '../../user'; import type { ChatAPI } from '../ChatAPI'; -export const processTooLongMessage = async (chat: ChatAPI, { msg, tmid }: Pick): Promise => { +export const processTooLongMessage = async (chat: ChatAPI, { msg }: Pick): Promise => { const maxAllowedSize = settings.peek('Message_MaxAllowedSize'); if (msg.length <= maxAllowedSize) { @@ -34,7 +34,7 @@ export const processTooLongMessage = async (chat: ChatAPI, { msg, tmid }: Pick => { if (!(await chat.data.isSubscribedToRoom())) { @@ -74,13 +73,13 @@ export const sendMessage = async ( chat.readStateManager.clearUnreadMark(); - const uploadsStore = tmid ? chat.threadUploads : chat.uploads; + const uploadsStore = chat.composer?.uploads; text = text.trim(); text = closeUnclosedCodeBlock(text); const mid = chat.currentEditingMessage.getMID(); - const hasFiles = uploadsStore.get().length > 0; + const hasFiles = uploadsStore && uploadsStore.get().length > 0; if (!text && !mid && !hasFiles) { // Nothing to do return false; diff --git a/apps/meteor/client/lib/chats/flows/uploadFiles.ts b/apps/meteor/client/lib/chats/flows/uploadFiles.ts index 7cf7c919ff9..5986282f17f 100644 --- a/apps/meteor/client/lib/chats/flows/uploadFiles.ts +++ b/apps/meteor/client/lib/chats/flows/uploadFiles.ts @@ -3,12 +3,17 @@ import { MAX_MULTIPLE_UPLOADED_FILES } from '../../../../lib/constants'; import { e2e } from '../../e2ee'; import { settings } from '../../settings'; import { dispatchToastMessage } from '../../toast'; -import type { ChatAPI, UploadsAPI } from '../ChatAPI'; +import type { ChatAPI } from '../ChatAPI'; export const uploadFiles = async ( chat: ChatAPI, - { files, uploadsStore, resetFileInput }: { files: readonly File[]; uploadsStore: UploadsAPI; resetFileInput?: () => void }, + { files, resetFileInput }: { files: readonly File[]; resetFileInput?: () => void }, ): Promise => { + const uploadsStore = chat.composer?.uploads; + if (!uploadsStore) { + throw new Error('No uploads store found in composer'); + } + const mergedFilesLength = files.length + uploadsStore.get().length; if (mergedFilesLength > MAX_MULTIPLE_UPLOADED_FILES) { return dispatchToastMessage({ diff --git a/apps/meteor/client/lib/chats/uploads.ts b/apps/meteor/client/lib/chats/uploads.ts index f79f8be5d91..f8eda3154f5 100644 --- a/apps/meteor/client/lib/chats/uploads.ts +++ b/apps/meteor/client/lib/chats/uploads.ts @@ -1,4 +1,4 @@ -import type { IRoom } from '@rocket.chat/core-typings'; +import type { IMessage, IRoom } from '@rocket.chat/core-typings'; import { Emitter } from '@rocket.chat/emitter'; import { Random } from '@rocket.chat/random'; import fileSize from 'filesize'; @@ -6,6 +6,7 @@ import fileSize from 'filesize'; import { getErrorMessage } from '../errorHandling'; import type { UploadsAPI, EncryptedFileUploadContent } from './ChatAPI'; import { isEncryptedUpload, type Upload } from './Upload'; +import { USER_ACTIVITIES, UserAction } from '../../../app/ui/client/lib/UserAction'; import { fileUploadIsValidContentType } from '../../../app/utils/client'; import { sdk } from '../../../app/utils/client/lib/SDKClient'; import { i18n } from '../../../app/utils/lib/i18n'; @@ -14,10 +15,12 @@ import { settings } from '../settings'; class UploadsStore extends Emitter<{ update: void; [x: `cancelling-${Upload['id']}`]: void }> implements UploadsAPI { private rid: string; - constructor({ rid }: { rid: string }) { - super(); + private tmid?: string; + constructor({ rid, tmid }: { rid: IRoom['_id']; tmid?: IMessage['_id'] }) { + super(); this.rid = rid; + this.tmid = tmid; } private uploads: readonly Upload[] = []; @@ -54,6 +57,10 @@ class UploadsStore extends Emitter<{ update: void; [x: `cancelling-${Upload['id' removeUpload = (id: Upload['id']): void => { this.set(this.uploads.filter((upload) => upload.id !== id)); + + if (this.uploads.length === 0) { + UserAction.stop(this.rid, USER_ACTIVITIES.USER_UPLOADING, { tmid: this.tmid }); + } }; editUploadFileName = (uploadId: Upload['id'], fileName: Upload['file']['name']) => { @@ -90,7 +97,10 @@ class UploadsStore extends Emitter<{ update: void; [x: `cancelling-${Upload['id' } }; - clear = () => this.set([]); + clear = () => { + this.set([]); + UserAction.stop(this.rid, USER_ACTIVITIES.USER_UPLOADING, { tmid: this.tmid }); + }; async send(file: File, encrypted?: EncryptedFileUploadContent): Promise { const maxFileSize = settings.peek('FileUpload_MaxFileSize'); @@ -177,7 +187,7 @@ class UploadsStore extends Emitter<{ update: void; [x: `cancelling-${Upload['id' this.once(`cancelling-${id}`, () => { xhr.abort(); - this.set(this.uploads.filter((upload) => upload.id !== id)); + this.removeUpload(id); reject(new Error(i18n.t('FileUpload_Canceled'))); }); }); @@ -187,4 +197,5 @@ class UploadsStore extends Emitter<{ update: void; [x: `cancelling-${Upload['id' } } -export const createUploadsAPI = ({ rid }: { rid: IRoom['_id'] }): UploadsAPI => new UploadsStore({ rid }); +export const createUploadsAPI = ({ rid, tmid }: { rid: IRoom['_id']; tmid?: IMessage['_id'] }): UploadsAPI => + new UploadsStore({ rid, tmid }); diff --git a/apps/meteor/client/views/composer/AudioMessageRecorder/AudioMessageRecorder.tsx b/apps/meteor/client/views/composer/AudioMessageRecorder/AudioMessageRecorder.tsx index 62a2b2ec562..543c975eda0 100644 --- a/apps/meteor/client/views/composer/AudioMessageRecorder/AudioMessageRecorder.tsx +++ b/apps/meteor/client/views/composer/AudioMessageRecorder/AudioMessageRecorder.tsx @@ -7,18 +7,16 @@ import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { AudioRecorder } from '../../../../app/ui/client/lib/recorderjs/AudioRecorder'; -import type { UploadsAPI } from '../../../lib/chats/ChatAPI'; import { useChat } from '../../room/contexts/ChatContext'; const audioRecorder = new AudioRecorder(); type AudioMessageRecorderProps = { rid: IRoom['_id']; - uploadsStore: UploadsAPI; isMicrophoneDenied?: boolean; }; -const AudioMessageRecorder = ({ rid, uploadsStore, isMicrophoneDenied }: AudioMessageRecorderProps): ReactElement | null => { +const AudioMessageRecorder = ({ rid, isMicrophoneDenied }: AudioMessageRecorderProps): ReactElement | null => { const { t } = useTranslation(); const [state, setState] = useState<'loading' | 'recording'>('recording'); @@ -91,7 +89,7 @@ const AudioMessageRecorder = ({ rid, uploadsStore, isMicrophoneDenied }: AudioMe const fileName = `${t('Audio_record')}.mp3`; const file = new File([blob], fileName, { type: 'audio/mpeg' }); - await chat?.flows.uploadFiles({ files: [file], uploadsStore }); + await chat?.flows.uploadFiles({ files: [file] }); }); useEffect(() => { diff --git a/apps/meteor/client/views/composer/VideoMessageRecorder/VideoMessageRecorder.tsx b/apps/meteor/client/views/composer/VideoMessageRecorder/VideoMessageRecorder.tsx index 685fe624703..23e20f8f969 100644 --- a/apps/meteor/client/views/composer/VideoMessageRecorder/VideoMessageRecorder.tsx +++ b/apps/meteor/client/views/composer/VideoMessageRecorder/VideoMessageRecorder.tsx @@ -8,13 +8,11 @@ import { useRef, useEffect, useState } from 'react'; import { UserAction, USER_ACTIVITIES } from '../../../../app/ui/client/lib/UserAction'; import { VideoRecorder } from '../../../../app/ui/client/lib/recorderjs/videoRecorder'; -import type { UploadsAPI } from '../../../lib/chats/ChatAPI'; import { useChat } from '../../room/contexts/ChatContext'; type VideoMessageRecorderProps = { rid: IRoom['_id']; tmid?: IMessage['_id']; - uploadsStore: UploadsAPI; reference: RefObject; } & Omit, 'is'>; @@ -38,7 +36,7 @@ const getVideoRecordingExtension = () => { return 'mp4'; }; -const VideoMessageRecorder = ({ rid, tmid, uploadsStore, reference }: VideoMessageRecorderProps) => { +const VideoMessageRecorder = ({ rid, tmid, reference }: VideoMessageRecorderProps) => { const t = useTranslation(); const videoRef = useRef(null); const dispatchToastMessage = useToastMessageDispatch(); @@ -86,7 +84,7 @@ const VideoMessageRecorder = ({ rid, tmid, uploadsStore, reference }: VideoMessa const cb = async (blob: Blob) => { const fileName = `${t('Video_record')}.${getVideoRecordingExtension()}`; const file = new File([blob], fileName, { type: VideoRecorder.getSupportedMimeTypes().split(';')[0] }); - await chat?.flows.uploadFiles({ files: [file], uploadsStore }); + await chat?.flows.uploadFiles({ files: [file] }); chat?.composer?.setRecordingVideo(false); }; diff --git a/apps/meteor/client/views/room/body/RoomBody.tsx b/apps/meteor/client/views/room/body/RoomBody.tsx index 22b4e8fc8cf..cf23ddd971c 100644 --- a/apps/meteor/client/views/room/body/RoomBody.tsx +++ b/apps/meteor/client/views/room/body/RoomBody.tsx @@ -117,8 +117,8 @@ const RoomBody = (): ReactElement => { surroundingMessagesJumpTpRef, ); - const [fileUploadTriggerProps, fileUploadOverlayProps] = useFileUploadDropTarget(chat.uploads); - const { uploads, isUploading } = useFileUpload(chat.uploads); + const [fileUploadTriggerProps, fileUploadOverlayProps] = useFileUploadDropTarget(); + const { uploads, isUploading } = useFileUpload(); const { messageListRef } = useMessageListNavigation(); const { innerRef: selectAndScrollRef, selectAllAndScrollToTop } = useSelectAllAndScrollToTop(); diff --git a/apps/meteor/client/views/room/body/hooks/useFileUpload.ts b/apps/meteor/client/views/room/body/hooks/useFileUpload.ts index 819aafa3eee..6d31166bb15 100644 --- a/apps/meteor/client/views/room/body/hooks/useFileUpload.ts +++ b/apps/meteor/client/views/room/body/hooks/useFileUpload.ts @@ -1,67 +1,49 @@ import { useCallback, useEffect, useMemo, useSyncExternalStore } from 'react'; -import type { UploadsAPI } from '../../../../lib/chats/ChatAPI'; import type { Upload } from '../../../../lib/chats/Upload'; import { useChat } from '../../contexts/ChatContext'; -export const useFileUpload = (store: UploadsAPI) => { +const emptySubscribe = () => () => undefined; +const emptyUploads: readonly Upload[] = []; +const getEmptyUploads = () => emptyUploads; +const getEmptyBool = () => false; + +export const useFileUpload = () => { const chat = useChat(); - if (!chat || !store) { + if (!chat) { throw new Error('No ChatContext provided'); } - useEffect(() => { - store.wipeFailedOnes(); - }, [store]); - - const uploads = useSyncExternalStore(store.subscribe, store.get); - const isProcessingUploads = useSyncExternalStore(store.subscribe, store.getProcessingUploads); + const store = chat.composer?.uploads; - const stopUploadingAction = useCallback(() => { - if (uploads.length === 1) { - chat.action.stop('uploading'); - } - }, [chat.action, uploads.length]); + const uploads = useSyncExternalStore(store?.subscribe ?? emptySubscribe, store?.get ?? getEmptyUploads); + const isProcessingUploads = useSyncExternalStore(store?.subscribe ?? emptySubscribe, store?.getProcessingUploads ?? getEmptyBool); - const handleRemoveUpload = useCallback( - (id: Upload['id']) => { - store.removeUpload(id); - stopUploadingAction(); - }, - [stopUploadingAction, store], - ); - - const handleCancelUpload = useCallback( - (id: Upload['id']) => { - store.cancel(id); - stopUploadingAction(); - }, - [stopUploadingAction, store], - ); + useEffect(() => { + store?.wipeFailedOnes(); - const handleEditUpload = useCallback((id: Upload['id'], fileName: string) => store.editUploadFileName(id, fileName), [store]); + return () => store?.clear(); + }, [chat.action, store]); const handleUploadFiles = useCallback( (files: readonly File[]): void => { - chat?.flows.uploadFiles({ files, uploadsStore: store }); + chat.flows.uploadFiles({ files }); }, - [chat, store], + [chat], ); const isUploading = uploads.length > 0 && uploads.some((upload) => upload.percentage < 100 && !upload.error); return useMemo( () => ({ + uploadsStore: store, uploads, hasUploads: uploads.length > 0, isUploading, isProcessingUploads, - handleRemoveUpload, - handleEditUpload, - handleCancelUpload, handleUploadFiles, }), - [uploads, isUploading, isProcessingUploads, handleRemoveUpload, handleEditUpload, handleCancelUpload, handleUploadFiles], + [store, uploads, isUploading, isProcessingUploads, handleUploadFiles], ); }; diff --git a/apps/meteor/client/views/room/body/hooks/useFileUploadDropTarget.ts b/apps/meteor/client/views/room/body/hooks/useFileUploadDropTarget.ts index 8c987f3e237..0a5ab4c11ab 100644 --- a/apps/meteor/client/views/room/body/hooks/useFileUploadDropTarget.ts +++ b/apps/meteor/client/views/room/body/hooks/useFileUploadDropTarget.ts @@ -5,15 +5,12 @@ import { useCallback, useMemo, useSyncExternalStore } from 'react'; import { useDropTarget } from './useDropTarget'; import { useReactiveValue } from '../../../../hooks/useReactiveValue'; -import type { UploadsAPI } from '../../../../lib/chats/ChatAPI'; import { roomCoordinator } from '../../../../lib/rooms/roomCoordinator'; import { useIsRoomOverMacLimit } from '../../../omnichannel/hooks/useIsRoomOverMacLimit'; import { useChat } from '../../contexts/ChatContext'; import { useRoom, useRoomSubscription } from '../../contexts/RoomContext'; -export const useFileUploadDropTarget = ( - uploadsStore: UploadsAPI, -): readonly [ +export const useFileUploadDropTarget = (): readonly [ fileUploadTriggerProps: { onDragEnter: (event: DragEvent) => void; }, @@ -66,7 +63,7 @@ export const useFileUploadDropTarget = ( return file; }); - chat?.flows.uploadFiles({ files: uploads, uploadsStore }); + chat?.flows.uploadFiles({ files: uploads }); }); const allOverlayProps = useMemo(() => { diff --git a/apps/meteor/client/views/room/composer/ComposerUserActionIndicator/ComposerUserActionIndicator.tsx b/apps/meteor/client/views/room/composer/ComposerUserActionIndicator/ComposerUserActionIndicator.tsx index 18c6610bf6d..a8e9a1f4a02 100644 --- a/apps/meteor/client/views/room/composer/ComposerUserActionIndicator/ComposerUserActionIndicator.tsx +++ b/apps/meteor/client/views/room/composer/ComposerUserActionIndicator/ComposerUserActionIndicator.tsx @@ -3,38 +3,55 @@ import type { ReactElement } from 'react'; import { useCallback, Fragment, useSyncExternalStore, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { UserAction } from '../../../../../app/ui/client/lib/UserAction'; +import { UserAction, USER_ACTIVITIES } from '../../../../../app/ui/client/lib/UserAction'; const maxUsernames = 5; +const ACTION_PRIORITY: Record = { + [USER_ACTIVITIES.USER_RECORDING]: 0, + [USER_ACTIVITIES.USER_UPLOADING]: 1, + [USER_ACTIVITIES.USER_TYPING]: 2, + [USER_ACTIVITIES.USER_PLAYING]: 3, +}; + const ComposerUserActionIndicator = ({ rid, tmid }: { rid: string; tmid?: string }): ReactElement => { const { t } = useTranslation(); const roomAction = useSyncExternalStore( UserAction.subscribe, useCallback(() => UserAction.get(tmid || rid), [rid, tmid]), ); - const actions = useMemo( - () => - Object.entries(roomAction ?? {}) - .map(([key, _users]) => { - const action = key.split('-')[1]; - - const users = Object.keys(_users); - if (users.length === 0) { - return; - } - - return { - action, - users, - }; - }) - .filter(Boolean) as { - action: 'typing' | 'recording' | 'uploading' | 'playing'; - users: string[]; - }[], - [roomAction], - ); + const actions = useMemo(() => { + const usersRendered = new Set(); + return Object.entries(roomAction ?? {}) + .sort(([a], [b]) => ACTION_PRIORITY[a] - ACTION_PRIORITY[b]) + .map(([key, _users]) => { + const action = key.split('-')[1]; + + const users = Object.keys(_users); + if (users.length === 0) { + return; + } + + const filteredUsers = users.filter((user) => !usersRendered.has(user)); + + if (filteredUsers.length === 0) { + return; + } + + for (const user of filteredUsers) { + usersRendered.add(user); + } + + return { + action, + users: filteredUsers, + }; + }) + .filter(Boolean) as { + action: 'typing' | 'recording' | 'uploading' | 'playing'; + users: string[]; + }[]; + }, [roomAction]); return ( chat.composer?.insertText(` :${emoji}: `)); }); - const uploadsStore = tmid ? chat.threadUploads : chat.uploads; - const { - uploads, - hasUploads, - handleUploadFiles, - handleEditUpload, - handleRemoveUpload, - handleCancelUpload, - isUploading, - isProcessingUploads, - } = useFileUpload(uploadsStore); + const { hasUploads, handleUploadFiles, isUploading, isProcessingUploads } = useFileUpload(); const handleSendMessage = useEffectEvent(() => { if (isUploading || isProcessingUploads) { @@ -436,9 +426,9 @@ const MessageBox = ({ unencryptedMessagesAllowed={unencryptedMessagesAllowed} isMobile={isMobile} /> - {isRecordingVideo && } + {isRecordingVideo && } - {isRecordingAudio && } + {isRecordingAudio && } - {hasUploads && ( - - )} + { +export const useFileUploadAction = (disabled: boolean): GenericMenuItemProps => { const { t } = useTranslation(); const fileUploadEnabled = useSetting('FileUpload_Enabled', true); const fileInputRef = useFileInput(fileInputProps); @@ -32,12 +31,12 @@ export const useFileUploadAction = (disabled: boolean, uploadsStore: UploadsAPI) }); return file; }); - chat?.flows.uploadFiles({ files: filesToUpload, uploadsStore, resetFileInput }); + chat?.flows.uploadFiles({ files: filesToUpload, resetFileInput }); }; fileInputRef.current?.addEventListener('change', handleUploadChange); return () => fileInputRef?.current?.removeEventListener('change', handleUploadChange); - }, [chat, fileInputRef, uploadsStore]); + }, [chat, fileInputRef]); const handleUpload = () => { fileInputRef?.current?.click(); diff --git a/apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/hooks/useWebdavActions.tsx b/apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/hooks/useWebdavActions.tsx index d6fa7c90e6c..7d85c2c2c8a 100644 --- a/apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/hooks/useWebdavActions.tsx +++ b/apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/hooks/useWebdavActions.tsx @@ -4,12 +4,11 @@ import { useSetModal, useSetting } from '@rocket.chat/ui-contexts'; import { useTranslation } from 'react-i18next'; import { useWebDAVAccountIntegrationsQuery } from '../../../../../../hooks/webdav/useWebDAVAccountIntegrationsQuery'; -import type { UploadsAPI } from '../../../../../../lib/chats/ChatAPI'; import { useChat } from '../../../../contexts/ChatContext'; import AddWebdavAccountModal from '../../../../webdav/AddWebdavAccountModal'; import WebdavFilePickerModal from '../../../../webdav/WebdavFilePickerModal'; -export const useWebdavActions = (disabled: boolean, uploadsStore: UploadsAPI): GenericMenuItemProps[] => { +export const useWebdavActions = (disabled: boolean): GenericMenuItemProps[] => { const enabled = useSetting('Webdav_Integration_Enabled', false); const { isSuccess, data } = useWebDAVAccountIntegrationsQuery({ enabled }); @@ -20,7 +19,7 @@ export const useWebdavActions = (disabled: boolean, uploadsStore: UploadsAPI): G const setModal = useSetModal(); const handleAddWebDav = () => setModal( setModal(null)} onConfirm={() => setModal(null)} />); - const handleUpload = async (file: File) => chat?.flows.uploadFiles({ files: [file], uploadsStore }); + const handleUpload = async (file: File) => chat?.flows.uploadFiles({ files: [file] }); const handleOpenWebdav = (account: IWebdavAccountIntegration) => setModal( setModal(null)} />); diff --git a/apps/meteor/client/views/room/composer/messageBox/MessageComposerFiles.tsx b/apps/meteor/client/views/room/composer/messageBox/MessageComposerFiles.tsx index e48055fd078..c197b935f3a 100644 --- a/apps/meteor/client/views/room/composer/messageBox/MessageComposerFiles.tsx +++ b/apps/meteor/client/views/room/composer/messageBox/MessageComposerFiles.tsx @@ -2,28 +2,26 @@ import { MessageComposerFileGroup } from '@rocket.chat/ui-composer'; import { useTranslation } from 'react-i18next'; import MessageComposerFileItem from './MessageComposerFileItem'; -import type { Upload } from '../../../../lib/chats/Upload'; +import { useFileUpload } from '../../body/hooks/useFileUpload'; -type MessageComposerFileGroupProps = { - uploads?: readonly Upload[]; - onRemove: (id: Upload['id']) => void; - onEdit: (id: Upload['id'], fileName: string) => void; - onCancel: (id: Upload['id']) => void; - disabled: boolean; -}; - -const MessageComposerFiles = ({ uploads, onRemove, onEdit, onCancel, disabled }: MessageComposerFileGroupProps) => { +const MessageComposerFiles = () => { const { t } = useTranslation(); + const { uploads, uploadsStore, isProcessingUploads, hasUploads } = useFileUpload(); + + if (!uploadsStore || !hasUploads) { + return null; + } + return ( - {uploads?.map((upload) => ( + {uploads.map((upload) => ( ))} diff --git a/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadChat.tsx b/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadChat.tsx index 003d88a50f6..1db591bcc70 100644 --- a/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadChat.tsx +++ b/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadChat.tsx @@ -51,7 +51,7 @@ const ThreadChat = ({ mainMessage }: ThreadChatProps) => { closeTab(); }, [closeTab]); - const [fileUploadTriggerProps, fileUploadOverlayProps] = useFileUploadDropTarget(chat.threadUploads); + const [fileUploadTriggerProps, fileUploadOverlayProps] = useFileUploadDropTarget(); const handleNavigateToPreviousMessage = useCallback((): void => { chat?.messageEditing.toPreviousMessage();