UNPKG

stream-chat-react-native-core

Version:

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

303 lines (250 loc) 8.12 kB
import axios from 'axios'; import type { AxiosAdapter, AxiosProgressEvent, InternalAxiosRequestConfig } from 'axios'; import type { StreamChat } from 'stream-chat'; import { isNativeMultipartUploadAvailable, NativeHandlers, NativeMultipartUploadProgressConfig, NativeMultipartUploadRequest, } from '../native'; type FormDataPartValue = | string | { contentType?: string; name?: string; type?: string; uri: string; }; type NativeMultipartAxiosRequestConfig = InternalAxiosRequestConfig & { onUploadProgress?: (event: AxiosProgressEvent) => void; uploadProgressOptions?: NativeMultipartUploadProgressConfig; uploadProgress?: (event: AxiosProgressEvent) => void; }; type ResolvableAxiosAdapter = Parameters<typeof axios.getAdapter>[0]; const DEFAULT_COMPLETION_PROGRESS_CAP = 90; const installedAdapters = new WeakSet<StreamChat>(); const getFormDataEntries = (data: unknown): [string, FormDataPartValue][] | null => { if (!data || typeof data !== 'object') { return null; } if ('entries' in data && typeof data.entries === 'function') { return Array.from(data.entries()) as [string, FormDataPartValue][]; } const parts = Reflect.get(data, '_parts'); if (Array.isArray(parts)) { return parts as [string, FormDataPartValue][]; } return null; }; const normalizeHeaders = ( headers: NativeMultipartAxiosRequestConfig['headers'], ): Record<string, string> => { const rawHeaders = headers?.toJSON() ?? {}; const normalizedHeaders: Record<string, string> = {}; Object.entries(rawHeaders ?? {}).forEach(([key, value]) => { if (value == null) { return; } normalizedHeaders[key] = Array.isArray(value) ? value.join(', ') : String(value); }); return normalizedHeaders; }; const getFileNameFromUri = (uri: string) => uri.split('/').filter(Boolean).pop() || 'file'; const getNativeProgressOptions = ( progress?: NativeMultipartUploadProgressConfig, ): NativeMultipartUploadProgressConfig | undefined => { if (!progress) { return undefined; } const nativeProgressOptions = { ...progress }; delete nativeProgressOptions.completionProgressCap; return Object.keys(nativeProgressOptions).length ? nativeProgressOptions : undefined; }; const createNativeMultipartRequest = ( client: StreamChat, config: NativeMultipartAxiosRequestConfig, ): NativeMultipartUploadRequest | null => { const entries = getFormDataEntries(config.data); if (!entries) { return null; } const parts: NativeMultipartUploadRequest['parts'] = []; for (const [fieldName, value] of entries) { if (typeof value === 'string') { parts.push({ fieldName, kind: 'text', value, }); continue; } if (value && typeof value === 'object' && 'uri' in value && typeof value.uri === 'string') { parts.push({ fieldName, fileName: value.name || getFileNameFromUri(value.uri), kind: 'file', mimeType: value.type || value.contentType, uri: value.uri, }); continue; } return null; } if (!parts.some((part) => part.kind === 'file')) { return null; } return { headers: normalizeHeaders(config.headers), method: (config.method || 'POST').toUpperCase(), parts, progress: getNativeProgressOptions(config.uploadProgressOptions), signal: config.signal, timeoutMs: config.timeout, url: client.axiosInstance.getUri(config), }; }; const toFiniteNumber = (value: unknown) => { if (typeof value === 'number') { return Number.isFinite(value) ? value : undefined; } if (typeof value === 'string' && value.trim()) { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : undefined; } return undefined; }; const getCompletionProgressCap = (config: NativeMultipartAxiosRequestConfig) => { const cap = toFiniteNumber(config.uploadProgressOptions?.completionProgressCap); if (cap === undefined) { return DEFAULT_COMPLETION_PROGRESS_CAP; } return Math.min(100, Math.max(0, cap)); }; const getDisplayLoaded = ({ completionProgressCap, loaded, total, }: { completionProgressCap: number; loaded: number; total?: number; }) => { if (typeof total !== 'number' || total <= 0) { return loaded; } return Math.min(loaded, total * (completionProgressCap / 100)); }; const getUploadProgressCallbacks = (config: NativeMultipartAxiosRequestConfig) => { const callbacks = [config.onUploadProgress, config.uploadProgress].filter( (callback): callback is NonNullable<typeof config.onUploadProgress> => typeof callback === 'function', ); return Array.from(new Set(callbacks)); }; const createUploadProgressEvent = ({ bytes, loaded, total, }: { bytes: unknown; loaded: unknown; total?: unknown; }) => { const normalizedBytes = toFiniteNumber(bytes) ?? 0; const normalizedLoaded = toFiniteNumber(loaded) ?? 0; const normalizedTotal = toFiniteNumber(total); return { bytes: normalizedBytes, download: false, event: undefined, lengthComputable: typeof normalizedTotal === 'number' && normalizedTotal > 0, loaded: normalizedLoaded, progress: typeof normalizedTotal === 'number' && normalizedTotal > 0 ? normalizedLoaded / normalizedTotal : undefined, total: normalizedTotal, upload: true, }; }; const nativeMultipartAxiosAdapter = async ( request: NativeMultipartUploadRequest, config: NativeMultipartAxiosRequestConfig, ) => { const uploadProgressCallbacks = getUploadProgressCallbacks(config); const completionProgressCap = getCompletionProgressCap(config); let lastLoaded = 0; const response = await NativeHandlers.multipartUpload({ ...request, onProgress: uploadProgressCallbacks.length ? ({ loaded, total }) => { const normalizedLoaded = toFiniteNumber(loaded) ?? 0; const normalizedTotal = toFiniteNumber(total); const displayLoaded = getDisplayLoaded({ completionProgressCap, loaded: normalizedLoaded, total: normalizedTotal, }); const event = createUploadProgressEvent({ bytes: Math.max(0, displayLoaded - lastLoaded), loaded: displayLoaded, total: normalizedTotal, }); lastLoaded = displayLoaded; uploadProgressCallbacks.forEach((callback) => callback(event)); } : undefined, }); if (!response) { throw new Error('Native multipart upload did not return a response'); } return { config, data: response.body, headers: response.headers ?? {}, request: null, status: response.status, statusText: response.statusText ?? '', }; }; const resolveAxiosAdapter = (adapter: ResolvableAxiosAdapter): AxiosAdapter => axios.getAdapter(adapter); const createNativeMultipartAwareAdapter = ( client: StreamChat, fallbackAdapter: ResolvableAxiosAdapter, ): AxiosAdapter => { const resolvedFallbackAdapter = resolveAxiosAdapter(fallbackAdapter); return (config) => { const nativeMultipartRequest = createNativeMultipartRequest( client, config as NativeMultipartAxiosRequestConfig, ); if (!nativeMultipartRequest) { return resolvedFallbackAdapter(config); } return nativeMultipartAxiosAdapter(nativeMultipartRequest, config); }; }; export const wrapAxiosAdapterWithNativeMultipart = ( client: StreamChat, fallbackAdapter: ResolvableAxiosAdapter, ): AxiosAdapter => { if (!isNativeMultipartUploadAvailable()) { return resolveAxiosAdapter(fallbackAdapter); } return createNativeMultipartAwareAdapter(client, fallbackAdapter); }; export const installNativeMultipartAdapter = (client: StreamChat) => { if (!isNativeMultipartUploadAvailable()) { return; } if (installedAdapters.has(client)) { return; } const previousAdapter = client.axiosInstance.defaults.adapter; client.axiosInstance.defaults.adapter = wrapAxiosAdapterWithNativeMultipart( client, previousAdapter, ); installedAdapters.add(client); };