This commit is contained in:
70
react/features/file-sharing/actionTypes.ts
Normal file
70
react/features/file-sharing/actionTypes.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* The type of redux action to update file progress.
|
||||
*
|
||||
* {
|
||||
* type: UPDATE_FILE_UPLOAD_PROGRESS,
|
||||
* fileId: string,
|
||||
* progress: number
|
||||
* }
|
||||
*/
|
||||
export const UPDATE_FILE_UPLOAD_PROGRESS = 'UPDATE_FILE_UPLOAD_PROGRESS';
|
||||
|
||||
/**
|
||||
* The type of redux action to upload files.
|
||||
*
|
||||
* {
|
||||
* type: UPLOAD_FILES,
|
||||
* files: Array<File>
|
||||
* }
|
||||
*/
|
||||
export const UPLOAD_FILES = 'UPLOAD_FILES';
|
||||
|
||||
/**
|
||||
* The type of redux action to add file data to the state.
|
||||
*
|
||||
* {
|
||||
* type: ADD_FILE,
|
||||
* file: IFileMetadata
|
||||
* }
|
||||
*/
|
||||
export const ADD_FILE = 'ADD_FILE';
|
||||
|
||||
/**
|
||||
* The type of redux action to add files to the state.
|
||||
*
|
||||
* {
|
||||
* type: _FILE_LIST_RECEIVED,
|
||||
* files: Array<IFileMetadata>
|
||||
* }
|
||||
*/
|
||||
export const _FILE_LIST_RECEIVED = '_FILE_LIST_RECEIVED';
|
||||
|
||||
/**
|
||||
* The type of redux action to remove a file from the state as it was removed from the backend.
|
||||
*
|
||||
* {
|
||||
* type: _FILE_REMOVED,
|
||||
* fileId: string
|
||||
* }
|
||||
*/
|
||||
export const _FILE_REMOVED = '_FILE_REMOVED';
|
||||
|
||||
/**
|
||||
* The type of redux action to remove a file from the backend.
|
||||
*
|
||||
* {
|
||||
* type: REMOVE_FILE,
|
||||
* fileId: string
|
||||
* }
|
||||
*/
|
||||
export const REMOVE_FILE = 'REMOVE_FILE';
|
||||
|
||||
/**
|
||||
* The type of redux action to download a file.
|
||||
*
|
||||
* {
|
||||
* type: DOWNLOAD_FILE,
|
||||
* fileId: string
|
||||
* }
|
||||
*/
|
||||
export const DOWNLOAD_FILE = 'DOWNLOAD_FILE';
|
||||
75
react/features/file-sharing/actions.ts
Normal file
75
react/features/file-sharing/actions.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import {
|
||||
ADD_FILE,
|
||||
DOWNLOAD_FILE,
|
||||
REMOVE_FILE,
|
||||
UPDATE_FILE_UPLOAD_PROGRESS,
|
||||
UPLOAD_FILES
|
||||
} from './actionTypes';
|
||||
import { IFileMetadata } from './types';
|
||||
|
||||
/**
|
||||
* Upload files.
|
||||
*
|
||||
* @param {File[]} files - The files to upload.
|
||||
* @returns {Object}
|
||||
*/
|
||||
export function uploadFiles(files: File[]) {
|
||||
return {
|
||||
type: UPLOAD_FILES,
|
||||
files
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a file's upload progress.
|
||||
*
|
||||
* @param {string} fileId - The ID of the file to update.
|
||||
* @param {number} progress - The new progress value.
|
||||
* @returns {Object}
|
||||
*/
|
||||
export function updateFileProgress(fileId: string, progress: number) {
|
||||
return {
|
||||
type: UPDATE_FILE_UPLOAD_PROGRESS,
|
||||
fileId,
|
||||
progress
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a file.
|
||||
*
|
||||
* @param {IFileMetadata} file - The file to add to the state.
|
||||
* @returns {Object}
|
||||
*/
|
||||
export function addFile(file: IFileMetadata) {
|
||||
return {
|
||||
type: ADD_FILE,
|
||||
file
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a file from the backend.
|
||||
*
|
||||
* @param {string} fileId - The ID of the file to remove.
|
||||
* @returns {Object}
|
||||
*/
|
||||
export function removeFile(fileId: string) {
|
||||
return {
|
||||
type: REMOVE_FILE,
|
||||
fileId
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a file.
|
||||
*
|
||||
* @param {string} fileId - The ID of the file to download.
|
||||
* @returns {Object}
|
||||
*/
|
||||
export function downloadFile(fileId: string) {
|
||||
return {
|
||||
type: DOWNLOAD_FILE,
|
||||
fileId
|
||||
};
|
||||
}
|
||||
460
react/features/file-sharing/components/web/FileSharing.tsx
Normal file
460
react/features/file-sharing/components/web/FileSharing.tsx
Normal file
@@ -0,0 +1,460 @@
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch, useSelector, useStore } from 'react-redux';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import { IReduxState } from '../../../app/types';
|
||||
import Avatar from '../../../base/avatar/components/Avatar';
|
||||
import Icon from '../../../base/icons/components/Icon';
|
||||
import { IconCloudUpload, IconDownload, IconTrash } from '../../../base/icons/svg';
|
||||
import BaseTheme from '../../../base/ui/components/BaseTheme.web';
|
||||
import Button from '../../../base/ui/components/web/Button';
|
||||
import { BUTTON_TYPES } from '../../../base/ui/constants.web';
|
||||
import { downloadFile, removeFile } from '../../actions';
|
||||
import {
|
||||
formatFileSize,
|
||||
formatTimestamp,
|
||||
getFileIcon,
|
||||
isFileUploadingEnabled,
|
||||
processFiles
|
||||
} from '../../functions.any';
|
||||
|
||||
const useStyles = makeStyles()(theme => {
|
||||
return {
|
||||
buttonContainer: {
|
||||
alignItems: 'center',
|
||||
bottom: 0,
|
||||
display: 'flex',
|
||||
justifyContent: 'end',
|
||||
gap: theme.spacing(2),
|
||||
position: 'absolute',
|
||||
right: theme.spacing(3),
|
||||
top: 0
|
||||
},
|
||||
|
||||
container: {
|
||||
boxSizing: 'border-box',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
margin: '0 auto',
|
||||
maxWidth: '600px',
|
||||
padding: theme.spacing(3),
|
||||
position: 'relative',
|
||||
width: '100%'
|
||||
},
|
||||
|
||||
dropZone: {
|
||||
backgroundColor: theme.palette.ui02,
|
||||
border: `2px dashed ${theme.palette.ui03}`,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
opacity: 0,
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
zIndex: 0,
|
||||
|
||||
'&.dragging': {
|
||||
backgroundColor: theme.palette.ui03,
|
||||
borderColor: theme.palette.action01,
|
||||
opacity: 0.8,
|
||||
zIndex: 2
|
||||
}
|
||||
},
|
||||
|
||||
fileIconContainer: {
|
||||
display: 'flex',
|
||||
margin: 'auto'
|
||||
},
|
||||
|
||||
fileItem: {
|
||||
backgroundColor: theme.palette.ui02,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
gap: theme.spacing(3),
|
||||
justifyContent: 'space-between',
|
||||
padding: theme.spacing(3),
|
||||
position: 'relative',
|
||||
|
||||
'& .actionIconVisibility': {
|
||||
opacity: 0,
|
||||
transition: 'opacity 0.2s'
|
||||
},
|
||||
|
||||
'& .timestampVisibility': {
|
||||
opacity: 1
|
||||
},
|
||||
|
||||
'&:hover': {
|
||||
backgroundColor: theme.palette.ui03,
|
||||
|
||||
'& .actionIconVisibility': {
|
||||
opacity: 1
|
||||
},
|
||||
|
||||
'& .timestampVisibility': {
|
||||
opacity: 0
|
||||
}
|
||||
},
|
||||
|
||||
'&.focused .actionIconVisibility': {
|
||||
opacity: 1
|
||||
},
|
||||
|
||||
'&.focused .timestampVisibility': {
|
||||
opacity: 0
|
||||
}
|
||||
},
|
||||
|
||||
fileItemDetails: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flexGrow: 2,
|
||||
gap: theme.spacing(1),
|
||||
justifyContent: 'center',
|
||||
minWidth: 0
|
||||
},
|
||||
|
||||
fileList: {
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
flexDirection: 'column',
|
||||
gap: theme.spacing(2),
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
|
||||
listStyleType: 'none',
|
||||
marginBottom: theme.spacing(8),
|
||||
marginTop: 0,
|
||||
overflowY: 'auto',
|
||||
padding: 0,
|
||||
zIndex: 1
|
||||
},
|
||||
|
||||
fileName: {
|
||||
...theme.typography.labelBold,
|
||||
gap: theme.spacing(1),
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
},
|
||||
|
||||
fileAuthorParticipant: {
|
||||
alignItems: 'center',
|
||||
display: 'inline-flex',
|
||||
gap: theme.spacing(1)
|
||||
},
|
||||
|
||||
fileAuthorParticipantName: {
|
||||
...theme.typography.labelBold,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
},
|
||||
|
||||
fileSize: {
|
||||
...theme.typography.labelRegular
|
||||
},
|
||||
|
||||
fileTimestamp: {
|
||||
...theme.typography.labelRegular,
|
||||
display: 'flex',
|
||||
lineHeight: '1.2rem',
|
||||
marginTop: theme.spacing(1),
|
||||
textAlign: 'center',
|
||||
},
|
||||
|
||||
hiddenInput: {
|
||||
visibility: 'hidden'
|
||||
},
|
||||
|
||||
noFilesContainer: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '88%',
|
||||
justifyContent: 'center',
|
||||
textAlign: 'center'
|
||||
},
|
||||
|
||||
noFilesText: {
|
||||
...theme.typography.bodyLongBold,
|
||||
color: theme.palette.text02,
|
||||
padding: '0 24px',
|
||||
textAlign: 'center'
|
||||
},
|
||||
|
||||
progressBar: {
|
||||
backgroundColor: theme.palette.ui03,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
height: 4,
|
||||
overflow: 'hidden',
|
||||
width: '100%'
|
||||
},
|
||||
|
||||
progressFill: {
|
||||
backgroundColor: theme.palette.action01,
|
||||
height: '100%',
|
||||
transition: 'width 0.3s ease'
|
||||
},
|
||||
|
||||
uploadButton: {
|
||||
bottom: theme.spacing(4),
|
||||
cursor: 'pointer',
|
||||
left: '50%',
|
||||
position: 'absolute',
|
||||
transform: 'translateX(-50%)',
|
||||
width: '85%',
|
||||
zIndex: 1
|
||||
},
|
||||
|
||||
uploadIcon: {
|
||||
margin: '0 auto'
|
||||
},
|
||||
|
||||
actionIcon: {
|
||||
background: 'transparent',
|
||||
border: 0,
|
||||
cursor: 'pointer',
|
||||
padding: theme.spacing(1),
|
||||
visibility: 'hidden',
|
||||
'&:focus': {
|
||||
outline: `2px solid ${theme.palette.action01}`
|
||||
}
|
||||
},
|
||||
|
||||
iconButton: {
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
marginLeft: '8px',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
|
||||
'&:focus-visible': {
|
||||
outline: `2px solid ${theme.palette.action01}`,
|
||||
borderRadius: '4px'
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const FileSharing = () => {
|
||||
const { classes } = useStyles();
|
||||
const [ isDragging, setIsDragging ] = useState(false);
|
||||
const [ isFocused, setIsFocused ] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const uploadButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useDispatch();
|
||||
const store = useStore();
|
||||
const { files } = useSelector((state: IReduxState) => state['features/file-sharing']);
|
||||
const sortedFiles = Array.from(files.values()).sort((a, b) => a.fileName.localeCompare(b.fileName));
|
||||
const isUploadEnabled = useSelector(isFileUploadingEnabled);
|
||||
|
||||
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(true);
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files) {
|
||||
processFiles(e.target.files as FileList, store);
|
||||
e.target.value = ''; // Reset the input value to allow re-uploading the same file
|
||||
uploadButtonRef.current?.focus();
|
||||
}
|
||||
}, [ processFiles ]);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
|
||||
if (e.dataTransfer.files?.length > 0) {
|
||||
processFiles(e.dataTransfer.files as FileList, store);
|
||||
}
|
||||
}, [ processFiles ]);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
fileInputRef.current?.click();
|
||||
}, []);
|
||||
|
||||
const handleKeyPress = useCallback((e: React.KeyboardEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
fileInputRef.current?.click();
|
||||
}
|
||||
}, []);
|
||||
|
||||
/* eslint-disable react/jsx-no-bind */
|
||||
return (
|
||||
<div className = { classes.container }>
|
||||
{
|
||||
isUploadEnabled && (
|
||||
<>
|
||||
<div
|
||||
className = { `${classes.dropZone} ${
|
||||
isDragging ? 'dragging' : ''
|
||||
}` }
|
||||
onDragEnter = { handleDragEnter }
|
||||
onDragLeave = { handleDragLeave }
|
||||
onDragOver = { handleDragOver }
|
||||
onDrop = { handleDrop } />
|
||||
{
|
||||
sortedFiles.length === 0 && (
|
||||
<div
|
||||
className = { classes.noFilesContainer }
|
||||
onClick = { handleClick }
|
||||
onKeyUp = { handleKeyPress }
|
||||
role = 'button'
|
||||
tabIndex = { 0 }>
|
||||
<Icon
|
||||
className = { classes.uploadIcon }
|
||||
color = { BaseTheme.palette.icon03 }
|
||||
size = { 160 }
|
||||
src = { IconCloudUpload } />
|
||||
<span className = { classes.noFilesText }>
|
||||
{ t('fileSharing.dragAndDrop') }
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<input
|
||||
className = { classes.hiddenInput }
|
||||
multiple = { true }
|
||||
onChange = { handleFileSelect }
|
||||
ref = { fileInputRef }
|
||||
tabIndex = { -1 }
|
||||
type = 'file' />
|
||||
</>
|
||||
)
|
||||
}
|
||||
{
|
||||
sortedFiles.length > 0 && (
|
||||
<ul className = { classes.fileList }>
|
||||
{
|
||||
sortedFiles.map(file => (
|
||||
<li
|
||||
className = { `${classes.fileItem} ${isFocused ? 'focused' : ''}` }
|
||||
key = { file.fileId }
|
||||
// Only remove focus when leaving the whole fileItem, not just moving between its buttons
|
||||
onBlur = { e => !e.currentTarget.contains(e.relatedTarget as Node) && setIsFocused(false) }
|
||||
onFocus = { () => setIsFocused(true) }
|
||||
tabIndex = { -1 }
|
||||
title = { file.fileName }>
|
||||
{
|
||||
(file.progress ?? 100) === 100 && (
|
||||
<>
|
||||
<div className = { classes.fileIconContainer }>
|
||||
<Icon
|
||||
color = { BaseTheme.palette.icon01 }
|
||||
size = { 64 }
|
||||
src = { getFileIcon(file.fileType) } />
|
||||
</div>
|
||||
<div className = { classes.fileItemDetails }>
|
||||
<div className = { classes.fileName }>
|
||||
{ file.fileName }
|
||||
</div>
|
||||
<div className = { classes.fileSize }>
|
||||
{ formatFileSize(file.fileSize) }
|
||||
</div>
|
||||
<div className = { classes.fileAuthorParticipant }>
|
||||
<Avatar
|
||||
displayName = { file.authorParticipantName }
|
||||
participantId = { file.authorParticipantId }
|
||||
size = { 16 } />
|
||||
<div className = { classes.fileAuthorParticipantName }>
|
||||
{ file.authorParticipantName }
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className = { `${classes.fileTimestamp} timestampVisibility` }>
|
||||
<pre>
|
||||
{ formatTimestamp(file.timestamp) }
|
||||
</pre>
|
||||
</div>
|
||||
<div className = { `${classes.buttonContainer} actionIconVisibility` }>
|
||||
<button
|
||||
aria-label = { `${t('fileSharing.downloadFile')} ${file.fileName}` }
|
||||
className = { `${classes.iconButton}` }
|
||||
onClick = { () => dispatch(downloadFile(file.fileId)) }
|
||||
type = 'button'>
|
||||
<Icon
|
||||
color = { BaseTheme.palette.icon01 }
|
||||
size = { 24 }
|
||||
src = { IconDownload } />
|
||||
</button>
|
||||
|
||||
{
|
||||
isUploadEnabled && (
|
||||
<button
|
||||
aria-label = { `${t('fileSharing.removeFile')} ${file.fileName}` }
|
||||
className = { `${classes.iconButton}` }
|
||||
onClick = { () => dispatch(removeFile(file.fileId)) }
|
||||
type = 'button'>
|
||||
<Icon
|
||||
color = { BaseTheme.palette.icon01 }
|
||||
size = { 24 }
|
||||
src = { IconTrash } />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
{
|
||||
(file.progress ?? 100) < 100 && (
|
||||
<>
|
||||
<div
|
||||
aria-label = { t('fileSharing.fileUploadProgress') }
|
||||
aria-valuemax = { 100 }
|
||||
aria-valuemin = { 0 }
|
||||
aria-valuenow = { file.progress }
|
||||
className = { classes.progressBar }
|
||||
role = 'progressbar'>
|
||||
<div
|
||||
className = { classes.progressFill }
|
||||
style = {{ width: `${file.progress}%` }} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
{
|
||||
isUploadEnabled && (
|
||||
<Button
|
||||
accessibilityLabel = { t('fileSharing.uploadFile') }
|
||||
className = { classes.uploadButton }
|
||||
labelKey = { 'fileSharing.uploadFile' }
|
||||
onClick = { handleClick }
|
||||
onKeyPress = { handleKeyPress }
|
||||
ref = { uploadButtonRef }
|
||||
type = { BUTTON_TYPES.PRIMARY } />
|
||||
)
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileSharing;
|
||||
4
react/features/file-sharing/constants.ts
Normal file
4
react/features/file-sharing/constants.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* The max file size we accept for upload.
|
||||
*/
|
||||
export const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
|
||||
157
react/features/file-sharing/functions.any.ts
Normal file
157
react/features/file-sharing/functions.any.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { IReduxState, IStore } from '../app/types';
|
||||
import { getLocalizedDateFormatter } from '../base/i18n/dateUtil';
|
||||
import {
|
||||
IconImage,
|
||||
IconShareDoc,
|
||||
IconVideo,
|
||||
IconVolumeUp
|
||||
} from '../base/icons/svg';
|
||||
import { MEET_FEATURES } from '../base/jwt/constants';
|
||||
import { isJwtFeatureEnabled } from '../base/jwt/functions';
|
||||
import { showErrorNotification } from '../notifications/actions';
|
||||
import { NOTIFICATION_TIMEOUT_TYPE, NOTIFICATION_TYPE } from '../notifications/constants';
|
||||
import { iAmVisitor } from '../visitors/functions';
|
||||
|
||||
import { uploadFiles } from './actions';
|
||||
import { MAX_FILE_SIZE } from './constants';
|
||||
|
||||
/**
|
||||
* Checks whether file sharing feature is enabled.
|
||||
*
|
||||
* @param {IReduxState} state - The redux state.
|
||||
* @returns {boolean} - Indicates if file sharing feature is enabled.
|
||||
*/
|
||||
export function isFileSharingEnabled(state: IReduxState) {
|
||||
const { fileSharing } = state['features/base/config'] ?? {};
|
||||
|
||||
return Boolean(fileSharing?.enabled && fileSharing?.apiUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the file extension from a file name.
|
||||
*
|
||||
* @param {string} fileName - The name of the file to extract the extension from.
|
||||
* @returns {string} The file extension or an empty string if none exists.
|
||||
*/
|
||||
export function getFileExtension(fileName: string): string {
|
||||
const parts = fileName.split('.');
|
||||
|
||||
if (parts.length > 1) {
|
||||
return parts.pop()?.toLowerCase() || '';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the appropriate icon for a file based on its type.
|
||||
*
|
||||
* @param {string} fileType - The file type.
|
||||
* @returns {Function} The icon component to use.
|
||||
*/
|
||||
export function getFileIcon(fileType: string) {
|
||||
if ([ 'mkv', 'mp4', 'mov', 'avi', 'webm' ].includes(fileType)) {
|
||||
return IconVideo;
|
||||
}
|
||||
|
||||
if ([ 'mp3', 'wav', 'ogg' ].includes(fileType)) {
|
||||
return IconVolumeUp;
|
||||
}
|
||||
|
||||
if ([ 'jpg', 'jpeg', 'png', 'gif' ].includes(fileType)) {
|
||||
return IconImage;
|
||||
}
|
||||
|
||||
return IconShareDoc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats the file size into a human-readable string.
|
||||
*
|
||||
* @param {number} bytes - The size in bytes.
|
||||
* @returns {string} The formatted file size string.
|
||||
*/
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (bytes <= 0) {
|
||||
return '0 Bytes';
|
||||
}
|
||||
|
||||
const sizes = [ 'Bytes', 'KB', 'MB', 'GB', 'TB', 'PB' ];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
const size = bytes / Math.pow(1024, i);
|
||||
|
||||
// Check if size is an integer after rounding to 2 decimals
|
||||
const rounded = Math.round(size * 100) / 100;
|
||||
const formattedSize = Number.isInteger(rounded) ? rounded : rounded.toFixed(2);
|
||||
|
||||
return `${formattedSize} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats the timestamp into a human-readable string.
|
||||
*
|
||||
* @param {number} timestamp - The timestamp to format.
|
||||
* @returns {string} The formatted timestamp string.
|
||||
*/
|
||||
export function formatTimestamp(timestamp: number): string {
|
||||
const date = getLocalizedDateFormatter(timestamp);
|
||||
const monthDay = date.format('MMM D'); // Eg. "May 15"
|
||||
const time = date.format('h:mm A'); // Eg. "2:30 PM"
|
||||
|
||||
return `${monthDay}\n${time}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes a list of files for upload.
|
||||
*
|
||||
* @param {FileList|File[]} fileList - The list of files to process.
|
||||
* @param {Dispatch} dispatch - The Redux dispatch function.
|
||||
* @returns {void}
|
||||
*/
|
||||
// @ts-ignore
|
||||
export const processFiles = (fileList: FileList | File[], store: IStore) => {
|
||||
const state = store.getState();
|
||||
const dispatch = store.dispatch;
|
||||
|
||||
const { maxFileSize = MAX_FILE_SIZE } = state['features/base/config']?.fileSharing ?? {};
|
||||
|
||||
const newFiles = Array.from(fileList as File[]).filter((file: File) => {
|
||||
|
||||
// No file size limitation
|
||||
if (maxFileSize === -1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check file size before upload
|
||||
if (file.size > maxFileSize) {
|
||||
dispatch(showErrorNotification({
|
||||
titleKey: 'fileSharing.fileTooLargeTitle',
|
||||
descriptionKey: 'fileSharing.fileTooLargeDescription',
|
||||
descriptionArguments: {
|
||||
maxFileSize: formatFileSize(maxFileSize)
|
||||
},
|
||||
appearance: NOTIFICATION_TYPE.ERROR
|
||||
}, NOTIFICATION_TIMEOUT_TYPE.STICKY));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
if (newFiles.length > 0) {
|
||||
dispatch(uploadFiles(newFiles as File[]));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines if file uploading is enabled based on JWT feature flags and file sharing settings.
|
||||
*
|
||||
* @param {IReduxState} state - Current state.
|
||||
* @returns {boolean} Indication of whether local user can upload files.
|
||||
*/
|
||||
export function isFileUploadingEnabled(state: IReduxState): boolean {
|
||||
return !iAmVisitor(state)
|
||||
&& isJwtFeatureEnabled(state, MEET_FEATURES.FILE_UPLOAD, false)
|
||||
&& isFileSharingEnabled(state);
|
||||
}
|
||||
3
react/features/file-sharing/logger.ts
Normal file
3
react/features/file-sharing/logger.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { getLogger } from '../base/logging/functions';
|
||||
|
||||
export default getLogger('features/file-sharing');
|
||||
250
react/features/file-sharing/middleware.web.ts
Normal file
250
react/features/file-sharing/middleware.web.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
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 { showErrorNotification, showNotification, showSuccessNotification } from '../notifications/actions';
|
||||
import { NOTIFICATION_TIMEOUT_TYPE, NOTIFICATION_TYPE } from '../notifications/constants';
|
||||
|
||||
import { DOWNLOAD_FILE, REMOVE_FILE, 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 }, previousConference) => {
|
||||
if (conference && !previousConference) {
|
||||
conference.on(JitsiConferenceEvents.FILE_SHARING_FILE_ADDED, (file: IFileMetadata) => {
|
||||
dispatch(addFile(file));
|
||||
});
|
||||
conference.on(JitsiConferenceEvents.FILE_SHARING_FILE_REMOVED, (fileId: string) => {
|
||||
dispatch({
|
||||
type: _FILE_REMOVED,
|
||||
fileId
|
||||
});
|
||||
});
|
||||
|
||||
conference.on(JitsiConferenceEvents.FILE_SHARING_FILES_RECEIVED, (files: object) => {
|
||||
dispatch({
|
||||
type: _FILE_LIST_RECEIVED,
|
||||
files
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Middleware that handles file sharing actions.
|
||||
*
|
||||
* @param {Store} store - The redux store.
|
||||
* @returns {Function}
|
||||
*/
|
||||
MiddlewareRegistry.register(store => next => action => {
|
||||
switch (action.type) {
|
||||
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 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);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
59
react/features/file-sharing/reducer.ts
Normal file
59
react/features/file-sharing/reducer.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import ReducerRegistry from '../base/redux/ReducerRegistry';
|
||||
|
||||
import { ADD_FILE, UPDATE_FILE_UPLOAD_PROGRESS, _FILE_LIST_RECEIVED, _FILE_REMOVED } from './actionTypes';
|
||||
import { IFileMetadata } from './types';
|
||||
|
||||
export interface IFileSharingState {
|
||||
files: Map<string, IFileMetadata>;
|
||||
}
|
||||
|
||||
const DEFAULT_STATE = {
|
||||
files: new Map<string, IFileMetadata>()
|
||||
};
|
||||
|
||||
ReducerRegistry.register<IFileSharingState>('features/file-sharing',
|
||||
(state = DEFAULT_STATE, action): IFileSharingState => {
|
||||
switch (action.type) {
|
||||
case ADD_FILE: {
|
||||
const newFiles = new Map(state.files);
|
||||
|
||||
newFiles.set(action.file.fileId, action.file);
|
||||
|
||||
return {
|
||||
files: newFiles
|
||||
};
|
||||
}
|
||||
|
||||
case _FILE_REMOVED: {
|
||||
const newFiles = new Map(state.files);
|
||||
|
||||
newFiles.delete(action.fileId);
|
||||
|
||||
return {
|
||||
files: newFiles
|
||||
};
|
||||
}
|
||||
|
||||
case UPDATE_FILE_UPLOAD_PROGRESS: {
|
||||
const newFiles = new Map(state.files);
|
||||
const file = newFiles.get(action.fileId);
|
||||
|
||||
if (file) {
|
||||
newFiles.set(action.fileId, { ...file, progress: action.progress });
|
||||
}
|
||||
|
||||
return {
|
||||
files: newFiles
|
||||
};
|
||||
}
|
||||
|
||||
case _FILE_LIST_RECEIVED: {
|
||||
return {
|
||||
files: new Map(Object.entries(action.files))
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
});
|
||||
12
react/features/file-sharing/types.ts
Normal file
12
react/features/file-sharing/types.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export interface IFileMetadata {
|
||||
authorParticipantId: string;
|
||||
authorParticipantJid: string;
|
||||
authorParticipantName: string;
|
||||
conferenceFullName: string;
|
||||
fileId: string;
|
||||
fileName: string;
|
||||
fileSize: number;
|
||||
fileType: string;
|
||||
progress?: number;
|
||||
timestamp: number;
|
||||
}
|
||||
27
react/features/file-sharing/utils.ts
Normal file
27
react/features/file-sharing/utils.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
const generateDownloadUrl = async (url: string) => {
|
||||
const resp = await fetch(url);
|
||||
const respBlob = await resp.blob();
|
||||
|
||||
const blob = new Blob([ respBlob ]);
|
||||
|
||||
return URL.createObjectURL(blob);
|
||||
};
|
||||
|
||||
export const downloadFile = async (url: string, fileName: string) => {
|
||||
const dowloadUrl = await generateDownloadUrl(url);
|
||||
const link = document.createElement('a');
|
||||
|
||||
if (fileName) {
|
||||
link.download = fileName;
|
||||
}
|
||||
link.href = dowloadUrl;
|
||||
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
|
||||
// fix for certain browsers
|
||||
setTimeout(() => {
|
||||
URL.revokeObjectURL(dowloadUrl);
|
||||
}, 0);
|
||||
};
|
||||
Reference in New Issue
Block a user