UNPKG

stream-chat

Version:

JS SDK for the Stream Chat API

657 lines (562 loc) 20 kB
import type { AttachmentManagerConfig, MinimumUploadRequestResult, UploadRequestFn, } from './configuration'; import { isLocalImageAttachment, isUploadedAttachment } from './attachmentIdentity'; import { createFileFromBlobs, ensureIsLocalAttachment, generateFileName, getAttachmentTypeFromMimeType, isFile, isFileList, isFileReference, isImageFile, } from './fileUtils'; import { AttachmentPostUploadMiddlewareExecutor, AttachmentPreUploadMiddlewareExecutor, } from './middleware/attachmentManager'; import { StateStore } from '../store'; import { generateUUIDv4 } from '../utils'; import { DEFAULT_UPLOAD_SIZE_LIMIT_BYTES } from '../constants'; import type { AttachmentLoadingState, FileLike, FileReference, LocalAttachment, LocalNotImageAttachment, LocalUploadAttachment, UploadPermissionCheckResult, } from './types'; import type { ChannelResponse, DraftMessage, LocalMessage } from '../types'; import type { MessageComposer } from './messageComposer'; import { mergeWithDiff } from '../utils/mergeWith'; export type FileUploadFilter = (file: Partial<LocalUploadAttachment>) => boolean; export type AttachmentManagerState = { attachments: LocalAttachment[]; }; export type AttachmentManagerOptions = { composer: MessageComposer; message?: DraftMessage | LocalMessage; }; const initState = ({ message, }: { message?: DraftMessage | LocalMessage; }): AttachmentManagerState => ({ attachments: (message?.attachments ?? []) ?.filter(({ og_scrape_url }) => !og_scrape_url) .map((att) => { const localMetadata = isUploadedAttachment(att) ? { id: generateUUIDv4(), uploadState: 'finished' } : { id: generateUUIDv4() }; return { ...att, localMetadata, } as LocalAttachment; }), }); export class AttachmentManager { readonly state: StateStore<AttachmentManagerState>; readonly composer: MessageComposer; readonly preUploadMiddlewareExecutor: AttachmentPreUploadMiddlewareExecutor; readonly postUploadMiddlewareExecutor: AttachmentPostUploadMiddlewareExecutor; private attachmentsByIdGetterCache: { attachmentsById: Record<string, LocalAttachment>; attachments: LocalAttachment[]; }; constructor({ composer, message }: AttachmentManagerOptions) { this.composer = composer; this.state = new StateStore<AttachmentManagerState>(initState({ message })); this.attachmentsByIdGetterCache = { attachmentsById: {}, attachments: [] }; this.preUploadMiddlewareExecutor = new AttachmentPreUploadMiddlewareExecutor({ composer, }); this.postUploadMiddlewareExecutor = new AttachmentPostUploadMiddlewareExecutor({ composer, }); } get attachmentsById() { const { attachments } = this.state.getLatestValue(); if (attachments !== this.attachmentsByIdGetterCache.attachments) { this.attachmentsByIdGetterCache.attachments = attachments; this.attachmentsByIdGetterCache.attachmentsById = attachments.reduce< Record<string, LocalAttachment> >((newAttachmentsById, attachment) => { // should never happen but does not hurt to check if (!attachment.localMetadata.id) return newAttachmentsById; newAttachmentsById[attachment.localMetadata.id] ??= attachment; return newAttachmentsById; }, {}); } return this.attachmentsByIdGetterCache.attachmentsById; } get client() { return this.composer.client; } get channel() { return this.composer.channel; } get config() { return this.composer.config.attachments; } get acceptedFiles() { return this.config.acceptedFiles; } set acceptedFiles(acceptedFiles: AttachmentManagerConfig['acceptedFiles']) { this.composer.updateConfig({ attachments: { acceptedFiles } }); } /* @deprecated attachments can be filtered using injecting pre-upload middleware */ get fileUploadFilter() { return this.config.fileUploadFilter; } /* @deprecated attachments can be filtered using injecting pre-upload middleware */ set fileUploadFilter(fileUploadFilter: AttachmentManagerConfig['fileUploadFilter']) { this.composer.updateConfig({ attachments: { fileUploadFilter } }); } get maxNumberOfFilesPerMessage() { return this.config.maxNumberOfFilesPerMessage; } set maxNumberOfFilesPerMessage( maxNumberOfFilesPerMessage: AttachmentManagerConfig['maxNumberOfFilesPerMessage'], ) { if (maxNumberOfFilesPerMessage === this.maxNumberOfFilesPerMessage) return; this.composer.updateConfig({ attachments: { maxNumberOfFilesPerMessage } }); } setCustomUploadFn = (doUploadRequest: UploadRequestFn) => { this.composer.updateConfig({ attachments: { doUploadRequest } }); }; get attachments() { return this.state.getLatestValue().attachments; } get hasUploadPermission() { return !!( this.channel.data?.own_capabilities as ChannelResponse['own_capabilities'] )?.includes('upload-file'); } get isUploadEnabled() { return this.hasUploadPermission && this.availableUploadSlots > 0; } get successfulUploads() { return this.getUploadsByState('finished'); } get successfulUploadsCount() { return this.successfulUploads.length; } get uploadsInProgressCount() { return this.getUploadsByState('uploading').length; } get failedUploadsCount() { return this.getUploadsByState('failed').length; } get blockedUploadsCount() { return this.getUploadsByState('blocked').length; } get pendingUploadsCount() { return this.getUploadsByState('pending').length; } get availableUploadSlots() { return ( this.config.maxNumberOfFilesPerMessage - this.successfulUploadsCount - this.uploadsInProgressCount ); } getUploadsByState(state: AttachmentLoadingState) { return Object.values(this.attachments).filter( ({ localMetadata }) => localMetadata.uploadState === state, ); } initState = ({ message }: { message?: DraftMessage | LocalMessage } = {}) => { this.state.next(initState({ message })); }; getAttachmentIndex = (localId: string) => { const attachmentsById = this.attachmentsById; return this.attachments.indexOf(attachmentsById[localId]); }; private prepareAttachmentUpdate = (attachmentToUpdate: LocalAttachment) => { const stateAttachments = this.attachments; const attachments = [...this.attachments]; const attachmentIndex = this.getAttachmentIndex(attachmentToUpdate.localMetadata.id); if (attachmentIndex === -1) return null; // do not re-organize newAttachments array otherwise indexing would no longer work // replace in place only with the attachments with the same id's const merged = mergeWithDiff<LocalAttachment>( stateAttachments[attachmentIndex], attachmentToUpdate, ); const updatesOnMerge = merged.diff && Object.keys(merged.diff.children).length; if (updatesOnMerge) { const localAttachment = ensureIsLocalAttachment(merged.result); if (localAttachment) { attachments.splice(attachmentIndex, 1, localAttachment); return attachments; } } return null; }; updateAttachment = (attachmentToUpdate: LocalAttachment) => { const updatedAttachments = this.prepareAttachmentUpdate(attachmentToUpdate); if (updatedAttachments) { this.state.partialNext({ attachments: updatedAttachments }); } }; upsertAttachments = (attachmentsToUpsert: LocalAttachment[]) => { if (!attachmentsToUpsert.length) return; let attachments = [...this.attachments]; let hasUpdates = false; attachmentsToUpsert.forEach((attachment) => { const updatedAttachments = this.prepareAttachmentUpdate(attachment); if (updatedAttachments) { attachments = updatedAttachments; hasUpdates = true; } else { const localAttachment = ensureIsLocalAttachment(attachment); if (localAttachment) { attachments.push(localAttachment); hasUpdates = true; } } }); if (hasUpdates) { this.state.partialNext({ attachments }); } }; removeAttachments = (localAttachmentIds: string[]) => { this.state.partialNext({ attachments: this.attachments.filter( (attachment) => !localAttachmentIds.includes(attachment.localMetadata?.id), ), }); }; getUploadConfigCheck = async ( fileLike: FileReference | FileLike, ): Promise<UploadPermissionCheckResult> => { const client = this.channel.getClient(); let appSettings; if (!client.appSettingsPromise) { appSettings = await client.getAppSettings(); } else { appSettings = await client.appSettingsPromise; } const uploadConfig = isImageFile(fileLike) ? appSettings?.app?.image_upload_config : appSettings?.app?.file_upload_config; if (!uploadConfig) return { uploadBlocked: false }; const { allowed_file_extensions, allowed_mime_types, blocked_file_extensions, blocked_mime_types, size_limit, } = uploadConfig; const sizeLimit = size_limit || DEFAULT_UPLOAD_SIZE_LIMIT_BYTES; const mimeType = fileLike.type; if (isFile(fileLike) || isFileReference(fileLike)) { if ( allowed_file_extensions?.length && !allowed_file_extensions.some((ext) => fileLike.name.toLowerCase().endsWith(ext.toLowerCase()), ) ) { return { uploadBlocked: true, reason: 'allowed_file_extensions' }; } if ( blocked_file_extensions?.length && blocked_file_extensions.some((ext) => fileLike.name.toLowerCase().endsWith(ext.toLowerCase()), ) ) { return { uploadBlocked: true, reason: 'blocked_file_extensions' }; } } if ( allowed_mime_types?.length && !allowed_mime_types.some((type) => type.toLowerCase() === mimeType?.toLowerCase()) ) { return { uploadBlocked: true, reason: 'allowed_mime_types' }; } if ( blocked_mime_types?.length && blocked_mime_types.some((type) => type.toLowerCase() === mimeType?.toLowerCase()) ) { return { uploadBlocked: true, reason: 'blocked_mime_types' }; } if (fileLike.size && fileLike.size > sizeLimit) { return { uploadBlocked: true, reason: 'size_limit' }; } return { uploadBlocked: false }; }; static toLocalUploadAttachment = ( fileLike: FileReference | FileLike, ): LocalUploadAttachment => { const file = isFileReference(fileLike) || isFile(fileLike) ? fileLike : createFileFromBlobs({ blobsArray: [fileLike], fileName: generateFileName(fileLike.type), mimeType: fileLike.type, }); const localAttachment: LocalUploadAttachment = { file_size: file.size, mime_type: file.type, localMetadata: { file, id: generateUUIDv4(), uploadState: 'pending', }, type: getAttachmentTypeFromMimeType(file.type), }; localAttachment[isImageFile(file) ? 'fallback' : 'title'] = file.name; if (isImageFile(file)) { localAttachment.localMetadata.previewUri = isFileReference(fileLike) ? fileLike.uri : URL.createObjectURL?.(fileLike); if (isFileReference(fileLike) && fileLike.height && fileLike.width) { localAttachment.original_height = fileLike.height; localAttachment.original_width = fileLike.width; } } if (isFileReference(fileLike) && fileLike.thumb_url) { localAttachment.thumb_url = fileLike.thumb_url; } if (isFileReference(fileLike) && fileLike.duration) { localAttachment.duration = fileLike.duration; } return localAttachment; }; // @deprecated use AttachmentManager.toLocalUploadAttachment(file) fileToLocalUploadAttachment = async ( fileLike: FileReference | FileLike, ): Promise<LocalUploadAttachment> => { const localAttachment = AttachmentManager.toLocalUploadAttachment(fileLike); const uploadPermissionCheck = await this.getUploadConfigCheck( localAttachment.localMetadata.file, ); localAttachment.localMetadata.uploadPermissionCheck = uploadPermissionCheck; localAttachment.localMetadata.uploadState = uploadPermissionCheck.uploadBlocked ? 'blocked' : 'pending'; return localAttachment; }; private ensureLocalUploadAttachment = async ( attachment: Partial<LocalUploadAttachment>, ) => { if (!attachment.localMetadata?.file) { this.client.notifications.addError({ message: 'File is required for upload attachment', origin: { emitter: 'AttachmentManager', context: { attachment } }, options: { type: 'validation:attachment:file:missing' }, }); return; } if (!attachment.localMetadata.id) { this.client.notifications.addError({ message: 'Local upload attachment missing local id', origin: { emitter: 'AttachmentManager', context: { attachment } }, options: { type: 'validation:attachment:id:missing' }, }); return; } if (!this.fileUploadFilter(attachment)) return; const newAttachment = await this.fileToLocalUploadAttachment( attachment.localMetadata.file, ); if (attachment.localMetadata.id) { newAttachment.localMetadata.id = attachment.localMetadata.id; } return newAttachment; }; /** * Method to perform the default upload behavior without checking for custom upload functions * to prevent recursive calls */ doDefaultUploadRequest = async (fileLike: FileReference | FileLike) => { if (isFileReference(fileLike)) { return this.channel[isImageFile(fileLike) ? 'sendImage' : 'sendFile']( fileLike.uri, fileLike.name, fileLike.type, ); } const file = isFile(fileLike) ? fileLike : createFileFromBlobs({ blobsArray: [fileLike], fileName: generateFileName(fileLike.type), mimeType: fileLike.type, }); // eslint-disable-next-line @typescript-eslint/no-unused-vars const { duration, ...result } = await this.channel[isImageFile(fileLike) ? 'sendImage' : 'sendFile'](file); return result; }; /** * todo: docs how to customize the image and file upload by overriding do */ doUploadRequest = async (fileLike: FileReference | FileLike) => { const customUploadFn = this.config.doUploadRequest; if (customUploadFn) { return await customUploadFn(fileLike); } return this.doDefaultUploadRequest(fileLike); }; // @deprecated use attachmentManager.uploadFile(file) uploadAttachment = async (attachment: LocalUploadAttachment) => { if (!this.isUploadEnabled) return; const localAttachment = await this.ensureLocalUploadAttachment(attachment); if (typeof localAttachment === 'undefined') return; if (localAttachment.localMetadata.uploadState === 'blocked') { this.upsertAttachments([localAttachment]); this.client.notifications.addError({ message: `The attachment upload was blocked`, origin: { emitter: 'AttachmentManager', context: { attachment, blockedAttachment: localAttachment }, }, options: { type: 'validation:attachment:upload:blocked', metadata: { reason: localAttachment.localMetadata.uploadPermissionCheck?.reason, }, }, }); return localAttachment; } this.upsertAttachments([ { ...attachment, localMetadata: { ...attachment.localMetadata, uploadState: 'uploading', }, }, ]); let response: MinimumUploadRequestResult; try { response = await this.doUploadRequest(localAttachment.localMetadata.file); } catch (error) { const reason = error instanceof Error ? error.message : 'unknown error'; const failedAttachment: LocalUploadAttachment = { ...attachment, localMetadata: { ...attachment.localMetadata, uploadState: 'failed', }, }; this.client.notifications.addError({ message: 'Error uploading attachment', origin: { emitter: 'AttachmentManager', context: { attachment, failedAttachment }, }, options: { type: 'api:attachment:upload:failed', metadata: { reason }, originalError: error instanceof Error ? error : undefined, }, }); this.updateAttachment(failedAttachment); return failedAttachment; } if (!response) { // Copied this from useImageUpload / useFileUpload. // If doUploadRequest returns any falsy value, then don't create the upload preview. // This is for the case if someone wants to handle failure on app level. this.removeAttachments([attachment.localMetadata.id]); return; } const uploadedAttachment: LocalUploadAttachment = { ...attachment, localMetadata: { ...attachment.localMetadata, uploadState: 'finished', }, }; if (isLocalImageAttachment(uploadedAttachment)) { if (uploadedAttachment.localMetadata.previewUri) { URL.revokeObjectURL(uploadedAttachment.localMetadata.previewUri); delete uploadedAttachment.localMetadata.previewUri; } uploadedAttachment.image_url = response.file; } else { (uploadedAttachment as LocalNotImageAttachment).asset_url = response.file; } if (response.thumb_url) { (uploadedAttachment as LocalNotImageAttachment).thumb_url = response.thumb_url; } this.updateAttachment(uploadedAttachment); return uploadedAttachment; }; uploadFile = async (file: FileReference | FileLike) => { const preUpload = await this.preUploadMiddlewareExecutor.execute({ eventName: 'prepare', initialValue: { attachment: AttachmentManager.toLocalUploadAttachment(file), }, mode: 'concurrent', }); let attachment: LocalUploadAttachment = preUpload.state.attachment; if (preUpload.status === 'discard') return attachment; // todo: remove with the next major release as filtering can be done in middleware // should we return the attachment object? if (!this.fileUploadFilter(attachment)) return attachment; if (attachment.localMetadata.uploadState === 'blocked') { this.upsertAttachments([attachment]); return preUpload.state.attachment; } attachment = { ...attachment, localMetadata: { ...attachment.localMetadata, uploadState: 'uploading', }, }; this.upsertAttachments([attachment]); let response: MinimumUploadRequestResult | undefined; let error: Error | undefined; try { response = await this.doUploadRequest(file); } catch (err) { error = err instanceof Error ? err : undefined; } const postUpload = await this.postUploadMiddlewareExecutor.execute({ eventName: 'postProcess', initialValue: { attachment: { ...attachment, localMetadata: { ...attachment.localMetadata, uploadState: error ? 'failed' : 'finished', }, }, error, response, }, mode: 'concurrent', }); attachment = postUpload.state.attachment; if (postUpload.status === 'discard') { this.removeAttachments([attachment.localMetadata.id]); return attachment; } this.updateAttachment(attachment); return attachment; }; uploadFiles = async (files: FileReference[] | FileList | FileLike[]) => { if (!this.isUploadEnabled) return; const iterableFiles: FileReference[] | FileLike[] = isFileList(files) ? Array.from(files) : files; return await Promise.all( iterableFiles.slice(0, this.availableUploadSlots).map(this.uploadFile), ); }; }