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, useMemo, useState } from "react"; import { FormattedMessage } from "react-intl"; import Dropzone from "../../components/upload/Dropzone"; import FileList from "../../components/upload/FileList"; import showCompletedUploadModal from "../../components/upload/modals/showCompletedUploadModal"; import useConfig from "../../hooks/config.hook"; import useTranslate from "../../hooks/useTranslate.hook"; import shareService from "../../services/share.service"; import { FileListItem, FileMetaData, FileUpload } from "../../types/File.type"; import toast from "../../utils/toast.util"; import { useRouter } from "next/router"; const promiseLimit = pLimit(3); const chunkSize = 10 * 1024 * 1024; // 10MB let errorToastShown = false; const EditableUpload = ({ maxShareSize, shareId, files: savedFiles = [], }: { maxShareSize?: number; isReverseShare?: boolean; shareId: string; files?: FileMetaData[]; }) => { const t = useTranslate(); const router = useRouter(); const config = useConfig(); const [existingFiles, setExistingFiles] = useState>(savedFiles); const [uploadingFiles, setUploadingFiles] = useState([]); const [isUploading, setIsUploading] = useState(false); const existingAndUploadedFiles: FileListItem[] = useMemo( () => [...uploadingFiles, ...existingFiles], [existingFiles, uploadingFiles], ); const dirty = useMemo(() => { return ( existingFiles.some((file) => !!file.deleted) || !!uploadingFiles.length ); }, [existingFiles, uploadingFiles]); const setFiles = (files: FileListItem[]) => { const _uploadFiles = files.filter( (file) => "uploadingProgress" in file, ) as FileUpload[]; const _existingFiles = files.filter( (file) => !("uploadingProgress" in file), ) as FileMetaData[]; setUploadingFiles(_uploadFiles); setExistingFiles(_existingFiles); }; maxShareSize ??= parseInt(config.get("share.maxSize")); const uploadFiles = async (files: FileUpload[]) => { const fileUploadPromises = files.map(async (file, fileIndex) => // Limit the number of concurrent uploads to 3 promiseLimit(async () => { let fileId: string; const setFileProgress = (progress: number) => { setUploadingFiles((files) => files.map((file, callbackIndex) => { if (fileIndex == callbackIndex) { file.uploadingProgress = progress; } return file; }), ); }; setFileProgress(1); let chunks = Math.ceil(file.size / chunkSize); // 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; const to = from + chunkSize; const blob = file.slice(from, to); try { await new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = async (event) => await shareService .uploadFile( shareId, event, { id: fileId, name: file.name, }, chunkIndex, chunks, ) .then((response) => { fileId = response.id; resolve(response); }) .catch(reject); reader.readAsDataURL(blob); }); 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; } } } }), ); await Promise.all(fileUploadPromises); }; const removeFiles = async () => { const removedFiles = existingFiles.filter((file) => !!file.deleted); if (removedFiles.length > 0) { await Promise.all( removedFiles.map(async (file) => { await shareService.removeFile(shareId, file.id); }), ); setExistingFiles(existingFiles.filter((file) => !file.deleted)); } }; const revertComplete = async () => { await shareService.revertComplete(shareId).then(); }; const completeShare = async () => { return await shareService.completeShare(shareId); }; const save = async () => { setIsUploading(true); try { await revertComplete(); await uploadFiles(uploadingFiles); const hasFailed = uploadingFiles.some( (file) => file.uploadingProgress == -1, ); if (!hasFailed) { await removeFiles(); } await completeShare(); if (!hasFailed) { toast.success(t("share.edit.notify.save-success")); router.back(); } } catch { toast.error(t("share.edit.notify.generic-error")); } finally { setIsUploading(false); } }; const appendFiles = (appendingFiles: FileUpload[]) => { setUploadingFiles([...appendingFiles, ...uploadingFiles]); }; useEffect(() => { // Check if there are any files that failed to upload const fileErrorCount = uploadingFiles.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; } }, [uploadingFiles]); return ( <> {existingAndUploadedFiles.length > 0 && ( )} ); }; export default EditableUpload;