import { v4 as uuidv4 } from 'uuid'; import { IStore } from '../app/types'; import { getCurrentConference } from '../base/conference/functions'; import { JitsiConferenceEvents } from '../base/lib-jitsi-meet'; import { getLocalParticipant, getParticipantDisplayName } from '../base/participants/functions'; import MiddlewareRegistry from '../base/redux/MiddlewareRegistry'; import StateListenerRegistry from '../base/redux/StateListenerRegistry'; import { addMessage, editMessage } from '../chat/actions.any'; import { ChatTabs, MESSAGE_TYPE_LOCAL, MESSAGE_TYPE_REMOTE } from '../chat/constants'; import { showErrorNotification, showNotification, showSuccessNotification } from '../notifications/actions'; import { NOTIFICATION_TIMEOUT_TYPE, NOTIFICATION_TYPE } from '../notifications/constants'; import { I_AM_VISITOR_MODE } from '../visitors/actionTypes'; import { ADD_FILE, DOWNLOAD_FILE, REMOVE_FILE, UPDATE_FILE_UPLOAD_PROGRESS, UPLOAD_FILES, _FILE_LIST_RECEIVED, _FILE_REMOVED } from './actionTypes'; import { addFile, removeFile, updateFileProgress } from './actions'; import { getFileExtension } from './functions.any'; import logger from './logger'; import { IFileMetadata } from './types'; import { downloadFile } from './utils'; /** * Registers a change handler for state['features/base/conference'].conference to * set the event listeners needed for the file sharing feature to operate. */ StateListenerRegistry.register( state => state['features/base/conference'].conference, (conference, { dispatch, getState }, previousConference) => { if (conference && !previousConference) { conference.on(JitsiConferenceEvents.FILE_SHARING_FILE_ADDED, (file: IFileMetadata) => { const state = getState(); const localParticipant = getLocalParticipant(state); const isRemoteFile = file.authorParticipantId !== localParticipant?.id; const { isOpen, focusedTab } = state['features/chat']; const isFileSharingTabVisible = isOpen && focusedTab === ChatTabs.FILE_SHARING; dispatch(addFile(file, isRemoteFile && !isFileSharingTabVisible)); if (isRemoteFile && !isFileSharingTabVisible) { dispatch(showNotification({ titleKey: 'fileSharing.newFileNotification', titleArguments: { participantName: file.authorParticipantName, fileName: file.fileName } }, NOTIFICATION_TIMEOUT_TYPE.SHORT)); } }); conference.on(JitsiConferenceEvents.FILE_SHARING_FILE_REMOVED, (fileId: string) => { const state = getState(); const localParticipant = getLocalParticipant(state); const { files } = state['features/file-sharing']; const { isOpen, focusedTab } = state['features/chat']; const removedFile = files.get(fileId); const isFileSharingTabVisible = isOpen && focusedTab === ChatTabs.FILE_SHARING; if (removedFile && removedFile.authorParticipantId === localParticipant?.id && !isFileSharingTabVisible) { dispatch(showNotification({ titleKey: 'fileSharing.fileRemovedByOther', titleArguments: { fileName: removedFile.fileName }, appearance: NOTIFICATION_TYPE.WARNING }, NOTIFICATION_TIMEOUT_TYPE.MEDIUM)); } dispatch({ type: _FILE_REMOVED, fileId }); }); conference.on(JitsiConferenceEvents.FILE_SHARING_FILES_RECEIVED, (files: object) => { const state = getState(); const localParticipant = getLocalParticipant(state); dispatch({ type: _FILE_LIST_RECEIVED, files, localParticipantId: localParticipant?.id }); }); } }); /** * Middleware that handles file sharing actions. * * @param {Store} store - The redux store. * @returns {Function} */ MiddlewareRegistry.register(store => next => action => { switch (action.type) { case I_AM_VISITOR_MODE: { if (!action.iAmVisitor) { const state = store.getState(); const conference = getCurrentConference(state); conference?.getFileSharing()?.requestFileList?.(); } return next(action); } case UPLOAD_FILES: { const state = store.getState(); const conference = getCurrentConference(state); conference?.getShortTermCredentials(conference?.getFileSharing()?.getIdentityType()).then((token: string) => { for (const file of action.files) { uploadFile(file, store, token); } }); return next(action); } case UPDATE_FILE_UPLOAD_PROGRESS: case ADD_FILE: { const result = next(action); const state = store.getState(); const { files } = state['features/file-sharing']; const file = action.type === ADD_FILE ? action.file as IFileMetadata : files.get(action.fileId); if (!file) { return result; } const localParticipant = getLocalParticipant(state); const isLocalFile = localParticipant?.id === file.authorParticipantId; // Only dispatch chat message for fully uploaded files (progress === 100). // Files that are still uploading have progress < 100, so we skip creating the message. // Once upload completes, for the local participant the file is broadcast with progress: 100 and the message // is created. Remote participants receive the file metadata only once the file is successfully uploaded and // the progress field will be undefined. if (file.progress === 100 || !isLocalFile) { store.dispatch(addMessage({ displayName: file.authorParticipantName, fileMetadata: file, hasRead: isLocalFile, isReaction: false, lobbyChat: false, message: '', // Empty message as the file metadata contains all info messageId: file.fileId, messageType: isLocalFile ? MESSAGE_TYPE_LOCAL : MESSAGE_TYPE_REMOTE, participantId: file.authorParticipantId, privateMessage: false, timestamp: file.timestamp })); } return result; } case REMOVE_FILE: { const state = store.getState(); const conference = getCurrentConference(state); const { files } = state['features/file-sharing']; const fileId = action.fileId; const existingMetadata = files.get(fileId); // ignore remove a file till the file is actually uploaded if (!conference || (existingMetadata?.progress ?? 100) !== 100) { return next(action); } // First, remove the file metadata so others won't attempt to download it anymore. conference.getFileSharing().removeFile(fileId); // remove it from local state store.dispatch({ type: _FILE_REMOVED, fileId }); const { fileSharing } = state['features/base/config']; const sessionId = conference.getMeetingUniqueId(); // Now delete it from the server. conference.getShortTermCredentials(conference.getFileSharing().getIdentityType()) .then((token: string) => fetch(`${fileSharing!.apiUrl!}/sessions/${sessionId}/files/${fileId}`, { method: 'DELETE', headers: { 'Authorization': `Bearer ${token}` } })) .then((response: { ok: any; statusText: any; }) => { if (!response.ok) { throw new Error(`Failed to delete file: ${response.statusText}`); } store.dispatch(showSuccessNotification({ titleKey: 'fileSharing.removeFileSuccess' }, NOTIFICATION_TIMEOUT_TYPE.SHORT)); }) .catch((error: any) => { logger.warn('Could not delete file:', error); }); return next(action); } case DOWNLOAD_FILE: { const state = store.getState(); const { fileSharing } = state['features/base/config']; const conference = getCurrentConference(state); const sessionId = conference?.getMeetingUniqueId(); conference?.getShortTermCredentials(conference?.getFileSharing()?.getIdentityType()) .then((token: string) => fetch(`${fileSharing!.apiUrl!}/sessions/${sessionId}/files/${action.fileId}`, { method: 'GET', headers: { 'Authorization': `Bearer ${token}` } })) .then((response: any) => response.json()) .then((data: { fileName: string; presignedUrl: string; }) => { const { presignedUrl, fileName } = data; if (!presignedUrl) { throw new Error('No presigned URL found in the response.'); } store.dispatch(showNotification({ titleKey: 'fileSharing.downloadStarted' }, NOTIFICATION_TIMEOUT_TYPE.SHORT)); return downloadFile(presignedUrl, fileName); }) .catch((error: any) => { logger.warn('Could not download file:', error); store.dispatch(showErrorNotification({ titleKey: 'fileSharing.downloadFailedTitle', descriptionKey: 'fileSharing.downloadFailedDescription', appearance: NOTIFICATION_TYPE.ERROR }, NOTIFICATION_TIMEOUT_TYPE.STICKY)); }); return next(action); } case _FILE_REMOVED: { const result = next(action); const state = store.getState(); const { messages } = state['features/chat']; // Find the message corresponding to this file and mark it as deleted. const fileMessage = messages.find(msg => msg.messageId === action.fileId); if (fileMessage?.fileMetadata) { // Replace the file metadata with just the isDeleted flag to avoid keeping unnecessary data. store.dispatch(editMessage({ ...fileMessage, fileMetadata: { isDeleted: true } as any })); } return result; } } return next(action); }); /** * Uploads a file to the server. * * @param {File} file - The file to upload. * @param {IStore} store - The redux store. * @param {string} token - The token to use for requests. * @returns {void} */ function uploadFile(file: File, store: IStore, token: string): void { const state = store.getState(); const conference = getCurrentConference(state); const sessionId = conference?.getMeetingUniqueId(); const localParticipant = getLocalParticipant(state); const { fileSharing } = state['features/base/config']; const { connection } = state['features/base/connection']; const roomJid = conference?.room?.roomjid; const jid = connection!.getJid(); const fileId = uuidv4(); const fileMetadata: IFileMetadata = { authorParticipantId: localParticipant!.id, authorParticipantJid: jid, authorParticipantName: getParticipantDisplayName(state, localParticipant!.id), conferenceFullName: roomJid ?? '', fileId, fileName: file.name, fileSize: file.size, fileType: getFileExtension(file.name), timestamp: Date.now() }; store.dispatch(addFile(fileMetadata)); store.dispatch(updateFileProgress(fileId, 1)); // Upload file. const formData = new FormData(); formData.append('metadata', JSON.stringify(fileMetadata)); // @ts-ignore formData.append('file', file as Blob, file.name); // Use XMLHttpRequest to track upload const xhr = new XMLHttpRequest(); const handleError = () => { logger.warn('Could not upload file:', xhr.statusText); store.dispatch(removeFile(fileId)); store.dispatch(showErrorNotification({ titleKey: 'fileSharing.uploadFailedTitle', descriptionKey: 'fileSharing.uploadFailedDescription', appearance: NOTIFICATION_TYPE.ERROR }, NOTIFICATION_TIMEOUT_TYPE.STICKY)); }; xhr.open('POST', `${fileSharing!.apiUrl!}/sessions/${sessionId}/files`); xhr.responseType = 'json'; if (token) { xhr.setRequestHeader('Authorization', `Bearer ${token}`); } xhr.upload.onprogress = event => { if (event.lengthComputable) { // We use 99% as the max value to avoid showing 100% before the // upload is actually finished, that is, when the request is completed. const percent = Math.min((event.loaded / event.total) * 100, 99); store.dispatch(updateFileProgress(fileId, percent)); } }; xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { store.dispatch(updateFileProgress(fileId, 100)); const fileSharingHandler = conference?.getFileSharing(); fileSharingHandler.addFile(fileMetadata); store.dispatch(showSuccessNotification({ titleKey: 'fileSharing.fileUploadedSuccessfully' }, NOTIFICATION_TIMEOUT_TYPE.SHORT)); } else { handleError(); } }; xhr.onerror = handleError; xhr.send(formData); }