Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
165 changes: 165 additions & 0 deletions src/components/compose-message/hooks/useAttachments.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
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(f1.size + f2.size);
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', () => {
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(() => 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);
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);
});
});
});
89 changes: 89 additions & 0 deletions src/components/compose-message/hooks/useAttachments.ts
Original file line number Diff line number Diff line change
@@ -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<AttachmentRef, 'blobId'> {
id: string;
status: AttachmentStatus;
blobId?: string;
}

const useAttachments = () => {
const { translate } = useTranslationContext();
const [attachments, setAttachments] = useState<AttachmentTask[]>([]);

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;
1 change: 1 addition & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
5 changes: 4 additions & 1 deletion src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 4 additions & 1 deletion src/i18n/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 4 additions & 1 deletion src/i18n/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 4 additions & 1 deletion src/i18n/locales/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
11 changes: 10 additions & 1 deletion src/services/upload-manager/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
6 changes: 3 additions & 3 deletions src/services/upload-manager/upload-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});

Expand Down Expand Up @@ -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();
});
});
Expand All @@ -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 () => {
Expand Down
3 changes: 1 addition & 2 deletions src/types/mail/upload-manager/index.ts
Original file line number Diff line number Diff line change
@@ -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;
};

Expand Down
Loading