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..f3e6f02 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,68 @@ 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 uploadPromise = task.uploadAsync(); + + let timeoutHandle: ReturnType | null = null; + 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) => { + timeoutHandle = 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.')); - } - }; + let result: Awaited; + try { + result = await Promise.race([uploadPromise, abortPromise, timeoutPromise]); + } finally { + if (timeoutHandle !== null) { + clearTimeout(timeoutHandle); + } + } - signal?.addEventListener('abort', onAbort, { once: true }); - xhr.send(blob); - }); + 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 +205,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 +266,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..f33941e 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,13 @@ 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 }) { const clampedProgress = Math.max(0, Math.min(1, progress)); const animatedProgress = useRef(new Animated.Value(clampedProgress)).current; @@ -198,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; @@ -226,56 +234,132 @@ 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) { + 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.'); + } + 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, + 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'); + 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.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.onerror = () => reject(new Error('Network error during upload.')); - xhr.onload = () => { - if (xhr.status < 200 || xhr.status >= 300) { - reject(new Error(`Upload failed (${xhr.status}).`)); + xhr.send(blob); + }); + } + + 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,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.blob, (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)) @@ -299,13 +387,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 +584,12 @@ export default function ImageUploader({ ) : ( - Failed + + Failed + {upload.error ? ( + {upload.error} + ) : null} + )} @@ -690,6 +790,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..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" }, @@ -87,10 +88,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,47 +108,62 @@ "lightningcss-linux-x64-gnu": "1.31.1" } }, - "frontend/node_modules/@types/node": { - "version": "24.11.0", + "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==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.16.0" + "@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/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==", + "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/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" + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" }, "peerDependencies": { - "@babel/core": "^7.0.0-0", - "react": "*", - "react-native": "*" + "@babel/core": "^7.0.0-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==", + "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==", + "dev": true, "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": { - "react": "*", - "react-native": "*" + "@babel/core": "^7.0.0-0" + } + }, + "frontend/node_modules/@types/node": { + "version": "24.11.0", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" } }, "frontend/node_modules/undici-types": { @@ -14464,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" }, @@ -14541,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==", - "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" + "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-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", 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" }