diff --git a/mobile/app/(app)/form-questionnaire/[questionId].tsx b/mobile/app/(app)/form-questionnaire/[questionId].tsx index 3a49bc7d3..dee9f4c36 100644 --- a/mobile/app/(app)/form-questionnaire/[questionId].tsx +++ b/mobile/app/(app)/form-questionnaire/[questionId].tsx @@ -1,7 +1,7 @@ import { router, useLocalSearchParams } from "expo-router"; import { Screen } from "../../../components/Screen"; -import Header from "../../../components/Header"; import { Icon } from "../../../components/Icon"; +import Header from "../../../components/Header"; import { Typography } from "../../../components/Typography"; import { XStack, YStack, Spinner } from "tamagui"; import LinearProgress from "../../../components/LinearProgress"; @@ -25,23 +25,29 @@ import WizardRatingFormInput from "../../../components/WizardFormInputs/WizardRa import { useFormSubmissionMutation } from "../../../services/mutations/form-submission.mutation"; import OptionsSheet from "../../../components/OptionsSheet"; import AddAttachment from "../../../components/AddAttachment"; - import { FileMetadata, useCamera } from "../../../hooks/useCamera"; -import { addAttachmentMutation } from "../../../services/mutations/attachments/add-attachment.mutation"; +import { + UploadAttachmentProgress, + useUploadAttachmentMutation, +} from "../../../services/mutations/attachments/add-attachment.mutation"; import QuestionAttachments from "../../../components/QuestionAttachments"; import QuestionNotes from "../../../components/QuestionNotes"; -import * as DocumentPicker from "expo-document-picker"; import AddNoteSheetContent from "../../../components/AddNoteSheetContent"; import { useFormById } from "../../../services/queries/forms.query"; import { useFormAnswers } from "../../../services/queries/form-submissions.query"; import { useNotesForQuestionId } from "../../../services/queries/notes.query"; import * as Crypto from "expo-crypto"; import { useTranslation } from "react-i18next"; -import { onlineManager } from "@tanstack/react-query"; +import { onlineManager, useQueryClient } from "@tanstack/react-query"; import { ApiFormQuestion } from "../../../services/interfaces/question.type"; import FormInput from "../../../components/FormInputs/FormInput"; import WarningDialog from "../../../components/WarningDialog"; +import { MULTIPART_FILE_UPLOAD_SIZE } from "../../../common/constants"; +import * as DocumentPicker from "expo-document-picker"; import { KeyboardAwareScrollView } from "react-native-keyboard-aware-scroll-view"; +import { AttachmentsKeys } from "../../../services/queries/attachments.query"; +import { useAttachmentUploadProgressState } from "../../../services/store/attachment-upload-state/attachment-upload-selector"; +import { AttachmentProgressStatusEnum } from "../../../services/store/attachment-upload-state/attachment-upload-slice"; type SearchParamType = { questionId: string; @@ -52,6 +58,9 @@ type SearchParamType = { const FormQuestionnaire = () => { const { t } = useTranslation(["polling_station_form_wizard", "common"]); const { questionId, formId, language } = useLocalSearchParams(); + const queryClient = useQueryClient(); + + if (!questionId || !formId || !language) { return Incorrect page params; @@ -62,6 +71,7 @@ const FormQuestionnaire = () => { const [addingNote, setAddingNote] = useState(false); const [deletingAnswer, setDeletingAnswer] = useState(false); const [isPreparingFile, setIsPreparingFile] = useState(false); + const [currentAttachment, setCurrentAttachment] = useState(''); const { data: currentForm, @@ -264,15 +274,20 @@ const FormQuestionnaire = () => { const { uploadCameraOrMedia } = useCamera(); const { - mutate: addAttachment, - isPending: isLoadingAddAttachmentt, + mutate: addAttachmentMultipart, isPaused, - } = addAttachmentMutation( + isPending: isUploadingAttachments, + } = useUploadAttachmentMutation( `Attachment_${questionId}_${selectedPollingStation?.pollingStationId}_${formId}_${questionId}`, ); const handleCameraUpload = async (type: "library" | "cameraPhoto") => { + await queryClient.setQueryData(AttachmentsKeys.addAttachments(), { + progress: 0, + status: "idle", + }); setIsPreparingFile(true); + const cameraResult = await uploadCameraOrMedia(type); if (!cameraResult) { @@ -280,41 +295,62 @@ const FormQuestionnaire = () => { return; } + const attachmentId = Crypto.randomUUID(); + setCurrentAttachment(attachmentId); if ( activeElectionRound && selectedPollingStation?.pollingStationId && formId && - activeQuestion.question.id + activeQuestion.question.id && + cameraResult.size ) { - addAttachment( + addAttachmentMultipart( { - id: Crypto.randomUUID(), + id: attachmentId, electionRoundId: activeElectionRound.id, pollingStationId: selectedPollingStation.pollingStationId, formId, questionId: activeQuestion.question.id, - fileMetadata: cameraResult, + fileName: cameraResult.name, + contentType: cameraResult.type, + numberOfUploadParts: Math.ceil(cameraResult.size / MULTIPART_FILE_UPLOAD_SIZE), // Calculate the number of parts that will be sent to the S3 Bucket. It is +1 (thus we use ceil). + filePath: cameraResult.uri, }, { - onSettled: () => setIsOptionsSheetOpen(false), onError: () => console.log("πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄ERORRπŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄"), + onSettled: () => { + setIsOptionsSheetOpen(false); + setIsPreparingFile(false); + setCurrentAttachment(''); + }, }, ); - setIsPreparingFile(false); - if (!onlineManager.isOnline()) { setIsOptionsSheetOpen(false); + setIsPreparingFile(false); } } }; const handleUploadAudio = async () => { + await queryClient.setQueryData(AttachmentsKeys.addAttachments(), { + progress: 0, + status: "idle", + }); + setIsPreparingFile(true); + const doc = await DocumentPicker.getDocumentAsync({ type: "audio/*", multiple: false, }); + + if (doc?.canceled) { + setIsPreparingFile(false); + return; + } + if (doc?.assets?.[0]) { const file = doc?.assets?.[0]; @@ -322,35 +358,49 @@ const FormQuestionnaire = () => { name: file.name, type: file.mimeType || "audio/mpeg", uri: file.uri, + size: file.size, }; + const attachmentId = Crypto.randomUUID(); + setCurrentAttachment(attachmentId); + if ( activeElectionRound && selectedPollingStation?.pollingStationId && formId && - activeQuestion.question.id + activeQuestion.question.id && + fileMetadata.size ) { - addAttachment( + await addAttachmentMultipart( { - id: Crypto.randomUUID(), + id: attachmentId, electionRoundId: activeElectionRound.id, pollingStationId: selectedPollingStation.pollingStationId, formId, questionId: activeQuestion.question.id, - fileMetadata, + fileName: fileMetadata.name, + contentType: fileMetadata.type, + numberOfUploadParts: Math.ceil(fileMetadata.size / MULTIPART_FILE_UPLOAD_SIZE), + filePath: fileMetadata.uri, }, { - onSettled: () => setIsOptionsSheetOpen(false), onError: () => console.log("πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄ERORRπŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄"), + onSettled: () => { + setIsOptionsSheetOpen(false); + setIsPreparingFile(false); + setCurrentAttachment(''); + }, }, ); if (!onlineManager.isOnline()) { setIsOptionsSheetOpen(false); + setIsPreparingFile(false); } + } else { + setIsOptionsSheetOpen(false); + setIsPreparingFile(false); } - } else { - // Cancelled } }; @@ -378,7 +428,7 @@ const FormQuestionnaire = () => { {t("progress_bar.label")} - {`${activeQuestion?.indexInDisplayedQuestions + 1}/${displayedQuestions.length}`} + {`${activeQuestion?.indexInDisplayedQuestions + 1} /${displayedQuestions.length}`} { setIsOptionsSheetOpen(open); addingNote && setAddingNote(false); }} - isLoading={(isLoadingAddAttachmentt && !isPaused) || isPreparingFile} + isLoading={(isUploadingAttachments && !isPaused) || isPreparingFile} // seems that this behaviour is handled differently and the sheet will move with keyboard even if this props is set to false on android moveOnKeyboardChange={Platform.OS === "android"} disableDrag={addingNote} > - {(isLoadingAddAttachmentt && !isPaused) || isPreparingFile ? ( - + {(isUploadingAttachments && !isPaused) || isPreparingFile ? ( + ) : addingNote ? ( { +const MediaLoading = ({ attachmentId }: { attachmentId: string }) => { const { t } = useTranslation("polling_station_form_wizard"); + const { progresses } = useAttachmentUploadProgressState() + + const message = useMemo(() => { + if (!progresses && !progresses[attachmentId]) { + return ""; + } + + switch (progresses[attachmentId]?.status) { + case AttachmentProgressStatusEnum.STARTING: + return t("attachments.upload.starting"); + case "compressing": + return `Compressing progress ${progresses[attachmentId]?.progress}%`; + case AttachmentProgressStatusEnum.INPROGRESS: + return `${t("attachments.upload.progress")} ${progresses[attachmentId]?.progress} %`; + case AttachmentProgressStatusEnum.COMPLETED: + return t("attachments.upload.completed"); + case AttachmentProgressStatusEnum.ABORTED: + return t("attachments.upload.aborted"); + default: + return ""; + } + }, [progresses]); + return ( - - {t("attachments.loading")} + + {message || "Uploading"} ); diff --git a/mobile/app/(app)/report-issue.tsx b/mobile/app/(app)/report-issue.tsx index a2a5ba23a..54b667eb6 100644 --- a/mobile/app/(app)/report-issue.tsx +++ b/mobile/app/(app)/report-issue.tsx @@ -1,35 +1,37 @@ -import React, { useMemo, useState } from "react"; -import { XStack, YStack } from "tamagui"; -import { Screen } from "../../components/Screen"; -import { Icon } from "../../components/Icon"; +import { useMemo, useState } from "react"; import { router } from "expo-router"; -import Header from "../../components/Header"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import Button from "../../components/Button"; -import FormInput from "../../components/FormInputs/FormInput"; -import Select from "../../components/Select"; import { useUserData } from "../../contexts/user/UserContext.provider"; import { Controller, useForm } from "react-hook-form"; import { PollingStationVisitVM } from "../../common/models/polling-station.model"; -import FormElement from "../../components/FormInputs/FormElement"; -import AddAttachment from "../../components/AddAttachment"; -import { Keyboard } from "react-native"; -import OptionsSheet from "../../components/OptionsSheet"; -import { Typography } from "../../components/Typography"; import { useAddQuickReport } from "../../services/mutations/quick-report/add-quick-report.mutation"; import * as Crypto from "expo-crypto"; import { FileMetadata, useCamera } from "../../hooks/useCamera"; -import { addAttachmentQuickReportMutation } from "../../services/mutations/quick-report/add-attachment-quick-report.mutation"; import { QuickReportLocationType } from "../../services/api/quick-report/post-quick-report.api"; import * as DocumentPicker from "expo-document-picker"; import { onlineManager, useMutationState, useQueryClient } from "@tanstack/react-query"; -import Card from "../../components/Card"; import { QuickReportKeys } from "../../services/queries/quick-reports.query"; -import * as Sentry from "@sentry/react-native"; -import { AddAttachmentQuickReportAPIPayload } from "../../services/api/quick-report/add-attachment-quick-report.api"; import { useTranslation } from "react-i18next"; import i18n from "../../common/config/i18n"; +import { AddAttachmentQuickReportStartAPIPayload, addAttachmentQuickReportMultipartAbort, addAttachmentQuickReportMultipartComplete } from "../../services/api/quick-report/add-attachment-quick-report.api"; +import { useUploadAttachmentQuickReportMutation } from "../../services/mutations/quick-report/add-attachment-quick-report.mutation"; +import { MULTIPART_FILE_UPLOAD_SIZE } from "../../common/constants"; import { KeyboardAwareScrollView } from "react-native-keyboard-aware-scroll-view"; +import { addAttachmentMultipartAbort, addAttachmentMultipartComplete, uploadS3Chunk } from "../../services/api/add-attachment.api"; +import * as FileSystem from 'expo-file-system'; +import { Buffer } from 'buffer'; +import { Keyboard } from "react-native"; +import { YStack, Card, XStack, Spinner } from "tamagui"; +import AddAttachment from "../../components/AddAttachment"; +import FormElement from "../../components/FormInputs/FormElement"; +import FormInput from "../../components/FormInputs/FormInput"; +import { Icon } from "../../components/Icon"; +import OptionsSheet from "../../components/OptionsSheet"; +import { Typography } from "../../components/Typography"; +import Header from "../../components/Header"; +import { Screen } from "../../components/Screen"; +import Select from "../../components/Select"; +import Button from "../../components/Button"; const mapVisitsToSelectPollingStations = (visits: PollingStationVisitVM[] = []) => { const pollingStationsForSelect = visits.map((visit) => { @@ -70,8 +72,11 @@ const ReportIssue = () => { const pollingStations = useMemo(() => mapVisitsToSelectPollingStations(visits), [visits]); const [optionsSheetOpen, setOptionsSheetOpen] = useState(false); const { t } = useTranslation("report_new_issue"); + const [isLoadingAttachment, setIsLoadingAttachment] = useState(false); + const [isPreparingFile, setIsPreparingFile] = useState(false); + const [uploadProgress, setUploadProgress] = useState(''); - const [attachments, setAttachments] = useState>( + const [attachments, setAttachments] = useState>( [], ); @@ -80,7 +85,8 @@ const ReportIssue = () => { isPending: isPendingAddQuickReport, isPaused: isPausedAddQuickReport, } = useAddQuickReport(); - const { mutateAsync: addAttachmentQReport } = addAttachmentQuickReportMutation(); + + const { mutateAsync: addAttachmentQReport, isPaused: isPausedStartAddAttachment, } = useUploadAttachmentQuickReportMutation(`Quick_Report_${activeElectionRound?.id}}`); const addAttachmentsMutationState = useMutationState({ filters: { mutationKey: QuickReportKeys.addAttachment() }, @@ -115,6 +121,9 @@ const ReportIssue = () => { const { uploadCameraOrMedia } = useCamera(); const handleCameraUpload = async (type: "library" | "cameraPhoto" | "cameraVideo") => { + setIsPreparingFile(true); + setUploadProgress(t("upload.preparing")) + const cameraResult = await uploadCameraOrMedia(type); if (!cameraResult || !activeElectionRound) { @@ -126,9 +135,12 @@ const ReportIssue = () => { ...attachments, { fileMetadata: cameraResult, id: Crypto.randomUUID() }, ]); + setIsPreparingFile(false); }; const handleUploadAudio = async () => { + setIsPreparingFile(true); + setUploadProgress(t("upload.preparing")) const doc = await DocumentPicker.getDocumentAsync({ type: "audio/*", multiple: false, @@ -145,16 +157,50 @@ const ReportIssue = () => { setOptionsSheetOpen(false); setAttachments((attachments) => [...attachments, { fileMetadata, id: Crypto.randomUUID() }]); + setIsPreparingFile(false); } else { // Cancelled } }; + const handleChunkUpload = async (filePath: string, uploadUrls: Record, uploadId: string, attachmentId: string, quickReportId: string) => { + try { + + let etags: Record = {}; + const urls = Object.values(uploadUrls); + for (const [index, url] of urls.entries()) { + const chunk = await FileSystem.readAsStringAsync(filePath, { length: MULTIPART_FILE_UPLOAD_SIZE, position: index * MULTIPART_FILE_UPLOAD_SIZE, encoding: FileSystem.EncodingType.Base64 }); + const buffer = Buffer.from(chunk, 'base64'); + const data = await uploadS3Chunk(url, buffer) + etags = { ...etags, [index + 1]: data.ETag } + }; + + + if (activeElectionRound?.id) { + await addAttachmentQuickReportMultipartComplete({ uploadId, etags, electionRoundId: activeElectionRound?.id, id: attachmentId, quickReportId, }) + } + } catch (err) { + // If error try to abort the upload + if (activeElectionRound?.id) { + setUploadProgress(t("upload.aborted")); + await addAttachmentQuickReportMultipartAbort({ id: attachmentId, uploadId, electionRoundId: activeElectionRound.id, quickReportId }) + } + } finally { + if (activeElectionRound?.id) { + queryClient.invalidateQueries({ + queryKey: QuickReportKeys.byElectionRound(activeElectionRound.id), + }); + } + } + } + const onSubmit = async (formData: ReportIssueFormType) => { if (!visits || !activeElectionRound) { return; } + + let quickReportLocationType = QuickReportLocationType.VisitedPollingStation; let pollingStationId: string | null = formData.polling_station_id; @@ -171,30 +217,35 @@ const ReportIssue = () => { const uuid = Crypto.randomUUID(); // Use the attachments to optimistically update the UI - const optimisticAttachments: AddAttachmentQuickReportAPIPayload[] = []; + const optimisticAttachments: AddAttachmentQuickReportStartAPIPayload[] = []; if (attachments.length > 0) { - const attachmentsMutations = attachments.map( - ({ fileMetadata, id }: { fileMetadata: FileMetadata; id: string }) => { - const payload: AddAttachmentQuickReportAPIPayload = { - id, - fileMetadata, + setOptionsSheetOpen(true); + setIsLoadingAttachment(true); + try { + // Upload each attachment + setUploadProgress(`${t("upload.starting")}`) + for (const [index, attachment] of attachments.entries()) { + const payload: AddAttachmentQuickReportStartAPIPayload = { + id: attachment.id, + fileName: attachment.fileMetadata.name, + filePath: attachment.fileMetadata.uri, + contentType: attachment.fileMetadata.type, + numberOfUploadParts: Math.ceil(attachment.fileMetadata.size! / MULTIPART_FILE_UPLOAD_SIZE), electionRoundId: activeElectionRound.id, quickReportId: uuid, }; + const data = await addAttachmentQReport(payload); + await handleChunkUpload(attachment.fileMetadata.uri, data.uploadUrls, data.uploadId, attachment.id, uuid); + setUploadProgress(`${t("upload.progress")} ${Math.round(((index + 1) / attachments.length) * 100 * 10) / 10} %`); optimisticAttachments.push(payload); - return addAttachmentQReport(payload); - }, - ); - try { - Promise.all(attachmentsMutations).then(() => { - queryClient.invalidateQueries({ - queryKey: QuickReportKeys.byElectionRound(activeElectionRound.id), - }); - }); + } + setUploadProgress(t("upload.completed")); } catch (err) { - Sentry.captureMessage("Failed to upload some attachments"); - Sentry.captureException(err); + console.log(err); + } finally { + setIsLoadingAttachment(false); + setOptionsSheetOpen(false); } } mutate( @@ -404,32 +455,35 @@ const ReportIssue = () => { - - - {t("media.menu.load")} - - - {t("media.menu.take_picture")} - - - {t("media.menu.upload_audio")} - - + {(isLoadingAttachment && !isPausedStartAddAttachment) || isPreparingFile ? ( + ) : + + + + {t("media.menu.load")} + + + {t("media.menu.take_picture")} + + + {t("media.menu.upload_audio")} + + } @@ -461,4 +515,16 @@ const ReportIssue = () => { ); }; +const MediaLoading = ({ progress }: { progress?: string }) => { + const { t } = useTranslation("polling_station_form_wizard"); + return ( + + + + {progress ? progress : t("attachments.loading")} + + + ); +}; + export default ReportIssue; diff --git a/mobile/assets/locales/en/translations.json b/mobile/assets/locales/en/translations.json index 4d2c54cf0..c91933925 100644 --- a/mobile/assets/locales/en/translations.json +++ b/mobile/assets/locales/en/translations.json @@ -257,6 +257,13 @@ "attachments": { "heading": "Uploaded media", "loading": "Adding attachment... ", + "upload": { + "preparing": "Preparing attachment...", + "starting": "Starting the upload...", + "progress": "Upload progress:", + "completed": "Upload completed.", + "aborted": "Upload aborted." + }, "add": "Add notes and media", "menu": { "add_note": "Add note", @@ -318,6 +325,13 @@ }, "report_new_issue": { "title": "Report new issue", + "upload": { + "preparing": "Preparing attachment...", + "starting": "Starting the upload...", + "progress": "Upload progress:", + "completed": "Upload completed.", + "aborted": "Upload aborted." + }, "media": { "heading": "Uploaded media", "add": "Add media", diff --git a/mobile/common/constants.ts b/mobile/common/constants.ts index d5d97e439..6f825c05d 100644 --- a/mobile/common/constants.ts +++ b/mobile/common/constants.ts @@ -9,3 +9,5 @@ export const SECURE_STORAGE_KEYS = { }; export const I18N_LANGUAGE = "i18n-language"; + +export const MULTIPART_FILE_UPLOAD_SIZE = 10 * 1024 * 1024; // 10MB. diff --git a/mobile/contexts/persist-query/PersistQueryContext.provider.tsx b/mobile/contexts/persist-query/PersistQueryContext.provider.tsx index d44aff2ec..bef54454d 100644 --- a/mobile/contexts/persist-query/PersistQueryContext.provider.tsx +++ b/mobile/contexts/persist-query/PersistQueryContext.provider.tsx @@ -5,7 +5,10 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import { notesKeys, pollingStationsKeys } from "../../services/queries.service"; import * as API from "../../services/definitions.api"; import { PersistGate } from "../../components/PersistGate"; -import { AddAttachmentAPIPayload, addAttachment } from "../../services/api/add-attachment.api"; +import { + AddAttachmentStartAPIPayload, + addAttachmentMultipartStart, +} from "../../services/api/add-attachment.api"; import { deleteAttachment } from "../../services/api/delete-attachment.api"; import { Note } from "../../common/models/note"; import { QuickReportKeys } from "../../services/queries/quick-reports.query"; @@ -14,21 +17,23 @@ import { addQuickReport, } from "../../services/api/quick-report/post-quick-report.api"; import { - AddAttachmentQuickReportAPIPayload, - addAttachmentQuickReport, + AddAttachmentQuickReportStartAPIPayload, + addAttachmentQuickReportMultipartStart, } from "../../services/api/quick-report/add-attachment-quick-report.api"; import { AttachmentApiResponse } from "../../services/api/get-attachments.api"; import { AttachmentsKeys } from "../../services/queries/attachments.query"; import { ASYNC_STORAGE_KEYS } from "../../common/constants"; import * as Sentry from "@sentry/react-native"; import SuperJSON from "superjson"; +import { uploadAttachmentMutationFn } from "../../services/mutations/attachments/add-attachment.mutation"; +import useStore from "../../services/store/store"; const queryClient = new QueryClient({ mutationCache: new MutationCache({ // There is also QueryCache - onSuccess: (data: unknown) => { - console.log("MutationCache ", data); - }, + // onSuccess: (data: unknown) => { + // console.log("MutationCache ", data); + // }, onError: (error: Error, _vars, _context, mutation) => { console.log("MutationCache error ", error); console.log( @@ -104,14 +109,15 @@ const PersistQueryContextProvider = ({ children }: React.PropsWithChildren) => { }); queryClient.setMutationDefaults(AttachmentsKeys.addAttachmentMutation(), { - mutationFn: async (payload: AddAttachmentAPIPayload) => { - return addAttachment(payload); + mutationFn: async (payload: AddAttachmentStartAPIPayload) => { + const { progresses: state, setProgresses } = useStore(); + return uploadAttachmentMutationFn(payload, setProgresses, state); }, }); queryClient.setMutationDefaults(AttachmentsKeys.deleteAttachment(), { mutationFn: async (payload: AttachmentApiResponse) => { - return payload.isNotSynched ? () => {} : deleteAttachment(payload); + return payload.isNotSynched ? () => { } : deleteAttachment(payload); }, }); @@ -129,7 +135,7 @@ const PersistQueryContextProvider = ({ children }: React.PropsWithChildren) => { queryClient.setMutationDefaults(notesKeys.deleteNote(), { mutationFn: async (payload: Note) => { - return payload.isNotSynched ? () => {} : API.deleteNote(payload); + return payload.isNotSynched ? () => { } : API.deleteNote(payload); }, }); @@ -137,14 +143,14 @@ const PersistQueryContextProvider = ({ children }: React.PropsWithChildren) => { mutationFn: async ({ attachments: _, ...payload - }: AddQuickReportAPIPayload & { attachments: AddAttachmentQuickReportAPIPayload[] }) => { + }: AddQuickReportAPIPayload & { attachments: AddAttachmentQuickReportStartAPIPayload[] }) => { return addQuickReport(payload); }, }); queryClient.setMutationDefaults(QuickReportKeys.addAttachment(), { - mutationFn: async (payload: AddAttachmentQuickReportAPIPayload) => { - return addAttachmentQuickReport(payload); + mutationFn: async (payload: AddAttachmentQuickReportStartAPIPayload) => { + return addAttachmentQuickReportMultipartStart(payload); }, }); @@ -197,6 +203,9 @@ const PersistQueryContextProvider = ({ children }: React.PropsWithChildren) => { // console.log("πŸ“πŸ“πŸ“πŸ“πŸ“πŸ“", SuperJSON.stringify(newPausedMutations)); if (pausedMutation?.length) { + const { setProgresses } = useStore(); + // Reset Attachment Progress + setProgresses(() => ({})); await queryClient.resumePausedMutations(); // Looks in the inmemory cache queryClient.invalidateQueries(); // Avoid using await, not to wait for queries to refetch (maybe not the case here as there are no active queries) console.log("βœ… Resume Paused Mutation & Invalidate Quries"); diff --git a/mobile/hooks/useCamera.tsx b/mobile/hooks/useCamera.tsx index e075da922..923dfb34c 100644 --- a/mobile/hooks/useCamera.tsx +++ b/mobile/hooks/useCamera.tsx @@ -1,7 +1,11 @@ import * as ImagePicker from "expo-image-picker"; import Toast from "react-native-toast-message"; -import { Video, Image } from "react-native-compressor"; +import { Video, Image, getVideoMetaData, getImageMetaData } from "react-native-compressor"; import * as Sentry from "@sentry/react-native"; +import { useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { AttachmentsKeys } from "../services/queries/attachments.query"; +import { UploadAttachmentProgress } from "../services/mutations/attachments/add-attachment.mutation"; /** * @@ -41,9 +45,12 @@ export type FileMetadata = { uri: string; name: string; type: string; + size?: number; }; export const useCamera = () => { + const queryClient = useQueryClient(); + const [status, requestPermission] = ImagePicker.useCameraPermissions(); const uploadCameraOrMedia = async ( @@ -78,12 +85,14 @@ export const useCamera = () => { ...(specifiedMediaType || { mediaTypes: ImagePicker.MediaTypeOptions.All }), allowsEditing: true, aspect: [4, 3], - quality: 0.1, + quality: 0.2, allowsMultipleSelection: false, videoQuality: ImagePicker.UIImagePickerControllerQualityType.Low, // TODO: careful here, Medium might be enough cameraType: ImagePicker.CameraType.back, }); + console.log("FileSize Before Compression ", result?.assets?.[0].fileSize); + if (result.canceled) { return; } @@ -91,25 +100,42 @@ export const useCamera = () => { const file = result.assets[0]; if (file) { let resultCompression = file.uri; + let fileSize = file.fileSize; try { if (file.type === "image") { resultCompression = await Image.compress(file.uri); + fileSize = (await getImageMetaData(resultCompression)).size; } else if (file.type === "video") { - resultCompression = await Video.compress(file.uri, {}, (progress) => { - console.log("Compression Progress: ", progress); - }); + resultCompression = await Video.compress( + file.uri, + { + progressDivider: 10, + }, + (progress) => { + console.log("Compression Progress: ", progress); + queryClient.setQueryData(AttachmentsKeys.addAttachments(), { + status: "compressing", + progress: +(progress * 100).toFixed(2), + }); + }, + ); + fileSize = (await getVideoMetaData(resultCompression)).size; } } catch (err) { + console.log(err); Sentry.captureException(err); } + console.log("FileSize AFTER Compression ", fileSize); + const filename = resultCompression.split("/").pop() || ""; const toReturn = { uri: resultCompression, name: filename, type: file.mimeType || "", + size: fileSize, }; return toReturn; } diff --git a/mobile/package-lock.json b/mobile/package-lock.json index 3c5caa660..cec5a9cfa 100644 --- a/mobile/package-lock.json +++ b/mobile/package-lock.json @@ -24,6 +24,7 @@ "@tanstack/react-query": "^5.36.0", "@tanstack/react-query-persist-client": "^5.36.0", "axios": "^1.6.8", + "buffer": "^6.0.3", "expo": "~50.0.17", "expo-application": "~5.8.4", "expo-build-properties": "~0.11.1", @@ -61,7 +62,8 @@ "react-native-toast-message": "^2.2.0", "superjson": "^2.2.1", "tamagui": "^1.93.2", - "zod": "^3.23.3" + "zod": "^3.23.3", + "zustand": "^4.5.2" }, "devDependencies": { "@babel/core": "^7.20.0", @@ -9526,6 +9528,29 @@ "readable-stream": "^3.4.0" } }, + "node_modules/bl/node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/bl/node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -9628,9 +9653,9 @@ } }, "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", "funding": [ { "type": "github", @@ -9647,7 +9672,7 @@ ], "dependencies": { "base64-js": "^1.3.1", - "ieee754": "^1.1.13" + "ieee754": "^1.2.1" } }, "node_modules/buffer-alloc": { @@ -20603,6 +20628,29 @@ "node": ">=10" } }, + "node_modules/whatwg-url-without-unicode/node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/whatwg-url-without-unicode/node_modules/webidl-conversions": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-5.0.0.tgz", diff --git a/mobile/package.json b/mobile/package.json index 80759ce88..b6f88928f 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -36,6 +36,7 @@ "@tanstack/react-query": "^5.36.0", "@tanstack/react-query-persist-client": "^5.36.0", "axios": "^1.6.8", + "buffer": "^6.0.3", "expo": "~50.0.17", "expo-application": "~5.8.4", "expo-build-properties": "~0.11.1", @@ -73,7 +74,8 @@ "react-native-toast-message": "^2.2.0", "superjson": "^2.2.1", "tamagui": "^1.93.2", - "zod": "^3.23.3" + "zod": "^3.23.3", + "zustand": "^4.5.2" }, "devDependencies": { "@babel/core": "^7.20.0", diff --git a/mobile/services/api/add-attachment.api.ts b/mobile/services/api/add-attachment.api.ts index e89b6c4d8..6534a1662 100644 --- a/mobile/services/api/add-attachment.api.ts +++ b/mobile/services/api/add-attachment.api.ts @@ -1,20 +1,36 @@ -import { FileMetadata } from "../../hooks/useCamera"; import API from "../api"; +import axios from "axios"; /** ======================================================================== ================= POST addAttachment ==================== ======================================================================== @description Sends a photo/video to the backend to be saved - @param {AddAttachmentAPIPayload} payload + @param {AddAttachmentStartAPIPayload} payload @returns {AddAttachmentAPIResponse} */ -export type AddAttachmentAPIPayload = { +export type AddAttachmentStartAPIPayload = { id: string; + filePath: string; electionRoundId: string; pollingStationId: string; formId: string; questionId: string; - fileMetadata: FileMetadata; + fileName: string; + contentType: string; + numberOfUploadParts: number; +}; + +export type AddAttachmentCompleteAPIPayload = { + electionRoundId: string; + id: string; + uploadId: string; + etags: Record; +}; + +export type AddAttachmentAbortAPIPayload = { + electionRoundId: string; + id: string; + uploadId: string; }; export type AddAttachmentAPIResponse = { @@ -25,30 +41,71 @@ export type AddAttachmentAPIResponse = { urlValidityInSeconds: number; }; -export const addAttachment = ({ - id, +// Multipart Upload - Add Attachment - Question +export const addAttachmentMultipartStart = ({ electionRoundId, pollingStationId, - fileMetadata: cameraResult, + id, formId, questionId, -}: AddAttachmentAPIPayload): Promise => { - const formData = new FormData(); - - formData.append("attachment", { - uri: cameraResult.uri, - name: cameraResult.name, - type: cameraResult.type, - } as unknown as Blob); - - formData.append("id", id); - formData.append("pollingStationId", pollingStationId); - formData.append("formId", formId); - formData.append("questionId", questionId); - - return API.postForm(`election-rounds/${electionRoundId}/attachments`, formData, { - headers: { - "Content-Type": "multipart/form-data", + fileName, + contentType, + numberOfUploadParts, +}: AddAttachmentStartAPIPayload): Promise<{ + uploadId: string; + uploadUrls: Record; +}> => { + return API.post( + `election-rounds/${electionRoundId}/attachments:init`, + { + pollingStationId, + electionRoundId, + id, + formId, + questionId, + fileName, + contentType, + numberOfUploadParts, }, - }).then((res) => res.data); + {}, + ).then((res) => res.data); +}; + +export const addAttachmentMultipartComplete = async ({ + uploadId, + id, + etags, + electionRoundId, +}: AddAttachmentCompleteAPIPayload): Promise => { + return API.post( + `election-rounds/${electionRoundId}/attachments/${id}:complete`, + { uploadId, etags }, + {}, + ).then((res) => res.data); +}; + +export const addAttachmentMultipartAbort = async ({ + uploadId, + id, + electionRoundId, +}: AddAttachmentAbortAPIPayload): Promise => { + return API.post( + `election-rounds/${electionRoundId}/attachments/${id}:abort`, + { uploadId }, + {}, + ).then((res) => res.data); +}; + +// Upload S3 Chunk of bytes (Buffer (array of bytes) - not Base64 - still bytes but written differently) +export const uploadS3Chunk = async (url: string, chunk: any): Promise<{ ETag: string }> => { + return axios + .put(url, chunk, { + timeout: 100000, + headers: { + "Content-Type": "application/json", + }, + }) + .then((res) => { + return { ETag: res.headers.etag }; + }); }; diff --git a/mobile/services/api/quick-report/add-attachment-quick-report.api.ts b/mobile/services/api/quick-report/add-attachment-quick-report.api.ts index 719435f6c..282e1a130 100644 --- a/mobile/services/api/quick-report/add-attachment-quick-report.api.ts +++ b/mobile/services/api/quick-report/add-attachment-quick-report.api.ts @@ -1,3 +1,4 @@ +import axios from "axios"; import { FileMetadata } from "../../../hooks/useCamera"; import API from "../../api"; import { QuickReportAttachmentAPIResponse } from "./get-quick-reports.api"; @@ -6,41 +7,82 @@ import { QuickReportAttachmentAPIResponse } from "./get-quick-reports.api"; ================= POST addAttachmentQuickReport ==================== ======================================================================== @description Sends a photo/video to the backend to be saved - @param {AddAttachmentQuickReportAPIPayload} payload + @param {AddAttachmentQuickReportStartAPIPayload} payload @returns {AddAttachmentQuickReportAPIResponse} */ -export type AddAttachmentQuickReportAPIPayload = { +export type AddAttachmentQuickReportStartAPIPayload = { electionRoundId: string; quickReportId: string; id: string; - fileMetadata: FileMetadata; + fileName: string; + filePath: string; + contentType: string; + numberOfUploadParts: number; +}; + +export type AddAttachmentQuickReportCompleteAPIPayload = { + electionRoundId: string; + id: string; + quickReportId: string; + uploadId: string; + etags: Record; +}; + +export type AddAttachmentQuickReportAbortAPIPayload = { + electionRoundId: string; + id: string; + quickReportId: string; + uploadId: string; }; export type AddAttachmentQuickReportAPIResponse = QuickReportAttachmentAPIResponse; -export const addAttachmentQuickReport = ({ +// Multipart Upload - Add Attachment - Question +export const addAttachmentQuickReportMultipartStart = ({ electionRoundId, - quickReportId, id, - fileMetadata, -}: AddAttachmentQuickReportAPIPayload): Promise => { - const formData = new FormData(); - - formData.append("attachment", { - uri: fileMetadata.uri, - name: fileMetadata.name, - type: fileMetadata.type, - } as unknown as Blob); - - formData.append("id", id); - - return API.postForm( - `election-rounds/${electionRoundId}/quick-reports/${quickReportId}/attachments`, - formData, + quickReportId, + fileName, + contentType, + numberOfUploadParts, +}: AddAttachmentQuickReportStartAPIPayload): Promise<{ + uploadId: string; + uploadUrls: Record; +}> => { + return API.post( + `election-rounds/${electionRoundId}/quick-reports/${quickReportId}/attachments/${id}:init`, { - headers: { - "Content-Type": "multipart/form-data", - }, + fileName, + contentType, + numberOfUploadParts, }, + {}, + ).then((res) => res.data); +}; + +export const addAttachmentQuickReportMultipartComplete = async ({ + uploadId, + id, + etags, + electionRoundId, + quickReportId, +}: AddAttachmentQuickReportCompleteAPIPayload): Promise => { + return API.post( + `election-rounds/${electionRoundId}/quick-reports/${quickReportId}/attachments/${id}:complete`, + { uploadId, etags }, + {}, + ).then((res) => res.data); +}; + +export const addAttachmentQuickReportMultipartAbort = async ({ + uploadId, + id, + electionRoundId, + quickReportId, +}: AddAttachmentQuickReportAbortAPIPayload): Promise => { + return API.post( + `election-rounds/${electionRoundId}/quick-reports/${quickReportId}/attachments/${id}:abort`, + { uploadId }, + {}, ).then((res) => res.data); }; diff --git a/mobile/services/mutations/attachments/add-attachment.mutation.ts b/mobile/services/mutations/attachments/add-attachment.mutation.ts index 32bb8d4c1..627dca1fb 100644 --- a/mobile/services/mutations/attachments/add-attachment.mutation.ts +++ b/mobile/services/mutations/attachments/add-attachment.mutation.ts @@ -1,24 +1,152 @@ -import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { QueryClient, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { - AddAttachmentAPIPayload, - AddAttachmentAPIResponse, - addAttachment, + AddAttachmentStartAPIPayload, + addAttachmentMultipartAbort, + addAttachmentMultipartComplete, + addAttachmentMultipartStart, + uploadS3Chunk, } from "../../api/add-attachment.api"; import { AttachmentApiResponse } from "../../api/get-attachments.api"; import { AttachmentsKeys } from "../../queries/attachments.query"; +import * as FileSystem from "expo-file-system"; +import { MULTIPART_FILE_UPLOAD_SIZE } from "../../../common/constants"; +import * as Sentry from "@sentry/react-native"; +import { Buffer } from "buffer"; +import useStore from "../../store/store"; +import { AttachmentProgressStatusEnum } from "../../store/attachment-upload-state/attachment-upload-slice"; -export const addAttachmentMutation = (scopeId: string) => { - const queryClient = useQueryClient(); +// export const handleChunkUpload = async ( +// filePath: string, +// uploadUrls: Record, +// queryClient: QueryClient, +// ) => { +// console.log("Handle chunk upload"); + +// let etags: Record = {}; +// const urls = Object.values(uploadUrls); +// for (const [index, url] of urls.entries()) { +// const chunk = await FileSystem.readAsStringAsync(filePath, { +// length: MULTIPART_FILE_UPLOAD_SIZE, +// position: index * MULTIPART_FILE_UPLOAD_SIZE, +// encoding: FileSystem.EncodingType.Base64, +// }); +// const buffer = Buffer.from(chunk, "base64"); +// const data = await uploadS3Chunk(url, buffer); + +// const progress = Math.round(((index + 1) / urls.length) * 100 * 10) / 10; +// queryClient.setQueryData( +// AttachmentsKeys.addAttachments(), +// (oldData) => { +// const toReturn: UploadAttachmentProgress = { +// ...oldData, +// progress, +// status: progress === 100 ? "completed" : "inprogress", +// }; +// console.log("toReturnProgress in handleChunkUpload", toReturn); + +// return toReturn; +// }, +// ); + +// etags = { ...etags, [index + 1]: data.ETag }; +// } + +// return etags; +// }; + +export const uploadAttachmentMutationFn = async ( + payload: AddAttachmentStartAPIPayload, + setProgress: (fn: (prev: Record) => Record) => void, + state: any, +) => { + setProgress((state) => ({ + ...state, + [payload.id]: { + progress: 0, + status: AttachmentProgressStatusEnum.STARTING, + }, + })); + const start = await addAttachmentMultipartStart(payload); + try { + let etags: Record = {}; + const urls = Object.values(start.uploadUrls); + + for (const [index, url] of urls.entries()) { + const chunk = await FileSystem.readAsStringAsync(payload.filePath, { + length: MULTIPART_FILE_UPLOAD_SIZE, + position: index * MULTIPART_FILE_UPLOAD_SIZE, + encoding: FileSystem.EncodingType.Base64, + }); + const buffer = Buffer.from(chunk, "base64"); + const progress = Math.round(((index + 1) / urls.length) * 100 * 10) / 10; + + setProgress((state) => ({ + ...state, + [payload.id]: { + progress: progress, + status: + progress === 100 + ? AttachmentProgressStatusEnum.COMPLETED + : AttachmentProgressStatusEnum.INPROGRESS, + }, + })); + const data = await uploadS3Chunk(url, buffer); + + etags = { ...etags, [index + 1]: data.ETag }; + } + const completed = await addAttachmentMultipartComplete({ + uploadId: start.uploadId, + etags, + electionRoundId: payload.electionRoundId, + id: payload.id, + }); + + setProgress((state) => ({ + ...state, + [payload.id]: { + progress: 100, + status: AttachmentProgressStatusEnum.COMPLETED, + }, + })); + + return completed; + } catch (err) { + Sentry.captureMessage("Upload failed, aborting!"); + Sentry.captureException(err); + + const aborted = addAttachmentMultipartAbort({ + id: payload.id, + uploadId: start.uploadId, + electionRoundId: payload.electionRoundId, + }); + setProgress((state) => ({ + ...state, + [payload.id]: { + progress: 0, + status: AttachmentProgressStatusEnum.ABORTED, + }, + })); + return aborted; + } +}; + +export type UploadAttachmentProgress = { + progress: number; + status: AttachmentProgressStatusEnum; +}; + +export const useUploadAttachmentMutation = (scopeId: string) => { + const queryClient = useQueryClient(); + const { progresses: state, setProgresses } = useStore(); return useMutation({ mutationKey: AttachmentsKeys.addAttachmentMutation(), scope: { id: scopeId, }, - mutationFn: async (payload: AddAttachmentAPIPayload): Promise => { - return addAttachment(payload); - }, - onMutate: async (payload: AddAttachmentAPIPayload) => { + mutationFn: (payload: AddAttachmentStartAPIPayload) => + uploadAttachmentMutationFn(payload, setProgresses, state), + onMutate: async (payload: AddAttachmentStartAPIPayload) => { const attachmentsQK = AttachmentsKeys.attachments( payload.electionRoundId, payload.pollingStationId, @@ -37,9 +165,9 @@ export const addAttachmentMutation = (scopeId: string) => { pollingStationId: payload.pollingStationId, formId: payload.formId, questionId: payload.questionId, - fileName: `${payload.fileMetadata.name}`, - mimeType: payload.fileMetadata.type, - presignedUrl: payload.fileMetadata.uri, // TODO @radulescuandrew is this working to display the media? + fileName: `${payload.fileName}`, + mimeType: payload.contentType, + presignedUrl: payload.filePath, urlValidityInSeconds: 3600, isNotSynched: true, }, @@ -48,7 +176,6 @@ export const addAttachmentMutation = (scopeId: string) => { return { previousData, attachmentsQK }; }, onError: (err, payload, context) => { - console.log(err); const attachmentsQK = AttachmentsKeys.attachments( payload.electionRoundId, payload.pollingStationId, @@ -57,6 +184,12 @@ export const addAttachmentMutation = (scopeId: string) => { queryClient.setQueryData(attachmentsQK, context?.previousData); }, onSettled: (_data, _err, variables) => { + setProgresses((state) => { + const { [variables.id]: toDelete, ...rest } = state; + return { + ...rest, + }; + }); return queryClient.invalidateQueries({ queryKey: AttachmentsKeys.attachments( variables.electionRoundId, diff --git a/mobile/services/mutations/quick-report/add-attachment-quick-report.mutation.ts b/mobile/services/mutations/quick-report/add-attachment-quick-report.mutation.ts index 3a28eeca3..6b2589dca 100644 --- a/mobile/services/mutations/quick-report/add-attachment-quick-report.mutation.ts +++ b/mobile/services/mutations/quick-report/add-attachment-quick-report.mutation.ts @@ -1,16 +1,19 @@ import { useMutation } from "@tanstack/react-query"; import { QuickReportKeys } from "../../queries/quick-reports.query"; import { - AddAttachmentQuickReportAPIPayload, - addAttachmentQuickReport, + AddAttachmentQuickReportStartAPIPayload, + addAttachmentQuickReportMultipartStart, } from "../../api/quick-report/add-attachment-quick-report.api"; -export const addAttachmentQuickReportMutation = () => { +// Multipart Upload - Start +export const useUploadAttachmentQuickReportMutation = (scopeId: string) => { return useMutation({ mutationKey: QuickReportKeys.addAttachment(), - mutationFn: async (payload: AddAttachmentQuickReportAPIPayload) => { - return addAttachmentQuickReport(payload); + scope: { + id: scopeId, }, + mutationFn: (payload: AddAttachmentQuickReportStartAPIPayload) => + addAttachmentQuickReportMultipartStart(payload), onError: (err, _variables, _context) => { console.log(err); }, diff --git a/mobile/services/mutations/quick-report/add-quick-report.mutation.ts b/mobile/services/mutations/quick-report/add-quick-report.mutation.ts index 9bcc309d6..40285efaf 100644 --- a/mobile/services/mutations/quick-report/add-quick-report.mutation.ts +++ b/mobile/services/mutations/quick-report/add-quick-report.mutation.ts @@ -5,7 +5,7 @@ import { QuickReportsAPIResponse, } from "../../api/quick-report/get-quick-reports.api"; import { AddQuickReportAPIPayload } from "../../api/quick-report/post-quick-report.api"; -import { AddAttachmentQuickReportAPIPayload } from "../../api/quick-report/add-attachment-quick-report.api"; +import { AddAttachmentQuickReportStartAPIPayload } from "../../api/quick-report/add-attachment-quick-report.api"; export const useAddQuickReport = () => { const queryClient = useQueryClient(); @@ -15,7 +15,7 @@ export const useAddQuickReport = () => { onMutate: async ({ attachments, ...payload - }: AddQuickReportAPIPayload & { attachments: AddAttachmentQuickReportAPIPayload[] }) => { + }: AddQuickReportAPIPayload & { attachments: AddAttachmentQuickReportStartAPIPayload[] }) => { // Cancel any outgoing refetches // (so they don't overwrite our optimistic update) const queryKey = QuickReportKeys.byElectionRound(payload.electionRoundId); @@ -27,10 +27,10 @@ export const useAddQuickReport = () => { const attachmentsToUpdate: QuickReportAttachmentAPIResponse[] = attachments.map((attach) => { return { electionRoundId: attach.electionRoundId, - fileName: attach.fileMetadata.name, + fileName: attach.fileName, id: attach.id, - mimeType: attach.fileMetadata.type, - presignedUrl: attach.fileMetadata.uri, + mimeType: attach.contentType, + presignedUrl: attach.filePath, quickReportId: attach.quickReportId, urlValidityInSeconds: 0, }; @@ -60,7 +60,9 @@ export const useAddQuickReport = () => { onSettled: ( _data, _err, - variables: AddQuickReportAPIPayload & { attachments: AddAttachmentQuickReportAPIPayload[] }, + variables: AddQuickReportAPIPayload & { + attachments: AddAttachmentQuickReportStartAPIPayload[]; + }, ) => { const queryKey = QuickReportKeys.byElectionRound(variables.electionRoundId); return queryClient.invalidateQueries({ queryKey }); diff --git a/mobile/services/queries/attachments.query.ts b/mobile/services/queries/attachments.query.ts index e91d9683e..c958db521 100644 --- a/mobile/services/queries/attachments.query.ts +++ b/mobile/services/queries/attachments.query.ts @@ -17,8 +17,9 @@ export const AttachmentsKeys = { "formId", formId, ] as const, - addAttachmentMutation: () => [...AttachmentsKeys.all, "add"] as const, + addAttachmentMutation: () => [...AttachmentsKeys.all, "add", "mutation"] as const, deleteAttachment: () => [...AttachmentsKeys.all, "delete"] as const, + addAttachments: () => [...AttachmentsKeys.all, "progress"] as const, }; export const GuidesKeys = { diff --git a/mobile/services/store/attachment-upload-state/attachment-upload-selector.ts b/mobile/services/store/attachment-upload-state/attachment-upload-selector.ts new file mode 100644 index 000000000..386da5c20 --- /dev/null +++ b/mobile/services/store/attachment-upload-state/attachment-upload-selector.ts @@ -0,0 +1,9 @@ +import useStore from "../store"; +import { IAttachmentProgressState } from "./attachment-upload-slice"; + +export const useAttachmentUploadProgressState = () => { + const progresses: Record = useStore( + (state) => state.progresses, + ); + return { progresses }; +}; diff --git a/mobile/services/store/attachment-upload-state/attachment-upload-slice.ts b/mobile/services/store/attachment-upload-state/attachment-upload-slice.ts new file mode 100644 index 000000000..94758278d --- /dev/null +++ b/mobile/services/store/attachment-upload-state/attachment-upload-slice.ts @@ -0,0 +1,26 @@ +export enum AttachmentProgressStatusEnum { + IDLE = "idle", + COMPRESSING = "compressing", + STARTING = "starting", + INPROGRESS = "inprogress", + ABORTED = "aborted", + COMPLETED = "completed", +} + +export interface IAttachmentProgressState { + progress: number; + status: AttachmentProgressStatusEnum; +} + +export const attachmentUploadProgressSlice = (set: any) => ({ + progresses: {}, + setProgresses: ( + fn: ( + prev: Record, + ) => Record, + ) => { + set((state: any) => ({ progresses: fn(state.progresses) })); + }, +}); + +export default { attachmentUploadProgressSlice }; diff --git a/mobile/services/store/store.ts b/mobile/services/store/store.ts new file mode 100644 index 000000000..3715aef92 --- /dev/null +++ b/mobile/services/store/store.ts @@ -0,0 +1,19 @@ +import { create } from "zustand"; +import { + AttachmentProgressStatusEnum, + IAttachmentProgressState, + attachmentUploadProgressSlice, +} from "./attachment-upload-state/attachment-upload-slice"; + +interface AttchmentUploadProgressState { + progresses: {}; + setProgresses: ( + fn: (prev: Record) => Record, + ) => void; +} + +const useStore = create()((set: any) => ({ + ...attachmentUploadProgressSlice(set), +})); + +export default useStore;