email: group messages into conversations (Gmail-style threading)
Hybrid grouping via a new thread_id column: new mail threads exactly on References/In-Reply-To (id is sha1(Message-Id), so a referenced id hashes to the ancestor's own id); already-synced mail is backfilled with a normalized-subject + counterpart key. Folder views collapse to one row per thread with a count badge; the reader shows the thread as a collapsible stack. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -300,7 +300,7 @@ export const EmailList = () => {
|
||||
) : (
|
||||
<div className="flex flex-1 flex-col divide-y divide-white/10 overflow-y-auto">
|
||||
{messages.map((msg: EmailSummary) => {
|
||||
const unread = !msg.read;
|
||||
const unread = msg.threadUnread !== undefined ? msg.threadUnread > 0 : !msg.read;
|
||||
return (
|
||||
<button
|
||||
key={msg.id}
|
||||
@@ -311,7 +311,14 @@ export const EmailList = () => {
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className={`truncate text-sm ${unread ? 'font-semibold' : 'font-medium opacity-70'}`}>{msg.from}</span>
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span className={`truncate text-sm ${unread ? 'font-semibold' : 'font-medium opacity-70'}`}>{msg.from}</span>
|
||||
{!!msg.threadCount && (
|
||||
<span className="shrink-0 rounded-full bg-muted px-1.5 text-xs tabular-nums opacity-60" title={`${msg.threadCount} messages`}>
|
||||
{msg.threadCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="shrink-0 text-xs opacity-50">{formatDate(msg.date)}</span>
|
||||
</div>
|
||||
<span className={`truncate text-sm ${unread ? 'font-medium' : 'opacity-70'}`}>{msg.subject}</span>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Mail, Reply } from 'lucide-react';
|
||||
import { Mail, Reply, Paperclip } from 'lucide-react';
|
||||
import { FileViewerProvider, FileViewerHeader, FileViewerBody } from 'officerdev';
|
||||
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useGlobal } from 'hooks/useGlobal';
|
||||
import type { EmailMessage } from 'types';
|
||||
import type { EmailMessage, EmailThread } from 'types';
|
||||
import { useComposer, replyDraft } from './Compose';
|
||||
|
||||
type OpenAttachment = {
|
||||
@@ -14,6 +14,8 @@ type OpenAttachment = {
|
||||
root: string;
|
||||
};
|
||||
|
||||
const senderName = (from: string) => from.replace(/\s*<[^>]+>$/, '').trim() || from;
|
||||
|
||||
const HtmlBody = ({ html }: { html: string }) => {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
|
||||
@@ -37,9 +39,84 @@ const HtmlBody = ({ html }: { html: string }) => {
|
||||
</html>
|
||||
`);
|
||||
doc.close();
|
||||
// Auto-size to content so each message sits at its natural height inside the stacked thread.
|
||||
const resize = () => {
|
||||
if (doc.body) iframe.style.height = `${doc.body.scrollHeight + 8}px`;
|
||||
};
|
||||
resize();
|
||||
doc.querySelectorAll('img').forEach((img) => img.addEventListener('load', resize));
|
||||
const t = setTimeout(resize, 300);
|
||||
return () => clearTimeout(t);
|
||||
}, [html]);
|
||||
|
||||
return <iframe ref={iframeRef} className="h-full w-full border-0" sandbox="allow-same-origin" title="Email body" />;
|
||||
return <iframe ref={iframeRef} className="w-full border-0" sandbox="allow-same-origin" title="Email body" />;
|
||||
};
|
||||
|
||||
type MessagePanelProps = {
|
||||
message: EmailMessage;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
onReply: () => void;
|
||||
onOpenAttachment: (index: number) => void;
|
||||
};
|
||||
|
||||
const MessagePanel = ({ message, open, onToggle, onReply, onOpenAttachment }: MessagePanelProps) => {
|
||||
if (!open) {
|
||||
return (
|
||||
<button onClick={onToggle} className="flex w-full items-center gap-2 px-4 py-2.5 text-left hover:bg-accent/40 cursor-pointer">
|
||||
<span className={`shrink-0 text-sm ${message.read ? 'opacity-70' : 'font-semibold'}`}>{senderName(message.from)}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-xs opacity-50">{message.snippet}</span>
|
||||
{!!message.attachmentCount && <Paperclip className="h-3 w-3 shrink-0 opacity-40" />}
|
||||
<span className="shrink-0 text-xs opacity-50">{new Date(message.date).toLocaleDateString()}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-start justify-between gap-3 px-4 py-2.5">
|
||||
<button onClick={onToggle} className="min-w-0 flex-1 text-left cursor-pointer">
|
||||
<div className="flex flex-col gap-0.5 text-sm opacity-80">
|
||||
<div>
|
||||
<span className="font-medium">{message.from}</span>
|
||||
</div>
|
||||
<div className="text-xs opacity-70">
|
||||
<span className="font-medium">To:</span> {message.to}
|
||||
</div>
|
||||
{message.cc && (
|
||||
<div className="text-xs opacity-70">
|
||||
<span className="font-medium">Cc:</span> {message.cc}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs opacity-60">{new Date(message.date).toLocaleString()}</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={onReply}
|
||||
className="shrink-0 flex items-center gap-1 rounded-md border px-2.5 py-1 text-xs hover:bg-accent cursor-pointer"
|
||||
title="Reply"
|
||||
>
|
||||
<Reply className="h-3.5 w-3.5" /> Reply
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{message.attachments.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 px-4 pb-2">
|
||||
{message.attachments.map((att, i) => (
|
||||
<button
|
||||
key={i}
|
||||
className="cursor-pointer rounded bg-accent px-2 py-0.5 text-xs hover:bg-accent/80"
|
||||
onClick={() => onOpenAttachment(i)}
|
||||
>
|
||||
{att.filename} ({Math.round(att.size / 1024)}KB)
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message.html ? <HtmlBody html={message.html} /> : <pre className="whitespace-pre-wrap px-4 pb-4 text-sm">{message.text}</pre>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const EmailReader = () => {
|
||||
@@ -48,27 +125,42 @@ export const EmailReader = () => {
|
||||
const [, openCompose] = useComposer();
|
||||
const [selectedId] = useGlobal<string | null>('EMAIL_SELECTED', null);
|
||||
const [openAttachment, setOpenAttachment] = useState<OpenAttachment | null>(null);
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
|
||||
const { data: message, isLoading } = useQuery({
|
||||
queryKey: ['email-message', selectedId],
|
||||
queryFn: () => client.get<EmailMessage>(`/email/messages/${selectedId}`),
|
||||
const { data: thread, isLoading } = useQuery({
|
||||
queryKey: ['email-thread', selectedId],
|
||||
queryFn: () => client.get<EmailThread>(`/email/thread/${selectedId}`),
|
||||
enabled: !!selectedId,
|
||||
});
|
||||
|
||||
// Mark as read when message loads
|
||||
// Whenever the thread changes, expand just the latest message (Gmail-style).
|
||||
useEffect(() => {
|
||||
if (!message || message.read) return;
|
||||
client.patch(`/email/messages/${message.id}/read`).then(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['email-messages'] });
|
||||
}).catch(() => {});
|
||||
}, [message?.id]);
|
||||
const messages = thread?.messages;
|
||||
if (messages?.length) setExpanded(new Set([messages[messages.length - 1]!.id]));
|
||||
}, [thread?.id, thread?.messages.length]);
|
||||
|
||||
const extractAttachment = async (index: number) => {
|
||||
if (!selectedId) return;
|
||||
const result = await client.post<OpenAttachment>(`/email/messages/${selectedId}/attachments/${index}/extract`);
|
||||
// Mark the whole conversation as read when opened.
|
||||
useEffect(() => {
|
||||
if (!thread || !selectedId || !thread.messages.some((m: EmailMessage) => !m.read)) return;
|
||||
client
|
||||
.patch(`/email/thread/${selectedId}/read`)
|
||||
.then(() => queryClient.invalidateQueries({ queryKey: ['email-messages'] }))
|
||||
.catch(() => {});
|
||||
}, [thread?.id]);
|
||||
|
||||
const extractAttachment = async (messageId: string, index: number) => {
|
||||
const result = await client.post<OpenAttachment>(`/email/messages/${messageId}/attachments/${index}/extract`);
|
||||
setOpenAttachment(result);
|
||||
};
|
||||
|
||||
const toggle = (id: string) =>
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
if (!selectedId) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 text-sm opacity-50">
|
||||
@@ -79,73 +171,44 @@ export const EmailReader = () => {
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm opacity-50">
|
||||
Loading...
|
||||
</div>
|
||||
);
|
||||
return <div className="flex h-full items-center justify-center text-sm opacity-50">Loading...</div>;
|
||||
}
|
||||
|
||||
if (!message) return null;
|
||||
if (!thread || thread.messages.length === 0) return null;
|
||||
|
||||
const latest = thread.messages[thread.messages.length - 1]!;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<div className="flex flex-col gap-1 border-b px-4 py-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<h2 className="text-lg font-semibold">{message.subject}</h2>
|
||||
<button
|
||||
onClick={() => openCompose(replyDraft(message))}
|
||||
className="shrink-0 flex items-center gap-1 rounded-md border px-2.5 py-1 text-xs hover:bg-accent cursor-pointer"
|
||||
title="Reply"
|
||||
>
|
||||
<Reply className="h-3.5 w-3.5" /> Reply
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 text-sm opacity-70">
|
||||
<div>
|
||||
<span className="font-medium">From:</span> {message.from}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">To:</span> {message.to}
|
||||
</div>
|
||||
{message.cc && (
|
||||
<div>
|
||||
<span className="font-medium">Cc:</span> {message.cc}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs opacity-60">{new Date(message.date).toLocaleString()}</div>
|
||||
</div>
|
||||
{message.attachments.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 pt-1">
|
||||
{message.attachments.map((att: { filename: string; size: number; contentType: string }, i: number) => (
|
||||
<button
|
||||
key={i}
|
||||
className="cursor-pointer rounded bg-accent px-2 py-0.5 text-xs hover:bg-accent/80"
|
||||
onClick={() => extractAttachment(i)}
|
||||
>
|
||||
{att.filename} ({Math.round(att.size / 1024)}KB)
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-start justify-between gap-3 border-b px-4 py-3">
|
||||
<h2 className="text-lg font-semibold">{thread.subject}</h2>
|
||||
<button
|
||||
onClick={() => openCompose(replyDraft(latest))}
|
||||
className="shrink-0 flex items-center gap-1 rounded-md border px-2.5 py-1 text-xs hover:bg-accent cursor-pointer"
|
||||
title="Reply"
|
||||
>
|
||||
<Reply className="h-3.5 w-3.5" /> Reply
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
{message.html ? (
|
||||
<HtmlBody html={message.html} />
|
||||
) : (
|
||||
<pre className="whitespace-pre-wrap p-4 text-sm">{message.text}</pre>
|
||||
)}
|
||||
|
||||
<div className="flex-1 divide-y divide-white/10 overflow-auto">
|
||||
{thread.messages.map((m: EmailMessage) => (
|
||||
<MessagePanel
|
||||
key={m.id}
|
||||
message={m}
|
||||
open={expanded.has(m.id)}
|
||||
onToggle={() => toggle(m.id)}
|
||||
onReply={() => openCompose(replyDraft(m))}
|
||||
onOpenAttachment={(index) => extractAttachment(m.id, index)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Dialog open={!!openAttachment} onOpenChange={(open) => !open && setOpenAttachment(null)}>
|
||||
<DialogContent className="flex h-[80vh] max-w-4xl flex-col gap-0 p-0">
|
||||
<DialogTitle className="sr-only">{openAttachment?.fileName}</DialogTitle>
|
||||
{openAttachment && (
|
||||
<FileViewerProvider
|
||||
filePath={openAttachment.filePath}
|
||||
fileName={openAttachment.fileName}
|
||||
root={openAttachment.root}
|
||||
>
|
||||
<FileViewerProvider filePath={openAttachment.filePath} fileName={openAttachment.fileName} root={openAttachment.root}>
|
||||
<div className="flex shrink-0 items-center gap-2 border-b px-4 pr-12 py-1.5">
|
||||
<FileViewerHeader />
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user