import React, { Component } from 'react'; import PropTypes from 'prop-types'; import { defineMessages, injectIntl } from 'react-intl'; import { TAB } from '/imports/utils/keyCodes'; import deviceInfo from '/imports/utils/deviceInfo'; import Button from '/imports/ui/components/button/component'; import Icon from '/imports/ui/components/icon/component'; import update from 'immutability-helper'; import logger from '/imports/startup/client/logger'; import { notify } from '/imports/ui/services/notification'; import { toast } from 'react-toastify'; import _ from 'lodash'; import { registerTitleView, unregisterTitleView } from '/imports/utils/dom-utils'; import Styled from './styles'; import Settings from '/imports/ui/services/settings'; import Checkbox from '/imports/ui/components/checkbox/component'; const { isMobile } = deviceInfo; const propTypes = { intl: PropTypes.object.isRequired, fileUploadConstraintsHint: PropTypes.bool.isRequired, fileSizeMax: PropTypes.number.isRequired, filePagesMax: PropTypes.number.isRequired, handleSave: PropTypes.func.isRequired, dispatchTogglePresentationDownloadable: PropTypes.func.isRequired, fileValidMimeTypes: PropTypes.arrayOf(PropTypes.object).isRequired, presentations: PropTypes.arrayOf(PropTypes.shape({ id: PropTypes.string.isRequired, filename: PropTypes.string.isRequired, isCurrent: PropTypes.bool.isRequired, conversion: PropTypes.object, upload: PropTypes.object, })).isRequired, isOpen: PropTypes.bool.isRequired, }; const defaultProps = { }; const intlMessages = defineMessages({ current: { id: 'app.presentationUploder.currentBadge', }, title: { id: 'app.presentationUploder.title', description: 'title of the modal', }, message: { id: 'app.presentationUploder.message', description: 'message warning the types of files accepted', }, uploadLabel: { id: 'app.presentationUploder.uploadLabel', description: 'confirm label when presentations are to be uploaded', }, confirmLabel: { id: 'app.presentationUploder.confirmLabel', description: 'confirm label when no presentations are to be uploaded', }, confirmDesc: { id: 'app.presentationUploder.confirmDesc', description: 'description of the confirm', }, dismissLabel: { id: 'app.presentationUploder.dismissLabel', description: 'used in the button that close modal', }, dismissDesc: { id: 'app.presentationUploder.dismissDesc', description: 'description of the dismiss', }, dropzoneLabel: { id: 'app.presentationUploder.dropzoneLabel', description: 'message warning where drop files for upload', }, dropzoneImagesLabel: { id: 'app.presentationUploder.dropzoneImagesLabel', description: 'message warning where drop images for upload', }, browseFilesLabel: { id: 'app.presentationUploder.browseFilesLabel', description: 'message use on the file browser', }, browseImagesLabel: { id: 'app.presentationUploder.browseImagesLabel', description: 'message use on the image browser', }, fileToUpload: { id: 'app.presentationUploder.fileToUpload', description: 'message used in the file selected for upload', }, extraHint: { id: 'app.presentationUploder.extraHint', description: 'message used to indicate upload file max sizes', }, rejectedError: { id: 'app.presentationUploder.rejectedError', description: 'some files rejected, please check the file mime types', }, uploadProcess: { id: 'app.presentationUploder.upload.progress', description: 'message that indicates the percentage of the upload', }, 413: { id: 'app.presentationUploder.upload.413', description: 'error that file exceed the size limit', }, 408: { id: 'app.presentationUploder.upload.408', description: 'error for token request timeout', }, 404: { id: 'app.presentationUploder.upload.404', description: 'error not found', }, 401: { id: 'app.presentationUploder.upload.401', description: 'error for failed upload token request.', }, conversionProcessingSlides: { id: 'app.presentationUploder.conversion.conversionProcessingSlides', description: 'indicates how many slides were converted', }, genericError: { id: 'app.presentationUploder.genericError', description: 'generic error while uploading/converting', }, genericConversionStatus: { id: 'app.presentationUploder.conversion.genericConversionStatus', description: 'indicates that file is being converted', }, TIMEOUT: { id: 'app.presentationUploder.conversion.timeout', }, GENERATING_THUMBNAIL: { id: 'app.presentationUploder.conversion.generatingThumbnail', description: 'indicatess that it is generating thumbnails', }, GENERATING_SVGIMAGES: { id: 'app.presentationUploder.conversion.generatingSvg', description: 'warns that it is generating svg images', }, GENERATED_SLIDE: { id: 'app.presentationUploder.conversion.generatedSlides', description: 'warns that were slides generated', }, PAGE_COUNT_EXCEEDED: { id: 'app.presentationUploder.conversion.pageCountExceeded', description: 'warns the user that the conversion failed because of the page count', }, PDF_HAS_BIG_PAGE: { id: 'app.presentationUploder.conversion.pdfHasBigPage', description: 'warns the user that the conversion failed because of the pdf page siz that exceeds the allowed limit', }, OFFICE_DOC_CONVERSION_INVALID: { id: 'app.presentationUploder.conversion.officeDocConversionInvalid', description: '', }, OFFICE_DOC_CONVERSION_FAILED: { id: 'app.presentationUploder.conversion.officeDocConversionFailed', description: 'warns the user that the conversion failed because of wrong office file', }, UNSUPPORTED_DOCUMENT: { id: 'app.presentationUploder.conversion.unsupportedDocument', description: 'warns the user that the file extension is not supported', }, isDownloadable: { id: 'app.presentationUploder.isDownloadableLabel', description: 'presentation is available for downloading by all viewers', }, isNotDownloadable: { id: 'app.presentationUploder.isNotDownloadableLabel', description: 'presentation is not available for downloading the viewers', }, removePresentation: { id: 'app.presentationUploder.removePresentationLabel', description: 'select to delete this presentation', }, setAsCurrentPresentation: { id: 'app.presentationUploder.setAsCurrentPresentation', description: 'set this presentation to be the current one', }, status: { id: 'app.presentationUploder.tableHeading.status', description: 'aria label status table heading', }, options: { id: 'app.presentationUploder.tableHeading.options', description: 'aria label for options table heading', }, filename: { id: 'app.presentationUploder.tableHeading.filename', description: 'aria label for file name table heading', }, uploading: { id: 'app.presentationUploder.uploading', description: 'uploading label for toast notification', }, uploadStatus: { id: 'app.presentationUploder.uploadStatus', description: 'upload status for toast notification', }, completed: { id: 'app.presentationUploder.completed', description: 'uploads complete label for toast notification', }, item: { id: 'app.presentationUploder.item', description: 'single item label', }, itemPlural: { id: 'app.presentationUploder.itemPlural', description: 'plural item label', }, clearErrors: { id: 'app.presentationUploder.clearErrors', description: 'button label for clearing upload errors', }, clearErrorsDesc: { id: 'app.presentationUploder.clearErrorsDesc', description: 'aria description for button clearing upload error', }, uploadViewTitle: { id: 'app.presentationUploder.uploadViewTitle', description: 'view name apended to document title', } }); class PresentationUploader extends Component { constructor(props) { super(props); this.state = { presentations: [], disableActions: false, toUploadCount: 0, }; this.toastId = null; this.hasError = null; // handlers this.handleFiledrop = this.handleFiledrop.bind(this); this.handleConfirm = this.handleConfirm.bind(this); this.handleDismiss = this.handleDismiss.bind(this); this.handleRemove = this.handleRemove.bind(this); this.handleCurrentChange = this.handleCurrentChange.bind(this); this.handleDismissToast = this.handleDismissToast.bind(this); this.handleToggleDownloadable = this.handleToggleDownloadable.bind(this); // renders this.renderDropzone = this.renderDropzone.bind(this); this.renderPicDropzone = this.renderPicDropzone.bind(this); this.renderPresentationList = this.renderPresentationList.bind(this); this.renderPresentationItem = this.renderPresentationItem.bind(this); this.renderPresentationItemStatus = this.renderPresentationItemStatus.bind(this); this.renderToastList = this.renderToastList.bind(this); this.renderToastItem = this.renderToastItem.bind(this); // utilities this.deepMergeUpdateFileKey = this.deepMergeUpdateFileKey.bind(this); this.updateFileKey = this.updateFileKey.bind(this); } componentDidUpdate(prevProps) { const { isOpen, presentations: propPresentations, intl } = this.props; const { presentations } = this.state; if (!isOpen && prevProps.isOpen) { unregisterTitleView(); } // Updates presentation list when chat modal opens to avoid missing presentations if (isOpen && !prevProps.isOpen) { registerTitleView(intl.formatMessage(intlMessages.uploadViewTitle)); const focusableElements = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'; const modal = document.getElementById('upload-modal'); const firstFocusableElement = modal?.querySelectorAll(focusableElements)[0]; const focusableContent = modal?.querySelectorAll(focusableElements); const lastFocusableElement = focusableContent[focusableContent.length - 1]; firstFocusableElement.focus(); modal.addEventListener('keydown', function(e) { let tab = e.key === 'Tab' || e.keyCode === TAB; if (!tab) return; if (e.shiftKey) { if (document.activeElement === firstFocusableElement) { lastFocusableElement.focus(); e.preventDefault(); } } else { if (document.activeElement === lastFocusableElement) { firstFocusableElement.focus(); e.preventDefault(); } } }); this.setState({ presentations: Object.values({ ...propPresentations, ...presentations, }), }); } if (presentations.length > 0) { const selected = propPresentations.filter((p) => p.isCurrent); if (selected.length > 0) Session.set('selectedToBeNextCurrent', selected[0].id); } if (this.toastId) { if (!prevProps.isOpen && isOpen) { this.handleDismissToast(this.toastId); } toast.update(this.toastId, { render: this.renderToastList(), }); } } componentWillUnmount() { Session.set('showUploadPresentationView', false); } handleDismissToast() { return toast.dismiss(this.toastId); } handleFiledrop(files, files2) { const { fileValidMimeTypes, intl } = this.props; const { toUploadCount } = this.state; const validMimes = fileValidMimeTypes.map((fileValid) => fileValid.mime); const validExtentions = fileValidMimeTypes.map((fileValid) => fileValid.extension); const [accepted, rejected] = _.partition(files .concat(files2), (f) => ( validMimes.includes(f.type) || validExtentions.includes(`.${f.name.split('.').pop()}`) )); const presentationsToUpload = accepted.map((file) => { const id = _.uniqueId(file.name); return { file, isDownloadable: false, // by default new presentations are set not to be downloadable id, filename: file.name, isCurrent: false, conversion: { done: false, error: false }, upload: { done: false, error: false, progress: 0 }, onProgress: (event) => { if (!event.lengthComputable) { this.deepMergeUpdateFileKey(id, 'upload', { progress: 100, done: true, }); return; } this.deepMergeUpdateFileKey(id, 'upload', { progress: (event.loaded / event.total) * 100, done: event.loaded === event.total, }); }, onConversion: (conversion) => { this.deepMergeUpdateFileKey(id, 'conversion', conversion); }, onUpload: (upload) => { this.deepMergeUpdateFileKey(id, 'upload', upload); }, onDone: (newId) => { this.updateFileKey(id, 'id', newId); }, }; }); this.setState(({ presentations }) => ({ presentations: presentations.concat(presentationsToUpload), toUploadCount: (toUploadCount + presentationsToUpload.length), }), () => { // after the state is set (files have been dropped), // make the first of the new presentations current if (presentationsToUpload && presentationsToUpload.length) { this.handleCurrentChange(presentationsToUpload[0].id); } }); if (rejected.length > 0) { notify(intl.formatMessage(intlMessages.rejectedError), 'error'); } } handleRemove(item, withErr = false) { if (withErr) { const { presentations } = this.props; this.hasError = false; return this.setState({ presentations, disableActions: false, }); } const { presentations } = this.state; const toRemoveIndex = presentations.indexOf(item); return this.setState({ presentations: update(presentations, { $splice: [[toRemoveIndex, 1]], }), }, () => { const { presentations: updatedPresentations, oldCurrentId } = this.state; const commands = {}; const currentIndex = updatedPresentations.findIndex((p) => p.isCurrent); const actualCurrentIndex = updatedPresentations.findIndex((p) => p.id === oldCurrentId); if (currentIndex === -1 && updatedPresentations.length > 0) { const newCurrentIndex = actualCurrentIndex === -1 ? 0 : actualCurrentIndex; commands[newCurrentIndex] = { $apply: (presentation) => { const p = presentation; p.isCurrent = true; return p; }, }; } const updatedCurrent = update(updatedPresentations, commands); this.setState({ presentations: updatedCurrent }); }); } handleCurrentChange(id) { const { presentations, disableActions } = this.state; if (disableActions) return; const currentIndex = presentations.findIndex((p) => p.isCurrent); const newCurrentIndex = presentations.findIndex((p) => p.id === id); const commands = {}; // we can end up without a current presentation if (currentIndex !== -1) { commands[currentIndex] = { $apply: (presentation) => { const p = presentation; p.isCurrent = false; return p; }, }; } commands[newCurrentIndex] = { $apply: (presentation) => { const p = presentation; p.isCurrent = true; return p; }, }; const presentationsUpdated = update(presentations, commands); this.setState({ presentations: presentationsUpdated }); } deepMergeUpdateFileKey(id, key, value) { const applyValue = (toUpdate) => update(toUpdate, { $merge: value }); this.updateFileKey(id, key, applyValue, '$apply'); } handleConfirm(hasNewUpload) { const { handleSave, selectedToBeNextCurrent, presentations: propPresentations, dispatchTogglePresentationDownloadable, } = this.props; const { disableActions, presentations } = this.state; const presentationsToSave = presentations; this.setState({ disableActions: true }); presentations.forEach(item => { if (item.upload.done) { const didDownloadableStateChange = propPresentations.some( (p) => p.id === item.id && p.isDownloadable !== item.isDownloadable ); if (didDownloadableStateChange) { dispatchTogglePresentationDownloadable(item, item.isDownloadable); } } }); if (hasNewUpload) { this.toastId = toast.info(this.renderToastList(), { hideProgressBar: true, autoClose: false, newestOnTop: true, closeOnClick: true, onClose: () => { this.toastId = null; }, }); } if (this.toastId) Session.set('UploadPresentationToastId', this.toastId); if (!disableActions) { Session.set('showUploadPresentationView', false); return handleSave(presentationsToSave) .then(() => { const hasError = presentations.some((p) => p.upload.error || p.conversion.error); if (!hasError) { this.setState({ disableActions: false, toUploadCount: 0, }); return; } // if there's error we don't want to close the modal this.setState({ disableActions: true, // preventClosing: true, }, () => { // if the selected current has error we revert back to the old one const newCurrent = presentations.find((p) => p.isCurrent); if (newCurrent.upload.error || newCurrent.conversion.error) { this.handleCurrentChange(selectedToBeNextCurrent); } }); }) .catch((error) => { logger.error({ logCode: 'presentationuploader_component_save_error', extraInfo: { error }, }, 'Presentation uploader catch error on confirm'); }); } Session.set('showUploadPresentationView', false); return null; } handleDismiss() { const { presentations } = this.state; const { presentations: propPresentations } = this.props; const ids = new Set(propPresentations.map((d) => d.ID)); const merged = [ ...propPresentations, ...presentations.filter((d) => !ids.has(d.ID)), ]; this.setState( { presentations: merged }, Session.set('showUploadPresentationView', false), ); } handleToggleDownloadable(item) { const { presentations } = this.state; const oldDownloadableState = item.isDownloadable; const outOfDatePresentationIndex = presentations.findIndex((p) => p.id === item.id); const commands = {}; commands[outOfDatePresentationIndex] = { $apply: (presentation) => { const p = presentation; p.isDownloadable = !oldDownloadableState; return p; }, }; const presentationsUpdated = update(presentations, commands); this.setState({ presentations: presentationsUpdated, }); } updateFileKey(id, key, value, operation = '$set') { this.setState(({ presentations }) => { const fileIndex = presentations.findIndex((f) => f.id === id); return fileIndex === -1 ? false : { presentations: update(presentations, { [fileIndex]: { $apply: (file) => update(file, { [key]: { [operation]: value, }, }), }, }), }; }); } renderToastItem(item) { const isUploading = !item.upload.done && item.upload.progress > 0; const isConverting = !item.conversion.done && item.upload.done; const hasError = item.conversion.error || item.upload.error; const isProcessing = (isUploading || isConverting) && !hasError; let icon = isProcessing ? 'blank' : 'check'; if (hasError) icon = 'circle_close'; return ( { if (hasError || isProcessing) Session.set('showUploadPresentationView', true); }} > {item.filename} {this.renderPresentationItemStatus(item)} ); } renderExtraHint() { const { intl, fileSizeMax, filePagesMax, } = this.props; const options = { 0: fileSizeMax/1000000, 1: filePagesMax, }; return ( {intl.formatMessage(intlMessages.extraHint, options)} ); } renderPresentationList() { const { presentations } = this.state; const { intl } = this.props; let presentationsSorted = presentations; try { presentationsSorted = presentations .sort((a, b) => a.uploadTimestamp - b.uploadTimestamp) .sort((a, b) => a.filename.localeCompare(b.filename)) .sort((a, b) => b.upload.progress - a.upload.progress) .sort((a, b) => b.conversion.done - a.conversion.done) .sort((a, b) => { const aUploadNotTriggeredYet = !a.upload.done && a.upload.progress === 0; const bUploadNotTriggeredYet = !b.upload.done && b.upload.progress === 0; return bUploadNotTriggeredYet - aUploadNotTriggeredYet; }); } catch (error) { logger.error({ logCode: 'presentationuploader_component_render_error', extraInfo: { error }, }, 'Presentation uploader catch error on render presentation list'); } return ( {intl.formatMessage(intlMessages.filename)} {intl.formatMessage(intlMessages.status)} {intl.formatMessage(intlMessages.options)} {presentationsSorted.map((item) => this.renderPresentationItem(item))} ); } renderToastList() { const { presentations, toUploadCount } = this.state; if (toUploadCount === 0) { return this.handleDismissToast(this.toastId); } const { intl } = this.props; let converted = 0; let presentationsSorted = presentations .filter((p) => (p.upload.progress || p.conversion.status) && p.file) .sort((a, b) => a.uploadTimestamp - b.uploadTimestamp) .sort((a, b) => a.conversion.done - b.conversion.done); presentationsSorted = presentationsSorted .splice(0, toUploadCount) .map((p) => { if (p.conversion.done) converted += 1; return p; }); let toastHeading = ''; const itemLabel = presentationsSorted.length > 1 ? intl.formatMessage(intlMessages.itemPlural) : intl.formatMessage(intlMessages.item); if (converted === 0) { toastHeading = intl.formatMessage(intlMessages.uploading, { 0: presentationsSorted.length, 1: itemLabel, }); } if (converted > 0 && converted !== presentationsSorted.length) { toastHeading = intl.formatMessage(intlMessages.uploadStatus, { 0: converted, 1: presentationsSorted.length, }); } if (converted === presentationsSorted.length) { toastHeading = intl.formatMessage(intlMessages.completed, { 0: converted, }); } return ( {toastHeading}
{presentationsSorted.map((item) => this.renderToastItem(item))}
); } renderPresentationItem(item) { const { disableActions } = this.state; const { intl, selectedToBeNextCurrent, allowDownloadable } = this.props; const isActualCurrent = selectedToBeNextCurrent ? item.id === selectedToBeNextCurrent : item.isCurrent; const isUploading = !item.upload.done && item.upload.progress > 0; const isConverting = !item.conversion.done && item.upload.done; const hasError = item.conversion.error || item.upload.error; const isProcessing = (isUploading || isConverting) && !hasError; if (hasError) { this.hasError = true; } const formattedDownloadableLabel = !item.isDownloadable ? intl.formatMessage(intlMessages.isDownloadable) : intl.formatMessage(intlMessages.isNotDownloadable); const formattedDownloadableAriaLabel = `${formattedDownloadableLabel} ${item.filename}`; const { animations } = Settings.application; const isRemovable = item.isRemovable return ( { isActualCurrent ? ( {intl.formatMessage(intlMessages.current)} ) : null } {item.filename} {this.renderPresentationItemStatus(item)} {hasError ? null : ( {allowDownloadable ? ( this.handleToggleDownloadable(item)} animations={animations} /> ) : null } this.handleCurrentChange(item.id)} disabled={disableActions} animations={animations} /> {isRemovable ? ( this.handleRemove(item)} animations={animations} /> ) : null } )} ); } renderDropzone() { const { intl, fileValidMimeTypes, } = this.props; const { disableActions } = this.state; if (disableActions && !this.hasError) return null; return this.hasError ? (
) : ( // Until the Dropzone package has fixed the mime type hover validation, the rejectClassName // prop is being remove to prevent the error styles from being applied to valid file types. // Error handling is being done in the onDrop prop. fileValid.extension)} disablepreview="true" onDrop={this.handleFiledrop} > {intl.formatMessage(intlMessages.dropzoneLabel)}   {intl.formatMessage(intlMessages.browseFilesLabel)} ); } renderPicDropzone() { const { intl, } = this.props; const { disableActions } = this.state; if (disableActions && !this.hasError) return null; return this.hasError ? (
) : ( {intl.formatMessage(intlMessages.dropzoneImagesLabel)}   {intl.formatMessage(intlMessages.browseImagesLabel)} ); } renderPresentationItemStatus(item) { const { intl } = this.props; if (!item.upload.done && item.upload.progress === 0) { return intl.formatMessage(intlMessages.fileToUpload); } if (!item.upload.done && !item.upload.error) { return intl.formatMessage(intlMessages.uploadProcess, { 0: Math.floor(item.upload.progress).toString(), }); } const constraint = {}; if (item.upload.done && item.upload.error) { if (item.conversion.status === 'FILE_TOO_LARGE') { constraint['0'] = ((item.conversion.maxFileSize) / 1000 / 1000).toFixed(2); } const errorMessage = intlMessages[item.upload.status] || intlMessages.genericError; return intl.formatMessage(errorMessage, constraint); } if (!item.conversion.done && item.conversion.error) { const errorMessage = intlMessages[item.conversion.status] || intlMessages.genericConversionStatus; switch (item.conversion.status) { case 'PAGE_COUNT_EXCEEDED': constraint['0'] = item.conversion.maxNumberPages; break; case 'PDF_HAS_BIG_PAGE': constraint['0'] = (item.conversion.bigPageSize / 1000 / 1000).toFixed(2); break; default: break; } return intl.formatMessage(errorMessage, constraint); } if (!item.conversion.done && !item.conversion.error) { if (item.conversion.pagesCompleted < item.conversion.numPages) { return intl.formatMessage(intlMessages.conversionProcessingSlides, { 0: item.conversion.pagesCompleted, 1: item.conversion.numPages, }); } const conversionStatusMessage = intlMessages[item.conversion.status] || intlMessages.genericConversionStatus; return intl.formatMessage(conversionStatusMessage); } return null; } render() { const { isOpen, isPresenter, intl, fileUploadConstraintsHint, } = this.props; if (!isPresenter) return null; const { presentations, disableActions } = this.state; let hasNewUpload = false; presentations.map((item) => { if (item.id.indexOf(item.filename) !== -1 && item.upload.progress === 0) hasNewUpload = true; }); return isOpen ? (

{intl.formatMessage(intlMessages.title)}

this.handleConfirm(hasNewUpload)} disabled={disableActions} label={hasNewUpload ? intl.formatMessage(intlMessages.uploadLabel) : intl.formatMessage(intlMessages.confirmLabel)} />
{`${intl.formatMessage(intlMessages.message)}`} {fileUploadConstraintsHint ? this.renderExtraHint() : null} {this.renderPresentationList()} {isMobile ? this.renderPicDropzone() : null} {this.renderDropzone()}
) : null; } } PresentationUploader.propTypes = propTypes; PresentationUploader.defaultProps = defaultProps; export default injectIntl(PresentationUploader);