Skip to content

Commit 7080102

Browse files
committed
fix: add defensive mime type manipulation for all file management
1 parent 04accdb commit 7080102

File tree

11 files changed

+194
-21
lines changed

11 files changed

+194
-21
lines changed
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
jest.mock(
2+
'expo-media-library',
3+
() => ({
4+
MediaType: {
5+
photo: 'photo',
6+
video: 'video',
7+
},
8+
SortBy: {
9+
modificationTime: 'modificationTime',
10+
},
11+
getAssetsAsync: jest.fn(),
12+
getPermissionsAsync: jest.fn(),
13+
requestPermissionsAsync: jest.fn(),
14+
}),
15+
{ virtual: true },
16+
);
17+
18+
jest.mock('../getLocalAssetUri', () => ({
19+
getLocalAssetUri: jest.fn(),
20+
}));
21+
22+
import * as MediaLibrary from 'expo-media-library';
23+
24+
import { getLocalAssetUri } from '../getLocalAssetUri';
25+
import { getPhotos } from '../getPhotos';
26+
27+
const mockedMediaLibrary = MediaLibrary as {
28+
getAssetsAsync: jest.Mock;
29+
getPermissionsAsync: jest.Mock;
30+
requestPermissionsAsync: jest.Mock;
31+
};
32+
33+
const mockedGetLocalAssetUri = getLocalAssetUri as jest.Mock;
34+
35+
describe('getPhotos', () => {
36+
beforeEach(() => {
37+
mockedMediaLibrary.getPermissionsAsync.mockResolvedValue({
38+
accessPrivileges: 'all',
39+
status: 'granted',
40+
});
41+
mockedMediaLibrary.requestPermissionsAsync.mockResolvedValue({
42+
status: 'granted',
43+
});
44+
mockedMediaLibrary.getAssetsAsync.mockReset();
45+
mockedGetLocalAssetUri.mockReset();
46+
mockedGetLocalAssetUri.mockResolvedValue(undefined);
47+
});
48+
49+
it('falls back to media-type mime strings when filename mime detection returns null', async () => {
50+
mockedMediaLibrary.getAssetsAsync.mockResolvedValue({
51+
assets: [
52+
{
53+
duration: 0,
54+
filename: 'IMG_0001',
55+
height: 100,
56+
id: 'photo-1',
57+
mediaType: MediaLibrary.MediaType.photo,
58+
uri: 'ph://photo-1',
59+
width: 200,
60+
},
61+
{
62+
duration: 12,
63+
filename: 'VID_0002',
64+
height: 300,
65+
id: 'video-1',
66+
mediaType: MediaLibrary.MediaType.video,
67+
uri: 'ph://video-1',
68+
width: 400,
69+
},
70+
],
71+
endCursor: undefined,
72+
hasNextPage: false,
73+
});
74+
75+
const result = await getPhotos({ after: undefined, first: 20 });
76+
77+
expect(result.assets).toEqual([
78+
expect.objectContaining({
79+
type: 'image/*',
80+
}),
81+
expect.objectContaining({
82+
type: 'video/*',
83+
}),
84+
]);
85+
});
86+
});

package/expo-package/src/optionalDependencies/getPhotos.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Platform } from 'react-native';
2+
23
import mime from 'mime';
34

45
import type { File } from 'stream-chat-react-native-core';
@@ -54,14 +55,16 @@ export const getPhotos = MediaLibrary
5455
const assets = await Promise.all(
5556
results.assets.map(async (asset) => {
5657
const localUri = await getLocalAssetUri(asset.id);
57-
const mimeType = mime.getType(asset.filename);
58+
const mimeType =
59+
mime.getType(asset.filename || asset.uri) ||
60+
(asset.mediaType === MediaLibrary.MediaType.video ? 'video/*' : 'image/*');
5861
return {
5962
duration: asset.duration * 1000,
6063
height: asset.height,
6164
name: asset.filename,
6265
size: 0,
6366
thumb_url: asset.mediaType === 'photo' ? undefined : asset.uri,
64-
type: mimeType,
67+
type: undefined,
6568
uri: localUri || asset.uri,
6669
width: asset.width,
6770
};

package/expo-package/src/optionalDependencies/pickDocument.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import mime from 'mime';
2+
13
let DocumentPicker;
24

35
try {
@@ -40,7 +42,10 @@ export const pickDocument = DocumentPicker
4042
return {
4143
assets: assets.map((asset) => ({
4244
...asset,
43-
type: asset.mimeType,
45+
type:
46+
asset.mimeType ||
47+
mime.getType(asset.name || asset.uri) ||
48+
'application/octet-stream',
4449
})),
4550
cancelled: false,
4651
};
@@ -50,7 +55,10 @@ export const pickDocument = DocumentPicker
5055
assets: [
5156
{
5257
...rest,
53-
type: rest.mimeType,
58+
type:
59+
rest.mimeType ||
60+
mime.getType(rest.name || rest.uri) ||
61+
'application/octet-stream',
5462
},
5563
],
5664
cancelled: false,

package/expo-package/src/optionalDependencies/pickImage.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Platform } from 'react-native';
2+
import mime from 'mime';
23
import { PickImageOptions } from 'stream-chat-react-native-core';
34
let ImagePicker;
45

@@ -47,7 +48,10 @@ export const pickImage = ImagePicker
4748
duration: asset.duration,
4849
name: asset.fileName,
4950
size: asset.fileSize,
50-
type: asset.mimeType,
51+
type:
52+
asset.mimeType ||
53+
mime.getType(asset.fileName || asset.uri) ||
54+
(asset.duration ? 'video/*' : 'image/*'),
5155
uri: asset.uri,
5256
}));
5357
return { assets, cancelled: false };

package/expo-package/src/optionalDependencies/takePhoto.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { Image, Platform } from 'react-native';
22

3+
import mime from 'mime';
4+
35
let ImagePicker;
46

57
try {
@@ -54,7 +56,9 @@ export const takePhoto = ImagePicker
5456
if (!photo) {
5557
return { cancelled: true };
5658
}
57-
if (photo.mimeType.includes('video')) {
59+
const mimeType =
60+
photo.mimeType || mime.getType(photo.uri) || (photo.duration ? 'video/*' : 'image/*');
61+
if (mimeType.includes('video')) {
5862
const clearFilter = new RegExp('[.:]', 'g');
5963
const date = new Date().toISOString().replace(clearFilter, '_');
6064
return {
@@ -63,7 +67,7 @@ export const takePhoto = ImagePicker
6367
duration: photo.duration, // in milliseconds
6468
name: 'video_recording_' + date + '.' + photo.uri.split('.').pop(),
6569
size: photo.fileSize,
66-
type: photo.mimeType,
70+
type: mimeType,
6771
uri: photo.uri,
6872
};
6973
} else {
@@ -96,7 +100,7 @@ export const takePhoto = ImagePicker
96100
cancelled: false,
97101
name: 'image_' + date + '.' + photo.uri.split('.').pop(),
98102
size: photo.fileSize,
99-
type: photo.mimeType,
103+
type: mimeType,
100104
uri: photo.uri,
101105
...size,
102106
};

package/native-package/src/optionalDependencies/getPhotos.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -92,9 +92,12 @@ export const getPhotos = CameraRollDependency
9292
results.edges.map(async (edge) => {
9393
const originalUri = edge.node?.image?.uri;
9494
const type =
95-
Platform.OS === 'ios'
96-
? mime.getType(edge.node.image.filename as string)
97-
: edge.node.type;
95+
(Platform.OS === 'ios'
96+
? mime.getType(edge.node.image.filename as string) || edge.node.type
97+
: edge.node.type) ||
98+
mime.getType(edge.node.image.filename as string) ||
99+
mime.getType(originalUri) ||
100+
(edge.node.image.playableDuration ? 'video/*' : 'image/*');
98101
const isImage = type.includes('image');
99102

100103
const uri =

package/native-package/src/optionalDependencies/pickImage.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Platform } from 'react-native';
2+
import mime from 'mime';
23
import { PickImageOptions } from 'stream-chat-react-native-core';
34
let ImagePicker;
45

@@ -28,7 +29,10 @@ export const pickImage = ImagePicker
2829
duration: asset.duration ? asset.duration * 1000 : undefined, // in milliseconds
2930
name: asset.fileName,
3031
size: asset.fileSize,
31-
type: asset.type,
32+
type:
33+
asset.type ||
34+
mime.getType(asset.fileName || asset.uri) ||
35+
(asset.duration ? 'video/*' : 'image/*'),
3236
uri: asset.uri,
3337
}));
3438
return { assets, cancelled: false };

package/native-package/src/optionalDependencies/takePhoto.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { AppState, Image, PermissionsAndroid, Platform } from 'react-native';
2+
import mime from 'mime';
23

34
let ImagePicker;
45

@@ -46,7 +47,11 @@ export const takePhoto = ImagePicker
4647
cancelled: true,
4748
};
4849
}
49-
if (asset.type.includes('video')) {
50+
const assetType =
51+
asset.type ||
52+
mime.getType(asset.fileName || asset.uri) ||
53+
(mediaType === 'video' || asset.duration ? 'video/*' : 'image/*');
54+
if (assetType.includes('video')) {
5055
const clearFilter = new RegExp('[.:]', 'g');
5156
const date = new Date().toISOString().replace(clearFilter, '_');
5257
return {
@@ -55,7 +60,7 @@ export const takePhoto = ImagePicker
5560
duration: asset.duration * 1000,
5661
name: 'video_recording_' + date + '.' + asset.fileName.split('.').pop(),
5762
size: asset.fileSize,
58-
type: asset.type,
63+
type: assetType,
5964
uri: asset.uri,
6065
};
6166
} else {
@@ -90,7 +95,7 @@ export const takePhoto = ImagePicker
9095
cancelled: false,
9196
name: 'image_' + date + '.' + asset.uri.split('.').pop(),
9297
size: asset.fileSize,
93-
type: asset.type,
98+
type: assetType,
9499
uri: asset.uri,
95100
...size,
96101
};

package/src/components/AttachmentPicker/components/AttachmentPickerItem.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ export const renderAttachmentPickerItem = ({ item }: { item: AttachmentPickerIte
168168
* Native iOS - Gives `image` or `video`
169169
* Expo Android/iOS - Gives `photo` or `video`
170170
**/
171-
const isVideoType = asset.type.includes('video');
171+
const isVideoType = asset.type?.includes('video');
172172

173173
if (isVideoType) {
174174
return (

package/src/contexts/messageInputContext/MessageInputContext.tsx

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import React, {
1010
import { Alert, Keyboard, Linking, TextInput, TextInputProps } from 'react-native';
1111

1212
import { BottomSheetHandleProps } from '@gorhom/bottom-sheet';
13+
import { lookup as lookupMimeType } from 'mime-types';
1314
import {
1415
LocalMessage,
1516
MessageComposer,
@@ -652,13 +653,26 @@ export const MessageInputProvider = ({
652653

653654
const uploadNewFile = useStableCallback(async (file: File) => {
654655
try {
656+
if (!file?.uri) {
657+
return;
658+
}
659+
660+
const fallbackMimeType =
661+
lookupMimeType(file.name || file.uri || '') ||
662+
(file.duration ? 'video/*' : file.height && file.width ? 'image/*' : undefined);
663+
const normalizedFile = {
664+
...file,
665+
type:
666+
file.type ||
667+
(typeof fallbackMimeType === 'string' ? fallbackMimeType : 'application/octet-stream'),
668+
};
655669
uploadAbortControllerRef.current.set(file.name, client.createAbortControllerForNextRequest());
656-
const fileURI = file.type.includes('image')
657-
? await compressedImageURI(file, value.compressImageQuality)
658-
: file.uri;
659-
const updatedFile = { ...file, uri: fileURI };
670+
const fileURI = normalizedFile.type.includes('image')
671+
? await compressedImageURI(normalizedFile, value.compressImageQuality)
672+
: normalizedFile.uri;
673+
const updatedFile = { ...normalizedFile, uri: fileURI };
660674
await attachmentManager.uploadFiles([updatedFile]);
661-
uploadAbortControllerRef.current.delete(file.name);
675+
uploadAbortControllerRef.current.delete(normalizedFile.name);
662676
} catch (error) {
663677
if (
664678
error instanceof Error &&

0 commit comments

Comments
 (0)