diff --git a/src/components/compose-message/components/AttachmentList.tsx b/src/components/compose-message/components/AttachmentList.tsx new file mode 100644 index 0000000..ef68fea --- /dev/null +++ b/src/components/compose-message/components/AttachmentList.tsx @@ -0,0 +1,42 @@ +import { ArrowClockwiseIcon, PaperclipIcon, SpinnerIcon, WarningIcon, XIcon } from '@phosphor-icons/react'; +import { bytesToString } from '@/utils/bytes-to-string'; +import { useTranslationContext } from '@/i18n'; +import { type AttachmentTask } from '../hooks/useAttachments'; +import { MAX_TOTAL_ATTACHMENT_BYTES_PER_MAIL } from '@/constants'; + +interface AttachmentListProps { + attachments: AttachmentTask[]; + totalSize: number; + onRemove: (id: string) => void; + onRetry: (id: string) => void; +} + +export const AttachmentList = ({ attachments, totalSize, onRemove, onRetry }: AttachmentListProps) => { + const { translate } = useTranslationContext(); + if (attachments.length === 0) return null; + return ( +
+
+ {attachments.map((a) => ( +
+ {a.status === 'uploading' && } + {a.status === 'done' && } + {a.status === 'error' && } + {a.name} + {bytesToString({ size: a.size })} + {a.status === 'error' && ( + onRetry(a.id)} /> + )} + onRemove(a.id)} /> +
+ ))} +
+

+ {translate('modals.composeMessageDialog.attachments.totalSize', { + used: bytesToString({ size: totalSize }), + max: bytesToString({ size: MAX_TOTAL_ATTACHMENT_BYTES_PER_MAIL }), + })} +

+
+ ); +}; diff --git a/src/components/compose-message/hooks/useComposeMessage.ts b/src/components/compose-message/hooks/useComposeMessage.ts index 131456b..79009d4 100644 --- a/src/components/compose-message/hooks/useComposeMessage.ts +++ b/src/components/compose-message/hooks/useComposeMessage.ts @@ -45,6 +45,15 @@ const useComposeMessage = (draft?: DraftMessage) => { setBccRecipients((prev) => prev.filter((r) => r.id !== id)); }, []); + const clear = () => { + setSubjectValue(''); + setToRecipients([]); + setCcRecipients([]); + setBccRecipients([]); + setShowCc(false); + setShowBcc(false); + }; + return { subjectValue, toRecipients, @@ -61,6 +70,7 @@ const useComposeMessage = (draft?: DraftMessage) => { onRemoveCcRecipient, onAddBccRecipient, onRemoveBccRecipient, + clear, }; }; diff --git a/src/components/compose-message/hooks/useComposeSend.test.ts b/src/components/compose-message/hooks/useComposeSend.test.ts index ac4034b..bcb61a6 100644 --- a/src/components/compose-message/hooks/useComposeSend.test.ts +++ b/src/components/compose-message/hooks/useComposeSend.test.ts @@ -41,6 +41,7 @@ const renderSend = (overrides: Partial[0]> = { bccRecipients: [], subject: 'Hi', editor, + attachments: [], onSent, ...overrides, }), @@ -172,14 +173,12 @@ describe('useComposeSend', () => { mocks.triggerLookup.mockReturnValue({ unwrap: () => Promise.resolve([{ address: 'bob@inxt.me', publicKey: 'bob-pk' }]), }); - const buildSpy = vi - .spyOn(MailEncryptionService.instance, 'buildEncryptionBlock') - .mockResolvedValue({ - version: 'v1', - encryptedText: 'ct', - encryptedPreview: 'cp', - wrappedKeys: [], - } as EncryptionBlock); + const buildSpy = vi.spyOn(MailEncryptionService.instance, 'buildEncryptionBlock').mockResolvedValue({ + version: 'v1', + encryptedText: 'ct', + encryptedPreview: 'cp', + wrappedKeys: [], + } as EncryptionBlock); const { result, onSent } = renderSend({ toRecipients: [recipient('bob@inxt.me')] }); diff --git a/src/components/compose-message/hooks/useComposeSend.ts b/src/components/compose-message/hooks/useComposeSend.ts index c80d055..d567c87 100644 --- a/src/components/compose-message/hooks/useComposeSend.ts +++ b/src/components/compose-message/hooks/useComposeSend.ts @@ -12,6 +12,7 @@ import { MailEncryptionService, type RecipientPublicKey } from '@/services/mail- import notificationsService, { ToastType } from '@/services/notifications'; import { useTranslationContext } from '@/i18n'; import type { Recipient } from '../types'; +import type { AttachmentTask } from './useAttachments'; export type EncryptionState = 'none' | 'unknown' | 'encrypted' | 'cleartext'; @@ -23,6 +24,7 @@ interface UseComposeSendParams { bccRecipients: Recipient[]; subject: string; editor: Editor | null; + attachments: AttachmentTask[]; onSent: () => void; } @@ -43,6 +45,7 @@ export const useComposeSend = ({ bccRecipients, subject, editor, + attachments, onSent, }: UseComposeSendParams): UseComposeSendResult => { const { translate } = useTranslationContext(); @@ -85,6 +88,15 @@ export const useComposeSend = ({ return; } + const attachmentsToSend: SendEmailRequest['attachments'] = attachments + .filter((a): a is AttachmentTask & { blobId: string } => a.status === 'done' && !!a.blobId) + .map((a) => ({ + blobId: a.blobId, + name: a.name, + size: a.size, + type: a.type, + })); + const htmlBody = editor?.getHTML() ?? ''; const textBody = editor?.getText() ?? ''; const cleartextPayload: SendEmailRequest = { @@ -94,6 +106,7 @@ export const useComposeSend = ({ subject, textBody: textBody || undefined, htmlBody: htmlBody || undefined, + attachments: attachmentsToSend, }; try { @@ -140,6 +153,7 @@ export const useComposeSend = ({ bcc: bccRecipients.length ? bccRecipients.map(toEmailAddress) : undefined, subject, encryption, + attachments: attachmentsToSend, }).unwrap(); } else { await sendEmail(cleartextPayload).unwrap(); @@ -160,6 +174,7 @@ export const useComposeSend = ({ subject, encryptionState, senderKeys, + attachments, triggerLookup, sendEmail, onSent, diff --git a/src/components/compose-message/index.tsx b/src/components/compose-message/index.tsx index 70f5311..e971d24 100644 --- a/src/components/compose-message/index.tsx +++ b/src/components/compose-message/index.tsx @@ -1,16 +1,18 @@ import { LockKeyIcon, PaperclipIcon, WarningIcon, XIcon } from '@phosphor-icons/react'; -import { useCallback } from 'react'; +import { useCallback, useRef, type ChangeEvent } from 'react'; import type { Recipient } from './types'; import { RecipientInput } from './components/RecipientInput'; +import { AttachmentList } from './components/AttachmentList'; import { Button, Input } from '@internxt/ui'; import RichTextEditor from './components/RichTextEditor'; import { EditorBar } from './components/editorBar'; import { ActionDialog, useActionDialog } from '@/context/dialog-manager'; import { useTranslationContext } from '@/i18n'; import useComposeMessage from './hooks/useComposeMessage'; -import useComposeSend from './hooks/useComposeSend'; +import useAttachments from './hooks/useAttachments'; import { useEditor } from '@tiptap/react'; import { EDITOR_CONFIG } from './config'; +import useComposeSend from './hooks/useComposeSend'; export interface DraftMessage { subject?: string; @@ -41,24 +43,46 @@ export const ComposeMessageDialog = () => { onShowBccRecipient, onShowCcRecipient, onSubjectChange, + clear: clearComposeMessage, } = useComposeMessage(); const title = draft.subject ?? translate('modals.composeMessageDialog.title'); const editor = useEditor(EDITOR_CONFIG); + const { + attachments, + totalSize: attachmentsTotalSize, + isUploading: isUploadingAttachments, + hasErrors: hasAttachmentErrors, + addFiles: addAttachmentFiles, + retry: retryAttachment, + remove: removeAttachment, + clear: clearAttachments, + } = useAttachments(); + const fileInputRef = useRef(null); + const onClose = useCallback(() => { + clearAttachments(); + clearComposeMessage(); + editor.commands.clearContent(); onComposeMessageDialogClose(ActionDialog.ComposeMessage); - }, [onComposeMessageDialogClose]); + }, [editor, clearComposeMessage, onComposeMessageDialogClose, clearAttachments]); - const { encryptionState, isSending, send } = useComposeSend({ - toRecipients, - ccRecipients, + const { send, encryptionState, isSending } = useComposeSend({ + attachments, bccRecipients, - subject: subjectValue, + ccRecipients, editor, + subject: subjectValue, + toRecipients, onSent: onClose, }); + const onFilesPicked = (e: ChangeEvent) => { + if (e.target.files?.length) addAttachmentFiles(e.target.files); + e.target.value = ''; + }; + if (!editor) return null; return ( @@ -136,7 +160,13 @@ export const ComposeMessageDialog = () => {
- {/* !TODO: Handle attachments */} + +
{encryptionState === 'encrypted' && ( @@ -157,10 +187,15 @@ export const ComposeMessageDialog = () => { {translate('modals.composeMessageDialog.cleartextBadge')} )} - -
diff --git a/src/features/mail/MailView.tsx b/src/features/mail/MailView.tsx index cdc5907..bdf0994 100644 --- a/src/features/mail/MailView.tsx +++ b/src/features/mail/MailView.tsx @@ -149,9 +149,11 @@ const MailView = ({ folder }: MailViewProps) => { cc={cc.map((u) => ({ name: u.name ?? '', email: u.email }))} bcc={bcc.map((u) => ({ name: u.name ?? '', email: u.email }))} mail={{ + id: activeMail.id, subject: decrypted.subject || activeMail.subject, receivedAt: activeMail.receivedAt, htmlBody: decrypted.htmlBody, + attachments: activeMail.attachments, isEncrypted: decrypted.isEncrypted, isDecrypting: decrypted.isDecrypting, decryptError: decrypted.decryptError, diff --git a/src/features/mail/components/mail-preview/index.tsx b/src/features/mail/components/mail-preview/index.tsx index b076f06..d7101cc 100644 --- a/src/features/mail/components/mail-preview/index.tsx +++ b/src/features/mail/components/mail-preview/index.tsx @@ -2,6 +2,7 @@ import { LockKeyIcon, WarningIcon } from '@phosphor-icons/react'; import { useTranslationContext } from '@/i18n'; import PreviewHeader, { type User } from './header'; import Preview from './preview'; +import type { EmailResponse } from '@internxt/sdk/dist/mail/types'; interface PreviewMailProps { from: User; @@ -9,9 +10,11 @@ interface PreviewMailProps { cc: User[]; bcc: User[]; mail: { + id: string; subject: string; receivedAt: string; htmlBody: string; + attachments?: EmailResponse['attachments']; isEncrypted?: boolean; isDecrypting?: boolean; decryptError?: boolean; @@ -23,7 +26,14 @@ const PreviewMail = ({ from, to, cc, bcc, mail }: PreviewMailProps) => { return (
- + {mail.isEncrypted && !mail.decryptError && (
@@ -39,7 +49,7 @@ const PreviewMail = ({ from, to, cc, bcc, mail }: PreviewMailProps) => { {mail.isDecrypting ? (
{translate('mail.preview.decrypting')}
) : ( - + )}
); diff --git a/src/features/mail/components/mail-preview/preview/index.tsx b/src/features/mail/components/mail-preview/preview/index.tsx index 6326a55..2a42d13 100644 --- a/src/features/mail/components/mail-preview/preview/index.tsx +++ b/src/features/mail/components/mail-preview/preview/index.tsx @@ -1,4 +1,10 @@ +import type { EmailResponse } from '@internxt/sdk/dist/mail/types'; +import { DownloadSimpleIcon, PaperclipIcon } from '@phosphor-icons/react'; import DOMPurify from 'dompurify'; +import { bytesToString } from '@/utils/bytes-to-string'; +import { MailService } from '@/services/sdk/mail'; +import notificationsService, { ToastType } from '@/services/notifications'; +import { useTranslationContext } from '@/i18n'; const purify = DOMPurify(); @@ -24,15 +30,42 @@ purify.addHook('afterSanitizeAttributes', (node) => { } }); +type EmailAttachment = NonNullable[number]; + interface PreviewProps { + mailId: string; subject: string; body: string; - attachments?: string[]; + attachments?: EmailResponse['attachments']; } -const Preview = ({ subject, body, attachments }: PreviewProps) => { +const Preview = ({ mailId, subject, body, attachments }: PreviewProps) => { + const { translate } = useTranslationContext(); const sanitizedBody = purify.sanitize(body); + const onDownload = async (attachment: EmailAttachment) => { + try { + const { data, contentType } = await MailService.instance.downloadAttachment( + mailId, + attachment.blobId, + attachment.name, + attachment.type, + ); + const blob = new Blob([data], { type: contentType || attachment.type }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = attachment.name; + link.click(); + setTimeout(() => URL.revokeObjectURL(url), 0); + } catch { + notificationsService.show({ + text: translate('mail.preview.errors.downloadAttachmentFailed'), + type: ToastType.Error, + }); + } + }; + return (
@@ -40,13 +73,23 @@ const Preview = ({ subject, body, attachments }: PreviewProps) => {
- {attachments?.map((attachment) => ( -
- - {attachment} - + {attachments && attachments.length > 0 && ( +
+ {attachments.map((attachment) => ( +
+ + {attachment.name} + {bytesToString({ size: attachment.size })} + onDownload(attachment)} + /> +
+ ))}
- ))} + )}
); }; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index b90cbb0..f5dd620 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -118,6 +118,9 @@ "bcc": "BCC", "decrypting": "Decrypting message…", "decryptFailed": "Could not decrypt this message", + "errors": { + "downloadAttachmentFailed": "Could not download the attachment" + }, "emptyEmail": { "unreadEmails": { "title": "Unread messages", @@ -197,7 +200,13 @@ "encryptedBadge": "End-to-end encrypted", "cleartextBadge": "Not encrypted", "errors": { + "noRecipients": "Add at least one recipient before sending", + "sendFailed": "Could not send the email", + "keyLookupFailed": "Could not fetch recipient keys", "attachmentsTooLarge": "Attachments exceed the {{maxSize}} limit" + }, + "attachments": { + "totalSize": "{{used}} of {{max}}" } }, "preferences": { @@ -238,4 +247,4 @@ "movedToFolder_many": "{{count}} conversations moved to \"{{folder}}\"", "undo": "Undo" } -} \ No newline at end of file +} diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 30e6cd4..db06cf7 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -120,6 +120,9 @@ "bcc": "CCO", "decrypting": "Descifrando mensaje…", "decryptFailed": "No se pudo descifrar este mensaje", + "errors": { + "downloadAttachmentFailed": "No se pudo descargar el adjunto" + }, "emptyEmail": { "unreadEmails": { "title": "Mensajes sin leer", @@ -199,7 +202,13 @@ "encryptedBadge": "Cifrado de extremo a extremo", "cleartextBadge": "Sin cifrar", "errors": { - "attachmentsTooLarge": "Los archivos adjuntos superan el límite de {{maxSize}}" + "noRecipients": "Agrega al menos un destinatario antes de enviar", + "sendFailed": "No se pudo enviar el correo", + "keyLookupFailed": "No se pudieron obtener las claves del destinatario", + "attachmentsTooLarge": "Los adjuntos superan el límite de {{maxSize}}" + }, + "attachments": { + "totalSize": "{{used}} de {{max}}" } }, "preferences": { @@ -240,4 +249,4 @@ "movedToFolder_many": "{{count}} conversaciones movidas a \"{{folder}}\"", "undo": "Deshacer" } -} \ No newline at end of file +} diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 573b4aa..a54298d 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -120,6 +120,9 @@ "bcc": "CCI", "decrypting": "Déchiffrement du message…", "decryptFailed": "Impossible de déchiffrer ce message", + "errors": { + "downloadAttachmentFailed": "Impossible de télécharger la pièce jointe" + }, "emptyEmail": { "unreadEmails": { "title": "Messages non lus", @@ -199,7 +202,13 @@ "encryptedBadge": "Chiffré de bout en bout", "cleartextBadge": "Non chiffré", "errors": { + "noRecipients": "Ajoutez au moins un destinataire avant d'envoyer", + "sendFailed": "Impossible d'envoyer le courriel", + "keyLookupFailed": "Impossible de récupérer les clés du destinataire", "attachmentsTooLarge": "Les pièces jointes dépassent la limite de {{maxSize}}" + }, + "attachments": { + "totalSize": "{{used}} sur {{max}}" } }, "preferences": { @@ -240,4 +249,4 @@ "movedToFolder_many": "{{count}} conversations déplacées vers « {{folder}} »", "undo": "Annuler" } -} \ No newline at end of file +} diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 591b679..4c80896 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -120,6 +120,9 @@ "bcc": "CCN", "decrypting": "Decrittazione del messaggio…", "decryptFailed": "Impossibile decrittare questo messaggio", + "errors": { + "downloadAttachmentFailed": "Impossibile scaricare l'allegato" + }, "emptyEmail": { "unreadEmails": { "title": "Messaggi non letti", @@ -199,7 +202,13 @@ "encryptedBadge": "Crittografia end-to-end", "cleartextBadge": "Non crittografato", "errors": { + "noRecipients": "Aggiungi almeno un destinatario prima di inviare", + "sendFailed": "Impossibile inviare l'email", + "keyLookupFailed": "Impossibile recuperare le chiavi del destinatario", "attachmentsTooLarge": "Gli allegati superano il limite di {{maxSize}}" + }, + "attachments": { + "totalSize": "{{used}} di {{max}}" } }, "preferences": { @@ -240,4 +249,4 @@ "movedToFolder_many": "{{count}} conversazioni spostate in \"{{folder}}\"", "undo": "Annulla" } -} \ No newline at end of file +}