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
42 changes: 42 additions & 0 deletions src/components/compose-message/components/AttachmentList.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="mt-3 flex flex-col gap-2">
<div className="flex flex-wrap gap-2">
{attachments.map((a) => (
<div key={a.id} className="flex items-center gap-1.5 rounded-md bg-gray-5 px-2 py-1">
{a.status === 'uploading' && <SpinnerIcon size={14} className="animate-spin" />}
{a.status === 'done' && <PaperclipIcon size={14} />}
{a.status === 'error' && <WarningIcon size={14} weight="fill" className="text-red" />}
<span className="text-sm font-medium text-gray-80">{a.name}</span>
<span className="text-xs text-gray-50">{bytesToString({ size: a.size })}</span>
{a.status === 'error' && (
<ArrowClockwiseIcon className="cursor-pointer" size={14} weight="bold" onClick={() => onRetry(a.id)} />
)}
<XIcon className="cursor-pointer" size={14} weight="bold" onClick={() => onRemove(a.id)} />
</div>
))}
</div>
<p className="text-xs text-gray-50">
{translate('modals.composeMessageDialog.attachments.totalSize', {
used: bytesToString({ size: totalSize }),
max: bytesToString({ size: MAX_TOTAL_ATTACHMENT_BYTES_PER_MAIL }),
})}
</p>
</div>
);
};
10 changes: 10 additions & 0 deletions src/components/compose-message/hooks/useComposeMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
};
Comment thread
xabg2 marked this conversation as resolved.

return {
subjectValue,
toRecipients,
Expand All @@ -61,6 +70,7 @@ const useComposeMessage = (draft?: DraftMessage) => {
onRemoveCcRecipient,
onAddBccRecipient,
onRemoveBccRecipient,
clear,
};
};

Expand Down
15 changes: 7 additions & 8 deletions src/components/compose-message/hooks/useComposeSend.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ const renderSend = (overrides: Partial<Parameters<typeof useComposeSend>[0]> = {
bccRecipients: [],
subject: 'Hi',
editor,
attachments: [],
onSent,
...overrides,
}),
Expand Down Expand Up @@ -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')] });

Expand Down
15 changes: 15 additions & 0 deletions src/components/compose-message/hooks/useComposeSend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -23,6 +24,7 @@ interface UseComposeSendParams {
bccRecipients: Recipient[];
subject: string;
editor: Editor | null;
attachments: AttachmentTask[];
onSent: () => void;
}

Expand All @@ -43,6 +45,7 @@ export const useComposeSend = ({
bccRecipients,
subject,
editor,
attachments,
onSent,
}: UseComposeSendParams): UseComposeSendResult => {
const { translate } = useTranslationContext();
Expand Down Expand Up @@ -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 = {
Expand All @@ -94,6 +106,7 @@ export const useComposeSend = ({
subject,
textBody: textBody || undefined,
htmlBody: htmlBody || undefined,
attachments: attachmentsToSend,
};

try {
Expand Down Expand Up @@ -140,6 +153,7 @@ export const useComposeSend = ({
bcc: bccRecipients.length ? bccRecipients.map(toEmailAddress) : undefined,
subject,
encryption,
attachments: attachmentsToSend,
}).unwrap();
} else {
await sendEmail(cleartextPayload).unwrap();
Expand All @@ -160,6 +174,7 @@ export const useComposeSend = ({
subject,
encryptionState,
senderKeys,
attachments,
triggerLookup,
sendEmail,
onSent,
Expand Down
55 changes: 45 additions & 10 deletions src/components/compose-message/index.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<HTMLInputElement>(null);
Comment thread
xabg2 marked this conversation as resolved.

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<HTMLInputElement>) => {
if (e.target.files?.length) addAttachmentFiles(e.target.files);
e.target.value = '';
};

if (!editor) return null;

return (
Expand Down Expand Up @@ -136,7 +160,13 @@ export const ComposeMessageDialog = () => {
<div className="pt-4">
<RichTextEditor editor={editor} />
</div>
{/* !TODO: Handle attachments */}
<AttachmentList
attachments={attachments}
totalSize={attachmentsTotalSize}
onRemove={removeAttachment}
onRetry={retryAttachment}
/>
<input ref={fileInputRef} type="file" multiple hidden onChange={onFilesPicked} />

<div className="mt-5 flex justify-end items-center space-x-2">
{encryptionState === 'encrypted' && (
Expand All @@ -157,10 +187,15 @@ export const ComposeMessageDialog = () => {
{translate('modals.composeMessageDialog.cleartextBadge')}
</span>
)}
<Button variant="ghost" onClick={() => {}} disabled={isSending}>
<Button variant="ghost" onClick={() => fileInputRef.current?.click()} disabled={isSending}>
<PaperclipIcon size={24} />
</Button>
<Button onClick={send} loading={isSending} disabled={isSending} variant={'primary'}>
<Button
onClick={send}
loading={isSending}
disabled={isSending || isUploadingAttachments || hasAttachmentErrors}
variant={'primary'}
>
{translate('actions.send')}
</Button>
</div>
Expand Down
2 changes: 2 additions & 0 deletions src/features/mail/MailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
14 changes: 12 additions & 2 deletions src/features/mail/components/mail-preview/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,19 @@ 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;
to: User[];
cc: User[];
bcc: User[];
mail: {
id: string;
subject: string;
receivedAt: string;
htmlBody: string;
attachments?: EmailResponse['attachments'];
isEncrypted?: boolean;
isDecrypting?: boolean;
decryptError?: boolean;
Expand All @@ -23,7 +26,14 @@ const PreviewMail = ({ from, to, cc, bcc, mail }: PreviewMailProps) => {

return (
<div className="flex flex-col w-full h-full">
<PreviewHeader sender={from} date={mail.receivedAt} to={to} cc={cc} bcc={bcc} attachmentsLength={0} />
<PreviewHeader
sender={from}
date={mail.receivedAt}
to={to}
cc={cc}
bcc={bcc}
attachmentsLength={mail.attachments?.length}
/>
{mail.isEncrypted && !mail.decryptError && (
<div className="mx-5 mt-2 inline-flex items-center gap-1 self-start rounded-full bg-green/10 px-2.5 py-1 text-sm font-medium text-green">
<LockKeyIcon size={14} weight="fill" />
Expand All @@ -39,7 +49,7 @@ const PreviewMail = ({ from, to, cc, bcc, mail }: PreviewMailProps) => {
{mail.isDecrypting ? (
<div className="p-5 text-gray-50">{translate('mail.preview.decrypting')}</div>
) : (
<Preview subject={mail.subject} body={mail.htmlBody} />
<Preview mailId={mail.id} subject={mail.subject} body={mail.htmlBody} attachments={mail.attachments} />
)}
</div>
);
Expand Down
Loading
Loading