attachments preview overlay, docx/xlsx/odf rendering, composer drag-drop & undo-send UX
Frontend: - Full-screen attachment preview overlay (AttachmentPreview) for image/pdf/txt plus office docs: .docx via docx-preview (rendered live into the sandboxed iframe so header/footer VML images survive), .xlsx via xlsx-preview, and .odt/.ods/.odp/.odg via a JSZip+DOMParser ODF walker (officeConvert). - Type-aware attachment icons (green spreadsheet, blue document, red PDF, image/archive); new document/image/spreadsheet/print SVGs. - Composer: drag-drop attachments (body drop inserts images inline, else attaches); inserted images are selectable/resizable/deletable (RichTextEditor). - Undo-send: "Sending…" banner with live "N seconds to Undo" countdown. - Search result list: pin/archive/delete update correctly; grey-out only for search hits; scoped "Search in Trash/Spam". Backend: - Search: prefix (search-as-you-type) matching, email-address search, and address-first result ordering; Trash/Junk excluded with scoped folder search. - Attachments: RFC 6266 Content-Disposition for non-ASCII filenames; one-time inline image loading. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rcj3TpydMTzTSmU7kunzf8
This commit is contained in:
@@ -65,6 +65,9 @@ export function MailLayout() {
|
||||
// Mail-list selection (checkboxes). Lives here so the reading pane can show the
|
||||
// bulk-action bar / count while any conversation is selected.
|
||||
const [checkedIds, setCheckedIds] = useState<Set<string>>(new Set());
|
||||
// Rows just archived/trashed/deleted from the list: greyed out and unclickable
|
||||
// for immediate feedback (search results don't always drop the row on refetch).
|
||||
const [dimmedIds, setDimmedIds] = useState<Set<string>>(new Set());
|
||||
const toggleCheck = (id: string) =>
|
||||
setCheckedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -116,9 +119,16 @@ export function MailLayout() {
|
||||
queryFn: api.remoteDrafts,
|
||||
enabled: isDrafts,
|
||||
});
|
||||
// Trash/Spam are excluded from normal search, so while viewing them the search
|
||||
// box scopes to that folder instead (via an in: operator) and searches only it.
|
||||
const searchScope =
|
||||
currentFolder === "trash" ? "in:trash" : currentFolder === "junk" ? "in:junk" : "";
|
||||
const effectiveQuery = searchScope
|
||||
? `${searchScope} ${debouncedTerm}`.trim()
|
||||
: debouncedTerm;
|
||||
const searchQuery = useQuery({
|
||||
queryKey: ["search", debouncedTerm],
|
||||
queryFn: () => api.search(debouncedTerm),
|
||||
queryKey: ["search", effectiveQuery],
|
||||
queryFn: () => api.search(effectiveQuery),
|
||||
enabled: searching,
|
||||
});
|
||||
const savedSearchesQuery = useQuery({ queryKey: ["saved-searches"], queryFn: api.savedSearches });
|
||||
@@ -192,6 +202,13 @@ export function MailLayout() {
|
||||
setTerm(s.query);
|
||||
navigate("/mail");
|
||||
};
|
||||
// Switch folder/account view. Clears any active search so the folder actually
|
||||
// loads — otherwise the list stays stuck on (now-stale) search results.
|
||||
const selectView = (v: string) => {
|
||||
setTerm("");
|
||||
setView(v);
|
||||
navigate("/mail");
|
||||
};
|
||||
|
||||
// Auto-mark an opened thread read after a short dwell (4s), not instantly — and
|
||||
// cancel it if you leave before then, so a quick glance doesn't mark it read.
|
||||
@@ -289,13 +306,18 @@ export function MailLayout() {
|
||||
detail.pinned ? ["\\Flagged"] : [],
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: ["threads"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["thread", detail.id] });
|
||||
};
|
||||
|
||||
// Per-thread actions for the mail-list hover buttons (act on a given id, not the
|
||||
// currently-open thread). Close the reading pane only if the acted thread is open.
|
||||
const listAfterMove = (id: string) => {
|
||||
// In a folder the refetch below removes the row (it left the folder's scope).
|
||||
// In search the row may still match a global query, so grey it out for feedback.
|
||||
if (searching) setDimmedIds((prev) => new Set(prev).add(id));
|
||||
queryClient.invalidateQueries({ queryKey: ["threads"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["search"] }); // keep search results in sync
|
||||
queryClient.invalidateQueries({ queryKey: ["quota"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["folder-counts"] });
|
||||
if (id === threadId) navigate("/mail");
|
||||
@@ -319,11 +341,13 @@ export function MailLayout() {
|
||||
const listTogglePin = async (id: string, pinned: boolean) => {
|
||||
await api.threadFlags(id, pinned ? [] : ["\\Flagged"], pinned ? ["\\Flagged"] : []);
|
||||
queryClient.invalidateQueries({ queryKey: ["threads"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["search"] }); // pin state on search results
|
||||
queryClient.invalidateQueries({ queryKey: ["thread", id] });
|
||||
};
|
||||
|
||||
// Selection resets when you leave the folder.
|
||||
useEffect(() => setCheckedIds(new Set()), [view]);
|
||||
useEffect(() => setDimmedIds(new Set()), [view, debouncedTerm]);
|
||||
|
||||
// Bulk actions run the given operation over every selected thread, then clear the
|
||||
// selection and refresh (navigating away if the open thread was among them).
|
||||
@@ -382,10 +406,7 @@ export function MailLayout() {
|
||||
onReplyAll: () => setReplySignal({ kind: "reply_all", seq: Date.now() }),
|
||||
onForward: () => setReplySignal({ kind: "forward", seq: Date.now() }),
|
||||
onArchive: archive,
|
||||
onGoInbox: () => {
|
||||
setView("folder:inbox");
|
||||
navigate("/mail");
|
||||
},
|
||||
onGoInbox: () => selectView("folder:inbox"),
|
||||
onEscape: () => setComposing(null),
|
||||
onNext: () => openRelative(1),
|
||||
onPrev: () => openRelative(-1),
|
||||
@@ -447,10 +468,7 @@ export function MailLayout() {
|
||||
active={view === f.view}
|
||||
collapsed={collapsed}
|
||||
title={collapsed ? t(f.label) : undefined}
|
||||
onClick={() => {
|
||||
setView(f.view);
|
||||
navigate("/mail");
|
||||
}}
|
||||
onClick={() => selectView(f.view)}
|
||||
>
|
||||
<Icon name={f.icon} size={f.icon === "trash" ? 20 : 18} />
|
||||
{!collapsed && <span className="flex-1 truncate">{t(f.label)}</span>}
|
||||
@@ -470,7 +488,7 @@ export function MailLayout() {
|
||||
<SidebarItem
|
||||
key={a.id}
|
||||
active={view === `account:${a.id}`}
|
||||
onClick={() => { setView(`account:${a.id}`); navigate("/mail"); }}
|
||||
onClick={() => selectView(`account:${a.id}`)}
|
||||
>
|
||||
<AccountDot
|
||||
color={a.color}
|
||||
@@ -569,7 +587,13 @@ export function MailLayout() {
|
||||
className={`w-full rounded-lg border border-app-border bg-app-bg px-3 py-1.5 text-[15px] text-app-fg placeholder:text-app-faint focus:border-app-accent focus:outline-none ${
|
||||
term ? "pr-14" : "pr-8"
|
||||
}`}
|
||||
placeholder={t("nav.search")}
|
||||
placeholder={
|
||||
currentFolder === "trash"
|
||||
? t("nav.searchInTrash")
|
||||
: currentFolder === "junk"
|
||||
? t("nav.searchInSpam")
|
||||
: t("nav.search")
|
||||
}
|
||||
value={term}
|
||||
onChange={(e) => setTerm(e.target.value)}
|
||||
/>
|
||||
@@ -683,6 +707,7 @@ export function MailLayout() {
|
||||
onDeletePermanent={listDeletePermanent}
|
||||
onTogglePin={listTogglePin}
|
||||
checkedIds={checkedIds}
|
||||
dimmedIds={dimmedIds}
|
||||
onToggleCheck={toggleCheck}
|
||||
/>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user