From c88b374a2f27c6b455f0a6feaa03ebabac8f79c8 Mon Sep 17 00:00:00 2001 From: Xavier Abad <77491413+xabg2@users.noreply.github.com> Date: Wed, 3 Jun 2026 15:25:04 +0200 Subject: [PATCH 1/3] feat: custom hook to handle attachments --- .../hooks/useAttachments.test.ts | 157 ++++++++++++++++++ .../compose-message/hooks/useAttachments.ts | 89 ++++++++++ src/constants.ts | 1 + src/i18n/locales/en.json | 5 +- src/i18n/locales/es.json | 5 +- src/i18n/locales/fr.json | 5 +- src/i18n/locales/it.json | 5 +- src/services/upload-manager/index.ts | 11 +- .../upload-manager/upload-manager.test.ts | 6 +- src/types/mail/upload-manager/index.ts | 3 +- 10 files changed, 277 insertions(+), 10 deletions(-) create mode 100644 src/components/compose-message/hooks/useAttachments.test.ts create mode 100644 src/components/compose-message/hooks/useAttachments.ts diff --git a/src/components/compose-message/hooks/useAttachments.test.ts b/src/components/compose-message/hooks/useAttachments.test.ts new file mode 100644 index 0000000..e791eae --- /dev/null +++ b/src/components/compose-message/hooks/useAttachments.test.ts @@ -0,0 +1,157 @@ +import { renderHook, act } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import useAttachments from './useAttachments'; +import { UploadManager } from '@/services/upload-manager'; +import notificationsService, { ToastType } from '@/services/notifications'; +import type { UploadAttachmentCallbacks, UploadHandle } from '@/types/mail/upload-manager'; +import { MAX_TOTAL_ATTACHMENT_BYTES_PER_MAIL } from '@/constants'; + +vi.mock('@/i18n', () => ({ useTranslationContext: () => ({ translate: (key: string) => key }) })); + +vi.mock('@/services/notifications', () => ({ + default: { show: vi.fn() }, + ToastType: { Success: 'success', Error: 'error', Warning: 'warning', Info: 'info', Loading: 'loading' }, +})); + +vi.mock('@/services/upload-manager', () => ({ + UploadManager: { instance: { run: vi.fn(), retry: vi.fn(), remove: vi.fn(), clear: vi.fn() } }, +})); + +const run = vi.mocked(UploadManager.instance.run); +const retry = vi.mocked(UploadManager.instance.retry); +const remove = vi.mocked(UploadManager.instance.remove); +const clear = vi.mocked(UploadManager.instance.clear); +const show = vi.mocked(notificationsService.show); + +let lastCallbacks: UploadAttachmentCallbacks | undefined; + +const fileOfSize = (size: number, name = 'a.txt', type = 'text/plain'): File => { + const f = new File(['x'], name, { type }); + Object.defineProperty(f, 'size', { value: size }); + return f; +}; + +describe('Attachments - custom hook', () => { + beforeEach(() => { + run.mockReset(); + retry.mockReset(); + remove.mockReset(); + clear.mockReset(); + show.mockReset(); + lastCallbacks = undefined; + run.mockImplementation((files: File[], callbacks: UploadAttachmentCallbacks): UploadHandle[] => { + lastCallbacks = callbacks; + return files.map((file, i) => ({ id: `id-${i}`, file })); + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('Adding files', () => { + test('When files are added, then files are handled as expected', () => { + const f1 = fileOfSize(100, '1.txt'); + const f2 = fileOfSize(200, '2.bin', 'application/octet-stream'); + const { result } = renderHook(() => useAttachments()); + + act(() => result.current.addFiles([f1, f2])); + + expect(run).toHaveBeenCalledWith([f1, f2], expect.any(Object)); + expect(result.current.attachments).toEqual([ + { id: 'id-0', name: '1.txt', size: 100, type: 'text/plain', status: 'uploading' }, + { id: 'id-1', name: '2.bin', size: 200, type: 'application/octet-stream', status: 'uploading' }, + ]); + expect(result.current.totalSize).toBe(300); + expect(result.current.isUploading).toBe(true); + expect(result.current.hasErrors).toBe(false); + }); + + test('When the new batch would exceed 25 MB total, then it shows a warning toast and does not enqueue', () => { + const big = fileOfSize(MAX_TOTAL_ATTACHMENT_BYTES_PER_MAIL + 1); + const { result } = renderHook(() => useAttachments()); + + act(() => result.current.addFiles([big])); + + expect(show).toHaveBeenCalledWith({ + text: 'modals.composeMessageDialog.errors.attachmentsTooLarge', + type: ToastType.Warning, + }); + expect(run).not.toHaveBeenCalled(); + expect(result.current.attachments).toHaveLength(0); + }); + + test('When the cumulative size reaches the 25 MB limit, then a subsequent batch is rejected', () => { + const half = fileOfSize(MAX_TOTAL_ATTACHMENT_BYTES_PER_MAIL / 2); + const { result } = renderHook(() => useAttachments()); + + act(() => result.current.addFiles([half])); + act(() => result.current.addFiles([fileOfSize(MAX_TOTAL_ATTACHMENT_BYTES_PER_MAIL / 2 + 1)])); + + expect(result.current.attachments).toHaveLength(1); + expect(show).toHaveBeenCalledTimes(1); + }); + }); + + describe('upload callbacks', () => { + test('When the manager reports success, then the attachment moves to done and the id of the blob is stored', () => { + const { result } = renderHook(() => useAttachments()); + act(() => result.current.addFiles([fileOfSize(10)])); + + act(() => lastCallbacks?.onSuccess('id-0', 'blob-1')); + + expect(result.current.attachments[0]).toMatchObject({ status: 'done', blobId: 'blob-1' }); + expect(result.current.isUploading).toBe(false); + expect(result.current.hasErrors).toBe(false); + }); + + test('When the manager reports an error, then the attachment moves to error', () => { + const { result } = renderHook(() => useAttachments()); + act(() => result.current.addFiles([fileOfSize(10)])); + + act(() => lastCallbacks?.onError('id-0', new Error('boom'))); + + expect(result.current.attachments[0].status).toBe('error'); + expect(result.current.hasErrors).toBe(true); + expect(result.current.isUploading).toBe(false); + }); + }); + + describe('retry', () => { + test('When retry is called on a failed attachment, then it goes back to uploading and the manager is notified', () => { + const { result } = renderHook(() => useAttachments()); + act(() => result.current.addFiles([fileOfSize(10)])); + act(() => lastCallbacks?.onError('id-0', new Error('x'))); + + act(() => result.current.retry('id-0')); + + expect(retry).toHaveBeenCalledWith('id-0', expect.any(Object)); + expect(result.current.attachments[0].status).toBe('uploading'); + }); + }); + + describe('remove', () => { + test('When remove is called, then the attachment is dropped and the manager is notified', () => { + const { result } = renderHook(() => useAttachments()); + act(() => result.current.addFiles([fileOfSize(10, '1.txt'), fileOfSize(20, '2.txt')])); + + act(() => result.current.remove('id-0')); + + expect(remove).toHaveBeenCalledWith('id-0'); + expect(result.current.attachments).toHaveLength(1); + expect(result.current.attachments[0].id).toBe('id-1'); + }); + }); + + describe('clear', () => { + test('When clear is called, then every attachment is removed from the manager and state', () => { + const { result } = renderHook(() => useAttachments()); + act(() => result.current.addFiles([fileOfSize(10, '1.txt'), fileOfSize(20, '2.txt')])); + + act(() => result.current.clear()); + + expect(clear).toHaveBeenCalledTimes(1); + expect(result.current.attachments).toHaveLength(0); + }); + }); +}); diff --git a/src/components/compose-message/hooks/useAttachments.ts b/src/components/compose-message/hooks/useAttachments.ts new file mode 100644 index 0000000..9634435 --- /dev/null +++ b/src/components/compose-message/hooks/useAttachments.ts @@ -0,0 +1,89 @@ +import { useCallback, useMemo, useState } from 'react'; +import { useTranslationContext } from '@/i18n'; +import notificationsService, { ToastType } from '@/services/notifications'; +import { bytesToString } from '@/utils/bytes-to-string'; +import { UploadManager } from '@/services/upload-manager'; +import type { AttachmentRef } from '@internxt/sdk/dist/mail/types'; +import { MAX_TOTAL_ATTACHMENT_BYTES_PER_MAIL } from '@/constants'; +import type { UploadAttachmentCallbacks } from '@/types/mail/upload-manager'; +import { ErrorService } from '@/services/error'; + +export type AttachmentStatus = 'uploading' | 'done' | 'error'; + +export interface AttachmentTask extends Omit { + id: string; + status: AttachmentStatus; + blobId?: string; +} + +const useAttachments = () => { + const { translate } = useTranslationContext(); + const [attachments, setAttachments] = useState([]); + + const totalSize = useMemo(() => attachments.reduce((s, a) => s + a.size, 0), [attachments]); + const isUploading = useMemo(() => attachments.some((a) => a.status === 'uploading'), [attachments]); + const hasErrors = useMemo(() => attachments.some((a) => a.status === 'error'), [attachments]); + + const onTaskCompleted = (attachmentTaskId: AttachmentTask['id'], blobId: string) => { + setAttachments((prev) => prev.map((a) => (a.id === attachmentTaskId ? { ...a, blobId, status: 'done' } : a))); + }; + + const onTaskError = (attachmentTaskId: AttachmentTask['id'], error: unknown) => { + const castedError = ErrorService.instance.castError(error); + setAttachments((prev) => prev.map((a) => (a.id === attachmentTaskId ? { ...a, status: 'error' } : a))); + console.error('ERROR UPLOADING ATTACHMENT', castedError); + }; + + const callbacks: UploadAttachmentCallbacks = { + onSuccess: onTaskCompleted, + onError: onTaskError, + }; + + const addFiles = useCallback( + (files: FileList | File[]) => { + const list = Array.from(files); + const incoming = list.reduce((s, f) => s + f.size, 0); + if (totalSize + incoming > MAX_TOTAL_ATTACHMENT_BYTES_PER_MAIL) { + notificationsService.show({ + text: translate('modals.composeMessageDialog.errors.attachmentsTooLarge', { + maxSize: bytesToString({ size: MAX_TOTAL_ATTACHMENT_BYTES_PER_MAIL }), + }), + type: ToastType.Warning, + }); + return; + } + const handles = UploadManager.instance.run(list, callbacks); + const pending: AttachmentTask[] = handles.map(({ id, file }) => ({ + id, + name: file.name, + size: file.size, + type: file.type ?? 'application/octet-stream', + status: 'uploading', + })); + setAttachments((prev) => [...prev, ...pending]); + }, + [totalSize, translate, callbacks], + ); + + const retry = useCallback( + (id: string) => { + setAttachments((prev) => prev.map((a) => (a.id === id ? { ...a, status: 'uploading' } : a))); + UploadManager.instance.retry(id, callbacks); + }, + [callbacks], + ); + + const remove = useCallback((id: string) => { + UploadManager.instance.remove(id); + setAttachments((prev) => prev.filter((a) => a.id !== id)); + }, []); + + const clear = useCallback(() => { + UploadManager.instance.clear(); + setAttachments([]); + }, []); + + return { attachments, totalSize, isUploading, hasErrors, addFiles, retry, remove, clear }; +}; + +export default useAttachments; diff --git a/src/constants.ts b/src/constants.ts index e2deab6..1e192aa 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -10,3 +10,4 @@ export const DEFAULT_USER_NAME = 'My Internxt'; export const INTERNXT_EMAIL_DOMAINS = ['@inxt.me', '@inxt.eu', '@encrypt.eu'] as const; export const DEFAULT_FOLDER_LIMIT = 15; +export const MAX_TOTAL_ATTACHMENT_BYTES_PER_MAIL = 25 * 1024 * 1024; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index cbc2b19..b90cbb0 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -195,7 +195,10 @@ "subject": "Subject", "message": "Message", "encryptedBadge": "End-to-end encrypted", - "cleartextBadge": "Not encrypted" + "cleartextBadge": "Not encrypted", + "errors": { + "attachmentsTooLarge": "Attachments exceed the {{maxSize}} limit" + } }, "preferences": { "title": "Preferences", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 6fcbcc8..30e6cd4 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -197,7 +197,10 @@ "subject": "Asunto", "message": "Mensaje", "encryptedBadge": "Cifrado de extremo a extremo", - "cleartextBadge": "Sin cifrar" + "cleartextBadge": "Sin cifrar", + "errors": { + "attachmentsTooLarge": "Los archivos adjuntos superan el límite de {{maxSize}}" + } }, "preferences": { "title": "Preferencias", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 108f4db..573b4aa 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -197,7 +197,10 @@ "subject": "Objet", "message": "Message", "encryptedBadge": "Chiffré de bout en bout", - "cleartextBadge": "Non chiffré" + "cleartextBadge": "Non chiffré", + "errors": { + "attachmentsTooLarge": "Les pièces jointes dépassent la limite de {{maxSize}}" + } }, "preferences": { "title": "Préférences", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 8f4df58..591b679 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -197,7 +197,10 @@ "subject": "Oggetto", "message": "Messaggio", "encryptedBadge": "Crittografia end-to-end", - "cleartextBadge": "Non crittografato" + "cleartextBadge": "Non crittografato", + "errors": { + "attachmentsTooLarge": "Gli allegati superano il limite di {{maxSize}}" + } }, "preferences": { "title": "Preferenze", diff --git a/src/services/upload-manager/index.ts b/src/services/upload-manager/index.ts index 80e5b42..3cbd431 100644 --- a/src/services/upload-manager/index.ts +++ b/src/services/upload-manager/index.ts @@ -23,7 +23,7 @@ export class UploadManager { try { const result = await this.uploadWithRetries(task); if (task.cancelled) return; - task.callbacks.onSuccess(task.id, result); + task.callbacks.onSuccess(task.id, result.blobId); if (this.tasks.get(task.id) === task) this.tasks.delete(task.id); } catch (error) { if (task.cancelled) return; @@ -71,6 +71,15 @@ export class UploadManager { this.tasks.delete(id); } + /** + * The clear method is used to cancel all uploads + * @returns - void + */ + clear(): void { + this.tasks.forEach((task) => this.cancelTask(task)); + this.tasks.clear(); + } + private cancelTask(task: UploadAttachmentTask): void { task.cancelled = true; task.canceler?.cancel(CANCEL_REASON); diff --git a/src/services/upload-manager/upload-manager.test.ts b/src/services/upload-manager/upload-manager.test.ts index 754e9f5..d7241e7 100644 --- a/src/services/upload-manager/upload-manager.test.ts +++ b/src/services/upload-manager/upload-manager.test.ts @@ -59,7 +59,7 @@ describe('Upload Manager', () => { const [{ id }] = UploadManager.instance.run([aFile()], { onSuccess, onError }); await flush(); - expect(onSuccess).toHaveBeenCalledWith(id, result); + expect(onSuccess).toHaveBeenCalledWith(id, result.blobId); expect(onError).not.toHaveBeenCalled(); }); @@ -137,7 +137,7 @@ describe('Upload Manager', () => { UploadManager.instance.retry(id, { onSuccess, onError }); await flush(); - expect(onSuccess).toHaveBeenCalledWith(id, result); + expect(onSuccess).toHaveBeenCalledWith(id, result.blobId); expect(onError).not.toHaveBeenCalled(); }); }); @@ -161,7 +161,7 @@ describe('Upload Manager', () => { const calls = [...onSuccess.mock.calls, ...onError.mock.calls]; expect(calls.some(([id]) => id === second.id)).toBe(false); - expect(onSuccess).toHaveBeenCalledWith(first.id, expect.any(Object)); + expect(onSuccess).toHaveBeenCalledWith(first.id, expect.any(String)); }); test('When remove is called while the upload is in flight, then the in-flight request is aborted', async () => { diff --git a/src/types/mail/upload-manager/index.ts b/src/types/mail/upload-manager/index.ts index 8327024..c89ec4b 100644 --- a/src/types/mail/upload-manager/index.ts +++ b/src/types/mail/upload-manager/index.ts @@ -1,8 +1,7 @@ -import type { UploadAttachmentResponse } from '@internxt/sdk/dist/mail/types'; import type { RequestCanceler } from '@internxt/sdk/dist/shared/http/types'; export type UploadAttachmentCallbacks = { - onSuccess: (id: string, result: UploadAttachmentResponse) => void; + onSuccess: (id: string, blobId: string) => void; onError: (id: string, error: unknown) => void; }; From 41ad2c34e888ac1a0366bac95721e6a1e326f5eb Mon Sep 17 00:00:00 2001 From: Xavier Abad <77491413+xabg2@users.noreply.github.com> Date: Thu, 4 Jun 2026 09:24:22 +0200 Subject: [PATCH 2/3] fix: compute total size for uploaded files only --- .../compose-message/hooks/useAttachments.test.ts | 10 +++++++++- src/components/compose-message/hooks/useAttachments.ts | 5 ++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/components/compose-message/hooks/useAttachments.test.ts b/src/components/compose-message/hooks/useAttachments.test.ts index e791eae..21b42af 100644 --- a/src/components/compose-message/hooks/useAttachments.test.ts +++ b/src/components/compose-message/hooks/useAttachments.test.ts @@ -62,9 +62,16 @@ describe('Attachments - custom hook', () => { { id: 'id-0', name: '1.txt', size: 100, type: 'text/plain', status: 'uploading' }, { id: 'id-1', name: '2.bin', size: 200, type: 'application/octet-stream', status: 'uploading' }, ]); - expect(result.current.totalSize).toBe(300); + expect(result.current.totalSize).toBe(0); expect(result.current.isUploading).toBe(true); expect(result.current.hasErrors).toBe(false); + + act(() => { + lastCallbacks?.onSuccess('id-0', 'blob-0'); + lastCallbacks?.onSuccess('id-1', 'blob-1'); + }); + + expect(result.current.totalSize).toBe(300); }); test('When the new batch would exceed 25 MB total, then it shows a warning toast and does not enqueue', () => { @@ -86,6 +93,7 @@ describe('Attachments - custom hook', () => { const { result } = renderHook(() => useAttachments()); act(() => result.current.addFiles([half])); + act(() => lastCallbacks?.onSuccess('id-0', 'blob-0')); act(() => result.current.addFiles([fileOfSize(MAX_TOTAL_ATTACHMENT_BYTES_PER_MAIL / 2 + 1)])); expect(result.current.attachments).toHaveLength(1); diff --git a/src/components/compose-message/hooks/useAttachments.ts b/src/components/compose-message/hooks/useAttachments.ts index 9634435..81643a9 100644 --- a/src/components/compose-message/hooks/useAttachments.ts +++ b/src/components/compose-message/hooks/useAttachments.ts @@ -20,7 +20,10 @@ const useAttachments = () => { const { translate } = useTranslationContext(); const [attachments, setAttachments] = useState([]); - const totalSize = useMemo(() => attachments.reduce((s, a) => s + a.size, 0), [attachments]); + const totalSize = useMemo( + () => attachments.filter((a) => a.status === 'done').reduce((s, a) => s + a.size, 0), + [attachments], + ); const isUploading = useMemo(() => attachments.some((a) => a.status === 'uploading'), [attachments]); const hasErrors = useMemo(() => attachments.some((a) => a.status === 'error'), [attachments]); From f9ffd3fae566e1c51f21bd3b25305d2b1ecb8d08 Mon Sep 17 00:00:00 2001 From: Xavier Abad <77491413+xabg2@users.noreply.github.com> Date: Thu, 4 Jun 2026 15:48:49 +0200 Subject: [PATCH 3/3] fix: remove wrong filter --- src/components/compose-message/hooks/useAttachments.test.ts | 2 +- src/components/compose-message/hooks/useAttachments.ts | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/components/compose-message/hooks/useAttachments.test.ts b/src/components/compose-message/hooks/useAttachments.test.ts index 21b42af..020874f 100644 --- a/src/components/compose-message/hooks/useAttachments.test.ts +++ b/src/components/compose-message/hooks/useAttachments.test.ts @@ -62,7 +62,7 @@ describe('Attachments - custom hook', () => { { id: 'id-0', name: '1.txt', size: 100, type: 'text/plain', status: 'uploading' }, { id: 'id-1', name: '2.bin', size: 200, type: 'application/octet-stream', status: 'uploading' }, ]); - expect(result.current.totalSize).toBe(0); + expect(result.current.totalSize).toBe(f1.size + f2.size); expect(result.current.isUploading).toBe(true); expect(result.current.hasErrors).toBe(false); diff --git a/src/components/compose-message/hooks/useAttachments.ts b/src/components/compose-message/hooks/useAttachments.ts index 81643a9..9634435 100644 --- a/src/components/compose-message/hooks/useAttachments.ts +++ b/src/components/compose-message/hooks/useAttachments.ts @@ -20,10 +20,7 @@ const useAttachments = () => { const { translate } = useTranslationContext(); const [attachments, setAttachments] = useState([]); - const totalSize = useMemo( - () => attachments.filter((a) => a.status === 'done').reduce((s, a) => s + a.size, 0), - [attachments], - ); + const totalSize = useMemo(() => attachments.reduce((s, a) => s + a.size, 0), [attachments]); const isUploading = useMemo(() => attachments.some((a) => a.status === 'uploading'), [attachments]); const hasErrors = useMemo(() => attachments.some((a) => a.status === 'error'), [attachments]);