import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { ApiResource, CurrentPersonResource } from '../types' import { AttachmentFlaggedEvent } from '../types/jolt_events/attachment_events' import { FileAttachment, FileUploadState, NativeAttachmentFile, } from '../types/resources/denormalized_attachment_resource_for_create' import { useApiGet } from './use_api' import { useApiClient } from './use_api_client' import { useChatConfiguration } from './use_chat_configuration' import { currentPersonRequestArgs } from './use_current_person' import { useJoltChannel, useJoltEvent } from './use_jolt' import { useUploadClient } from './use_upload_client' export interface FileError { file_type?: string[] file_size?: boolean } export function useAttachmentUploader({ conversationId, draftAttachments, onFlaggedInMessageForm, }: { conversationId: number draftAttachments?: FileAttachment[] onFlaggedInMessageForm?: (id: string) => void }) { const apiClient = useApiClient() const uploadApi = useUploadClient() const { allowedFileExtensions, maxFileSizeInBytes, maxAttachmentsPerMessage } = useChatConfiguration() const maxFileSizeInMb = Number((maxFileSizeInBytes / (1024 * 1024)).toFixed(1)) const [attachments, setAttachments] = useState(() => draftAttachments || []) const attachmentsRef = useRef(draftAttachments ?? []) const uploadState = useRef({}) const [lastUploadId, setLastUploadId] = useState() const numberOfAttachments = attachments.length const [errorMessage, setErrorMessage] = useState(null) useEffect(() => { attachmentsRef.current = attachments }, [attachments]) const { data: currentPerson } = useApiGet(currentPersonRequestArgs) const joltChannel = useJoltChannel(`chat.people.${currentPerson?.id}`, Boolean(currentPerson?.id)) useJoltEvent(joltChannel, 'attachment.flagged', (e: AttachmentFlaggedEvent) => { const flaggedId = e.data.data.attachment_id if (attachmentsRef.current.some(a => a.id === flaggedId)) { setAttachments(prev => prev.map(a => (a.id === flaggedId ? { ...a, flagged: true, status: 'error' } : a)) ) onFlaggedInMessageForm?.(flaggedId) } }) const handleFilesAttached = useCallback( (files: NativeAttachmentFile[]) => { const fileErrors = {} as FileError const validFiles = files.filter(file => { const extension = file.name.toLowerCase().split('.').pop() as string const isValidExtension = allowedFileExtensions.includes(`.${extension}`) const isValidFileSize = file.size <= maxFileSizeInBytes if (!isValidExtension) { fileErrors.file_type ||= [] fileErrors.file_type.push(extension) } if (!isValidFileSize) { fileErrors.file_size = true } return isValidFileSize && isValidExtension }) const errorMessages: string[] = [] if (fileErrors.file_type) { errorMessages.push( `The following file types are not supported: ${fileErrors.file_type.join(', ')}` ) } if (fileErrors.file_size) { errorMessages.push(`File size exceeds ${maxFileSizeInMb} MB`) } if (numberOfAttachments + validFiles.length > maxAttachmentsPerMessage) { errorMessages.push(`You can't attach more than ${maxAttachmentsPerMessage} files at once.`) } if (errorMessages.length > 0) { setErrorMessage(errorMessages.join('\n')) return } const newAttachments: FileAttachment[] = validFiles.map(file => ({ file, status: 'uploading', uploadedAt: Date.now(), })) if (newAttachments && newAttachments.length > 0) { setAttachments(prevAttachments => [...prevAttachments, ...newAttachments]) newAttachments.forEach(attachment => { uploadApi .uploadFile(attachment.file) .then(({ id: uploadedFileId }) => apiClient.chat.post>({ url: `/me/conversations/${conversationId}/message_attachments`, data: { data: { type: 'MessageAttachment', attributes: { uploaded_file_id: uploadedFileId }, }, }, }) ) .then(({ data: { id: messageAttachmentId } }) => { uploadState.current[attachment.file.name] = { status: 'success', id: messageAttachmentId, } setLastUploadId(messageAttachmentId) }) .catch(async err => { const isFlagged = err?.code === 'image_flagged' uploadState.current[attachment.file.name] = { status: 'error', flagged: isFlagged, } if (!isFlagged) { // Dev builds surface the raw server detail to shorten the // feedback loop on backend errors (e.g. AWS SSO re-auth). // Production users always see the generic message. const serverDetail = __DEV__ ? await extractDevOnlyServerErrorDetail(err) : null setErrorMessage(serverDetail ?? 'This file could not be uploaded.') } setLastUploadId(attachment.file.name) }) }) } }, [ numberOfAttachments, uploadApi, apiClient.chat, conversationId, allowedFileExtensions, maxFileSizeInBytes, maxFileSizeInMb, maxAttachmentsPerMessage, ] ) useEffect(() => { if (!lastUploadId) return setLastUploadId(undefined) setAttachments( attachments.map(attachment => { // Don't risk overwriting ids already set if (attachment.id) return attachment const state = uploadState.current[attachment.file.name] if (state) { return { ...attachment, id: state.id, status: state.status, flagged: state.flagged } } return attachment }) ) }, [attachments, lastUploadId]) const removeAttachment = useCallback((attachment: FileAttachment) => { setAttachments(prevAttachments => prevAttachments.filter(a => a.file.uri !== attachment.file.uri) ) }, []) const removeFlaggedAttachments = useCallback(() => { setAttachments(prevAttachments => prevAttachments.filter(a => !a.flagged)) }, []) const reset = useCallback(() => { setAttachments([]) setErrorMessage(null) }, []) const pendingUploads = attachments.filter(a => a.status === 'uploading').length > 0 const flaggedAttachmentCount = attachments.filter(a => a.flagged).length const attachmentIds = useMemo( () => attachments.filter(a => a.status === 'success' && a.id).map(a => a.id as string), [attachments] ) return { attachments, attachmentIds, handleFilesAttached, removeAttachment, removeFlaggedAttachments, reset, pendingUploads, errorMessage, flaggedAttachmentCount, remainingAttachable: Math.max(0, maxAttachmentsPerMessage - numberOfAttachments), } } async function extractDevOnlyServerErrorDetail(err: unknown): Promise { const response = err as Response | null if (!response?.clone) return null try { const body = await response.clone().json() const detail = body?.errors?.[0]?.detail ?? body?.detail return typeof detail === 'string' ? detail : null } catch { return null } } interface MessageAttachmentResource { type: 'MessageAttachment' id: string uploadedFileId: string filename: string messageSortKey: string metadata: Record checksum: string contentType: string byteSize: number }