UNPKG

stream-chat-react-native-core

Version:

The official React Native and Expo components for Stream Chat, a service for building chat applications

385 lines (327 loc) 9.96 kB
import { NativeEventEmitter } from 'react-native'; const STREAM_MULTIPART_UPLOAD_PROGRESS_EVENT = 'streamMultipartUploadProgress'; const CANCELED_ERROR_CODE = 'ERR_CANCELED'; export type NativeMultipartAbortSignal = { aborted: boolean; addEventListener?: (...args: unknown[]) => unknown; onabort?: ((...args: unknown[]) => unknown) | null; removeEventListener?: (...args: unknown[]) => unknown; }; export type NativeMultipartUploadHeader = { name: string; value: string; }; export type NativeMultipartUploadPart = | { fieldName: string; kind: 'file'; fileName: string; mimeType?: string; uri: string; } | { fieldName: string; kind: 'text'; value: string; }; export type NativeMultipartUploaderProgressConfig = { count?: number; intervalMs?: number; }; export type NativeMultipartUploadProgressConfig = NativeMultipartUploaderProgressConfig & { /** * Maximum progress percentage reported while the native request body is still being sent. * Completion is represented by the upload request resolving and the upload indicator being removed. * * @default 90 */ completionProgressCap?: number; }; export type NativeMultipartUploadProgressEvent = { loaded: number; total?: number | null; uploadId: string; }; export type NativeMultipartUploadNativeResponse = { body: string; headers?: ReadonlyArray<NativeMultipartUploadHeader> | null; status: number; statusText?: string | null; }; export type NativeMultipartUploadResult = { body: string; headers?: Record<string, string>; status: number; statusText?: string; }; export type NativeMultipartUploadRequest = { headers: Record<string, string>; method: string; onProgress?: (progress: { loaded: number; total?: number }) => void; parts: NativeMultipartUploadPart[]; progress?: NativeMultipartUploadProgressConfig; signal?: NativeMultipartAbortSignal; timeoutMs?: number; url: string; }; export type NativeMultipartUploaderRequest = Omit<NativeMultipartUploadRequest, 'progress'> & { progress?: NativeMultipartUploaderProgressConfig; uploadId: string; }; export type NativeMultipartUpload = ( request: NativeMultipartUploadRequest, ) => Promise<NativeMultipartUploadResult | undefined> | never; export type NativeMultipartUploader = ( request: NativeMultipartUploaderRequest, ) => Promise<NativeMultipartUploadResult>; export type NativeMultipartUploaderModule = { addListener(eventType: string): void; cancelUpload(uploadId: string): Promise<void>; removeListeners(count: number): void; uploadMultipart( uploadId: string, url: string, method: string, headers: ReadonlyArray<NativeMultipartUploadHeader>, parts: ReadonlyArray<NativeMultipartUploadPart>, progress?: NativeMultipartUploaderProgressConfig, timeoutMs?: number | null, ): Promise<NativeMultipartUploadNativeResponse>; }; export type NativeMultipartUploadEventEmitter = { addListener( eventType: string, listener: (event: NativeMultipartUploadProgressEvent) => void, ): { remove: () => void }; }; export type NativeMultipartCanceledError = Error & { __CANCEL__: true; code: typeof CANCELED_ERROR_CODE; }; type CreateNativeMultipartUploaderOptions = { eventEmitter?: NativeMultipartUploadEventEmitter; }; type CreateNativeMultipartUploadOptions = { getLocalAssetUri?: ((uri: string) => Promise<string | null | undefined>) | null; uploadIdFactory?: () => string; uploadMultipart?: NativeMultipartUploader; }; const createDefaultUploadId = () => `stream-upload-${Date.now()}-${Math.random().toString(16).slice(2)}`; const createCanceledError = (): NativeMultipartCanceledError => { const error = new Error('Request aborted') as NativeMultipartCanceledError; error.name = 'CanceledError'; error.code = CANCELED_ERROR_CODE; // eslint-disable-next-line no-underscore-dangle -- Axios marks cancellation with this legacy field, and callers still use axios.isCancel. error.__CANCEL__ = true; return error; }; const toUploadHeaders = (headers: Record<string, string>): NativeMultipartUploadHeader[] => Object.entries(headers).map(([name, value]) => ({ name, value })); const fromUploadHeaders = ( headers?: ReadonlyArray<NativeMultipartUploadHeader> | null, ): Record<string, string> | undefined => { if (!headers?.length) { return undefined; } return headers.reduce<Record<string, string>>((acc, header) => { acc[header.name] = header.value; return acc; }, {}); }; const addAbortHandler = (signal: NativeMultipartAbortSignal | undefined, onAbort: () => void) => { if (!signal) { return () => undefined; } let handled = false; const handleAbort = () => { if (handled) { return; } handled = true; onAbort(); }; if (typeof signal.addEventListener === 'function') { signal.addEventListener('abort', handleAbort, { once: true }); return () => { signal.removeEventListener?.('abort', handleAbort); }; } const previousOnAbort = signal.onabort; const chainedOnAbort = (...args: unknown[]) => { previousOnAbort?.(...args); handleAbort(); }; signal.onabort = chainedOnAbort; return () => { if (signal.onabort === chainedOnAbort) { signal.onabort = previousOnAbort ?? null; } }; }; const getNativeProgressConfig = ( progress?: NativeMultipartUploadProgressConfig, ): NativeMultipartUploaderProgressConfig | undefined => { if (!progress) { return undefined; } const nativeProgressConfig = { ...progress }; delete nativeProgressConfig.completionProgressCap; return Object.keys(nativeProgressConfig).length ? nativeProgressConfig : undefined; }; const isPhotoLibraryUri = (uri: string) => { const normalizedUri = uri.toLowerCase(); return normalizedUri.startsWith('ph://') || normalizedUri.startsWith('assets-library://'); }; const sanitizeResolvedFileUri = (uri: string) => { const normalizedUri = uri.startsWith('/') ? `file://${uri}` : uri; if (!normalizedUri.startsWith('file://')) { return normalizedUri; } return normalizedUri.split('#')[0].split('?')[0]; }; const resolvePartUri = async ( part: NativeMultipartUploadPart, getLocalAssetUri: CreateNativeMultipartUploadOptions['getLocalAssetUri'], ): Promise<NativeMultipartUploadPart> => { if ( part.kind !== 'file' || typeof getLocalAssetUri !== 'function' || !isPhotoLibraryUri(part.uri) ) { return part; } try { const resolvedUri = await getLocalAssetUri(part.uri); return { ...part, uri: resolvedUri ? sanitizeResolvedFileUri(resolvedUri) : part.uri, }; } catch { return part; } }; export const createNativeMultipartUploader = ( nativeModule: NativeMultipartUploaderModule | null | undefined, options: CreateNativeMultipartUploaderOptions = {}, ): NativeMultipartUploader | undefined => { if (!nativeModule) { return undefined; } const multipartUploadEventEmitter = options.eventEmitter ?? new NativeEventEmitter(nativeModule); return async ({ headers, method, onProgress, parts, progress, signal, timeoutMs, uploadId, url, }: NativeMultipartUploaderRequest): Promise<NativeMultipartUploadResult> => { let progressSubscription: | { remove: () => void; } | undefined; let uploadStarted = false; const abortUpload = async () => { try { await nativeModule.cancelUpload(uploadId); } catch { // Ignore cancellation races for already-finished uploads. } }; const handleAbort = () => { if (uploadStarted) { abortUpload().catch(() => undefined); } }; if (signal?.aborted) { throw createCanceledError(); } const removeAbortListener = addAbortHandler(signal, handleAbort); if (signal?.aborted) { removeAbortListener(); throw createCanceledError(); } if (onProgress) { progressSubscription = multipartUploadEventEmitter.addListener( STREAM_MULTIPART_UPLOAD_PROGRESS_EVENT, (event: NativeMultipartUploadProgressEvent) => { if (event.uploadId !== uploadId) { return; } onProgress({ loaded: event.loaded, total: typeof event.total === 'number' ? event.total : undefined, }); }, ); } try { uploadStarted = true; const response = await nativeModule.uploadMultipart( uploadId, url, method, toUploadHeaders(headers), parts, progress ?? {}, timeoutMs, ); if (signal?.aborted) { throw createCanceledError(); } return { body: response.body, headers: fromUploadHeaders(response.headers), status: response.status, statusText: typeof response.statusText === 'string' ? response.statusText : undefined, }; } catch (error) { if (signal?.aborted) { throw createCanceledError(); } throw error; } finally { progressSubscription?.remove(); removeAbortListener(); } }; }; export const createNativeMultipartUpload = ({ getLocalAssetUri, uploadIdFactory = createDefaultUploadId, uploadMultipart, }: CreateNativeMultipartUploadOptions): NativeMultipartUpload | undefined => { if (!uploadMultipart) { return undefined; } return async ({ headers, method, onProgress, parts, progress, signal, timeoutMs, url, }: NativeMultipartUploadRequest) => { const resolvedParts = await Promise.all( parts.map((part) => resolvePartUri(part, getLocalAssetUri)), ); return uploadMultipart({ headers, method, onProgress, parts: resolvedParts, progress: getNativeProgressConfig(progress), signal, timeoutMs, uploadId: uploadIdFactory(), url, }); }; };