import axios from "axios"; import Constants from "expo-constants"; import * as ImagePicker from "expo-image-picker"; import moment from 'moment'; import { all, call, delay, fork, put, select, takeEvery, takeLatest } from "redux-saga/effects"; import env from "../../env"; import { client } from '../../graphql/client'; import { INSERT_NEW_DOCUMENT } from "../../graphql/documents.queries"; import { axiosAuthInterceptorId } from "../../util/CleanAxios"; import { fetchImageFromUri, replaceAccents } from '../../util/uploadUtils'; import { selectBodyshop, selectCurrentUser } from "../user/user.selectors"; import { mediaUploadCompleted, mediaUploadFailure, mediaUploadProgressBulk, mediaUploadProgressOne, mediaUploadStart, mediaUploadSuccessOne } from "./photos.actions"; import PhotosActionTypes from "./photos.types"; //Required to prevent headers from getting set and rejected from Cloudinary. let cleanAxios = axios.create(); cleanAxios.interceptors.request.eject(axiosAuthInterceptorId); export function* onOpenImagePicker() { yield takeLatest(PhotosActionTypes.OPEN_IMAGE_PICKER, openImagePickerAction); } export function* openImagePickerAction({ payload: jobid }) { try { if (Constants.platform.ios) { const cameraRollStatus = yield ImagePicker.requestMediaLibraryPermissionsAsync(); const cameraStatus = yield ImagePicker.requestCameraPermissionsAsync(); if ( cameraRollStatus.status !== "granted" || cameraStatus.status !== "granted" ) { alert("Sorry, we need these permissions to make this work!"); return; } } let result = yield ImagePicker.launchImageLibraryAsync({ mediaTypes: ["images", "videos"], aspect: [4, 3], quality: 1, allowsMultipleSelection: true, exif: true, }); if (!(result.canceled)) { yield put(mediaUploadStart({ photos: result.assets, jobid })); } } catch (error) { console.log("Saga Error: open Picker", error); } } export function* onMediaUploadStart() { yield takeLatest(PhotosActionTypes.MEDIA_UPLOAD_START, mediaUploadStartAction); } export function* mediaUploadStartAction({ payload: { photos, jobid } }) { try { console.log("Starting upload for", photos.length, "photos"); const bodyshop = yield select(selectBodyshop); if (bodyshop.uselocalmediaserver) { yield call(uploadToLocalMediaServer, photos, bodyshop, jobid); } else { // Process photos in batches to avoid overwhelming the system const batchSize = 3; // Upload 3 photos concurrently const batches = []; for (let i = 0; i < photos.length; i += batchSize) { batches.push(photos.slice(i, i + batchSize)); } // Process each batch sequentially, but photos within batch concurrently for (const batch of batches) { const uploadTasks = batch.map((photo, index) => fork(uploadSinglePhoto, photo, bodyshop, index, jobid) ); // Wait for current batch to complete before starting next batch yield all(uploadTasks); // Small delay between batches to prevent overwhelming the server yield delay(100); } } yield put(mediaUploadCompleted()); } catch (error) { console.log("Saga Error: upload start", error, error.stack); yield put(mediaUploadFailure(error.message)); } } function* uploadSinglePhoto(photo, bodyshop, index, jobid) { try { yield put(mediaUploadProgressOne({ ...photo, status: 'starting', progress: 0 })); const photoBlob = yield fetchImageFromUri(photo.uri); //has a name, and type. const extension = photoBlob.data?.name?.split('.').pop(); const key = `${bodyshop.id}/${jobid}/${replaceAccents( photoBlob.data.name ).replace(/[^A-Z0-9]+/gi, "_")}-${new Date().getTime()}.${extension}` yield call(uploadToImageProxy, photo, photoBlob, extension, key, bodyshop, jobid); yield put(mediaUploadSuccessOne(photo)); } catch (error) { console.log(`Upload failed for photo ${photo.uri}:`, error); yield put(mediaUploadFailure({ ...photo, status: "error", error: error.message })); } } function* uploadToLocalMediaServer(photos, bodyshop, jobid) { try { const options = { headers: { "Content-Type": "multipart/form-data", ims_token: bodyshop.localmediatoken, }, onUploadProgress: (e) => { put(mediaUploadProgressBulk({ progress: e.loaded / e.total, loaded: e.loaded })); }, }; const formData = new FormData(); formData.append("jobid", jobid); for (const file of photos) { formData.append("file", { uri: file.uri, type: file.mimeType, name: file.fileName, }); } formData.append("skip_thumbnail", true); try { const imexMediaServerResponse = yield call(axios.post, `${bodyshop.localmediaserverhttp}/jobs/upload`, formData, options ); if (imexMediaServerResponse.status !== 200) { console.log("Error uploading documents:", JSON.stringify(imexMediaServerResponse, null, 2)); } else { // onSuccess({ // duration: imexMediaServerResponse.headers["x-response-time"], // }); } } catch (error) { console.log("Error uploading documents:", error.message, JSON.stringify(error, null, 2)); } } catch (error) { console.log("Uncaught error", error); } } function* uploadToImageProxy(photo, photoBlob, extension, key, bodyshop, jobid) { try { yield put(mediaUploadProgressOne({ ...photo, status: 'uploading', })); //Get the signed url allowing us to PUT to S3. const signedURLResponse = yield call(axios.post, `${env.API_URL}/media/imgproxy/sign`, { filenames: [key], bodyshopid: bodyshop.id, jobid, } ); if (signedURLResponse.status !== 200) { console.log("Error Getting Signed URL", signedURLResponse.statusText); throw new Error(`Error getting signed URL : ${signedURLResponse.statusText}`); } const { presignedUrl: preSignedUploadUrlToS3, key: s3Key } = signedURLResponse.data.signedUrls[0]; let uploadResult try { uploadResult = yield new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open("PUT", preSignedUploadUrlToS3); xhr.setRequestHeader("Content-Type", photoBlob.type); xhr.upload.onprogress = (e) => { if (e.lengthComputable) { put(mediaUploadProgressOne({ ...photo, progress: e.loaded / e.total, loaded: e.loaded })); } }; xhr.onload = () => { if (xhr.status === 200) { resolve(true); } else { reject(new Error(`Upload failed: ${xhr.statusText}`)); } }; xhr.onerror = (req, event) => { reject(new Error("Network error")); }; xhr.send(photoBlob); }); } catch (error) { console.log("Error uploading to S3", error.message, error.stack); throw error; } if (uploadResult) { //Create doc record. const uploaded_by = yield select(selectCurrentUser); const [date, time] = photo.exif?.DateTime?.split(' ') || []; const [year, month, day] = date ? date.split(':') : []; const [hours, minutes, seconds] = time ? time.split(':') : []; const pictureMoment = moment(`${year}-${month}-${day}T${hours}:${minutes}:${seconds}`); yield call(client.mutate, ({ mutation: INSERT_NEW_DOCUMENT, variables: { docInput: [ { ...(jobid ? { jobid: jobid } : {}), uploaded_by: uploaded_by.email, key: s3Key, type: photoBlob.type, extension: extension, bodyshopid: bodyshop.id, size: photoBlob.size, ...(photo.exif?.DateTime //TODO :Need to find how to do this. ? { takenat: pictureMoment } : {}), }, ], }, })); } } catch (error) { console.log("Error uploading to image proxy", JSON.stringify(error)); throw new Error(`Image proxy upload failed: ${error.message}`); } } // Handle cancellation of uploads export function* onCancelUpload() { yield takeEvery(PhotosActionTypes.CANCEL_UPLOAD, cancelUploadAction); } function* cancelUploadAction({ payload: photoId }) { // const task = uploadTasks.get(photoId); // if (task) { // yield cancel(task); // uploadTasks.delete(photoId); // yield put(mediaUploadFailure({ photoId, error: 'Upload cancelled' })); // } } export function* photosSagas() { yield all([ call(onOpenImagePicker), call(onMediaUploadStart), //call(onCancelUpload) ]); }