@sendbird/uikit-react-native
Version:
Sendbird UIKit for React Native: A feature-rich and customizable chat UI kit with messaging, channel management, and user authentication.
265 lines (239 loc) • 9.49 kB
text/typescript
import type { CameraRoll } from '@react-native-camera-roll/camera-roll';
import { Platform } from 'react-native';
import type * as DocumentPicker from 'react-native-document-picker';
import type * as FileAccess from 'react-native-file-access';
import type * as ImagePicker from 'react-native-image-picker';
import type * as Permissions from 'react-native-permissions';
import type { Permission } from 'react-native-permissions';
import {
Logger,
getFileExtension,
getFileExtensionFromMime,
getFileExtensionFromUri,
getFileType,
normalizeFileName,
} from '@sendbird/uikit-utils';
import SBUError from '../libs/SBUError';
import nativePermissionGranted from '../utils/nativePermissionGranted';
import normalizeFile from '../utils/normalizeFile';
import type {
FilePickerResponse,
FileServiceInterface,
OpenCameraOptions,
OpenDocumentOptions,
OpenMediaLibraryOptions,
SaveOptions,
} from './types';
function getAndroidStoragePermissionsByAPILevel(permissionModule: typeof Permissions): Permission[] {
if (Platform.OS !== 'android') return [];
if (Platform.Version > 32) {
return [];
}
if (Platform.Version > 28) {
return [permissionModule.PERMISSIONS.ANDROID.READ_EXTERNAL_STORAGE];
}
return [
permissionModule.PERMISSIONS.ANDROID.WRITE_EXTERNAL_STORAGE,
permissionModule.PERMISSIONS.ANDROID.READ_EXTERNAL_STORAGE,
];
}
const createNativeFileService = ({
imagePickerModule,
documentPickerModule,
permissionModule,
mediaLibraryModule,
fsModule,
}: {
imagePickerModule: typeof ImagePicker;
documentPickerModule: typeof DocumentPicker;
permissionModule: typeof Permissions;
mediaLibraryModule: typeof CameraRoll;
fsModule: typeof FileAccess;
}): FileServiceInterface => {
const requiredPermissions: Permission[] = Platform.select({
ios: [permissionModule.PERMISSIONS.IOS.CAMERA],
android: [permissionModule.PERMISSIONS.ANDROID.CAMERA],
default: [],
});
const optionalPermissions: Permission[] = Platform.select({
ios: [permissionModule.PERMISSIONS.IOS.MICROPHONE],
android: [],
default: [],
});
const mediaLibraryPermissions: Permission[] = Platform.select({
ios: [permissionModule.PERMISSIONS.IOS.PHOTO_LIBRARY, permissionModule.PERMISSIONS.IOS.PHOTO_LIBRARY_ADD_ONLY],
android: getAndroidStoragePermissionsByAPILevel(permissionModule),
default: [],
});
class NativeFileService implements FileServiceInterface {
async hasCameraPermission(): Promise<boolean> {
const status = await permissionModule.checkMultiple(requiredPermissions);
return nativePermissionGranted(status);
}
async requestCameraPermission(): Promise<boolean> {
const requiredPermissionsStatus = await permissionModule.requestMultiple(requiredPermissions);
if (!nativePermissionGranted(requiredPermissionsStatus)) return false;
await permissionModule.requestMultiple(optionalPermissions);
return true;
}
async hasMediaLibraryPermission(): Promise<boolean> {
const status = await permissionModule.checkMultiple(mediaLibraryPermissions);
return nativePermissionGranted(status);
}
async requestMediaLibraryPermission(): Promise<boolean> {
const status = await permissionModule.requestMultiple(mediaLibraryPermissions);
return nativePermissionGranted(status);
}
async openCamera(options?: OpenCameraOptions): Promise<FilePickerResponse> {
const hasPermission = await this.hasCameraPermission();
if (!hasPermission) {
const granted = await this.requestCameraPermission();
if (!granted) {
options?.onOpenFailure?.(SBUError.PERMISSIONS_DENIED);
return null;
}
}
const response = await imagePickerModule.launchCamera({
presentationStyle: 'fullScreen',
cameraType: options?.cameraType ?? 'back',
mediaType: (() => {
switch (options?.mediaType) {
case 'photo':
return 'photo';
case 'video':
return 'video';
case 'all':
return 'mixed';
default:
return 'photo';
}
})(),
});
if (response.didCancel) return null;
if (response.errorCode === 'camera_unavailable') {
options?.onOpenFailure?.(SBUError.DEVICE_UNAVAILABLE, new Error(response.errorMessage));
return null;
}
const { fileName: name, fileSize: size, type, uri } = response.assets?.[0] ?? {};
return normalizeFile({ uri, size, name, type });
}
async openMediaLibrary(options?: OpenMediaLibraryOptions): Promise<FilePickerResponse[] | null> {
/**
* NOTE: options.selectionLimit {@link https://github.com/react-native-image-picker/react-native-image-picker#options}
* We do not support 0 (any number of files)
**/
const selectionLimit = options?.selectionLimit || 1;
const hasPermission = await this.hasMediaLibraryPermission();
if (!hasPermission) {
const granted = await this.requestMediaLibraryPermission();
if (!granted) {
options?.onOpenFailure?.(SBUError.PERMISSIONS_DENIED);
return null;
}
}
const response = await imagePickerModule.launchImageLibrary({
presentationStyle: 'fullScreen',
selectionLimit,
mediaType: (() => {
switch (options?.mediaType) {
case 'photo':
return 'photo';
case 'video':
return 'video';
case 'all':
return 'mixed';
default:
return 'photo';
}
})(),
});
if (response.didCancel) return null;
if (response.errorCode === 'camera_unavailable') {
options?.onOpenFailure?.(SBUError.DEVICE_UNAVAILABLE, new Error(response.errorMessage));
return null;
}
return Promise.all(
(response.assets || [])
.slice(0, selectionLimit)
.map(({ fileName: name, fileSize: size, type, uri }) => normalizeFile({ uri, size, name, type })),
);
}
async openDocument(options?: OpenDocumentOptions): Promise<FilePickerResponse> {
try {
const { uri, size, name, type } = await documentPickerModule.pickSingle();
return normalizeFile({ uri, size, name, type });
} catch (e) {
if (!documentPickerModule.isCancel(e) && documentPickerModule.isInProgress(e)) {
options?.onOpenFailure?.(SBUError.UNKNOWN, e);
}
return null;
}
}
async save(options: SaveOptions): Promise<string> {
const hasPermission = await this.hasMediaLibraryPermission();
if (!hasPermission) {
const granted = await this.requestMediaLibraryPermission();
if (!granted) throw new Error('Permission not granted');
}
const { downloadedPath, file } = await this.downloadFile(options);
if (Platform.OS === 'ios') {
if (file.type === 'image' || file.type === 'video') {
const mediaTypeMap = { 'image': 'photo', 'video': 'video' } as const;
const mediaType = mediaTypeMap[file.type];
await mediaLibraryModule.save(downloadedPath, { type: mediaType });
}
}
if (Platform.OS === 'android') {
const externalDirMap = { 'file': 'downloads', 'audio': 'audio', 'image': 'images', 'video': 'video' } as const;
const externalDir = externalDirMap[file.type];
await fsModule.FileSystem.cpExternal(downloadedPath, file.name, externalDir).catch(() => {
Logger.error('Failed to save file to external storage. Retry saving to downloads directory instead.');
return fsModule.FileSystem.cpExternal(downloadedPath, file.name, 'downloads');
});
}
return downloadedPath;
}
private buildDownloadPath = async (options: SaveOptions) => {
const dirname = Platform.select({ android: fsModule.Dirs.CacheDir, default: fsModule.Dirs.DocumentDir });
const context = { dirname, filename: options.fileName };
const extension =
getFileExtension(options.fileName) ||
getFileExtensionFromMime(options.fileType) ||
getFileExtension(options.fileUrl) ||
(await getFileExtensionFromUri(options.fileUrl));
if (extension) context.filename = normalizeFileName(context.filename, extension);
return { path: `${context.dirname}/${context.filename}`, ...context };
};
private downloadFile = async (options: SaveOptions) => {
const { path, filename } = await this.buildDownloadPath(options);
await fsModule.FileSystem.fetch(options.fileUrl, { path });
return {
downloadedPath: path,
file: {
name: filename,
type: getFileType(getFileExtension(path)),
} as const,
};
};
createRecordFilePath(customExtension = 'm4a'): { recordFilePath: string; uri: string } {
const filename = `record-${Date.now()}.${customExtension}`;
const path = `${fsModule.Dirs.CacheDir}/${filename}`;
return Platform.select({
ios: {
uri: path,
recordFilePath: filename,
},
android: {
uri: path.startsWith('file://') ? path : 'file://' + path,
recordFilePath: path,
},
default: {
uri: path,
recordFilePath: path,
},
});
}
}
return new NativeFileService();
};
export default createNativeFileService;