diff --git a/redux/photos/photos.actions.js b/redux/photos/photos.actions.js index a6ff986..7be5545 100644 --- a/redux/photos/photos.actions.js +++ b/redux/photos/photos.actions.js @@ -18,4 +18,9 @@ export const mediaUploadProgressOne = (progressUpdate) => ({ export const mediaUploadFailure = (error) => ({ type: PhotosActionTypes.MEDIA_UPLOAD_FAILURE, payload: error, +}); + +export const mediaUploadSuccessOne = (photo) => ({ + type: PhotosActionTypes.MEDIA_UPLOAD_SUCCESS_ONE, + payload: photo, }); \ No newline at end of file diff --git a/redux/photos/photos.reducer.js b/redux/photos/photos.reducer.js index 3e92fb6..2892e71 100644 --- a/redux/photos/photos.reducer.js +++ b/redux/photos/photos.reducer.js @@ -30,6 +30,11 @@ const photosReducer = (state = INITIAL_STATE, action) => { ...state, progress: { ...state.progress, [action.payload.assetId]: { ...state.progress[action.payload.assetId], ...action.payload } } }; + case PhotosActionTypes.MEDIA_UPLOAD_SUCCESS_ONE: + return { + ...state, + progress: { ...state.progress, [action.payload.assetId]: { ...state.progress[action.payload.assetId], progress: 100, status: 'completed' } } + }; default: return state; diff --git a/redux/photos/photos.sagas.js b/redux/photos/photos.sagas.js index b9f343e..2c8b176 100644 --- a/redux/photos/photos.sagas.js +++ b/redux/photos/photos.sagas.js @@ -1,18 +1,21 @@ 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 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 { fetchArrayBufferFromUri, fetchImageFromUri, replaceAccents } from '../../util/uploadUtils'; -import { selectBodyshop } from "../user/user.selectors"; +import { fetchImageFromUri, replaceAccents } from '../../util/uploadUtils'; +import { selectBodyshop, selectCurrentUser } from "../user/user.selectors"; import { - mediaUploadComplete, mediaUploadFailure, mediaUploadProgressOne, - mediaUploadStart + mediaUploadStart, + mediaUploadSuccessOne } from "./photos.actions"; + import PhotosActionTypes from "./photos.types"; //Required to prevent headers from getting set and rejected from Cloudinary. @@ -44,6 +47,7 @@ export function* openImagePickerAction({ payload: jobid }) { aspect: [4, 3], quality: 1, allowsMultipleSelection: true, + exif: true, }); if (!(result.canceled)) { yield put(mediaUploadStart({ photos: result.assets, jobid })); @@ -80,10 +84,10 @@ export function* mediaUploadStartAction({ payload: { photos, jobid } }) { // Small delay between batches to prevent overwhelming the server yield delay(100); } - yield put(mediaUploadComplete()); + //yield put(mediaUploadSuccess(photo)); } catch (error) { - console.log("Saga Error: upload start", error); + console.log("Saga Error: upload start", error, error.stack); yield put(mediaUploadFailure(error.message)); } } @@ -93,8 +97,6 @@ function* uploadSinglePhoto(photo, bodyshop, index, jobid) { 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( @@ -102,15 +104,15 @@ function* uploadSinglePhoto(photo, bodyshop, index, jobid) { ).replace(/[^A-Z0-9]+/gi, "_")}-${new Date().getTime()}.${extension}` if (bodyshop.uselocalmediaserver) { - yield call(uploadToLocalMediaServer, photo, photoBlob, photoArrayBuffer, key, bodyshop, jobid); + yield call(uploadToLocalMediaServer, photo, photoBlob, extension, key, bodyshop, jobid); } else { - yield call(uploadToImageProxy, photo, photoBlob, photoArrayBuffer, key, bodyshop, jobid); + yield call(uploadToImageProxy, photo, photoBlob, extension, key, bodyshop, jobid); } - // yield put(mediaUploadSuccess({ photoId, photo })); + yield put(mediaUploadSuccessOne(photo)); } catch (error) { - console.log(`Upload failed for photo ${photo.assetId}:`, error); + console.log(`Upload failed for photo ${photo.uri}:`, error); yield put(mediaUploadFailure({ ...photo, status: "error", error: error.message })); } } @@ -152,7 +154,7 @@ function* uploadToLocalMediaServer(photo, key) { } } -function* uploadToImageProxy(photo, photoBlob, photoArrayBuffer, key, bodyshop, jobid) { +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. @@ -172,31 +174,69 @@ function* uploadToImageProxy(photo, photoBlob, photoArrayBuffer, key, bodyshop, 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, - }, - }); - + let uploadResult 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(); + 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) { @@ -205,38 +245,6 @@ function* uploadToImageProxy(photo, photoBlob, photoArrayBuffer, key, bodyshop, } } -// 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); diff --git a/redux/photos/photos.types.js b/redux/photos/photos.types.js index ebede2c..b62d45d 100644 --- a/redux/photos/photos.types.js +++ b/redux/photos/photos.types.js @@ -1,7 +1,7 @@ const PhotosActionTypes = { OPEN_IMAGE_PICKER: "OPEN_IMAGE_PICKER", MEDIA_UPLOAD_START: "MEDIA_UPLOAD_START", - MEDIUA_UPLOAD_SUCCESS: "MEDIA_UPLOAD_SUCCESS", + MEDIA_UPLOAD_SUCCESS_ONE: "MEDIA_UPLOAD_SUCCESS_ONE", MEDIA_UPLOAD_FAILURE: "MEDIA_UPLOAD_FAILURE", MEDIA_UPLOAD_PROGRESS_UPDATE_ONE: "MEDIA_UPLOAD_PROGRESS_UPDATE_ONE", };