import { Button, Group } from "@mantine/core"; import { useModals } from "@mantine/modals"; import { cleanNotifications } from "@mantine/notifications"; import { AxiosError } from "axios"; import pLimit from "p-limit"; import { useEffect, useRef, useState } from "react"; import { FormattedMessage } from "react-intl"; import Meta from "../../components/Meta"; import Dropzone from "../../components/upload/Dropzone"; import FileList from "../../components/upload/FileList"; import showCompletedUploadModal from "../../components/upload/modals/showCompletedUploadModal"; import showCreateUploadModal from "../../components/upload/modals/showCreateUploadModal"; import useConfig from "../../hooks/config.hook"; import useConfirmLeave from "../../hooks/confirm-leave.hook"; import useTranslate from "../../hooks/useTranslate.hook"; import useUser from "../../hooks/user.hook"; import shareService from "../../services/share.service"; import { FileUpload } from "../../types/File.type"; import { CreateShare, Share } from "../../types/share.type"; import toast from "../../utils/toast.util"; import { useRouter } from "next/router"; const promiseLimit = pLimit(3); let errorToastShown = false; let createdShare: Share; const Upload = ({ maxShareSize, isReverseShare = false, simplified, }: { maxShareSize?: number; isReverseShare: boolean; simplified: boolean; }) => { const modals = useModals(); const router = useRouter(); const t = useTranslate(); const { user } = useUser(); const config = useConfig(); const [files, setFiles] = useState([]); const [isUploading, setisUploading] = useState(false); useConfirmLeave({ message: t("upload.notify.confirm-leave"), enabled: isUploading, }); const chunkSize = useRef(parseInt(config.get("share.chunkSize"))); maxShareSize ??= parseInt(config.get("share.maxSize")); const autoOpenCreateUploadModal = config.get("share.autoOpenShareModal"); const uploadFiles = async (share: CreateShare, files: FileUpload[]) => { setisUploading(true); try { const isReverseShare = router.pathname != "/upload"; createdShare = await shareService.create(share, isReverseShare); } catch (e) { toast.axiosError(e); setisUploading(false); return; } const fileUploadPromises = files.map(async (file, fileIndex) => // Limit the number of concurrent uploads to 3 promiseLimit(async () => { let fileId; const setFileProgress = (progress: number) => { setFiles((files) => files.map((file, callbackIndex) => { if (fileIndex == callbackIndex) { file.uploadingProgress = progress; } return file; }), ); }; setFileProgress(1); let chunks = Math.ceil(file.size / chunkSize.current); // If the file is 0 bytes, we still need to upload 1 chunk if (chunks == 0) chunks++; for (let chunkIndex = 0; chunkIndex < chunks; chunkIndex++) { const from = chunkIndex * chunkSize.current; const to = from + chunkSize.current; const blob = file.slice(from, to); try { await shareService .uploadFile( createdShare.id, blob, { id: fileId, name: file.name, }, chunkIndex, chunks, ) .then((response) => { fileId = response.id; }); setFileProgress(((chunkIndex + 1) / chunks) * 100); } catch (e) { if ( e instanceof AxiosError && e.response?.data.error == "unexpected_chunk_index" ) { // Retry with the expected chunk index chunkIndex = e.response!.data!.expectedChunkIndex - 1; continue; } else { setFileProgress(-1); // Retry after 5 seconds await new Promise((resolve) => setTimeout(resolve, 5000)); chunkIndex = -1; continue; } } } }), ); Promise.all(fileUploadPromises); }; const showCreateUploadModalCallback = (files: FileUpload[]) => { showCreateUploadModal( modals, { isUserSignedIn: user ? true : false, isReverseShare, allowUnauthenticatedShares: config.get( "share.allowUnauthenticatedShares", ), enableEmailRecepients: config.get("email.enableShareEmailRecipients"), maxExpiration: config.get("share.maxExpiration"), shareIdLength: config.get("share.shareIdLength"), simplified, }, files, uploadFiles, ); }; const handleDropzoneFilesChanged = (files: FileUpload[]) => { if (autoOpenCreateUploadModal) { setFiles(files); showCreateUploadModalCallback(files); } else { setFiles((oldArr) => [...oldArr, ...files]); } }; useEffect(() => { // Check if there are any files that failed to upload const fileErrorCount = files.filter( (file) => file.uploadingProgress == -1, ).length; if (fileErrorCount > 0) { if (!errorToastShown) { toast.error( t("upload.notify.count-failed", { count: fileErrorCount }), { withCloseButton: false, autoClose: false, }, ); } errorToastShown = true; } else { cleanNotifications(); errorToastShown = false; } // Complete share if ( files.length > 0 && files.every((file) => file.uploadingProgress >= 100) && fileErrorCount == 0 ) { shareService .completeShare(createdShare.id) .then((share) => { setisUploading(false); showCompletedUploadModal(modals, share); setFiles([]); }) .catch(() => toast.error(t("upload.notify.generic-error"))); } }, [files]); return ( <> 0 ? t("share.edit.append-upload") : undefined } maxShareSize={maxShareSize} onFilesChanged={handleDropzoneFilesChanged} isUploading={isUploading} /> {files.length > 0 && ( files={files} setFiles={setFiles} /> )} ); }; export default Upload;