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 { mediaUploadFailure, 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); // 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(mediaUploadSuccess(photo)); } 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}` if (bodyshop.uselocalmediaserver) { yield call(uploadToLocalMediaServer, photo, photoBlob, extension, key, bodyshop, jobid); } else { 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(photo, key) { try { // yield put(mediaUploadProgress({ photoId, status: 'uploading', progress: 25 })); // const formData = new FormData(); // formData.append('file', { // uri: photo.uri, // type: photo.type || 'image/jpeg', // name: photo.fileName || `photo_${Date.now()}.jpg`, // }); // yield put(mediaUploadProgress({ photoId, status: 'uploading', progress: 50 })); // const response = yield call(fetch, 'YOUR_LOCAL_MEDIA_SERVER_ENDPOINT', { // method: 'POST', // body: formData, // headers: { // 'Content-Type': 'multipart/form-data', // }, // }); // yield put(mediaUploadProgress({ photoId, status: 'uploading', progress: 75 })); // if (!response.ok) { // throw new Error(`Upload failed: ${response.status}`); // } // const result = yield call([response, 'json']); // yield put(mediaUploadProgress({ photoId, status: 'completed', progress: 100 })); // return result; } catch (error) { throw new Error(`Local media server upload failed: ${error.message}`); } } 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) => { console.log("*** ~ awaitnewPromise ~ event:", e); if (e.lengthComputable) { console.log(`Upload progress for ${photo.uri}:`, e.loaded / e.total); put(mediaUploadProgressOne({ ...photo, progress: e.loaded / e.total })); } }; 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}`); const documentInsert = 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 } : {}), }, ], }, })); console.log("*** ~ uploadToImageProxy ~ documentInsert:", JSON.stringify(documentInsert, null, 2)); } } 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) ]); }