Basic upload and document insert working for imgproxy.

This commit is contained in:
Patrick Fic
2025-10-14 13:10:32 -07:00
parent 9bab7080e6
commit 7fe1ea65f2
4 changed files with 90 additions and 72 deletions

View File

@@ -18,4 +18,9 @@ export const mediaUploadProgressOne = (progressUpdate) => ({
export const mediaUploadFailure = (error) => ({ export const mediaUploadFailure = (error) => ({
type: PhotosActionTypes.MEDIA_UPLOAD_FAILURE, type: PhotosActionTypes.MEDIA_UPLOAD_FAILURE,
payload: error, payload: error,
});
export const mediaUploadSuccessOne = (photo) => ({
type: PhotosActionTypes.MEDIA_UPLOAD_SUCCESS_ONE,
payload: photo,
}); });

View File

@@ -30,6 +30,11 @@ const photosReducer = (state = INITIAL_STATE, action) => {
...state, ...state,
progress: { ...state.progress, [action.payload.assetId]: { ...state.progress[action.payload.assetId], ...action.payload } } 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: default:
return state; return state;

View File

@@ -1,18 +1,21 @@
import axios from "axios"; import axios from "axios";
import Constants from "expo-constants"; import Constants from "expo-constants";
import * as ImagePicker from "expo-image-picker"; import * as ImagePicker from "expo-image-picker";
import { END, eventChannel } from 'redux-saga'; import moment from 'moment';
import { all, call, delay, fork, put, select, take, takeEvery, takeLatest } from "redux-saga/effects"; import { all, call, delay, fork, put, select, takeEvery, takeLatest } from "redux-saga/effects";
import env from "../../env"; import env from "../../env";
import { client } from '../../graphql/client';
import { INSERT_NEW_DOCUMENT } from "../../graphql/documents.queries";
import { axiosAuthInterceptorId } from "../../util/CleanAxios"; import { axiosAuthInterceptorId } from "../../util/CleanAxios";
import { fetchArrayBufferFromUri, fetchImageFromUri, replaceAccents } from '../../util/uploadUtils'; import { fetchImageFromUri, replaceAccents } from '../../util/uploadUtils';
import { selectBodyshop } from "../user/user.selectors"; import { selectBodyshop, selectCurrentUser } from "../user/user.selectors";
import { import {
mediaUploadComplete,
mediaUploadFailure, mediaUploadFailure,
mediaUploadProgressOne, mediaUploadProgressOne,
mediaUploadStart mediaUploadStart,
mediaUploadSuccessOne
} from "./photos.actions"; } from "./photos.actions";
import PhotosActionTypes from "./photos.types"; import PhotosActionTypes from "./photos.types";
//Required to prevent headers from getting set and rejected from Cloudinary. //Required to prevent headers from getting set and rejected from Cloudinary.
@@ -44,6 +47,7 @@ export function* openImagePickerAction({ payload: jobid }) {
aspect: [4, 3], aspect: [4, 3],
quality: 1, quality: 1,
allowsMultipleSelection: true, allowsMultipleSelection: true,
exif: true,
}); });
if (!(result.canceled)) { if (!(result.canceled)) {
yield put(mediaUploadStart({ photos: result.assets, jobid })); 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 // Small delay between batches to prevent overwhelming the server
yield delay(100); yield delay(100);
} }
yield put(mediaUploadComplete()); //yield put(mediaUploadSuccess(photo));
} catch (error) { } catch (error) {
console.log("Saga Error: upload start", error); console.log("Saga Error: upload start", error, error.stack);
yield put(mediaUploadFailure(error.message)); yield put(mediaUploadFailure(error.message));
} }
} }
@@ -93,8 +97,6 @@ function* uploadSinglePhoto(photo, bodyshop, index, jobid) {
yield put(mediaUploadProgressOne({ ...photo, status: 'starting', progress: 0 })); yield put(mediaUploadProgressOne({ ...photo, status: 'starting', progress: 0 }));
const photoBlob = yield fetchImageFromUri(photo.uri); //has a name, and type. 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 extension = photoBlob.data?.name?.split('.').pop();
const key = `${bodyshop.id}/${jobid}/${replaceAccents( 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}` ).replace(/[^A-Z0-9]+/gi, "_")}-${new Date().getTime()}.${extension}`
if (bodyshop.uselocalmediaserver) { if (bodyshop.uselocalmediaserver) {
yield call(uploadToLocalMediaServer, photo, photoBlob, photoArrayBuffer, key, bodyshop, jobid); yield call(uploadToLocalMediaServer, photo, photoBlob, extension, key, bodyshop, jobid);
} else { } 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) { } 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 })); 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 { try {
yield put(mediaUploadProgressOne({ ...photo, status: 'uploading', })); yield put(mediaUploadProgressOne({ ...photo, status: 'uploading', }));
//Get the signed url allowing us to PUT to S3. //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 } = const { presignedUrl: preSignedUploadUrlToS3, key: s3Key } =
signedURLResponse.data.signedUrls[0]; signedURLResponse.data.signedUrls[0];
// Create an eventChannel to bridge imperative upload progress events into the saga world let uploadResult
const uploadChannel = yield call(createAxiosUploadChannel, {
url: preSignedUploadUrlToS3,
data: photoArrayBuffer,
method: 'put',
headers: {
'Content-Type': photoBlob.type,
'Content-Length': photoBlob.size,
},
});
try { try {
while (true) { uploadResult = yield new Promise((resolve, reject) => {
const { progress, error, success } = yield take(uploadChannel); const xhr = new XMLHttpRequest();
if (progress != null) { xhr.open("PUT", preSignedUploadUrlToS3);
yield put(mediaUploadProgressOne({ ...photo, progress })); xhr.setRequestHeader("Content-Type", photoBlob.type);
} else if (error) {
throw error; xhr.upload.onprogress = (e) => {
} else if (success) { console.log("*** ~ awaitnewPromise ~ event:", e);
yield put(mediaUploadProgressOne({ ...photo, progress: 100, status: 'completed' })); if (e.lengthComputable) {
break; console.log(`Upload progress for ${photo.uri}:`, e.loaded / e.total);
} put(mediaUploadProgressOne({ ...photo, progress: e.loaded / e.total }));
} }
} finally { };
uploadChannel.close();
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) { } 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 // Handle cancellation of uploads
export function* onCancelUpload() { export function* onCancelUpload() {
yield takeEvery(PhotosActionTypes.CANCEL_UPLOAD, cancelUploadAction); yield takeEvery(PhotosActionTypes.CANCEL_UPLOAD, cancelUploadAction);

View File

@@ -1,7 +1,7 @@
const PhotosActionTypes = { const PhotosActionTypes = {
OPEN_IMAGE_PICKER: "OPEN_IMAGE_PICKER", OPEN_IMAGE_PICKER: "OPEN_IMAGE_PICKER",
MEDIA_UPLOAD_START: "MEDIA_UPLOAD_START", 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_FAILURE: "MEDIA_UPLOAD_FAILURE",
MEDIA_UPLOAD_PROGRESS_UPDATE_ONE: "MEDIA_UPLOAD_PROGRESS_UPDATE_ONE", MEDIA_UPLOAD_PROGRESS_UPDATE_ONE: "MEDIA_UPLOAD_PROGRESS_UPDATE_ONE",
}; };