diff --git a/src/components/compose-message/hooks/useComposeSend.test.ts b/src/components/compose-message/hooks/useComposeSend.test.ts index b4ce56d..6a22e3b 100644 --- a/src/components/compose-message/hooks/useComposeSend.test.ts +++ b/src/components/compose-message/hooks/useComposeSend.test.ts @@ -311,22 +311,25 @@ describe('useComposeSend', () => { vi.spyOn(MailEncryptionService.instance, 'buildEncryptionBlock').mockResolvedValue(mockEncryptionBlock); }); - test('When sending an email opened from a draft, then the draftId is forwarded to sendEmail so the server consumes the draft', async () => { + test('When sending an email opened from a draft, then the saved draft identifier is included in the send request so the server can consume it', async () => { + const resolveDraftId = vi.fn().mockResolvedValue('draft-42'); const { result, onSent } = renderSend({ toRecipients: [recipient('bob@inxt.me')], - draftId: 'draft-42', + resolveDraftId, }); await act(async () => { await result.current.send(); }); + expect(resolveDraftId).toHaveBeenCalled(); expect(mocks.sendEmail).toHaveBeenCalledWith(expect.objectContaining({ draftId: 'draft-42' })); expect(onSent).toHaveBeenCalled(); }); - test('When sending a brand-new email (no draftId), then sendEmail is called without a draftId', async () => { - const { result, onSent } = renderSend({ toRecipients: [recipient('bob@inxt.me')] }); + test('When sending a brand-new email with no saved draft, then the send request omits any draft identifier', async () => { + const resolveDraftId = vi.fn().mockResolvedValue(null); + const { result, onSent } = renderSend({ toRecipients: [recipient('bob@inxt.me')], resolveDraftId }); await act(async () => { await result.current.send(); diff --git a/src/components/compose-message/hooks/useComposeSend.ts b/src/components/compose-message/hooks/useComposeSend.ts index babcc64..33a1310 100644 --- a/src/components/compose-message/hooks/useComposeSend.ts +++ b/src/components/compose-message/hooks/useComposeSend.ts @@ -30,7 +30,7 @@ interface UseComposeSendParams { attachments: AttachmentTask[]; attachmentsSessionKey: Uint8Array; inReplyTo?: string; - draftId?: string; + resolveDraftId?: () => Promise; onSent: () => void; markResolvingInherited: (id: string) => void; markInheritedResolved: (id: string, blobId: string) => void; @@ -59,7 +59,7 @@ export const useComposeSend = ({ attachments, attachmentsSessionKey, inReplyTo, - draftId, + resolveDraftId, onSent, markResolvingInherited, markInheritedResolved, @@ -224,6 +224,8 @@ export const useComposeSend = ({ const deliveryMode = (encryptionState === 'internxt' ? 'INTERNXT' : 'EXTERNAL') as DeliveryMode; + const draftId = (await resolveDraftId?.()) ?? undefined; + await sendEmail({ to: toRecipients.map(toEmailAddress), cc: ccRecipients.length ? ccRecipients.map(toEmailAddress) : undefined, @@ -254,7 +256,7 @@ export const useComposeSend = ({ attachments, attachmentsSessionKey, inReplyTo, - draftId, + resolveDraftId, triggerLookup, sendEmail, onSent, diff --git a/src/components/compose-message/hooks/useDraftMessage.test.ts b/src/components/compose-message/hooks/useDraftMessage.test.ts index 9c55ddb..ee85b3a 100644 --- a/src/components/compose-message/hooks/useDraftMessage.test.ts +++ b/src/components/compose-message/hooks/useDraftMessage.test.ts @@ -21,7 +21,7 @@ vi.mock('@/store/api/mail', () => ({ useGetMailAccountKeysQuery: () => ({ data: mocks.senderKeys }), })); -const editor = { getHTML: () => '

hi

', getText: () => 'hi' } as unknown as Editor; +const editor = { getHTML: () => '

hi

', getText: () => 'hi', on: vi.fn(), off: vi.fn() } as unknown as Editor; const recipient = (email: string): Recipient => ({ id: email, email }); const mockEncryptionBlock = { @@ -91,7 +91,7 @@ describe('Draft Message', () => { test('When a new compose is empty, then the draft is not created', async () => { vi.spyOn(MailEncryptionService.instance, 'buildEncryptionBlock').mockResolvedValue(mockEncryptionBlock); - const emptyEditor = { getHTML: () => '

', getText: () => '' } as unknown as Editor; + const emptyEditor = { getHTML: () => '

', getText: () => '', on: vi.fn(), off: vi.fn() } as unknown as Editor; const { result } = renderDraft({ editor: emptyEditor }); @@ -190,4 +190,86 @@ describe('Draft Message', () => { expect(mocks.createDraft).toHaveBeenCalledTimes(1); expect(mocks.updateDraft).toHaveBeenCalledWith(expect.objectContaining({ draftId: 'new-draft-1' })); }); + + test('When a save is triggered while another is in flight, then it waits and updates instead of creating a duplicate', async () => { + vi.spyOn(MailEncryptionService.instance, 'buildEncryptionBlock').mockResolvedValue(mockEncryptionBlock); + + let resolveCreate!: (value: { id: string; receivedAt: string }) => void; + mocks.createDraft.mockReturnValue({ + unwrap: () => + new Promise((resolve) => { + resolveCreate = resolve; + }), + }); + + const { result } = renderDraft({ subject: 'Racy', toRecipients: [recipient('bob@inxt.me')] }); + + await act(async () => { + const first = result.current.saveDraft(); + const second = result.current.saveDraft(); + + await vi.advanceTimersByTimeAsync(0); + + resolveCreate({ id: 'new-draft-1', receivedAt: '2026-06-22T13:42:30Z' }); + await Promise.all([first, second]); + }); + + expect(mocks.createDraft).toHaveBeenCalledTimes(1); + expect(mocks.updateDraft).toHaveBeenCalledTimes(1); + expect(mocks.updateDraft).toHaveBeenCalledWith(expect.objectContaining({ draftId: 'new-draft-1' })); + }); + + test('When resolving the draft id before send, then it waits for the in-flight save and returns the fresh draft id', async () => { + vi.spyOn(MailEncryptionService.instance, 'buildEncryptionBlock').mockResolvedValue(mockEncryptionBlock); + + let resolveCreate!: (value: { id: string; receivedAt: string }) => void; + mocks.createDraft.mockReturnValue({ + unwrap: () => + new Promise((resolve) => { + resolveCreate = resolve; + }), + }); + + const { result } = renderDraft({ subject: 'Send race', toRecipients: [recipient('bob@inxt.me')] }); + + let resolvedId: string | null = null; + await act(async () => { + const save = result.current.saveDraft(); + await vi.advanceTimersByTimeAsync(0); + const resolve = result.current.resolveDraftId(); + resolveCreate({ id: 'new-draft-1', receivedAt: '2026-06-22T13:42:30Z' }); + [, resolvedId] = await Promise.all([save, resolve]); + }); + + expect(resolvedId).toBe('new-draft-1'); + }); + + test('When the body is edited, then autosave re-arms from the editor update event', async () => { + vi.spyOn(MailEncryptionService.instance, 'buildEncryptionBlock').mockResolvedValue(mockEncryptionBlock); + + let updateHandler: (() => void) | undefined; + const typedEditor = { + getHTML: () => '

hi

', + getText: () => 'hi', + on: (event: string, cb: () => void) => { + if (event === 'update') updateHandler = cb; + }, + off: vi.fn(), + } as unknown as Editor; + + renderDraft({ editor: typedEditor, subject: 'Typing' }); + expect(updateHandler).toBeDefined(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(6000); + updateHandler!(); + await vi.advanceTimersByTimeAsync(6000); + }); + expect(mocks.createDraft).not.toHaveBeenCalled(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(4000); + }); + expect(mocks.createDraft).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/components/compose-message/hooks/useDraftMessage.ts b/src/components/compose-message/hooks/useDraftMessage.ts index a057289..d9e4a26 100644 --- a/src/components/compose-message/hooks/useDraftMessage.ts +++ b/src/components/compose-message/hooks/useDraftMessage.ts @@ -44,6 +44,7 @@ interface UseDraftMessageResult { isDiscarding: boolean; handleDraftDiscard: () => Promise; saveDraft: () => Promise; + resolveDraftId: () => Promise; clearDraftRef: () => void; } @@ -63,6 +64,8 @@ export const useDraftMessage = ({ // Using a ref instead an state to avoid losing data on re-renders const draftIdRef = useRef(existentDraftId ?? null); const draftReceivedAtRef = useRef(draftReceivedAt ?? null); + const pendingSaveRef = useRef | null>(null); + const activeSavesRef = useRef(0); useEffect(() => { if (existentDraftId && draftIdRef.current === null) { @@ -107,32 +110,59 @@ export const useDraftMessage = ({ }; }, [toRecipients, ccRecipients, bccRecipients, subject, editor, attachments, attachmentsSessionKey, senderKeys]); - const saveDraft = useCallback(async () => { + const clearAutosaveTimer = useCallback(() => { if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = null; } + }, []); + + const performSave = useCallback(async () => { + const payload = await buildPayload(); + if (!payload) return; + + if (!draftIdRef.current && isPayloadEmpty(payload, editor)) return; + + if (draftIdRef.current) { + const { id: newDraftId, receivedAt } = await updateDraft({ draftId: draftIdRef.current, payload }).unwrap(); + draftIdRef.current = newDraftId; + draftReceivedAtRef.current = receivedAt; + } else { + const { id, receivedAt } = await createDraft(payload).unwrap(); + draftIdRef.current = id; + draftReceivedAtRef.current = receivedAt; + } + }, [buildPayload, createDraft, updateDraft, editor]); + + const saveDraft = useCallback(async () => { + clearAutosaveTimer(); + + const previous = pendingSaveRef.current ?? Promise.resolve(); + const current = previous.catch(() => undefined).then(performSave); + pendingSaveRef.current = current; + activeSavesRef.current += 1; setIsSaving(true); try { - const payload = await buildPayload(); - if (!payload) return; - - if (!draftIdRef.current && isPayloadEmpty(payload, editor)) return; - - if (draftIdRef.current) { - const { id: newDraftId, receivedAt } = await updateDraft({ draftId: draftIdRef.current, payload }).unwrap(); - draftIdRef.current = newDraftId; - draftReceivedAtRef.current = receivedAt; - } else { - const { id, receivedAt } = await createDraft(payload).unwrap(); - draftIdRef.current = id; - draftReceivedAtRef.current = receivedAt; - } + await current; } finally { - setIsSaving(false); + activeSavesRef.current -= 1; + if (activeSavesRef.current === 0) setIsSaving(false); + if (pendingSaveRef.current === current) pendingSaveRef.current = null; } - }, [buildPayload, createDraft, updateDraft, editor]); + }, [performSave, clearAutosaveTimer]); + + const resolveDraftId = useCallback(async (): Promise => { + clearAutosaveTimer(); + if (pendingSaveRef.current) { + try { + await pendingSaveRef.current; + } catch { + // Save failed; proceed with the last id that reached the server. no op + } + } + return draftIdRef.current; + }, [clearAutosaveTimer]); const handleDraftDiscard = useCallback(async () => { if (!draftIdRef.current) return; @@ -140,27 +170,37 @@ export const useDraftMessage = ({ }, [discardDraft]); const clearDraftRef = useCallback(() => { - if (timerRef.current) { - clearTimeout(timerRef.current); - timerRef.current = null; - } + clearAutosaveTimer(); draftIdRef.current = null; draftReceivedAtRef.current = null; resetDiscardDraft(); - }, [resetDiscardDraft]); + }, [resetDiscardDraft, clearAutosaveTimer]); - useEffect(() => { - if (timerRef.current) clearTimeout(timerRef.current); + const scheduleAutosave = useCallback(() => { + clearAutosaveTimer(); timerRef.current = setTimeout(() => { saveDraft().catch(() => { // Autosave failures are silent; user can retry on close }); }, AUTOSAVE_DELAY_MS); + }, [saveDraft, clearAutosaveTimer]); + + useEffect(() => { + scheduleAutosave(); return () => { if (timerRef.current) clearTimeout(timerRef.current); }; - }, [toRecipients, ccRecipients, bccRecipients, subject, editor, attachments, saveDraft]); + }, [toRecipients, ccRecipients, bccRecipients, subject, editor, attachments, scheduleAutosave]); + + useEffect(() => { + if (!editor) return; + const onUpdate = () => scheduleAutosave(); + editor.on('update', onUpdate); + return () => { + editor.off('update', onUpdate); + }; + }, [editor, scheduleAutosave]); return { draftId: draftIdRef.current, @@ -169,6 +209,7 @@ export const useDraftMessage = ({ isDiscarding, handleDraftDiscard, saveDraft, + resolveDraftId, clearDraftRef, }; }; diff --git a/src/components/compose-message/index.tsx b/src/components/compose-message/index.tsx index f92b4d3..7c133f7 100644 --- a/src/components/compose-message/index.tsx +++ b/src/components/compose-message/index.tsx @@ -143,7 +143,7 @@ export const ComposeMessageDialog = () => { const fileInputRef = useRef(null); - const { saveDraft, handleDraftDiscard, isDiscarding, draftId, draftSavedAt, clearDraftRef } = useDraftMessage({ + const { saveDraft, resolveDraftId, handleDraftDiscard, isDiscarding, draftSavedAt, clearDraftRef } = useDraftMessage({ existentDraftId: initialDraftId, draftReceivedAt: initialReceivedAtDraft, attachments, @@ -197,7 +197,7 @@ export const ComposeMessageDialog = () => { subject: subjectValue, toRecipients, inReplyTo: inReplyItemId, - draftId: draftId ?? undefined, + resolveDraftId, onSent, markResolvingInherited, markInheritedResolved,