From 055c47e377b927e0640b90b1d539db5fc558054f Mon Sep 17 00:00:00 2001 From: Taye-Staats Date: Fri, 17 Apr 2026 16:26:13 -0700 Subject: [PATCH 1/3] resolve merge conflicts using latest dependencies --- tsconfig.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tsconfig.json b/tsconfig.json index 4e0d695..f051b9c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,5 +8,6 @@ "resolveJsonModule": true, "strict": true }, - "exclude": ["node_modules", "dist", "build", ".expo"] + "exclude": ["node_modules", "dist", "build", ".expo"], + "extends": "expo/tsconfig.base" } From c7ccb4fadaa552ea71cb7335477e2b8d940a007d Mon Sep 17 00:00:00 2001 From: Taye-Staats Date: Sat, 18 Apr 2026 16:17:15 -0700 Subject: [PATCH 2/3] Fix native image uploads and move login helper out of app --- .../app/auth/__tests__/loginRedirect.test.ts | 2 +- frontend/app/auth/login.tsx | 2 +- frontend/app/profile/edit.tsx | 110 ++++++------ frontend/components/ImageUploader.tsx | 107 +++++++----- frontend/{app => lib}/auth/loginRedirect.ts | 0 frontend/package.json | 5 +- package-lock.json | 161 +++++++++++++++--- 7 files changed, 260 insertions(+), 127 deletions(-) rename frontend/{app => lib}/auth/loginRedirect.ts (100%) diff --git a/frontend/app/auth/__tests__/loginRedirect.test.ts b/frontend/app/auth/__tests__/loginRedirect.test.ts index b4f7726..3f98e2d 100644 --- a/frontend/app/auth/__tests__/loginRedirect.test.ts +++ b/frontend/app/auth/__tests__/loginRedirect.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from '@jest/globals'; -import { getLoginEntryAction } from '../loginRedirect'; +import { getLoginEntryAction } from '../../../lib/auth/loginRedirect'; describe('getLoginEntryAction', () => { it('keeps waiting while the profile query is unresolved', () => { diff --git a/frontend/app/auth/login.tsx b/frontend/app/auth/login.tsx index 98676e5..704c5b5 100644 --- a/frontend/app/auth/login.tsx +++ b/frontend/app/auth/login.tsx @@ -20,7 +20,7 @@ import { getEmailValidationError, PROFILE_BOUNDS } from '@polybuys/shared'; import { useEntranceAnimation } from '../../hooks/useEntranceAnimation'; import { useAuth } from '../../hooks/useAuth'; import { requestPermissionAndSyncToken } from '../../hooks/usePushNotifications'; -import { getLoginEntryAction, type LoginStep } from './loginRedirect'; +import { getLoginEntryAction, type LoginStep } from '../../lib/auth/loginRedirect'; import { colors, typography, spacing, borderRadius } from '../../theme/tokens'; const APP_REVIEW_EMAIL = (process.env.EXPO_PUBLIC_APP_REVIEW_EMAIL ?? '').toLowerCase().trim(); diff --git a/frontend/app/profile/edit.tsx b/frontend/app/profile/edit.tsx index 9bae9a4..9e1ecff 100644 --- a/frontend/app/profile/edit.tsx +++ b/frontend/app/profile/edit.tsx @@ -12,6 +12,7 @@ import { useRouter } from 'expo-router'; import { useMutation, useQuery } from 'convex/react'; import { api } from 'convex/_generated/api'; import { Id } from 'convex/_generated/dataModel'; +import * as FileSystem from 'expo-file-system/legacy'; import * as ImagePicker from 'expo-image-picker'; import { SaveFormat, manipulateAsync } from 'expo-image-manipulator'; import { getEmailValidationError } from '@polybuys/shared'; @@ -40,74 +41,61 @@ type UploadOptions = { async function uploadImageToConvex( uploadUrl: string, - blob: Blob, + fileUri: string, { signal, timeoutMs = PROFILE_IMAGE_BOUNDS.UPLOAD_TIMEOUT_MS }: UploadOptions = {} ): Promise> { - return await new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest(); - let settled = false; - - const rejectOnce = (error: Error) => { - if (settled) return; - settled = true; - cleanup(); - reject(error); - }; - const resolveOnce = (value: Id<'_storage'>) => { - if (settled) return; - settled = true; - cleanup(); - resolve(value); - }; + const task = FileSystem.createUploadTask(uploadUrl, fileUri, { + headers: { + 'Content-Type': 'image/jpeg', + }, + httpMethod: 'POST', + uploadType: FileSystem.FileSystemUploadType.BINARY_CONTENT, + }); + + const abortPromise = new Promise((_, reject) => { const onAbort = () => { - try { - xhr.abort(); - } catch { - // no-op - } - rejectOnce(new Error('Image upload was cancelled.')); - }; - const cleanup = () => { - xhr.onerror = null; - xhr.onload = null; - xhr.onabort = null; - xhr.ontimeout = null; - signal?.removeEventListener('abort', onAbort); + void task.cancelAsync().catch(() => undefined); + reject(new Error('Image upload was cancelled.')); }; if (signal?.aborted) { - rejectOnce(new Error('Image upload was cancelled.')); + onAbort(); return; } - xhr.open('POST', uploadUrl); - xhr.timeout = timeoutMs; - xhr.setRequestHeader('Content-Type', blob.type || 'image/jpeg'); + signal?.addEventListener('abort', onAbort, { once: true }); + }); - xhr.onerror = () => rejectOnce(new Error('Network error during image upload.')); - xhr.onabort = () => rejectOnce(new Error('Image upload was cancelled.')); - xhr.ontimeout = () => rejectOnce(new Error('Image upload timed out. Please try again.')); - xhr.onload = () => { - if (xhr.status < 200 || xhr.status >= 300) { - rejectOnce(new Error(`Image upload failed (${xhr.status}).`)); - return; - } + const timeoutPromise = new Promise((_, reject) => { + const timeout = setTimeout(() => { + void task.cancelAsync().catch(() => undefined); + reject(new Error('Image upload timed out. Please try again.')); + }, timeoutMs); - try { - const parsed = JSON.parse(xhr.responseText) as { storageId?: Id<'_storage'> }; - if (!parsed.storageId) { - rejectOnce(new Error('Upload response missing storage ID.')); - return; - } - resolveOnce(parsed.storageId); - } catch { - rejectOnce(new Error('Upload response could not be parsed.')); - } - }; - - signal?.addEventListener('abort', onAbort, { once: true }); - xhr.send(blob); + void Promise.resolve().finally(() => clearTimeout(timeout)); }); + + const result = await Promise.race([task.uploadAsync(), abortPromise, timeoutPromise]); + + if (!result) { + throw new Error('Image upload was cancelled.'); + } + if (result.status < 200 || result.status >= 300) { + throw new Error(`Image upload failed (${result.status}).`); + } + + try { + const parsed = JSON.parse(result.body) as { storageId?: Id<'_storage'> }; + if (!parsed.storageId) { + throw new Error('Upload response missing storage ID.'); + } + return parsed.storageId; + } catch (error) { + if (error instanceof Error && error.message === 'Upload response missing storage ID.') { + throw error; + } + throw new Error('Upload response could not be parsed.'); + } } export default function ProfileEditScreen() { @@ -210,9 +198,12 @@ export default function ProfileEditScreen() { format: SaveFormat.JPEG, }); - const blob = await (await fetch(manipulated.uri)).blob(); + const fileInfo = await FileSystem.getInfoAsync(manipulated.uri); const maxBytes = PROFILE_IMAGE_BOUNDS.MAX_FILE_SIZE_MB * 1024 * 1024; - if (blob.size > maxBytes) { + if (!fileInfo.exists || fileInfo.isDirectory) { + throw new Error('Prepared profile image file is missing.'); + } + if (fileInfo.size > maxBytes) { throw new Error( `Profile image is too large after compression (max ${PROFILE_IMAGE_BOUNDS.MAX_FILE_SIZE_MB} MB).` ); @@ -268,12 +259,11 @@ export default function ProfileEditScreen() { let nextPicture: Id<'_storage'> | null = picture; if (pendingPictureUri) { - const blob = await (await fetch(pendingPictureUri)).blob(); const uploadUrl = await generateUploadUrl({}); uploadAbortRef.current?.abort(); const abortController = new AbortController(); uploadAbortRef.current = abortController; - nextPicture = await uploadImageToConvex(uploadUrl, blob, { + nextPicture = await uploadImageToConvex(uploadUrl, pendingPictureUri, { signal: abortController.signal, }); uploadAbortRef.current = null; diff --git a/frontend/components/ImageUploader.tsx b/frontend/components/ImageUploader.tsx index 8e10583..54b4e96 100644 --- a/frontend/components/ImageUploader.tsx +++ b/frontend/components/ImageUploader.tsx @@ -11,6 +11,7 @@ import { Text, View, } from 'react-native'; +import * as FileSystem from 'expo-file-system/legacy'; import { colors, borderRadius, typography, spacing } from '../theme/tokens'; import * as ImagePicker from 'expo-image-picker'; import { SaveFormat, manipulateAsync } from 'expo-image-manipulator'; @@ -43,6 +44,8 @@ type PickedImage = { isObjectUrl?: boolean; }; +const IMAGE_UPLOADER_LOG_PREFIX = '[ImageUploader]'; + function UploadProgressBar({ progress, compact = false }: { progress: number; compact?: boolean }) { const clampedProgress = Math.max(0, Math.min(1, progress)); const animatedProgress = useRef(new Animated.Value(clampedProgress)).current; @@ -226,56 +229,65 @@ export default function ImageUploader({ format: SaveFormat.JPEG, }); - const blobResponse = await fetch(manipulated.uri); - const blob = await blobResponse.blob(); - const maxBytes = maxFileSizeMB * 1024 * 1024; - if (blob.size > maxBytes) { + const fileInfo = await FileSystem.getInfoAsync(manipulated.uri); + if (!fileInfo.exists || fileInfo.isDirectory) { + throw new Error('Compressed image file is missing.'); + } + if (fileInfo.size > maxBytes) { throw new Error(`Image is too large after compression (max ${maxFileSizeMB} MB).`); } return { - blob, uri: manipulated.uri, }; } - async function uploadToConvex(blob: Blob, onProgress: (progress: number) => void) { + async function uploadToConvex(fileUri: string, onProgress: (progress: number) => void) { const uploadUrl = await generateUploadUrl({}); - - return await new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest(); - xhr.open('POST', uploadUrl); - xhr.setRequestHeader('Content-Type', blob.type || 'image/jpeg'); - - xhr.upload.onprogress = (event) => { - if (!event.lengthComputable) { - return; - } - onProgress(event.loaded / event.total); - }; - - xhr.onerror = () => reject(new Error('Network error during upload.')); - xhr.onload = () => { - if (xhr.status < 200 || xhr.status >= 300) { - reject(new Error(`Upload failed (${xhr.status}).`)); + const task = FileSystem.createUploadTask( + uploadUrl, + fileUri, + { + headers: { + 'Content-Type': 'image/jpeg', + }, + httpMethod: 'POST', + uploadType: FileSystem.FileSystemUploadType.BINARY_CONTENT, + }, + (progressEvent) => { + if (progressEvent.totalBytesExpectedToSend <= 0) { return; } + onProgress(progressEvent.totalBytesSent / progressEvent.totalBytesExpectedToSend); + } + ); + const result = await task.uploadAsync(); - try { - const parsed = JSON.parse(xhr.responseText) as { storageId?: string }; - if (!parsed.storageId) { - reject(new Error('Upload response missing storage ID.')); - return; - } - resolve(parsed.storageId); - } catch { - reject(new Error('Upload response could not be parsed.')); - } - }; + if (!result) { + throw new Error('Upload was cancelled.'); + } + if (result.status < 200 || result.status >= 300) { + const responseText = typeof result.body === 'string' ? result.body.trim().slice(0, 200) : ''; + throw new Error( + responseText + ? `Upload failed (${result.status}): ${responseText}` + : `Upload failed (${result.status}).` + ); + } - xhr.send(blob); - }); + try { + const parsed = JSON.parse(result.body) as { storageId?: string }; + if (!parsed.storageId) { + throw new Error('Upload response missing storage ID.'); + } + return parsed.storageId; + } catch (error) { + if (error instanceof Error && error.message === 'Upload response missing storage ID.') { + throw error; + } + throw new Error('Upload response could not be parsed.'); + } } function removeImage(imageId: string) { @@ -285,7 +297,7 @@ export default function ImageUploader({ async function startUpload(picked: PickedImage, localId: string) { try { const compressed = await compressAndValidate(picked); - const storageId = await uploadToConvex(compressed.blob, (progress) => { + const storageId = await uploadToConvex(compressed.uri, (progress) => { setPendingUploads((prev) => prev.map((upload) => (upload.localId === localId ? { ...upload, progress } : upload)) ); @@ -299,13 +311,20 @@ export default function ImageUploader({ revokeObjectUrl(localId); onImagesChange((prev) => [...prev, storageId]); } catch (error) { + const resolvedError = + error instanceof Error ? error : new Error('Upload failed for an unknown reason.'); + console.error(IMAGE_UPLOADER_LOG_PREFIX, 'Upload failed', { + localId, + pickedUri: picked.uri, + message: resolvedError.message, + }); setPendingUploads((prev) => prev.map((upload) => upload.localId === localId ? { ...upload, status: 'error', - error: error instanceof Error ? error.message : 'Upload failed', + error: resolvedError.message, } : upload ) @@ -489,7 +508,12 @@ export default function ImageUploader({ ) : ( - Failed + + Failed + {upload.error ? ( + {upload.error} + ) : null} + )} @@ -690,6 +714,11 @@ const styles = StyleSheet.create({ color: colors.errorText, fontWeight: '700', }, + errorDetailText: { + ...typography.footnote, + color: colors.white, + textAlign: 'center', + }, errorActions: { flexDirection: 'row', gap: 6, diff --git a/frontend/app/auth/loginRedirect.ts b/frontend/lib/auth/loginRedirect.ts similarity index 100% rename from frontend/app/auth/loginRedirect.ts rename to frontend/lib/auth/loginRedirect.ts diff --git a/frontend/package.json b/frontend/package.json index 15962c2..d356533 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -37,10 +37,11 @@ "react": "19.2.0", "react-dom": "19.2.0", "react-native": "0.83.2", - "react-native-reanimated": "^3.19.2", + "react-native-reanimated": "4.2.1", "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.23.0", - "react-native-web": "^0.21.0" + "react-native-web": "^0.21.0", + "react-native-worklets": "0.7.2" }, "devDependencies": { "@babel/core": "^7.26.0", diff --git a/package-lock.json b/package-lock.json index 22974d1..a523fda 100644 --- a/package-lock.json +++ b/package-lock.json @@ -87,10 +87,11 @@ "react": "19.2.0", "react-dom": "19.2.0", "react-native": "0.83.2", - "react-native-reanimated": "^3.19.2", + "react-native-reanimated": "4.2.1", "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.23.0", - "react-native-web": "^0.21.0" + "react-native-web": "^0.21.0", + "react-native-worklets": "0.7.2" }, "devDependencies": { "@babel/core": "^7.26.0", @@ -106,6 +107,92 @@ "lightningcss-linux-x64-gnu": "1.31.1" } }, + "frontend/node_modules/@babel/plugin-transform-class-properties": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.27.1.tgz", + "integrity": "sha512-D0VcalChDMtuRvJIu3U/fwWjf8ZMykz5iZsg77Nuj821vCKI3zCyRLwRdWbsuJ/uRwZhZ002QtCqIkwC/ZkvbA==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "frontend/node_modules/@babel/plugin-transform-classes": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.4.tgz", + "integrity": "sha512-cFOlhIYPBv/iBoc+KS3M6et2XPtbT2HiCRfBXWtfpc9OAyostldxIf9YAYB6ypURBBbx+Qv6nyrLzASfJe+hBA==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-globals": "^7.28.0", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-replace-supers": "^7.27.1", + "@babel/traverse": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "frontend/node_modules/@babel/plugin-transform-nullish-coalescing-operator": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.27.1.tgz", + "integrity": "sha512-aGZh6xMo6q9vq1JGcw58lZ1Z0+i0xB2x0XaauNIUXd6O1xXc3RwoWEBlsTQrY4KQ9Jf0s5rgD6SiNkaUdJegTA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "frontend/node_modules/@babel/plugin-transform-optional-chaining": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.27.1.tgz", + "integrity": "sha512-BQmKPPIuc8EkZgNKsv0X4bPmOoayeu4F1YCwx2/CfmDSXDbp7GnzlUH+/ul5VGfRg1AoFPsrIThlEBj2xb4CAg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "frontend/node_modules/@babel/preset-typescript": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.27.1.tgz", + "integrity": "sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-option": "^7.27.1", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-transform-modules-commonjs": "^7.27.1", + "@babel/plugin-transform-typescript": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "frontend/node_modules/@types/node": { "version": "24.11.0", "dev": true, @@ -115,40 +202,66 @@ } }, "frontend/node_modules/react-native-reanimated": { - "version": "3.19.2", - "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-3.19.2.tgz", - "integrity": "sha512-RpnmoNwmR4y2kyGcAtGUG31mbPtR4E0Q73dFa9jhoKFBecaHEHxz0/oWL0H+zuEy7dLxR8oslGGlu6+fcCMm+g==", - "license": "MIT", - "dependencies": { - "@babel/plugin-transform-arrow-functions": "^7.0.0-0", - "@babel/plugin-transform-class-properties": "^7.0.0-0", - "@babel/plugin-transform-classes": "^7.0.0-0", - "@babel/plugin-transform-nullish-coalescing-operator": "^7.0.0-0", - "@babel/plugin-transform-optional-chaining": "^7.0.0-0", - "@babel/plugin-transform-shorthand-properties": "^7.0.0-0", - "@babel/plugin-transform-template-literals": "^7.0.0-0", - "@babel/plugin-transform-unicode-regex": "^7.0.0-0", - "@babel/preset-typescript": "^7.16.7", - "convert-source-map": "^2.0.0", - "invariant": "^2.2.4", - "react-native-is-edge-to-edge": "1.1.7" + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.2.1.tgz", + "integrity": "sha512-/NcHnZMyOvsD/wYXug/YqSKw90P9edN0kEPL5lP4PFf1aQ4F1V7MKe/E0tvfkXKIajy3Qocp5EiEnlcrK/+BZg==", + "license": "MIT", + "dependencies": { + "react-native-is-edge-to-edge": "1.2.1", + "semver": "7.7.3" }, "peerDependencies": { - "@babel/core": "^7.0.0-0", "react": "*", - "react-native": "*" + "react-native": "*", + "react-native-worklets": ">=0.7.0" } }, "frontend/node_modules/react-native-reanimated/node_modules/react-native-is-edge-to-edge": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.1.7.tgz", - "integrity": "sha512-EH6i7E8epJGIcu7KpfXYXiV2JFIYITtq+rVS8uEb+92naMRBdxhTuS8Wn2Q7j9sqyO0B+Xbaaf9VdipIAmGW4w==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.2.1.tgz", + "integrity": "sha512-FLbPWl/MyYQWz+KwqOZsSyj2JmLKglHatd3xLZWskXOpRaio4LfEDEz8E/A6uD8QoTHW6Aobw1jbEwK7KMgR7Q==", + "license": "MIT", + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "frontend/node_modules/react-native-worklets": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.7.2.tgz", + "integrity": "sha512-DuLu1kMV/Uyl9pQHp3hehAlThoLw7Yk2FwRTpzASOmI+cd4845FWn3m2bk9MnjUw8FBRIyhwLqYm2AJaXDXsog==", "license": "MIT", + "dependencies": { + "@babel/plugin-transform-arrow-functions": "7.27.1", + "@babel/plugin-transform-class-properties": "7.27.1", + "@babel/plugin-transform-classes": "7.28.4", + "@babel/plugin-transform-nullish-coalescing-operator": "7.27.1", + "@babel/plugin-transform-optional-chaining": "7.27.1", + "@babel/plugin-transform-shorthand-properties": "7.27.1", + "@babel/plugin-transform-template-literals": "7.27.1", + "@babel/plugin-transform-unicode-regex": "7.27.1", + "@babel/preset-typescript": "7.27.1", + "convert-source-map": "2.0.0", + "semver": "7.7.3" + }, "peerDependencies": { + "@babel/core": "*", "react": "*", "react-native": "*" } }, + "frontend/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "frontend/node_modules/undici-types": { "version": "7.16.0", "dev": true, From b171c38b3fc22a11805244af91bf70d151544f96 Mon Sep 17 00:00:00 2001 From: Taye-Staats Date: Sat, 18 Apr 2026 16:32:00 -0700 Subject: [PATCH 3/3] fix: address upload timeout and web image upload paths --- frontend/app/profile/edit.tsx | 15 +- frontend/components/ImageUploader.tsx | 90 ++++++++- package-lock.json | 267 +++++++++++++------------- package.json | 7 +- 4 files changed, 229 insertions(+), 150 deletions(-) diff --git a/frontend/app/profile/edit.tsx b/frontend/app/profile/edit.tsx index 9e1ecff..f3e6f02 100644 --- a/frontend/app/profile/edit.tsx +++ b/frontend/app/profile/edit.tsx @@ -51,7 +51,9 @@ async function uploadImageToConvex( httpMethod: 'POST', uploadType: FileSystem.FileSystemUploadType.BINARY_CONTENT, }); + const uploadPromise = task.uploadAsync(); + let timeoutHandle: ReturnType | null = null; const abortPromise = new Promise((_, reject) => { const onAbort = () => { void task.cancelAsync().catch(() => undefined); @@ -67,15 +69,20 @@ async function uploadImageToConvex( }); const timeoutPromise = new Promise((_, reject) => { - const timeout = setTimeout(() => { + timeoutHandle = setTimeout(() => { void task.cancelAsync().catch(() => undefined); reject(new Error('Image upload timed out. Please try again.')); }, timeoutMs); - - void Promise.resolve().finally(() => clearTimeout(timeout)); }); - const result = await Promise.race([task.uploadAsync(), abortPromise, timeoutPromise]); + let result: Awaited; + try { + result = await Promise.race([uploadPromise, abortPromise, timeoutPromise]); + } finally { + if (timeoutHandle !== null) { + clearTimeout(timeoutHandle); + } + } if (!result) { throw new Error('Image upload was cancelled.'); diff --git a/frontend/components/ImageUploader.tsx b/frontend/components/ImageUploader.tsx index 54b4e96..f33941e 100644 --- a/frontend/components/ImageUploader.tsx +++ b/frontend/components/ImageUploader.tsx @@ -44,6 +44,11 @@ type PickedImage = { isObjectUrl?: boolean; }; +type PreparedUpload = { + uri: string; + blob?: Blob; +}; + const IMAGE_UPLOADER_LOG_PREFIX = '[ImageUploader]'; function UploadProgressBar({ progress, compact = false }: { progress: number; compact?: boolean }) { @@ -201,7 +206,7 @@ export default function ImageUploader({ }; } - async function compressAndValidate(picked: PickedImage) { + async function compressAndValidate(picked: PickedImage): Promise { let workingUri = picked.uri; let originalWidth = picked.width; let originalHeight = picked.height; @@ -230,6 +235,21 @@ export default function ImageUploader({ }); const maxBytes = maxFileSizeMB * 1024 * 1024; + if (Platform.OS === 'web') { + const response = await fetch(manipulated.uri); + if (!response.ok) { + throw new Error(`Unable to read image data (${response.status}).`); + } + const blob = await response.blob(); + if (blob.size > maxBytes) { + throw new Error(`Image is too large after compression (max ${maxFileSizeMB} MB).`); + } + return { + uri: manipulated.uri, + blob, + }; + } + const fileInfo = await FileSystem.getInfoAsync(manipulated.uri); if (!fileInfo.exists || fileInfo.isDirectory) { throw new Error('Compressed image file is missing.'); @@ -243,8 +263,60 @@ export default function ImageUploader({ }; } - async function uploadToConvex(fileUri: string, onProgress: (progress: number) => void) { + async function uploadToConvex( + fileUri: string, + onProgress: (progress: number) => void, + blob?: Blob + ) { const uploadUrl = await generateUploadUrl({}); + if (Platform.OS === 'web') { + if (!blob) { + throw new Error('Missing image data for web upload.'); + } + + return await new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open('POST', uploadUrl); + xhr.setRequestHeader('Content-Type', blob.type || 'image/jpeg'); + + xhr.upload.onprogress = (progressEvent) => { + if (!progressEvent.lengthComputable) { + return; + } + onProgress(progressEvent.loaded / progressEvent.total); + }; + + xhr.onerror = () => reject(new Error('Network error during upload.')); + xhr.onload = () => { + if (xhr.status < 200 || xhr.status >= 300) { + const responseText = + typeof xhr.responseText === 'string' ? xhr.responseText.trim().slice(0, 200) : ''; + reject( + new Error( + responseText + ? `Upload failed (${xhr.status}): ${responseText}` + : `Upload failed (${xhr.status}).` + ) + ); + return; + } + + try { + const parsed = JSON.parse(xhr.responseText) as { storageId?: string }; + if (!parsed.storageId) { + reject(new Error('Upload response missing storage ID.')); + return; + } + resolve(parsed.storageId); + } catch { + reject(new Error('Upload response could not be parsed.')); + } + }; + + xhr.send(blob); + }); + } + const task = FileSystem.createUploadTask( uploadUrl, fileUri, @@ -297,11 +369,15 @@ export default function ImageUploader({ async function startUpload(picked: PickedImage, localId: string) { try { const compressed = await compressAndValidate(picked); - const storageId = await uploadToConvex(compressed.uri, (progress) => { - setPendingUploads((prev) => - prev.map((upload) => (upload.localId === localId ? { ...upload, progress } : upload)) - ); - }); + const storageId = await uploadToConvex( + compressed.uri, + (progress) => { + setPendingUploads((prev) => + prev.map((upload) => (upload.localId === localId ? { ...upload, progress } : upload)) + ); + }, + compressed.blob + ); setPendingUploads((prev) => prev.map((upload) => (upload.localId === localId ? { ...upload, progress: 1 } : upload)) diff --git a/package-lock.json b/package-lock.json index a523fda..4889ea3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,11 +28,12 @@ "eslint-plugin-react": "^7.37.2", "eslint-plugin-react-hooks": "^5.1.0", "eslint-plugin-react-native": "^4.1.0", - "expo-router": "~55.0.3", "husky": "^9.1.7", "jest": "^29.7.0", "lint-staged": "^15.2.10", "prettier": "^3.4.2", + "react-native-reanimated": "4.2.1", + "react-native-worklets": "0.7.2", "ts-jest": "^29.2.5", "typescript": "^5.7.2" }, @@ -111,6 +112,7 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.27.1.tgz", "integrity": "sha512-D0VcalChDMtuRvJIu3U/fwWjf8ZMykz5iZsg77Nuj821vCKI3zCyRLwRdWbsuJ/uRwZhZ002QtCqIkwC/ZkvbA==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-create-class-features-plugin": "^7.27.1", @@ -123,30 +125,11 @@ "@babel/core": "^7.0.0-0" } }, - "frontend/node_modules/@babel/plugin-transform-classes": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.4.tgz", - "integrity": "sha512-cFOlhIYPBv/iBoc+KS3M6et2XPtbT2HiCRfBXWtfpc9OAyostldxIf9YAYB6ypURBBbx+Qv6nyrLzASfJe+hBA==", - "license": "MIT", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-globals": "^7.28.0", - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-replace-supers": "^7.27.1", - "@babel/traverse": "^7.28.4" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, "frontend/node_modules/@babel/plugin-transform-nullish-coalescing-operator": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.27.1.tgz", "integrity": "sha512-aGZh6xMo6q9vq1JGcw58lZ1Z0+i0xB2x0XaauNIUXd6O1xXc3RwoWEBlsTQrY4KQ9Jf0s5rgD6SiNkaUdJegTA==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" @@ -162,6 +145,7 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.27.1.tgz", "integrity": "sha512-BQmKPPIuc8EkZgNKsv0X4bPmOoayeu4F1YCwx2/CfmDSXDbp7GnzlUH+/ul5VGfRg1AoFPsrIThlEBj2xb4CAg==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", @@ -174,25 +158,6 @@ "@babel/core": "^7.0.0-0" } }, - "frontend/node_modules/@babel/preset-typescript": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.27.1.tgz", - "integrity": "sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-validator-option": "^7.27.1", - "@babel/plugin-syntax-jsx": "^7.27.1", - "@babel/plugin-transform-modules-commonjs": "^7.27.1", - "@babel/plugin-transform-typescript": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, "frontend/node_modules/@types/node": { "version": "24.11.0", "dev": true, @@ -201,67 +166,6 @@ "undici-types": "~7.16.0" } }, - "frontend/node_modules/react-native-reanimated": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.2.1.tgz", - "integrity": "sha512-/NcHnZMyOvsD/wYXug/YqSKw90P9edN0kEPL5lP4PFf1aQ4F1V7MKe/E0tvfkXKIajy3Qocp5EiEnlcrK/+BZg==", - "license": "MIT", - "dependencies": { - "react-native-is-edge-to-edge": "1.2.1", - "semver": "7.7.3" - }, - "peerDependencies": { - "react": "*", - "react-native": "*", - "react-native-worklets": ">=0.7.0" - } - }, - "frontend/node_modules/react-native-reanimated/node_modules/react-native-is-edge-to-edge": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.2.1.tgz", - "integrity": "sha512-FLbPWl/MyYQWz+KwqOZsSyj2JmLKglHatd3xLZWskXOpRaio4LfEDEz8E/A6uD8QoTHW6Aobw1jbEwK7KMgR7Q==", - "license": "MIT", - "peerDependencies": { - "react": "*", - "react-native": "*" - } - }, - "frontend/node_modules/react-native-worklets": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.7.2.tgz", - "integrity": "sha512-DuLu1kMV/Uyl9pQHp3hehAlThoLw7Yk2FwRTpzASOmI+cd4845FWn3m2bk9MnjUw8FBRIyhwLqYm2AJaXDXsog==", - "license": "MIT", - "dependencies": { - "@babel/plugin-transform-arrow-functions": "7.27.1", - "@babel/plugin-transform-class-properties": "7.27.1", - "@babel/plugin-transform-classes": "7.28.4", - "@babel/plugin-transform-nullish-coalescing-operator": "7.27.1", - "@babel/plugin-transform-optional-chaining": "7.27.1", - "@babel/plugin-transform-shorthand-properties": "7.27.1", - "@babel/plugin-transform-template-literals": "7.27.1", - "@babel/plugin-transform-unicode-regex": "7.27.1", - "@babel/preset-typescript": "7.27.1", - "convert-source-map": "2.0.0", - "semver": "7.7.3" - }, - "peerDependencies": { - "@babel/core": "*", - "react": "*", - "react-native": "*" - } - }, - "frontend/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "frontend/node_modules/undici-types": { "version": "7.16.0", "dev": true, @@ -14577,29 +14481,35 @@ } }, "node_modules/react-native-reanimated": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.3.0.tgz", - "integrity": "sha512-HOTTPdKtddXTOsmQxDASXEwLS3lqEHrKERD3XOgzSqWJ7L3x81Pnx7mTcKx1FKdkgomMug/XSmm1C6Z7GIowxA==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.2.1.tgz", + "integrity": "sha512-/NcHnZMyOvsD/wYXug/YqSKw90P9edN0kEPL5lP4PFf1aQ4F1V7MKe/E0tvfkXKIajy3Qocp5EiEnlcrK/+BZg==", "license": "MIT", - "optional": true, - "peer": true, "dependencies": { - "react-native-is-edge-to-edge": "^1.3.1", - "semver": "^7.7.3" + "react-native-is-edge-to-edge": "1.2.1", + "semver": "7.7.3" }, "peerDependencies": { "react": "*", - "react-native": "0.81 - 0.85", - "react-native-worklets": "0.8.x" + "react-native": "*", + "react-native-worklets": ">=0.7.0" + } + }, + "node_modules/react-native-reanimated/node_modules/react-native-is-edge-to-edge": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.2.1.tgz", + "integrity": "sha512-FLbPWl/MyYQWz+KwqOZsSyj2JmLKglHatd3xLZWskXOpRaio4LfEDEz8E/A6uD8QoTHW6Aobw1jbEwK7KMgR7Q==", + "license": "MIT", + "peerDependencies": { + "react": "*", + "react-native": "*" } }, "node_modules/react-native-reanimated/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "license": "ISC", - "optional": true, - "peer": true, "bin": { "semver": "bin/semver.js" }, @@ -14654,39 +14564,120 @@ "license": "MIT" }, "node_modules/react-native-worklets": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.8.1.tgz", - "integrity": "sha512-oWP/lStsAHU6oYCaWDXrda/wOHVdhusQJz1e6x9gPnXdFf4ndNDAOtWCmk2zGrAnlapfyA3rM6PCQq94mPg9cw==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.7.2.tgz", + "integrity": "sha512-DuLu1kMV/Uyl9pQHp3hehAlThoLw7Yk2FwRTpzASOmI+cd4845FWn3m2bk9MnjUw8FBRIyhwLqYm2AJaXDXsog==", "license": "MIT", - "optional": true, - "peer": true, "dependencies": { - "@babel/plugin-transform-arrow-functions": "^7.27.1", - "@babel/plugin-transform-class-properties": "^7.27.1", - "@babel/plugin-transform-classes": "^7.28.4", - "@babel/plugin-transform-nullish-coalescing-operator": "^7.27.1", - "@babel/plugin-transform-optional-chaining": "^7.27.1", - "@babel/plugin-transform-shorthand-properties": "^7.27.1", - "@babel/plugin-transform-template-literals": "^7.27.1", - "@babel/plugin-transform-unicode-regex": "^7.27.1", - "@babel/preset-typescript": "^7.27.1", - "convert-source-map": "^2.0.0", - "semver": "^7.7.3" + "@babel/plugin-transform-arrow-functions": "7.27.1", + "@babel/plugin-transform-class-properties": "7.27.1", + "@babel/plugin-transform-classes": "7.28.4", + "@babel/plugin-transform-nullish-coalescing-operator": "7.27.1", + "@babel/plugin-transform-optional-chaining": "7.27.1", + "@babel/plugin-transform-shorthand-properties": "7.27.1", + "@babel/plugin-transform-template-literals": "7.27.1", + "@babel/plugin-transform-unicode-regex": "7.27.1", + "@babel/preset-typescript": "7.27.1", + "convert-source-map": "2.0.0", + "semver": "7.7.3" }, "peerDependencies": { "@babel/core": "*", - "@react-native/metro-config": "*", "react": "*", - "react-native": "0.81 - 0.85" + "react-native": "*" + } + }, + "node_modules/react-native-worklets/node_modules/@babel/plugin-transform-class-properties": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.27.1.tgz", + "integrity": "sha512-D0VcalChDMtuRvJIu3U/fwWjf8ZMykz5iZsg77Nuj821vCKI3zCyRLwRdWbsuJ/uRwZhZ002QtCqIkwC/ZkvbA==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/react-native-worklets/node_modules/@babel/plugin-transform-classes": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.4.tgz", + "integrity": "sha512-cFOlhIYPBv/iBoc+KS3M6et2XPtbT2HiCRfBXWtfpc9OAyostldxIf9YAYB6ypURBBbx+Qv6nyrLzASfJe+hBA==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-globals": "^7.28.0", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-replace-supers": "^7.27.1", + "@babel/traverse": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/react-native-worklets/node_modules/@babel/plugin-transform-nullish-coalescing-operator": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.27.1.tgz", + "integrity": "sha512-aGZh6xMo6q9vq1JGcw58lZ1Z0+i0xB2x0XaauNIUXd6O1xXc3RwoWEBlsTQrY4KQ9Jf0s5rgD6SiNkaUdJegTA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/react-native-worklets/node_modules/@babel/plugin-transform-optional-chaining": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.27.1.tgz", + "integrity": "sha512-BQmKPPIuc8EkZgNKsv0X4bPmOoayeu4F1YCwx2/CfmDSXDbp7GnzlUH+/ul5VGfRg1AoFPsrIThlEBj2xb4CAg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/react-native-worklets/node_modules/@babel/preset-typescript": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.27.1.tgz", + "integrity": "sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-option": "^7.27.1", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-transform-modules-commonjs": "^7.27.1", + "@babel/plugin-transform-typescript": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, "node_modules/react-native-worklets/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "license": "ISC", - "optional": true, - "peer": true, "bin": { "semver": "bin/semver.js" }, diff --git a/package.json b/package.json index cf8bf5d..8f9198f 100644 --- a/package.json +++ b/package.json @@ -35,11 +35,12 @@ "eslint-plugin-react": "^7.37.2", "eslint-plugin-react-hooks": "^5.1.0", "eslint-plugin-react-native": "^4.1.0", - "expo-router": "~55.0.3", "husky": "^9.1.7", "jest": "^29.7.0", "lint-staged": "^15.2.10", "prettier": "^3.4.2", + "react-native-reanimated": "4.2.1", + "react-native-worklets": "0.7.2", "ts-jest": "^29.2.5", "typescript": "^5.7.2" }, @@ -51,6 +52,10 @@ "node": "20.19.4", "npm": "10.8.0" }, + "overrides": { + "react-native-reanimated": "4.2.1", + "react-native-worklets": "0.7.2" + }, "lint-staged": { "*.{js,jsx,ts,tsx}": [ "eslint --fix",