compose: single close button + attachment support (paste, drag-drop, file picker)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { X, Loader2, Send } from 'lucide-react';
|
import { X, Loader2, Send, Paperclip } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
@@ -82,8 +82,11 @@ export const ComposeModal = () => {
|
|||||||
const [showCc, setShowCc] = useState(false);
|
const [showCc, setShowCc] = useState(false);
|
||||||
const [subject, setSubject] = useState('');
|
const [subject, setSubject] = useState('');
|
||||||
const [body, setBody] = useState('');
|
const [body, setBody] = useState('');
|
||||||
|
const [files, setFiles] = useState<File[]>([]);
|
||||||
|
const [dragging, setDragging] = useState(false);
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
const seeded = useRef<ComposeDraft | null>(null);
|
const seeded = useRef<ComposeDraft | null>(null);
|
||||||
|
const fileInput = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (draft && draft !== seeded.current) {
|
if (draft && draft !== seeded.current) {
|
||||||
@@ -93,10 +96,29 @@ export const ComposeModal = () => {
|
|||||||
setShowCc(!!draft.cc);
|
setShowCc(!!draft.cc);
|
||||||
setSubject(draft.subject ?? '');
|
setSubject(draft.subject ?? '');
|
||||||
setBody(draft.body ?? '');
|
setBody(draft.body ?? '');
|
||||||
|
setFiles([]);
|
||||||
}
|
}
|
||||||
if (!draft) seeded.current = null;
|
if (!draft) seeded.current = null;
|
||||||
}, [draft]);
|
}, [draft]);
|
||||||
|
|
||||||
|
const addFiles = (incoming: FileList | File[]) => {
|
||||||
|
const list = Array.from(incoming).map((f) =>
|
||||||
|
// Clipboard images often come nameless — give them a sensible filename.
|
||||||
|
f.name ? f : new File([f], `pasted-${Date.now()}.${(f.type.split('/')[1] || 'png')}`, { type: f.type }),
|
||||||
|
);
|
||||||
|
if (list.length) setFiles((prev) => [...prev, ...list]);
|
||||||
|
};
|
||||||
|
const onPaste = (ev: React.ClipboardEvent) => {
|
||||||
|
const items = Array.from(ev.clipboardData?.items ?? []).filter((it) => it.kind === 'file');
|
||||||
|
const pasted = items.map((it) => it.getAsFile()).filter((f): f is File => !!f);
|
||||||
|
if (pasted.length) addFiles(pasted);
|
||||||
|
};
|
||||||
|
const onDrop = (ev: React.DragEvent) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
setDragging(false);
|
||||||
|
if (ev.dataTransfer?.files?.length) addFiles(ev.dataTransfer.files);
|
||||||
|
};
|
||||||
|
|
||||||
const close = () => setDraft(null);
|
const close = () => setDraft(null);
|
||||||
|
|
||||||
const send = async () => {
|
const send = async () => {
|
||||||
@@ -106,7 +128,14 @@ export const ComposeModal = () => {
|
|||||||
}
|
}
|
||||||
setSending(true);
|
setSending(true);
|
||||||
try {
|
try {
|
||||||
await client.post('/email/send', { to, cc: cc || undefined, subject, body, inReplyTo: draft?.inReplyTo });
|
const fd = new FormData();
|
||||||
|
fd.append('to', to);
|
||||||
|
if (cc.trim()) fd.append('cc', cc);
|
||||||
|
fd.append('subject', subject);
|
||||||
|
fd.append('body', body);
|
||||||
|
if (draft?.inReplyTo) fd.append('inReplyTo', draft.inReplyTo);
|
||||||
|
files.forEach((f) => fd.append('files', f));
|
||||||
|
await client.post('/email/send', fd);
|
||||||
toast.success('Email sent');
|
toast.success('Email sent');
|
||||||
// Gmail files the sent copy in Sent, so refresh the list to surface it.
|
// Gmail files the sent copy in Sent, so refresh the list to surface it.
|
||||||
queryClient.invalidateQueries({ queryKey: ['email-messages'] });
|
queryClient.invalidateQueries({ queryKey: ['email-messages'] });
|
||||||
@@ -118,16 +147,31 @@ export const ComposeModal = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fmtSize = (n: number) => (n < 1024 ? `${n} B` : n < 1024 * 1024 ? `${(n / 1024).toFixed(0)} KB` : `${(n / 1024 / 1024).toFixed(1)} MB`);
|
||||||
|
|
||||||
if (!draft) return null;
|
if (!draft) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open onOpenChange={(o) => !o && close()}>
|
<Dialog open onOpenChange={(o) => !o && close()}>
|
||||||
<DialogContent className="flex h-[70vh] max-w-2xl flex-col gap-0 overflow-hidden p-0">
|
<DialogContent
|
||||||
<div className="flex items-center justify-between border-b px-4 py-2.5">
|
className="flex h-[70vh] max-w-2xl flex-col gap-0 overflow-hidden p-0"
|
||||||
|
onPaste={onPaste}
|
||||||
|
onDrop={onDrop}
|
||||||
|
onDragOver={(ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
setDragging(true);
|
||||||
|
}}
|
||||||
|
onDragLeave={(ev) => {
|
||||||
|
if (ev.currentTarget === ev.target) setDragging(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{dragging && (
|
||||||
|
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center rounded-md border-2 border-dashed border-duck-teal bg-background/80 text-sm font-medium">
|
||||||
|
Drop files to attach
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center border-b px-4 py-2.5 pr-10">
|
||||||
<DialogTitle className="text-sm font-semibold">{/^re:/i.test(subject) ? 'Reply' : 'New message'}</DialogTitle>
|
<DialogTitle className="text-sm font-semibold">{/^re:/i.test(subject) ? 'Reply' : 'New message'}</DialogTitle>
|
||||||
<button onClick={close} className="opacity-50 hover:opacity-100 cursor-pointer">
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center border-b">
|
<div className="flex items-center border-b">
|
||||||
@@ -162,7 +206,43 @@ export const ComposeModal = () => {
|
|||||||
className="flex-1 resize-none bg-transparent px-4 py-3 text-sm outline-none placeholder:opacity-40"
|
className="flex-1 resize-none bg-transparent px-4 py-3 text-sm outline-none placeholder:opacity-40"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{files.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5 border-t px-4 py-2">
|
||||||
|
{files.map((f, i) => (
|
||||||
|
<span key={`${f.name}-${i}`} className="flex items-center gap-1.5 rounded bg-accent px-2 py-1 text-xs">
|
||||||
|
<Paperclip className="h-3 w-3 opacity-60" />
|
||||||
|
<span className="max-w-[16rem] truncate">{f.name}</span>
|
||||||
|
<span className="opacity-50">{fmtSize(f.size)}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setFiles((prev) => prev.filter((_, idx) => idx !== i))}
|
||||||
|
className="opacity-50 hover:opacity-100 cursor-pointer"
|
||||||
|
title="Remove"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-2 border-t px-4 py-2.5">
|
<div className="flex items-center justify-end gap-2 border-t px-4 py-2.5">
|
||||||
|
<input
|
||||||
|
ref={fileInput}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={(ev) => {
|
||||||
|
if (ev.target.files?.length) addFiles(ev.target.files);
|
||||||
|
ev.target.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => fileInput.current?.click()}
|
||||||
|
className="mr-auto flex items-center gap-1.5 rounded-md px-2 py-1.5 text-sm opacity-60 hover:opacity-100 cursor-pointer"
|
||||||
|
title="Attach files"
|
||||||
|
>
|
||||||
|
<Paperclip className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
<button onClick={close} className="rounded-md px-4 py-1.5 text-sm opacity-60 hover:opacity-100 cursor-pointer">
|
<button onClick={close} className="rounded-md px-4 py-1.5 text-sm opacity-60 hover:opacity-100 cursor-pointer">
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -26,20 +26,32 @@ async function getSmtpTransport(userId: number): Promise<{ transport: ReturnType
|
|||||||
return { transport, from: acct.email };
|
return { transport, from: acct.email };
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /send — compose/reply. Gmail auto-files the sent copy in "Sent", so IMAP sync picks it up.
|
// POST /send — compose/reply (multipart; `files` are attachments). Gmail auto-files the sent copy in
|
||||||
|
// "Sent", so IMAP sync picks it up.
|
||||||
emailRouter.post('/send', async (ctx) => {
|
emailRouter.post('/send', async (ctx) => {
|
||||||
const user = ctx.get('user');
|
const user = ctx.get('user');
|
||||||
const b = await ctx.req.json<{ to: string; cc?: string; bcc?: string; subject?: string; body?: string; inReplyTo?: string }>();
|
const form = await ctx.req.parseBody({ all: true });
|
||||||
if (!b.to?.trim()) throw errors.BAD_REQUEST('At least one recipient is required');
|
const str = (v: unknown) => (typeof v === 'string' ? v : '');
|
||||||
|
const to = str(form.to).trim();
|
||||||
|
if (!to) throw errors.BAD_REQUEST('At least one recipient is required');
|
||||||
|
|
||||||
|
const rawFiles = form.files;
|
||||||
|
const files = (Array.isArray(rawFiles) ? rawFiles : rawFiles ? [rawFiles] : []).filter((f): f is File => f instanceof File);
|
||||||
|
const attachments = await Promise.all(
|
||||||
|
files.map(async (f) => ({ filename: f.name, content: Buffer.from(await f.arrayBuffer()), contentType: f.type || undefined })),
|
||||||
|
);
|
||||||
|
|
||||||
|
const inReplyTo = str(form.inReplyTo).trim();
|
||||||
const { transport, from } = await getSmtpTransport(user.id);
|
const { transport, from } = await getSmtpTransport(user.id);
|
||||||
await transport.sendMail({
|
await transport.sendMail({
|
||||||
from,
|
from,
|
||||||
to: b.to,
|
to,
|
||||||
cc: b.cc?.trim() || undefined,
|
cc: str(form.cc).trim() || undefined,
|
||||||
bcc: b.bcc?.trim() || undefined,
|
bcc: str(form.bcc).trim() || undefined,
|
||||||
subject: b.subject?.trim() || '(no subject)',
|
subject: str(form.subject).trim() || '(no subject)',
|
||||||
text: b.body ?? '',
|
text: str(form.body),
|
||||||
...(b.inReplyTo ? { headers: { 'In-Reply-To': b.inReplyTo, References: b.inReplyTo } } : {}),
|
...(attachments.length ? { attachments } : {}),
|
||||||
|
...(inReplyTo ? { headers: { 'In-Reply-To': inReplyTo, References: inReplyTo } } : {}),
|
||||||
});
|
});
|
||||||
return ctx.json({ ok: true });
|
return ctx.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user