import { InfiniteData, useMutation } from '@tanstack/react-query' import { chatQueryClient } from '../contexts/api_provider' import { ApiCollection, ApiResource, MessageResource } from '../types' import { DenormalizedAttachmentResourceForCreate } from '../types/resources/denormalized_attachment_resource_for_create' import { deleteRecordInPagesData, Haptic, updateOrCreateRecordInPagesData, updateRecordInPagesData, } from '../utils' import { isNewMessage, mergeMessageUpdate } from '../utils/cache/messages_cache' import { optimisticallyCreateMessage } from '../utils/cache/optimistically_create_message' import { optimisticallyUpdateMessage } from '../utils/cache/optimistically_update_message' import { startMessageCreationTracking } from '../utils/performance_tracking' import { getMessagesQueryKey, getMessagesRequestArgs } from '../utils/request/get_messages' import { useApiClient } from './use_api_client' import { useCurrentPerson } from './use_current_person' interface Props { conversationId: number message?: MessageResource replyRootId?: string | null } interface MessageMutationVariables { text: string attachments?: DenormalizedAttachmentResourceForCreate[] idempotentKey?: string } type ResolvedMessageMutationVariables = MessageMutationVariables & { idempotentKey: string } export function useMessageCreateOrUpdate({ conversationId, message, replyRootId }: Props) { const messageId = message?.id || null const isEditing = !isNewMessage(message) const apiClient = useApiClient() const currentPerson = useCurrentPerson() const mutation = useMutation({ mutationFn: ({ text, attachments, idempotentKey }: ResolvedMessageMutationVariables) => { const requestParams = getMessagesRequestArgs({ conversation_id: conversationId }) const fieldsWithValueJoined = Object.fromEntries( Object.entries(requestParams.data.fields).map(([k, v]) => [k, v.join(',')]) ) let attributes: any = { text, ...(attachments ? { attachments } : {}), ...(!isEditing && replyRootId ? { reply_root: { id: replyRootId } } : {}), } if (!isEditing) { attributes.idempotent_key = idempotentKey startMessageCreationTracking(idempotentKey) } const data = { ...requestParams.data, data: { type: 'Message', attributes, }, fields: fieldsWithValueJoined, } if (isEditing) { return apiClient.chat.patch>({ url: `/me/conversations/${conversationId}/messages/${messageId}`, data, }) } else { return apiClient.chat.post>({ url: `/me/conversations/${conversationId}/messages`, data, }) } }, onMutate: async ({ text, attachments, idempotentKey }: ResolvedMessageMutationVariables) => { if (message && isEditing) { const optimisticMessage = optimisticallyUpdateMessage({ conversationId, message, text, replyRootId, }) return { message: optimisticMessage } } const optimisticMessage = optimisticallyCreateMessage({ conversationId, message, text, attachments, currentPerson, replyRootId, idempotentKey, }) return { message: optimisticMessage } }, onError: (error, variables, context) => { const { message: optimisticMessage } = context || {} Haptic.notificationError() // Add error to the optimistic message from the cache on error if (optimisticMessage) { const queryKey = getMessagesQueryKey({ conversation_id: conversationId, reply_root_id: replyRootId, }) chatQueryClient.setQueryData( queryKey, (data: InfiniteData> | undefined) => updateRecordInPagesData({ data, record: optimisticMessage, processRecord: (_next, prev) => ({ ...prev, error: error.message || 'Failed to send message', pending: false, // Mark as no longer pending }), }) ) } }, onSuccess: (result: ApiResource, variables, context) => { const { message: optimisticMessage } = context || {} const updatedMessage = result.data type QueryData = InfiniteData> const queryKey = getMessagesQueryKey({ conversation_id: conversationId, reply_root_id: replyRootId, }) // First remove the optimistic message if it exists if (optimisticMessage) { chatQueryClient.setQueryData(queryKey, data => deleteRecordInPagesData({ data, record: optimisticMessage, }) ) } // Then add the real message chatQueryClient.setQueryData(queryKey, data => updateOrCreateRecordInPagesData({ data, record: updatedMessage, processRecord: (record, current) => mergeMessageUpdate(record, current), }) ) }, }) const resolveVariables = ( variables: MessageMutationVariables ): ResolvedMessageMutationVariables => ({ ...variables, idempotentKey: variables.idempotentKey ?? message?.idempotentKey ?? insecureUUID(), }) const mutate = ( variables: MessageMutationVariables, options?: Parameters[1] ) => mutation.mutate(resolveVariables(variables), options) const mutateAsync = ( variables: MessageMutationVariables, options?: Parameters[1] ) => mutation.mutateAsync(resolveVariables(variables), options) return { ...mutation, mutate, mutateAsync } } /** * Generate a random UUID (v4) for idempotent keys. * Uses Math.random, which is not cryptographically secure. * An actual crypto library requires native dependencies. * This is OK for now since idempotent keys are not security-sensitive * or need to be guaranteed unique. * They are short lived and we use it in combination with the message's creator_id so * their impact is scoped only the current user. */ function insecureUUID(): string { return 'xxxxxxxx-xxxx-4xxx-Nxxx-xxxxxxxxxxxx' .replace(/x/g, () => Math.floor(Math.random() * 16).toString(16)) .replace(/N/g, () => (Math.floor(Math.random() * 4) + 8).toString(16)) }