import axios from "axios"; import Constants from "expo-constants"; import * as ImagePicker from "expo-image-picker"; import { END, eventChannel } from 'redux-saga'; import { all, call, delay, fork, put, select, take, takeEvery, takeLatest } from "redux-saga/effects"; import env from "../../env"; import { axiosAuthInterceptorId } from "../../util/CleanAxios"; import { fetchArrayBufferFromUri, fetchImageFromUri, replaceAccents } from '../../util/uploadUtils'; import { selectBodyshop } from "../user/user.selectors"; import { mediaUploadComplete, mediaUploadFailure, mediaUploadProgressOne, mediaUploadStart } 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, }); 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(mediaUploadComplete()); } catch (error) { console.log("Saga Error: upload start", error); 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 photoArrayBuffer = yield fetchArrayBufferFromUri(photo.uri); 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, photoArrayBuffer, key, bodyshop, jobid); } else { yield call(uploadToImageProxy, photo, photoBlob, photoArrayBuffer, key, bodyshop, jobid); } // yield put(mediaUploadSuccess({ photoId, photo })); } catch (error) { console.log(`Upload failed for photo ${photo.assetId}:`, 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, photoArrayBuffer, 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]; // Create an eventChannel to bridge imperative upload progress events into the saga world const uploadChannel = yield call(createAxiosUploadChannel, { url: preSignedUploadUrlToS3, data: photoArrayBuffer, method: 'put', headers: { 'Content-Type': photoBlob.type, 'Content-Length': photoBlob.size, }, }); try { while (true) { const { progress, error, success } = yield take(uploadChannel); if (progress != null) { yield put(mediaUploadProgressOne({ ...photo, progress })); } else if (error) { throw error; } else if (success) { yield put(mediaUploadProgressOne({ ...photo, progress: 100, status: 'completed' })); break; } } } finally { uploadChannel.close(); } } catch (error) { console.log("Error uploading to image proxy", JSON.stringify(error)); throw new Error(`Image proxy upload failed: ${error.message}`); } } // Helper: turn axios upload progress into an eventChannel so we can yield put from saga function createAxiosUploadChannel({ url, data, method = 'put', headers }) { return eventChannel(emitter => { const controller = new AbortController(); cleanAxios({ method, url, data, headers, signal: controller.signal, // NOTE: cannot yield here, so we emit events and handle them in saga loop onUploadProgress: (e) => { console.log("Upload progress event:", e); if (e && e.total) { emitter({ progress: e.loaded / e.total }); } } }).then(() => { emitter({ success: true }); emitter(END); }).catch(err => { emitter({ error: err }); emitter(END); }); // Unsubscribe / cancellation logic return () => { controller.abort() }; }); } // 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) ]); }