snapshot of https://github.com/jitsi/jitsi-meet.git @ fd1749dd28f4138aa80234fc7f311b19aaffa0e7 (2026-09-15)
This commit is contained in:
376
react/features/file-sharing/middleware.web.ts
Normal file
376
react/features/file-sharing/middleware.web.ts
Normal file
@@ -0,0 +1,376 @@
|
||||
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
|
||||
});
|
||||
|
||||
// Notify external API about file deletion (for remote participants)
|
||||
APP.API.notifyFileDeleted(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
|
||||
});
|
||||
|
||||
// Notify external API about existing files (for participants joining late)
|
||||
Object.values(files).forEach((file: IFileMetadata) => {
|
||||
APP.API.notifyFileUploaded(file);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 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
|
||||
}));
|
||||
|
||||
APP.API.notifyFileUploaded(file);
|
||||
}
|
||||
|
||||
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
|
||||
});
|
||||
|
||||
// Notify external API about file deletion (for the local participant who deleted it)
|
||||
APP.API.notifyFileDeleted(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);
|
||||
}
|
||||
Reference in New Issue
Block a user