diff --git a/package/expo-package/src/optionalDependencies/__tests__/getPhotos.test.ts b/package/expo-package/src/optionalDependencies/__tests__/getPhotos.test.ts new file mode 100644 index 0000000000..24afe40d6c --- /dev/null +++ b/package/expo-package/src/optionalDependencies/__tests__/getPhotos.test.ts @@ -0,0 +1,86 @@ +jest.mock( + 'expo-media-library', + () => ({ + MediaType: { + photo: 'photo', + video: 'video', + }, + SortBy: { + modificationTime: 'modificationTime', + }, + getAssetsAsync: jest.fn(), + getPermissionsAsync: jest.fn(), + requestPermissionsAsync: jest.fn(), + }), + { virtual: true }, +); + +jest.mock('../getLocalAssetUri', () => ({ + getLocalAssetUri: jest.fn(), +})); + +import * as MediaLibrary from 'expo-media-library'; + +import { getLocalAssetUri } from '../getLocalAssetUri'; +import { getPhotos } from '../getPhotos'; + +const mockedMediaLibrary = MediaLibrary as { + getAssetsAsync: jest.Mock; + getPermissionsAsync: jest.Mock; + requestPermissionsAsync: jest.Mock; +}; + +const mockedGetLocalAssetUri = getLocalAssetUri as jest.Mock; + +describe('getPhotos', () => { + beforeEach(() => { + mockedMediaLibrary.getPermissionsAsync.mockResolvedValue({ + accessPrivileges: 'all', + status: 'granted', + }); + mockedMediaLibrary.requestPermissionsAsync.mockResolvedValue({ + status: 'granted', + }); + mockedMediaLibrary.getAssetsAsync.mockReset(); + mockedGetLocalAssetUri.mockReset(); + mockedGetLocalAssetUri.mockResolvedValue(undefined); + }); + + it('falls back to media-type mime strings when filename mime detection returns null', async () => { + mockedMediaLibrary.getAssetsAsync.mockResolvedValue({ + assets: [ + { + duration: 0, + filename: 'IMG_0001', + height: 100, + id: 'photo-1', + mediaType: MediaLibrary.MediaType.photo, + uri: 'ph://photo-1', + width: 200, + }, + { + duration: 12, + filename: 'VID_0002', + height: 300, + id: 'video-1', + mediaType: MediaLibrary.MediaType.video, + uri: 'ph://video-1', + width: 400, + }, + ], + endCursor: undefined, + hasNextPage: false, + }); + + const result = await getPhotos({ after: undefined, first: 20 }); + + expect(result.assets).toEqual([ + expect.objectContaining({ + type: 'image/*', + }), + expect.objectContaining({ + type: 'video/*', + }), + ]); + }); +}); diff --git a/package/expo-package/src/optionalDependencies/getPhotos.ts b/package/expo-package/src/optionalDependencies/getPhotos.ts index 1647350cea..c0bbe84696 100644 --- a/package/expo-package/src/optionalDependencies/getPhotos.ts +++ b/package/expo-package/src/optionalDependencies/getPhotos.ts @@ -1,4 +1,5 @@ import { Platform } from 'react-native'; + import mime from 'mime'; import type { File } from 'stream-chat-react-native-core'; @@ -54,7 +55,9 @@ export const getPhotos = MediaLibrary const assets = await Promise.all( results.assets.map(async (asset) => { const localUri = await getLocalAssetUri(asset.id); - const mimeType = mime.getType(asset.filename); + const mimeType = + mime.getType(asset.filename || asset.uri) || + (asset.mediaType === MediaLibrary.MediaType.video ? 'video/*' : 'image/*'); return { duration: asset.duration * 1000, height: asset.height, diff --git a/package/expo-package/src/optionalDependencies/pickDocument.ts b/package/expo-package/src/optionalDependencies/pickDocument.ts index 5071c8d729..b906fcdbbf 100644 --- a/package/expo-package/src/optionalDependencies/pickDocument.ts +++ b/package/expo-package/src/optionalDependencies/pickDocument.ts @@ -1,3 +1,5 @@ +import mime from 'mime'; + let DocumentPicker; try { @@ -40,7 +42,10 @@ export const pickDocument = DocumentPicker return { assets: assets.map((asset) => ({ ...asset, - type: asset.mimeType, + type: + asset.mimeType || + mime.getType(asset.name || asset.uri) || + 'application/octet-stream', })), cancelled: false, }; @@ -50,7 +55,10 @@ export const pickDocument = DocumentPicker assets: [ { ...rest, - type: rest.mimeType, + type: + rest.mimeType || + mime.getType(rest.name || rest.uri) || + 'application/octet-stream', }, ], cancelled: false, diff --git a/package/expo-package/src/optionalDependencies/pickImage.ts b/package/expo-package/src/optionalDependencies/pickImage.ts index 280710b4c2..7f441b6a74 100644 --- a/package/expo-package/src/optionalDependencies/pickImage.ts +++ b/package/expo-package/src/optionalDependencies/pickImage.ts @@ -1,4 +1,5 @@ import { Platform } from 'react-native'; +import mime from 'mime'; import { PickImageOptions } from 'stream-chat-react-native-core'; let ImagePicker; @@ -47,7 +48,10 @@ export const pickImage = ImagePicker duration: asset.duration, name: asset.fileName, size: asset.fileSize, - type: asset.mimeType, + type: + asset.mimeType || + mime.getType(asset.fileName || asset.uri) || + (asset.duration ? 'video/*' : 'image/*'), uri: asset.uri, })); return { assets, cancelled: false }; diff --git a/package/expo-package/src/optionalDependencies/takePhoto.ts b/package/expo-package/src/optionalDependencies/takePhoto.ts index 53580644ed..47c8e36ece 100644 --- a/package/expo-package/src/optionalDependencies/takePhoto.ts +++ b/package/expo-package/src/optionalDependencies/takePhoto.ts @@ -1,5 +1,7 @@ import { Image, Platform } from 'react-native'; +import mime from 'mime'; + let ImagePicker; try { @@ -54,7 +56,9 @@ export const takePhoto = ImagePicker if (!photo) { return { cancelled: true }; } - if (photo.mimeType.includes('video')) { + const mimeType = + photo.mimeType || mime.getType(photo.uri) || (photo.duration ? 'video/*' : 'image/*'); + if (mimeType.includes('video')) { const clearFilter = new RegExp('[.:]', 'g'); const date = new Date().toISOString().replace(clearFilter, '_'); return { @@ -63,7 +67,7 @@ export const takePhoto = ImagePicker duration: photo.duration, // in milliseconds name: 'video_recording_' + date + '.' + photo.uri.split('.').pop(), size: photo.fileSize, - type: photo.mimeType, + type: mimeType, uri: photo.uri, }; } else { @@ -96,7 +100,7 @@ export const takePhoto = ImagePicker cancelled: false, name: 'image_' + date + '.' + photo.uri.split('.').pop(), size: photo.fileSize, - type: photo.mimeType, + type: mimeType, uri: photo.uri, ...size, }; diff --git a/package/native-package/src/optionalDependencies/getPhotos.ts b/package/native-package/src/optionalDependencies/getPhotos.ts index 0b1c0d22c9..f83db0b257 100644 --- a/package/native-package/src/optionalDependencies/getPhotos.ts +++ b/package/native-package/src/optionalDependencies/getPhotos.ts @@ -92,9 +92,12 @@ export const getPhotos = CameraRollDependency results.edges.map(async (edge) => { const originalUri = edge.node?.image?.uri; const type = - Platform.OS === 'ios' - ? mime.getType(edge.node.image.filename as string) - : edge.node.type; + (Platform.OS === 'ios' + ? mime.getType(edge.node.image.filename as string) || edge.node.type + : edge.node.type) || + mime.getType(edge.node.image.filename as string) || + mime.getType(originalUri) || + (edge.node.image.playableDuration ? 'video/*' : 'image/*'); const isImage = type.includes('image'); const uri = diff --git a/package/native-package/src/optionalDependencies/pickImage.ts b/package/native-package/src/optionalDependencies/pickImage.ts index dc7966dd99..5bbec26a31 100644 --- a/package/native-package/src/optionalDependencies/pickImage.ts +++ b/package/native-package/src/optionalDependencies/pickImage.ts @@ -1,4 +1,5 @@ import { Platform } from 'react-native'; +import mime from 'mime'; import { PickImageOptions } from 'stream-chat-react-native-core'; let ImagePicker; @@ -28,7 +29,10 @@ export const pickImage = ImagePicker duration: asset.duration ? asset.duration * 1000 : undefined, // in milliseconds name: asset.fileName, size: asset.fileSize, - type: asset.type, + type: + asset.type || + mime.getType(asset.fileName || asset.uri) || + (asset.duration ? 'video/*' : 'image/*'), uri: asset.uri, })); return { assets, cancelled: false }; diff --git a/package/native-package/src/optionalDependencies/takePhoto.ts b/package/native-package/src/optionalDependencies/takePhoto.ts index a508624217..5d3c07837b 100644 --- a/package/native-package/src/optionalDependencies/takePhoto.ts +++ b/package/native-package/src/optionalDependencies/takePhoto.ts @@ -1,4 +1,5 @@ import { AppState, Image, PermissionsAndroid, Platform } from 'react-native'; +import mime from 'mime'; let ImagePicker; @@ -46,7 +47,11 @@ export const takePhoto = ImagePicker cancelled: true, }; } - if (asset.type.includes('video')) { + const assetType = + asset.type || + mime.getType(asset.fileName || asset.uri) || + (mediaType === 'video' || asset.duration ? 'video/*' : 'image/*'); + if (assetType.includes('video')) { const clearFilter = new RegExp('[.:]', 'g'); const date = new Date().toISOString().replace(clearFilter, '_'); return { @@ -55,7 +60,7 @@ export const takePhoto = ImagePicker duration: asset.duration * 1000, name: 'video_recording_' + date + '.' + asset.fileName.split('.').pop(), size: asset.fileSize, - type: asset.type, + type: assetType, uri: asset.uri, }; } else { @@ -90,7 +95,7 @@ export const takePhoto = ImagePicker cancelled: false, name: 'image_' + date + '.' + asset.uri.split('.').pop(), size: asset.fileSize, - type: asset.type, + type: assetType, uri: asset.uri, ...size, }; diff --git a/package/src/components/AttachmentPicker/components/AttachmentPickerItem.tsx b/package/src/components/AttachmentPicker/components/AttachmentPickerItem.tsx index fb3bc84acb..61e82d37fd 100644 --- a/package/src/components/AttachmentPicker/components/AttachmentPickerItem.tsx +++ b/package/src/components/AttachmentPicker/components/AttachmentPickerItem.tsx @@ -168,7 +168,7 @@ export const renderAttachmentPickerItem = ({ item }: { item: AttachmentPickerIte * Native iOS - Gives `image` or `video` * Expo Android/iOS - Gives `photo` or `video` **/ - const isVideoType = asset.type.includes('video'); + const isVideoType = asset.type?.includes('video'); if (isVideoType) { return ( diff --git a/package/src/contexts/messageInputContext/MessageInputContext.tsx b/package/src/contexts/messageInputContext/MessageInputContext.tsx index dca13d3320..61cc14f540 100644 --- a/package/src/contexts/messageInputContext/MessageInputContext.tsx +++ b/package/src/contexts/messageInputContext/MessageInputContext.tsx @@ -10,6 +10,7 @@ import React, { import { Alert, Keyboard, Linking, TextInput, TextInputProps } from 'react-native'; import { BottomSheetHandleProps } from '@gorhom/bottom-sheet'; +import { lookup as lookupMimeType } from 'mime-types'; import { LocalMessage, MessageComposer, @@ -652,13 +653,26 @@ export const MessageInputProvider = ({ const uploadNewFile = useStableCallback(async (file: File) => { try { + if (!file?.uri) { + return; + } + + const fallbackMimeType = + lookupMimeType(file.name || file.uri || '') || + (file.duration ? 'video/*' : file.height && file.width ? 'image/*' : undefined); + const normalizedFile = { + ...file, + type: + file.type || + (typeof fallbackMimeType === 'string' ? fallbackMimeType : 'application/octet-stream'), + }; uploadAbortControllerRef.current.set(file.name, client.createAbortControllerForNextRequest()); - const fileURI = file.type.includes('image') - ? await compressedImageURI(file, value.compressImageQuality) - : file.uri; - const updatedFile = { ...file, uri: fileURI }; + const fileURI = normalizedFile.type.includes('image') + ? await compressedImageURI(normalizedFile, value.compressImageQuality) + : normalizedFile.uri; + const updatedFile = { ...normalizedFile, uri: fileURI }; await attachmentManager.uploadFiles([updatedFile]); - uploadAbortControllerRef.current.delete(file.name); + uploadAbortControllerRef.current.delete(normalizedFile.name); } catch (error) { if ( error instanceof Error && diff --git a/package/src/contexts/messageInputContext/__tests__/filePickers.test.tsx b/package/src/contexts/messageInputContext/__tests__/filePickers.test.tsx index 6585f39e1f..85cc4e38bc 100644 --- a/package/src/contexts/messageInputContext/__tests__/filePickers.test.tsx +++ b/package/src/contexts/messageInputContext/__tests__/filePickers.test.tsx @@ -221,6 +221,47 @@ describe("MessageInputContext's pickAndUploadImageFromNativePicker", () => { }); }, ); + + it('does not crash when pickImage returns an asset with a null mime type', async () => { + const { attachmentManager } = channel.messageComposer; + jest.spyOn(NativeHandlers, 'pickImage').mockImplementation( + jest.fn().mockResolvedValue({ + assets: [ + { + duration: 0, + height: 100, + name: 'IMG_0001', + size: 123, + type: null, + uri: 'file:///tmp/IMG_0001', + width: 200, + }, + ], + cancelled: false, + }), + ); + + jest.spyOn(attachmentManager, 'availableUploadSlots', 'get').mockReturnValue(2); + + const { result } = renderHook(() => useMessageInputContext(), { + initialProps, + wrapper: (props) => , + }); + + const uploadFilesSpy = jest.spyOn(attachmentManager, 'uploadFiles'); + + await waitFor(() => { + result.current.pickAndUploadImageFromNativePicker(); + }); + + await waitFor(() => { + expect(uploadFilesSpy).toHaveBeenCalledWith([ + expect.objectContaining({ + type: 'image/*', + }), + ]); + }); + }); }); describe("MessageInputContext's takeAndUploadImage", () => {