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:
2026-07-26 16:15:49 +02:00
co-authored by Claude Opus 4.8
parent c023cfa15e
commit dd0b089049
22 changed files with 2726 additions and 82 deletions
+11 -1
View File
@@ -7,6 +7,7 @@ import io
import uuid
import zipfile
from typing import Annotated
from urllib.parse import quote
from fastapi import APIRouter, Depends, Query
from fastapi.responses import HTMLResponse, Response
@@ -146,6 +147,15 @@ async def message_cid(
return Response(content=data, media_type=media_type)
def _content_disposition(disposition: str, filename: str) -> str:
"""RFC 6266 Content-Disposition safe for non-ASCII names. HTTP header values
are Latin-1, so a raw Polish/CJK filename (ł, ń, …) crashes the response with
a 500; emit an ASCII fallback plus a UTF-8 ``filename*`` for modern clients."""
ascii_name = filename.encode("ascii", "ignore").decode("ascii").replace('"', "").strip()
fallback = ascii_name or "attachment"
return f"{disposition}; filename=\"{fallback}\"; filename*=UTF-8''{quote(filename)}"
async def _real_attachments(db: AsyncSession, message_id: uuid.UUID) -> list[Attachment]:
"""Downloadable (non-inline) attachments for a message, in a stable order."""
rows = await db.scalars(
@@ -234,5 +244,5 @@ async def message_attachment(
return Response(
content=data,
media_type=media_type,
headers={"Content-Disposition": f'{disposition}; filename="{att.filename}"'},
headers={"Content-Disposition": _content_disposition(disposition, att.filename)},
)
+28
View File
@@ -257,6 +257,17 @@ class PgSearchBackend(SearchBackend):
for cond in conditions:
stmt = stmt.where(cond)
# Exclude Trash/Junk from search by default (Gmail-style), so deleting a
# message from search results removes it. An explicit `in:trash`/`in:junk`
# overrides this to search there on purpose.
if not _targets_removal_folder(groups):
stmt = stmt.where(
or_(
Mailbox.special_use.is_(None),
Mailbox.special_use.notin_(_REMOVAL_FOLDERS),
)
)
decoded = _decode_cursor(cursor)
if decoded is not None:
cur_hit, cur_date, cur_id = decoded
@@ -291,6 +302,23 @@ class PgSearchBackend(SearchBackend):
return hits, next_cursor
_REMOVAL_FOLDERS = ("trash", "junk")
def _targets_removal_folder(groups: list[list[dict[str, object]]]) -> bool:
"""True if the query explicitly scopes to Trash/Junk via an ``in:`` filter, in
which case the default removal-folder exclusion must not apply."""
for group in groups:
for clause in group:
if (
clause.get("type") == "filter"
and clause.get("key") == "in"
and str(clause.get("value", "")).strip().lower() in _REMOVAL_FOLDERS
):
return True
return False
def _positive_text_terms(groups: list[list[dict[str, object]]]) -> list[str]:
terms: list[str] = []
for group in groups:
+36
View File
@@ -234,6 +234,42 @@ async def test_attachments_list_and_zip(api: ApiCtx) -> None:
assert archive.read("notes.txt") == b"hello attachment"
async def test_attachment_download_non_ascii_filename(api: ApiCtx) -> None:
# A Polish filename (ł, ń) is not Latin-1, which would crash the response with
# a 500 if put raw in Content-Disposition. It must serve 200 with RFC 6266.
from app.indexer import blobstore
from app.models import Attachment
assert api.session is not None
mid = api.message_ids[-1]
payload = b"PK\x03\x04 zip-ish bytes"
sha = blobstore.store(api.user_id, payload)
name = "oferta - składowe ceny.ods"
api.session.add(
Attachment(
message_id=mid,
user_id=api.user_id,
filename=name,
mime_type="application/vnd.oasis.opendocument.spreadsheet",
size_bytes=len(payload),
content_id=None,
blob_sha256=sha,
)
)
await api.session.commit()
aid = next(
a["id"] for a in (await api.client.get(f"/api/messages/{mid}/attachments")).json()
if a["filename"] == name
)
resp = await api.client.get(f"/api/messages/{mid}/attachments/{aid}")
assert resp.status_code == 200 # not 500
assert resp.content == payload
cd = resp.headers["content-disposition"]
assert "filename*=UTF-8''" in cd # UTF-8 encoded name for modern clients
assert "sk%C5%82adowe" in cd # ł percent-encoded
async def test_quota_reports_usage_and_default(api: ApiCtx) -> None:
resp = await api.client.get("/api/settings/quota")
assert resp.status_code == 200
+23 -2
View File
@@ -86,6 +86,8 @@ async def _ingest(
body: str,
flags: frozenset[str] = frozenset(),
attach: bool = False,
mailbox_id: uuid.UUID | None = None,
special: str = "inbox",
) -> None:
ctx.uid += 1
m = EmailMessage()
@@ -101,8 +103,8 @@ async def _ingest(
ctx.session,
user_id=ctx.user_id,
account_id=ctx.account_id,
mailbox_id=ctx.mailbox_id,
mailbox_special_use="inbox",
mailbox_id=mailbox_id or ctx.mailbox_id,
mailbox_special_use=special,
remote=RemoteMessage(
uid=ctx.uid, raw=m.as_bytes(), flags=flags, internal_date=datetime.now(UTC)
),
@@ -145,6 +147,25 @@ async def seeded(ctx: Ctx) -> Ctx:
return ctx
async def test_search_excludes_trash_by_default(ctx: Ctx) -> None:
# Deleting a message from search results (it moves to Trash) removes it from the
# results — search excludes Trash/Junk unless in:trash/in:junk asks for them.
from app.models import Mailbox
await _ingest(ctx, frm="Bob <bob@corp.com>", subject="quarterly zephyr", body="hi")
trash = Mailbox(
account_id=ctx.account_id, user_id=ctx.user_id, path="Trash", special_use="trash"
)
ctx.session.add(trash)
await ctx.session.flush()
await _ingest(
ctx, frm="X <x@corp.com>", subject="zephyr in trash", body="bye",
mailbox_id=trash.id, special="trash",
)
assert await _search(ctx, "zephyr") == 1 # inbox only; the trashed one is hidden
assert await _search(ctx, "in:trash zephyr") == 1 # explicit -> searches Trash
async def test_free_text(seeded: Ctx) -> None:
assert await _search(seeded, "revenue") == 1
assert await _search(seeded, "lunch") == 1
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="26" height="26" viewBox="0 0 24 24" fill="none">
<path d="M6 2.5h7L19 8v13a1.5 1.5 0 0 1-1.5 1.5h-11A1.5 1.5 0 0 1 5 21V4A1.5 1.5 0 0 1 6 2.5z" fill="#2563eb" opacity="0.15"/>
<path d="M13 2.5L19 8h-4.5A1.5 1.5 0 0 1 13 6.5V2.5z" fill="#2563eb" opacity="0.4"/>
<path d="M8 12.5h8M8 15.5h8M8 18.5h5" stroke="#2563eb" stroke-width="1.3" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 420 B

+35
View File
@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="26"
height="26"
viewBox="0 0 24 24"
fill="none"
version="1.1"
id="svg3"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs3" />
<path
d="M6 2.5h7L19 8v13a1.5 1.5 0 0 1-1.5 1.5h-11A1.5 1.5 0 0 1 5 21V4A1.5 1.5 0 0 1 6 2.5z"
fill="#2563eb"
opacity="0.15"
id="path1"
style="fill:#eb9425;fill-opacity:1" />
<path
d="M13 2.5L19 8h-4.5A1.5 1.5 0 0 1 13 6.5V2.5z"
fill="#2563eb"
opacity="0.4"
id="path2"
style="fill:#eb9125;fill-opacity:1" />
<path
style="fill:none;fill-opacity:1;stroke:#ff6600;stroke-width:0.875077;stroke-linecap:round;stroke-dasharray:none"
d="m 7.911187,18.240819 1.7315517,-2.380972 1.5272013,1.130759 2.233989,-3.074296 2.869342,4.385995 z"
id="path3" />
<circle
style="fill:none;fill-opacity:1;stroke:#ff6600;stroke-width:0.719258;stroke-linecap:round;stroke-dasharray:none"
id="path4"
cx="9.4974737"
cy="12.776868"
r="0.96485692" />
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+75
View File
@@ -0,0 +1,75 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="26"
height="26"
viewBox="0 0 24 24"
fill="none"
version="1.1"
id="svg3"
sodipodi:docname="spreadsheet.svg"
inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="45.038463"
inkscape:cx="13.299743"
inkscape:cy="13.62169"
inkscape:window-width="2560"
inkscape:window-height="1376"
inkscape:window-x="2560"
inkscape:window-y="27"
inkscape:window-maximized="1"
inkscape:current-layer="svg3" />
<defs
id="defs3" />
<path
d="M6 2.5h7L19 8v13a1.5 1.5 0 0 1-1.5 1.5h-11A1.5 1.5 0 0 1 5 21V4A1.5 1.5 0 0 1 6 2.5z"
fill="#2563eb"
opacity="0.15"
id="path1"
style="fill:#1ba81e;fill-opacity:1" />
<path
d="M13 2.5L19 8h-4.5A1.5 1.5 0 0 1 13 6.5V2.5z"
fill="#2563eb"
opacity="0.4"
id="path2"
style="fill:#1bab1e;fill-opacity:1" />
<rect
style="fill:#2ca02c;fill-opacity:1;stroke:none;stroke-width:0.998284;stroke-linecap:round;stroke-dasharray:none"
id="rect3"
width="3.1597841"
height="2.5984228"
x="7.9113088"
y="12.715921" />
<rect
style="fill:#2ca02c;fill-opacity:1;stroke:none;stroke-width:0.998284;stroke-linecap:round;stroke-dasharray:none"
id="rect3-2"
width="3.1597841"
height="2.5984228"
x="7.927196"
y="17.143465" />
<rect
style="fill:#2ca02c;fill-opacity:1;stroke:none;stroke-width:0.998284;stroke-linecap:round;stroke-dasharray:none"
id="rect3-6"
width="3.1597841"
height="2.5984228"
x="12.990695"
y="12.726586" />
<rect
style="fill:#2ca02c;fill-opacity:1;stroke:none;stroke-width:0.998284;stroke-linecap:round;stroke-dasharray:none"
id="rect3-2-2"
width="3.1597841"
height="2.5984228"
x="13.006582"
y="17.154131" />
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

+1103 -15
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -15,10 +15,14 @@
"dependencies": {
"@tanstack/react-query": "~5.51.0",
"@tanstack/react-virtual": "~3.8.0",
"docx-preview": "^0.4.0",
"jszip": "^3.10.1",
"pdfjs-dist": "^4.10.38",
"qrcode.react": "~4.1.0",
"react": "~18.3.1",
"react-dom": "~18.3.1",
"react-router-dom": "~6.26.0",
"xlsx-preview": "^1.0.5",
"zod": "~3.23.8"
},
"devDependencies": {
+109 -7
View File
@@ -142,7 +142,17 @@ export function Composer({
const [attachments, setAttachments] = useState<AttachmentOut[]>([]);
const [attachError, setAttachError] = useState("");
const [status, setStatus] = useState<"" | "saving" | "saved" | "sending" | "error">("");
const [undo, setUndo] = useState<{ outboxId: string } | null>(null);
const [undo, setUndo] = useState<{ outboxId: string; until: number } | null>(null);
const [undoLeft, setUndoLeft] = useState(0);
// Tick the undo countdown while the window is open.
useEffect(() => {
if (!undo) return;
const id = window.setInterval(
() => setUndoLeft(Math.max(0, Math.ceil((undo.until - Date.now()) / 1000))),
500,
);
return () => window.clearInterval(id);
}, [undo]);
const [showClosePrompt, setShowClosePrompt] = useState(false);
const draftId = useRef<string | null>(init.draftId ?? null);
const sentRef = useRef(false); // send initiated -> don't mirror to Drafts on close
@@ -224,7 +234,7 @@ export function Composer({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const addFiles = async (files: FileList) => {
const addFiles = async (files: FileList | File[]) => {
markDirty();
setAttachError("");
for (const file of Array.from(files)) {
@@ -246,6 +256,77 @@ export function Composer({
setAttachments((prev) => prev.filter((a) => a.id !== id));
};
// Drag & drop: files dropped on the writing area insert inline (images) or
// attach (other types); dropped anywhere above it, everything attaches.
const editorAreaRef = useRef<HTMLDivElement>(null);
const [dragKind, setDragKind] = useState<null | "body" | "attach">(null);
const readDataUrl = (file: File): Promise<string> =>
new Promise((resolve, reject) => {
const r = new FileReader();
r.onload = () => resolve(String(r.result));
r.onerror = () => reject(r.error);
r.readAsDataURL(file);
});
const setCaretToPoint = (x: number, y: number) => {
const d = document as Document & {
caretPositionFromPoint?: (x: number, y: number) => { offsetNode: Node; offset: number } | null;
};
let range: Range | null = null;
if (d.caretRangeFromPoint) range = d.caretRangeFromPoint(x, y);
else if (d.caretPositionFromPoint) {
const p = d.caretPositionFromPoint(x, y);
if (p) {
range = document.createRange();
range.setStart(p.offsetNode, p.offset);
range.collapse(true);
}
}
if (range && editorAreaRef.current?.contains(range.startContainer)) {
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(range);
}
};
const onDragOver = (e: React.DragEvent) => {
if (!Array.from(e.dataTransfer.types).includes("Files")) return;
e.preventDefault();
setDragKind(editorAreaRef.current?.contains(e.target as Node) ? "body" : "attach");
};
const onDragLeave = (e: React.DragEvent) => {
if (!e.currentTarget.contains(e.relatedTarget as Node)) setDragKind(null);
};
const onDrop = async (e: React.DragEvent) => {
const files = e.dataTransfer.files;
if (!files || !files.length) return;
e.preventDefault();
const inBody = editorAreaRef.current?.contains(e.target as Node);
setDragKind(null);
if (!inBody) {
void addFiles(files);
return;
}
setCaretToPoint(e.clientX, e.clientY);
const others: File[] = [];
for (const file of Array.from(files)) {
if (file.type.startsWith("image/")) {
try {
editorRef.current?.insertHtml(`<img src="${await readDataUrl(file)}" style="max-width:100%">`);
markDirty();
} catch {
others.push(file); // fall back to attaching if the read fails
}
} else {
others.push(file);
}
}
if (others.length) void addFiles(others);
};
// The editor's seed content. New messages get the account signature (2 blank
// lines above it); replies keep the quoted original in a marked block so the
// signature can later be inserted above it.
@@ -325,8 +406,12 @@ export function Composer({
if (!draftId.current) draftId.current = (await api.createDraft(payload())).id;
else await api.updateDraft(draftId.current, payload());
const res = await api.send(draftId.current);
setUndo({ outboxId: res.outbox_id });
undoTimer.current = window.setTimeout(onClose, 10000);
// Drive the countdown + auto-close off the server's undo deadline.
const until = new Date(res.undo_until).getTime();
const remaining = Math.max(0, until - Date.now());
setUndo({ outboxId: res.outbox_id, until });
setUndoLeft(Math.ceil(remaining / 1000));
undoTimer.current = window.setTimeout(onClose, remaining);
} catch (e) {
sentRef.current = false;
setStatus("error");
@@ -403,22 +488,37 @@ export function Composer({
if (undo) {
return (
<div className="m-3 flex items-center justify-between rounded-lg bg-app-fg px-4 py-2.5 text-sm text-app-surface shadow-lg">
<span>{t("compose.sent")}</span>
<button className="font-medium text-app-accent" onClick={doUndo}>
<span>{t("compose.sending")}</span>
<span className="flex items-center gap-1.5">
{undoLeft > 0 && (
<span className="tabular-nums text-app-surface/70">
{t("compose.undoIn", { count: undoLeft })}
</span>
)}
<button className="font-medium text-app-accent hover:underline" onClick={doUndo}>
{t("compose.undo")}
</button>
</span>
</div>
);
}
return (
<div
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
className={`relative flex flex-col overflow-hidden border-app-border bg-app-elevated ${
fullWindow
? "h-full"
: `rounded-xl border shadow-2xl ${expanded ? "h-full" : ""}`
}`}
>
{dragKind && (
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center border-2 border-dashed border-app-accent bg-app-accent/10 text-sm font-medium text-app-accent">
{dragKind === "body" ? t("compose.dropBody") : t("compose.dropAttach")}
</div>
)}
{showClosePrompt && (
<div
className="absolute inset-0 z-40 flex items-center justify-center bg-black/40 p-4"
@@ -672,7 +772,8 @@ export function Composer({
</div>
)}
{/* Body */}
{/* Body — wrapped so a drop here is detected as the writing area. */}
<div ref={editorAreaRef} className="flex min-h-0 flex-1 flex-col">
{bodyReady ? (
<RichTextEditor
ref={editorRef}
@@ -687,5 +788,6 @@ export function Composer({
<div className="min-h-[250px] flex-1 px-3 py-3 text-sm text-app-faint">…</div>
)}
</div>
</div>
);
}
@@ -8,6 +8,8 @@ export interface RichTextEditorHandle {
replaceSignature: (html: string) => void;
// Insert the signature below the body but above quoted content (reply menu).
insertSignature: (html: string) => void;
// Insert HTML (e.g. a dropped inline image) at the caret, or append it.
insertHtml: (html: string) => void;
}
// A lightweight WYSIWYG editor over a contenteditable div. The DOM is the source
@@ -153,6 +155,9 @@ export const RichTextEditor = forwardRef<
const paletteRef = useRef<HTMLDivElement>(null);
const [active, setActive] = useState<Record<string, boolean>>({});
const [colorMenu, setColorMenu] = useState<null | "fore" | "hilite">(null);
// Currently-selected inline image + its handle box (px, relative to the editor).
const [selImg, setSelImg] = useState<HTMLImageElement | null>(null);
const [box, setBox] = useState<{ left: number; top: number; width: number; height: number } | null>(null);
// Seed the editable region exactly once.
useEffect(() => {
@@ -194,6 +199,19 @@ export const RichTextEditor = forwardRef<
el.insertBefore(spacerAfter, anchor);
onInput(el.innerHTML);
},
insertHtml: (html: string) => {
const el = ref.current;
if (!el || !html) return;
el.focus();
// Insert at the current caret; if that fails (no selection in the editor),
// append before the signature/quoted block.
if (!document.execCommand("insertHTML", false, html)) {
const block = document.createElement("div");
block.innerHTML = html;
el.insertBefore(block, el.querySelector(".wmc-signature, .wmc-quoted"));
}
onInput(el.innerHTML);
},
}));
// Remember the caret/selection so native pickers (color/file) that steal focus
@@ -294,6 +312,75 @@ export const RichTextEditor = forwardRef<
reader.readAsDataURL(file);
};
// ── Inline-image select / resize / delete ─────────────────────────────────
const computeBox = useCallback(() => {
const img = selImg;
const ed = ref.current;
if (!img || !ed) return null;
const ir = img.getBoundingClientRect();
const er = ed.getBoundingClientRect();
return { left: ir.left - er.left, top: ir.top - er.top, width: ir.width, height: ir.height };
}, [selImg]);
useEffect(() => setBox(computeBox()), [selImg, computeBox]);
// Reposition handles on window resize; deselect on an outside click.
useEffect(() => {
if (!selImg) return;
const onWin = () => setBox(computeBox());
const onDown = (e: MouseEvent) => {
const el = e.target as HTMLElement;
if (el === selImg || el.closest("[data-img-handle]")) return;
setSelImg(null);
};
window.addEventListener("resize", onWin);
document.addEventListener("mousedown", onDown);
return () => {
window.removeEventListener("resize", onWin);
document.removeEventListener("mousedown", onDown);
};
}, [selImg, computeBox]);
const startResize = (corner: "nw" | "ne" | "sw" | "se") => (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const img = selImg;
const ed = ref.current;
if (!img || !ed) return;
const startX = e.clientX;
const startW = img.getBoundingClientRect().width;
const maxW = ed.clientWidth - 24; // minus px-3 padding
const onMove = (ev: MouseEvent) => {
const dx = ev.clientX - startX;
const grow = corner === "se" || corner === "ne" ? dx : -dx;
img.style.width = `${Math.round(Math.max(24, Math.min(startW + grow, maxW)))}px`;
img.style.height = "auto";
setBox(computeBox());
};
const onUp = () => {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
emit();
setBox(computeBox());
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
};
const onEditorClick = (e: React.MouseEvent) => {
const el = e.target as HTMLElement;
setSelImg(el.tagName === "IMG" ? (el as HTMLImageElement) : null);
};
const onEditorKeyDown = (e: React.KeyboardEvent) => {
if (selImg && (e.key === "Delete" || e.key === "Backspace")) {
e.preventDefault();
selImg.remove();
setSelImg(null);
emit();
}
};
const Btn = ({
onClick,
title,
@@ -452,6 +539,7 @@ export const RichTextEditor = forwardRef<
{ICONS.clear}
</Btn>
</div>
<div className="relative flex min-h-0 flex-1 flex-col">
<div
ref={ref}
contentEditable
@@ -462,8 +550,33 @@ export const RichTextEditor = forwardRef<
onInput={(e) => onInput((e.target as HTMLDivElement).innerHTML)}
onKeyUp={refreshActive}
onMouseUp={refreshActive}
onClick={onEditorClick}
onKeyDown={onEditorKeyDown}
onScroll={() => setBox(computeBox())}
className={`wmc-editor ${minHeight} flex-1 overflow-y-auto px-3 py-3 text-sm text-app-fg outline-none`}
/>
{selImg && box && (
<>
<div
className="pointer-events-none absolute z-10 border-2 border-app-accent"
style={{ left: box.left - 1, top: box.top - 1, width: box.width + 2, height: box.height + 2 }}
/>
{(["nw", "ne", "sw", "se"] as const).map((c) => (
<div
key={c}
data-img-handle
onMouseDown={startResize(c)}
className="absolute z-10 h-2.5 w-2.5 rounded-sm border border-white bg-app-accent shadow"
style={{
left: (c[1] === "w" ? box.left : box.left + box.width) - 5,
top: (c[0] === "n" ? box.top : box.top + box.height) - 5,
cursor: c === "nw" || c === "se" ? "nwse-resize" : "nesw-resize",
}}
/>
))}
</>
)}
</div>
</div>
);
});
+10 -4
View File
@@ -61,6 +61,7 @@ export function MessageList({
onDeletePermanent,
onTogglePin,
checkedIds,
dimmedIds,
onToggleCheck,
}: {
threads: ThreadSummary[];
@@ -73,6 +74,7 @@ export function MessageList({
onDeletePermanent: (id: string) => void;
onTogglePin: (id: string, pinned: boolean) => void;
checkedIds: Set<string>;
dimmedIds?: Set<string>;
onToggleCheck: (id: string) => void;
}) {
const t = useT();
@@ -103,6 +105,7 @@ export function MessageList({
const unread = thread.unread_count > 0;
const selected = selectedId === thread.id;
const checked = checkedIds.has(thread.id);
const dimmed = dimmedIds?.has(thread.id) ?? false;
const primary = thread.participants[0];
const names =
thread.participants.map((p) => p.name || p.email).join(", ") || "(no one)";
@@ -116,10 +119,11 @@ export function MessageList({
<div
key={thread.id}
role="button"
tabIndex={0}
onClick={() => onSelect(thread.id)}
tabIndex={dimmed ? -1 : 0}
aria-disabled={dimmed}
onClick={() => !dimmed && onSelect(thread.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") onSelect(thread.id);
if (!dimmed && (e.key === "Enter" || e.key === " ")) onSelect(thread.id);
}}
style={{
position: "absolute",
@@ -129,7 +133,9 @@ export function MessageList({
height: item.size,
transform: `translateY(${item.start}px)`,
}}
className="group absolute inset-x-0 cursor-pointer px-2 pt-[7px]"
className={`group absolute inset-x-0 px-2 pt-[7px] ${
dimmed ? "pointer-events-none opacity-40" : "cursor-pointer"
}`}
>
<div
className={`flex h-full items-center gap-3 rounded-lg px-3 ${
@@ -0,0 +1,528 @@
// Full-screen attachment preview overlay (SPEC §12 M2): text / image / pdf.
// Chrome: name + type badge top-left, print + download top-right, prev/next
// attachment arrows on the sides, and scale (+ pdf page) controls at the bottom.
import * as pdfjs from "pdfjs-dist";
import PdfWorker from "pdfjs-dist/build/pdf.worker.min.mjs?worker";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useT } from "../../i18n";
import { api } from "../../lib/api";
import type { AttachmentOut } from "../../lib/types";
import { Icon } from "../Icon";
import { convertOffice, renderDocxInto } from "./officeConvert";
import { extOf, kindOf } from "./previewKind";
pdfjs.GlobalWorkerOptions.workerPort = new PdfWorker();
export function AttachmentPreview({
messageId,
attachments,
index,
onIndex,
onClose,
}: {
messageId: string;
attachments: AttachmentOut[];
index: number;
onIndex: (i: number) => void;
onClose: () => void;
}) {
const t = useT();
const att = attachments[index];
const kind = kindOf(att.filename);
const url = api.attachmentUrl(messageId, att.id);
const [zoom, setZoom] = useState(1);
const [page, setPage] = useState({ current: 1, total: 0 });
const textRef = useRef("");
const officeRef = useRef(""); // converted office HTML, for printing
const prev = useCallback(
() => onIndex((index - 1 + attachments.length) % attachments.length),
[index, attachments.length, onIndex],
);
const next = useCallback(
() => onIndex((index + 1) % attachments.length),
[index, attachments.length, onIndex],
);
useEffect(() => setZoom(1), [att.id]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
else if (e.key === "ArrowLeft") prev();
else if (e.key === "ArrowRight") next();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose, prev, next]);
const download = () => {
const a = document.createElement("a");
a.href = api.attachmentDownloadUrl(messageId, att.id);
a.download = att.filename;
a.click();
};
// Print via a hidden iframe so the browser's native print handles pdf/image; for
// text we print a wrapped document (its inline URL isn't a renderable type).
const printAtt = () => {
const frame = document.createElement("iframe");
Object.assign(frame.style, {
position: "fixed",
right: "0",
bottom: "0",
width: "0",
height: "0",
border: "0",
});
let printed = false;
frame.onload = () => {
// Ignore the initial empty (about:blank) load — only print once the real
// content is in, otherwise the first dialog previews a blank page.
let href = "";
try {
href = frame.contentWindow?.location.href ?? "";
} catch {
/* same-origin content; ignore */
}
if (printed || href === "about:blank") return;
printed = true;
frame.contentWindow?.focus();
frame.contentWindow?.print();
window.setTimeout(() => frame.remove(), 60_000);
};
// Set the source BEFORE appending so the frame navigates straight to the
// content instead of first loading about:blank and printing that.
if (kind === "text") {
// A .txt is served as octet-stream (not renderable via URL), so print the
// already-loaded text in a wrapped document.
const esc = textRef.current
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
frame.srcdoc = `<pre style="font:13px/1.5 ui-monospace,monospace;white-space:pre-wrap;word-break:break-word;padding:16px">${esc}</pre>`;
} else if (kind === "office") {
// Office docs are converted to HTML client-side; print that.
frame.srcdoc = wrapOfficeDoc(officeRef.current);
} else {
frame.src = url;
}
document.body.appendChild(frame);
};
const single = attachments.length <= 1;
return createPortal(
<div className="fixed inset-0 z-50 flex flex-col bg-black/85 backdrop-blur-sm">
{/* Top bar */}
<div className="flex shrink-0 items-center justify-between px-4 py-3 text-white">
<div className="flex min-w-0 items-center gap-3">
<button
type="button"
onClick={onClose}
title={t("common.close")}
aria-label={t("common.close")}
className="rounded p-1.5 text-white/80 hover:bg-white/10 hover:text-white"
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round">
<path d="M5 5l10 10M15 5L5 15" />
</svg>
</button>
<span className="rounded bg-red-600 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide">
{extOf(att.filename) || "file"}
</span>
<span className="truncate text-sm" title={att.filename}>
{att.filename}
</span>
</div>
<div className="flex items-center gap-1">
<button
type="button"
onClick={printAtt}
title={t("mail.print")}
aria-label={t("mail.print")}
className="rounded p-1.5 text-white/80 hover:bg-white/10 hover:text-white"
>
<Icon name="print" size={19} />
</button>
<button
type="button"
onClick={download}
title={t("mail.download")}
aria-label={t("mail.download")}
className="rounded p-1.5 text-white/80 hover:bg-white/10 hover:text-white"
>
<svg width="19" height="19" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M10 3v9M6 9l4 4 4-4M4 16h12" />
</svg>
</button>
</div>
</div>
{/* Content — click the empty area (not the media or a control) to dismiss. */}
<div
className="relative min-h-0 flex-1"
onClick={(e) => {
if (!(e.target as Element).closest("img, iframe, canvas, pre, button, a")) onClose();
}}
>
{kind === "image" && <ImageView url={url} zoom={zoom} alt={att.filename} />}
{kind === "text" && (
<TextView url={url} zoom={zoom} onLoaded={(tx) => (textRef.current = tx)} />
)}
{kind === "pdf" && (
<PdfView url={url} zoom={zoom} onPage={(current, total) => setPage({ current, total })} />
)}
{kind === "office" && (
<OfficeView
url={url}
filename={att.filename}
zoom={zoom}
onHtml={(h) => (officeRef.current = h)}
/>
)}
{/* Prev / next attachment */}
{!single && (
<>
<SideArrow dir="left" onClick={prev} label={t("mail.previewPrev")} />
<SideArrow dir="right" onClick={next} label={t("mail.previewNext")} />
</>
)}
</div>
{/* Bottom controls */}
<div className="flex shrink-0 items-center justify-center py-3">
<div className="flex items-center gap-2 rounded-full bg-black/60 px-3 py-1.5 text-sm text-white ring-1 ring-white/15">
{kind === "pdf" && page.total > 0 && (
<>
<span className="tabular-nums">
{t("mail.previewPage", { current: page.current, total: page.total })}
</span>
<span className="mx-1 h-4 w-px bg-white/20" />
</>
)}
<ZoomButton onClick={() => setZoom((z) => Math.max(0.25, +(z - 0.2).toFixed(2)))} label="−" />
<button
type="button"
onClick={() => setZoom(1)}
title={t("mail.previewResetZoom")}
className="rounded px-1.5 text-white/80 hover:text-white"
>
<svg width="16" height="16" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round">
<circle cx="9" cy="9" r="6" />
<path d="M13.5 13.5L17 17" />
</svg>
</button>
<ZoomButton onClick={() => setZoom((z) => Math.min(5, +(z + 0.2).toFixed(2)))} label="+" />
</div>
</div>
</div>,
document.body,
);
}
function SideArrow({ dir, onClick, label }: { dir: "left" | "right"; onClick: () => void; label: string }) {
return (
<button
type="button"
onClick={onClick}
title={label}
aria-label={label}
className={`absolute top-1/2 -translate-y-1/2 rounded-full bg-black/40 p-2 text-white/80 ring-1 ring-white/15 hover:bg-black/70 hover:text-white ${
dir === "left" ? "left-3" : "right-3"
}`}
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d={dir === "left" ? "M15 5l-7 7 7 7" : "M9 5l7 7-7 7"} />
</svg>
</button>
);
}
function ZoomButton({ onClick, label }: { onClick: () => void; label: string }) {
return (
<button
type="button"
onClick={onClick}
className="w-6 rounded text-center text-lg leading-none text-white/80 hover:text-white"
aria-label={label === "+" ? "zoom in" : "zoom out"}
>
{label}
</button>
);
}
function ImageView({ url, zoom, alt }: { url: string; zoom: number; alt: string }) {
return (
<div className="flex h-full w-full items-center justify-center overflow-auto p-4">
<img
src={url}
alt={alt}
className="max-h-full max-w-full object-contain transition-transform"
style={{ transform: `scale(${zoom})` }}
/>
</div>
);
}
function TextView({
url,
zoom,
onLoaded,
}: {
url: string;
zoom: number;
onLoaded: (text: string) => void;
}) {
const [text, setText] = useState<string | null>(null);
const [failed, setFailed] = useState(false);
useEffect(() => {
let cancelled = false;
fetch(url, { credentials: "include" })
.then((r) => (r.ok ? r.text() : Promise.reject(new Error("load failed"))))
.then((tx) => {
if (cancelled) return;
setText(tx);
onLoaded(tx);
})
.catch(() => !cancelled && setFailed(true));
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [url]);
return (
<div className="mx-auto h-full max-w-4xl overflow-auto p-6">
{failed ? (
<p className="text-white/70">—</p>
) : (
<pre
className="whitespace-pre-wrap break-words rounded-lg bg-white p-6 font-mono text-gray-900 shadow-lg"
style={{ fontSize: `${13 * zoom}px`, lineHeight: 1.5 }}
>
{text ?? ""}
</pre>
)}
</div>
);
}
function wrapOfficeDoc(bodyHtml: string): string {
return `<!doctype html><html><head><meta charset="utf-8"><style>
html,body{margin:0}
body{font-family:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;font-size:15px;
line-height:1.5;color:#1a1a1a;background:#fff;padding:32px;word-break:break-word}
img{max-width:100%;height:auto}
table{border-collapse:collapse;max-width:100%}
td,th{border:1px solid #ccc;padding:4px 8px;vertical-align:top}
h1,h2,h3,h4,h5,h6{line-height:1.25;margin:.6em 0 .3em}
a{color:#2563eb}
</style></head><body>${bodyHtml}</body></html>`;
}
function OfficeView({
url,
filename,
zoom,
onHtml,
}: {
url: string;
filename: string;
zoom: number;
onHtml: (html: string) => void;
}) {
const t = useT();
const frameRef = useRef<HTMLIFrameElement>(null);
// Either a ready-to-inject HTML string (xlsx/odf) or a raw buffer that
// docx-preview renders live into the iframe (docx — see renderDocxInto).
const [content, setContent] = useState<
{ kind: "html"; html: string } | { kind: "docx"; buffer: ArrayBuffer } | null
>(null);
const [error, setError] = useState<string | null>(null);
const isDocx = extOf(filename) === "docx";
useEffect(() => {
let cancelled = false;
setContent(null);
setError(null);
(async () => {
let diag = "";
try {
const resp = await fetch(url, { credentials: "include", cache: "no-store" });
const buf = await resp.arrayBuffer();
const head = Array.from(new Uint8Array(buf).slice(0, 4))
.map((b) => b.toString(16).padStart(2, "0"))
.join(" ");
diag = ` [${resp.status} ${resp.headers.get("content-type") ?? "?"} ${buf.byteLength}B ${head}]`;
if (isDocx) {
if (!cancelled) setContent({ kind: "docx", buffer: buf });
} else {
const html = await convertOffice(filename, buf);
if (cancelled) return;
setContent({ kind: "html", html });
onHtml(html);
}
} catch (e) {
console.error("attachment preview: office conversion failed", e);
if (!cancelled) setError((e instanceof Error ? e.message : String(e)) + diag);
}
})();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [url, filename]);
// docx needs a blank white page docx-preview fills in on load; xlsx/odf inject
// their converted HTML directly. Embed the url so navigating docx→docx changes
// the srcdoc and forces a fresh load (and thus a fresh render).
const srcDoc = useMemo(() => {
if (content == null) return "";
if (content.kind === "docx") return wrapOfficeDoc(`<!--${url}-->`);
return wrapOfficeDoc(content.html);
}, [content, url]);
// Apply zoom as font-size on the (same-origin) frame body — no reload/flicker.
useEffect(() => {
const b = frameRef.current?.contentDocument?.body;
if (b) b.style.fontSize = `${Math.round(15 * zoom)}px`;
}, [zoom, content]);
const onLoad = () => {
const doc = frameRef.current?.contentDocument;
if (!doc) return;
if (doc.body) doc.body.style.fontSize = `${Math.round(15 * zoom)}px`;
// Links must not navigate the preview frame (same rule as message bodies).
doc.addEventListener("click", (e) => {
const a = (e.target as Element | null)?.closest?.("a[href]") as HTMLAnchorElement | null;
if (!a) return;
e.preventDefault();
const raw = (a.getAttribute("href") || "").trim();
if (/^https?:\/\//i.test(raw)) window.open(a.href, "_blank", "noopener,noreferrer");
});
// docx-preview must render into this live document so header/footer VML images
// (sized via getBBox) survive; snapshot the result afterwards for printing.
if (content?.kind === "docx" && doc.body) {
renderDocxInto(doc.body, doc.head, content.buffer)
.then(() => {
doc.body.style.fontSize = `${Math.round(15 * zoom)}px`;
onHtml(doc.body.innerHTML);
})
.catch((e: unknown) => {
console.error("attachment preview: docx render failed", e);
setError(e instanceof Error ? e.message : String(e));
});
}
};
if (error) {
return (
<div className="flex h-full flex-col items-center justify-center gap-2 p-6 text-center text-white/70">
<span>{t("mail.loadFailed")}</span>
<span className="max-w-lg break-words text-xs opacity-60">{error}</span>
</div>
);
}
if (content == null) {
return <div className="flex h-full items-center justify-center text-white/70">{t("mail.loadingThread")}</div>;
}
// Spreadsheets are landscape/wide — use the full overlay width; text documents
// read better constrained to a page-like column.
const wide = ["xlsx", "ods"].includes(extOf(filename));
return (
<div className={`mx-auto h-full p-4 ${wide ? "w-full" : "max-w-4xl"}`}>
<iframe
ref={frameRef}
title={filename}
sandbox="allow-same-origin"
srcDoc={srcDoc}
onLoad={onLoad}
className="h-full w-full rounded-lg bg-white"
/>
</div>
);
}
function PdfView({
url,
zoom,
onPage,
}: {
url: string;
zoom: number;
onPage: (current: number, total: number) => void;
}) {
const scrollRef = useRef<HTMLDivElement>(null);
const docRef = useRef<pdfjs.PDFDocumentProxy | null>(null);
const [numPages, setNumPages] = useState(0);
// Load the document once.
useEffect(() => {
let cancelled = false;
(async () => {
const buf = await (await fetch(url, { credentials: "include" })).arrayBuffer();
if (cancelled) return;
const doc = await pdfjs.getDocument({ data: buf }).promise;
if (cancelled) return;
docRef.current = doc;
setNumPages(doc.numPages);
onPage(1, doc.numPages);
})();
return () => {
cancelled = true;
docRef.current?.destroy();
docRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [url]);
// (Re)render all pages to canvases when the doc or zoom changes.
useEffect(() => {
const doc = docRef.current;
const container = scrollRef.current;
if (!doc || !container) return;
let cancelled = false;
(async () => {
container.replaceChildren();
const targetW = Math.min(920, (container.clientWidth || 900) - 32) * zoom;
for (let n = 1; n <= doc.numPages; n++) {
const p = await doc.getPage(n);
const base = p.getViewport({ scale: 1 });
const viewport = p.getViewport({ scale: targetW / base.width });
const dpr = window.devicePixelRatio || 1;
const canvas = document.createElement("canvas");
canvas.width = Math.floor(viewport.width * dpr);
canvas.height = Math.floor(viewport.height * dpr);
canvas.style.width = `${viewport.width}px`;
canvas.style.height = `${viewport.height}px`;
canvas.className = "mx-auto mb-4 bg-white shadow-xl";
canvas.dataset.page = String(n);
const ctx = canvas.getContext("2d");
if (!ctx || cancelled) return;
ctx.scale(dpr, dpr);
container.appendChild(canvas);
await p.render({ canvasContext: ctx, viewport }).promise;
}
})();
return () => {
cancelled = true;
};
}, [numPages, zoom]);
const onScroll = () => {
const container = scrollRef.current;
if (!container || !docRef.current) return;
const canvases = container.querySelectorAll<HTMLCanvasElement>("canvas");
let current = 1;
for (const c of canvases) {
if (c.offsetTop - 80 <= container.scrollTop) current = Number(c.dataset.page);
}
onPage(current, docRef.current.numPages);
};
return <div ref={scrollRef} onScroll={onScroll} className="h-full overflow-auto py-4" />;
}
+113 -9
View File
@@ -1,9 +1,22 @@
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { lazy, Suspense, useState } from "react";
import imageSvgRaw from "../../../icons/image.svg?raw";
import spreadsheetSvgRaw from "../../../icons/spreadsheet.svg?raw";
import { useT } from "../../i18n";
import { api } from "../../lib/api";
import type { AttachmentOut } from "../../lib/types";
import { extOf, isPreviewable } from "./previewKind";
// These icons carry their own (baked-in) colours via inline styles, so render
// them raw rather than through <Icon>, which strips styles. Drop the XML prolog.
const IMAGE_SVG = imageSvgRaw.replace(/<\?xml[^>]*\?>/, "");
const SPREADSHEET_SVG = spreadsheetSvgRaw.replace(/<\?xml[^>]*\?>/, "");
// Lazy: the preview bundles pdf.js (~500 kB), loaded only when one is opened.
const AttachmentPreview = lazy(() =>
import("./AttachmentPreview").then((m) => ({ default: m.AttachmentPreview })),
);
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
@@ -11,7 +24,9 @@ function formatSize(bytes: number): string {
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function FileGlyph() {
// Base document glyph: folded-corner page. Used (blue) for text and (grey) for
// any other type; the coloured/pictorial variants below cover pdf/image/archive.
function FileGlyph({ label }: { label?: string }) {
return (
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" aria-hidden>
<path
@@ -19,16 +34,77 @@ function FileGlyph() {
fill="currentColor"
opacity="0.15"
/>
<path
d="M13 2.5L19 8h-4.5A1.5 1.5 0 0 1 13 6.5V2.5z"
<path d="M13 2.5L19 8h-4.5A1.5 1.5 0 0 1 13 6.5V2.5z" fill="currentColor" opacity="0.4" />
{label ? (
<text
x="11.5"
y="18"
textAnchor="middle"
fontSize="6.5"
fontWeight="700"
fill="currentColor"
opacity="0.4"
>
{label}
</text>
) : (
<path
d="M8 12.5h8M8 15.5h8M8 18.5h5"
stroke="currentColor"
strokeWidth="1.3"
strokeLinecap="round"
/>
<path d="M8 12.5h8M8 15.5h8M8 18.5h5" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" />
)}
</svg>
);
}
// Archive icon for zip/rar/… (box with a zipper).
function ArchiveGlyph() {
return (
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden>
<path d="M4 7l1.4-3h13.2L20 7" strokeLinejoin="round" />
<rect x="4" y="7" width="16" height="13" rx="1.5" />
<path d="M12 4v3.5" />
<rect x="10.4" y="11" width="3.2" height="3.2" rx="0.5" fill="currentColor" stroke="none" />
</svg>
);
}
const IMAGE_ICON_EXT = new Set(["jpg", "jpeg", "png", "gif", "webp", "bmp", "svg", "tiff", "heic"]);
const ARCHIVE_EXT = new Set(["zip", "rar", "7z", "tar", "gz", "bz2", "xz"]);
const SPREADSHEET_EXT = new Set(["xlsx", "ods"]);
const DOC_EXT = new Set(["txt", "docx", "odt"]); // blue document icon
function AttachmentIcon({ filename }: { filename: string }) {
const e = extOf(filename);
if (e === "pdf") {
return (
<span className="shrink-0 text-red-600 dark:text-red-500">
<FileGlyph label="PDF" />
</span>
);
}
if (SPREADSHEET_EXT.has(e)) {
return <span className="shrink-0" aria-hidden dangerouslySetInnerHTML={{ __html: SPREADSHEET_SVG }} />;
}
if (IMAGE_ICON_EXT.has(e)) {
return <span className="shrink-0" aria-hidden dangerouslySetInnerHTML={{ __html: IMAGE_SVG }} />;
}
if (ARCHIVE_EXT.has(e)) {
return (
<span className="shrink-0 text-amber-500">
<ArchiveGlyph />
</span>
);
}
// Blue document for text/docx/odt; grey for everything else.
return (
<span className={`shrink-0 ${DOC_EXT.has(e) ? "text-app-accent" : "text-app-muted"}`}>
<FileGlyph />
</span>
);
}
function DownloadGlyph() {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
@@ -40,6 +116,8 @@ function DownloadGlyph() {
export function Attachments({ messageId }: { messageId: string }) {
const t = useT();
const [selected, setSelected] = useState<Set<string>>(new Set());
// Index into the previewable subset that's open in the full-screen viewer.
const [previewIdx, setPreviewIdx] = useState<number | null>(null);
const { data } = useQuery({
queryKey: ["attachments", messageId],
@@ -49,6 +127,12 @@ export function Attachments({ messageId }: { messageId: string }) {
const attachments = data ?? [];
if (attachments.length === 0) return null;
const previewable = attachments.filter((a) => isPreviewable(a.filename));
const openPreview = (id: string) => {
const i = previewable.findIndex((a) => a.id === id);
if (i >= 0) setPreviewIdx(i);
};
const toggle = (id: string) =>
setSelected((prev) => {
const next = new Set(prev);
@@ -85,13 +169,22 @@ export function Attachments({ messageId }: { messageId: string }) {
className="h-3.5 w-3.5 shrink-0 accent-app-accent"
aria-label={att.filename}
/>
<span className="shrink-0 text-app-accent">
<FileGlyph />
</span>
<AttachmentIcon filename={att.filename} />
<span className="min-w-0 flex-1">
{isPreviewable(att.filename) ? (
<button
type="button"
onClick={() => openPreview(att.id)}
title={att.filename}
className="block w-full truncate text-left text-sm font-medium text-app-accent hover:underline"
>
{att.filename}
</button>
) : (
<span className="block truncate text-sm font-medium text-app-fg" title={att.filename}>
{att.filename}
</span>
)}
<span className="block text-xs text-app-faint">{formatSize(att.size_bytes)}</span>
</span>
<a
@@ -114,6 +207,17 @@ export function Attachments({ messageId }: { messageId: string }) {
{t("mail.downloadAll", { count: attachments.length })}
</a>
</div>
{previewIdx !== null && (
<Suspense fallback={null}>
<AttachmentPreview
messageId={messageId}
attachments={previewable}
index={previewIdx}
onIndex={setPreviewIdx}
onClose={() => setPreviewIdx(null)}
/>
</Suspense>
)}
</div>
);
}
@@ -0,0 +1,415 @@
// Client-side conversion of office documents to HTML for preview.
// .docx -> docx-preview, rendered live into the iframe (see renderDocxInto)
// .xlsx -> xlsx-preview (exceljs) -> styled tables
// .odt/.ods/.odp/.odg -> JSZip + DOMParser walk of content.xml
//
// The ODF walker is adapted from ReaderJS (github.com/Alpaq92/ReaderJS, MIT):
// it maps the text/table/draw namespaces to a small set of safe HTML tags and
// escapes all text/attributes. The result is rendered in a script-less sandboxed
// iframe (see OfficeView), so no active content can run.
import JSZip from "jszip";
import xlsxPreview from "xlsx-preview"; // CJS/UMD: default import is the reliable form
import { extOf } from "./previewKind";
// xlsx/odf convert to a self-contained HTML string. .docx is handled separately
// by renderDocxInto — docx-preview must render into the live, displayed iframe so
// its layout-time work (VML image sizing via getBBox, image/font loading) runs in
// a real document; serializing to a string would drop header/footer VML images.
export async function convertOffice(filename: string, buffer: ArrayBuffer): Promise<string> {
const ext = extOf(filename);
if (ext === "xlsx") {
// separateSheets gives self-contained per-sheet HTML (styled tables); the
// default single-string output embeds sheets as <object data="blob:…">, which
// won't render inside our sandboxed srcdoc iframe. Each sheet is
// <body>…</body><style>…</style>; turn the <body> into a <div> (keeping the
// trailing <style>) so multiple sheets nest cleanly in one document.
// minimumRows/Cols default to a padded grid (lots of empty cells); 1 makes it
// render just the sheet's used range (max(usedRange, minimum)).
const sheets = await xlsxPreview.xlsx2Html(buffer, {
separateSheets: true,
minimumRows: 1,
minimumCols: 1,
});
const arr = Array.isArray(sheets) ? sheets.map(String) : [String(sheets)];
return arr
.map((s) => s.replace(/<body[^>]*>/i, '<div class="xlsx-sheet">').replace(/<\/body>/i, "</div>"))
.join('\n<hr style="margin:24px 0;border:none;border-top:1px solid #ccc">\n');
}
return odfToHtml(buffer);
}
// ── .docx ────────────────────────────────────────────────────────────────────
// docx-preview reproduces Word formatting (colours, alignment, fonts, sizes,
// underline, tables, lists) far more faithfully than a semantic converter.
//
// It must render into a LIVE, displayed document: it sizes VML images (e.g.
// header/footer logos) with getBBox() inside a requestAnimationFrame callback,
// and getBBox only returns real geometry for laid-out elements. Rendering into a
// detached/off-screen node and snapshotting innerHTML drops those images. So we
// render straight into the preview iframe's own document.
export async function renderDocxInto(
bodyEl: HTMLElement,
styleEl: HTMLElement,
buffer: ArrayBuffer,
): Promise<void> {
const { renderAsync } = await import("docx-preview");
await renderAsync(buffer, bodyEl, styleEl, {
inWrapper: false, // no grey page wrapper; the iframe provides the white canvas
ignoreWidth: true, // flow to the container width instead of fixed page size
ignoreHeight: true,
breakPages: false,
useBase64URL: true, // inline images/fonts so they load inside the iframe
});
// Let the rAF-driven VML image sizing settle before callers snapshot the HTML.
await new Promise<void>((r) => requestAnimationFrame(() => requestAnimationFrame(() => r())));
}
// ── ODF (OpenDocument) ──────────────────────────────────────────────────────
const NS = {
office: "urn:oasis:names:tc:opendocument:xmlns:office:1.0",
text: "urn:oasis:names:tc:opendocument:xmlns:text:1.0",
table: "urn:oasis:names:tc:opendocument:xmlns:table:1.0",
draw: "urn:oasis:names:tc:opendocument:xmlns:drawing:1.0",
style: "urn:oasis:names:tc:opendocument:xmlns:style:1.0",
fo: "urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0",
xlink: "http://www.w3.org/1999/xlink",
};
const IMG_MIME: Record<string, string> = {
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
svg: "image/svg+xml",
webp: "image/webp",
};
async function odfToHtml(buffer: ArrayBuffer): Promise<string> {
const zip = await JSZip.loadAsync(buffer);
const mimeEntry = zip.file("mimetype");
const mime = mimeEntry ? (await mimeEntry.async("string")).trim() : "";
if (mime && !mime.startsWith("application/vnd.oasis.opendocument")) {
throw new Error(`unexpected ODF mimetype: ${mime}`);
}
const contentEntry = zip.file("content.xml");
if (!contentEntry) throw new Error("ODF file has no content.xml");
const xmlDoc = new DOMParser().parseFromString(await contentEntry.async("string"), "application/xml");
if (xmlDoc.getElementsByTagName("parsererror").length) throw new Error("content.xml parse error");
const styles = parseStyles(xmlDoc);
// Spreadsheets need a grid-aware renderer (repeats, cell styles, alignment);
// the generic walker below handles text/presentation/drawing documents.
const spreadsheet = xmlDoc.getElementsByTagNameNS(NS.office, "spreadsheet")[0];
if (spreadsheet) return renderSpreadsheet(spreadsheet, styles);
const imgMap = await extractImages(zip);
// getElementsByTagNameNS is reliable on XML documents (the "*|body" CSS selector
// is flaky across engines); fall back to the whole document.
const body = xmlDoc.getElementsByTagNameNS(NS.office, "body")[0] ?? xmlDoc.documentElement;
const sb: string[] = [];
nodeToHtml(body, sb, imgMap, styles);
return sb.join("");
}
async function extractImages(zip: JSZip): Promise<Record<string, string>> {
const entries: [string, JSZip.JSZipObject][] = [];
zip.forEach((path, file) => {
if (/^(Pictures|images)\//i.test(path) && !file.dir) entries.push([path, file]);
});
const map: Record<string, string> = {};
for (const [path, file] of entries) {
const b64 = await file.async("base64");
const mime = IMG_MIME[path.split(".").pop()?.toLowerCase() ?? ""] ?? "application/octet-stream";
map[path] = `data:${mime};base64,${b64}`; // data URI: no blob lifecycle to manage
}
return map;
}
function esc(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function nodeToHtml(
node: Node,
sb: string[],
imgMap: Record<string, string>,
styles: OdfStyles,
): void {
if (node.nodeType === Node.TEXT_NODE) {
sb.push(esc(node.textContent ?? ""));
return;
}
if (node.nodeType !== Node.ELEMENT_NODE) return;
const el = node as Element;
const ns = el.namespaceURI;
const local = el.localName;
// Direct formatting (bold/italic/colour/alignment) lives in the style referenced
// by text:style-name; apply it inline.
const styleName =
el.getAttributeNS(NS.table, "style-name") ?? el.getAttributeNS(NS.text, "style-name") ?? "";
const css = styles.css.get(styleName) ?? "";
if (ns === NS.text) {
if (local === "p") return wrap("p", el, sb, imgMap, styles, css);
if (local === "h") {
const lvl = Math.min(6, Math.max(1, parseInt(el.getAttributeNS(NS.text, "outline-level") || "1", 10)));
return wrap(`h${lvl}`, el, sb, imgMap, styles, css);
}
if (local === "span") return wrap("span", el, sb, imgMap, styles, css);
if (local === "a") {
const href = el.getAttributeNS(NS.xlink, "href") || "#";
sb.push(`<a href="${esc(href)}" target="_blank" rel="noopener noreferrer">`);
children(el, sb, imgMap, styles);
sb.push("</a>");
return;
}
if (local === "line-break") return void sb.push("<br>");
if (local === "s") return void sb.push("&nbsp;");
if (local === "tab") return void sb.push("&emsp;");
if (local === "list") return wrap("ul", el, sb, imgMap, styles);
if (local === "list-item") return wrap("li", el, sb, imgMap, styles);
}
if (ns === NS.table) {
if (local === "table") return wrap("table", el, sb, imgMap, styles, css);
if (local === "table-row") return wrap("tr", el, sb, imgMap, styles);
if (local === "table-cell") return wrap("td", el, sb, imgMap, styles, css);
}
if (ns === NS.draw) {
if (local === "frame") return children(el, sb, imgMap, styles);
if (local === "image") {
const href = el.getAttributeNS(NS.xlink, "href") || "";
const src = imgMap[href] ?? imgMap[href.replace(/^\//, "")] ?? "";
if (src) sb.push(`<img src="${src}" style="max-width:100%;height:auto">`);
return;
}
}
children(el, sb, imgMap, styles); // anything else: recurse
}
function wrap(
tag: string,
el: Element,
sb: string[],
imgMap: Record<string, string>,
styles: OdfStyles,
css = "",
): void {
sb.push(css ? `<${tag} style="${css}">` : `<${tag}>`);
children(el, sb, imgMap, styles);
sb.push(`</${tag}>`);
}
function children(
el: Element,
sb: string[],
imgMap: Record<string, string>,
styles: OdfStyles,
): void {
for (const child of Array.from(el.childNodes)) nodeToHtml(child, sb, imgMap, styles);
}
// ── ODF spreadsheet (.ods) ──────────────────────────────────────────────────
// A grid-aware renderer: honours number-columns/rows-repeated (so cells land in
// the right columns), applies automatic cell styles (background, alignment,
// weight, border, column widths), right-aligns numbers, and trims to the used
// range so the huge trailing empty repeats aren't rendered.
const MAX_COLS = 128;
const MAX_ROWS = 2000;
interface OdfStyles {
css: Map<string, string>; // style-name -> inline CSS (text/paragraph/table-cell)
colWidth: Map<string, string>;
}
function childElemsNS(parent: Element, ns: string, local: string): Element[] {
const out: Element[] = [];
for (const c of Array.from(parent.childNodes)) {
if (c.nodeType === Node.ELEMENT_NODE) {
const e = c as Element;
if (e.namespaceURI === ns && e.localName === local) out.push(e);
}
}
return out;
}
function clampInt(v: string | null, dflt: number): number {
const n = v ? parseInt(v, 10) : NaN;
return Number.isFinite(n) && n > 0 ? n : dflt;
}
function cssBorder(v: string): string {
// Hairline widths (e.g. 0.06pt) render inconsistently; bump sub-1pt to 1px.
return v.replace(/^([\d.]+)pt/, (_m, n: string) => (parseFloat(n) < 1 ? "1px" : `${n}pt`));
}
// Build the inline CSS for a style from its text/paragraph/table-cell properties.
// Covers direct character formatting (bold/italic/underline/strike/colour/size/
// font) and paragraph alignment, plus cell background/border for spreadsheets.
function extractCss(st: Element): string {
const css: string[] = [];
const tp = childElemsNS(st, NS.style, "text-properties")[0];
if (tp) {
const fw = tp.getAttributeNS(NS.fo, "font-weight");
if (fw && fw !== "normal") css.push(`font-weight:${fw}`);
const fst = tp.getAttributeNS(NS.fo, "font-style");
if (fst && fst !== "normal") css.push(`font-style:${fst}`);
const color = tp.getAttributeNS(NS.fo, "color");
if (color) css.push(`color:${color}`);
const hl = tp.getAttributeNS(NS.fo, "background-color");
if (hl && hl !== "transparent") css.push(`background-color:${hl}`);
const size = tp.getAttributeNS(NS.fo, "font-size");
if (size) css.push(`font-size:${size}`);
const font = tp.getAttributeNS(NS.style, "font-name") || tp.getAttributeNS(NS.fo, "font-family");
if (font) css.push(`font-family:${font.replace(/"/g, "")}`);
const deco: string[] = [];
const ul = tp.getAttributeNS(NS.style, "text-underline-style");
if (ul && ul !== "none") deco.push("underline");
const lt = tp.getAttributeNS(NS.style, "text-line-through-style");
if (lt && lt !== "none") deco.push("line-through");
if (deco.length) css.push(`text-decoration:${deco.join(" ")}`);
const va = tp.getAttributeNS(NS.style, "text-position");
if (va?.startsWith("super")) css.push("vertical-align:super");
else if (va?.startsWith("sub")) css.push("vertical-align:sub");
}
const pp = childElemsNS(st, NS.style, "paragraph-properties")[0];
if (pp) {
const ta = pp.getAttributeNS(NS.fo, "text-align");
if (ta) css.push(`text-align:${ta === "start" ? "left" : ta === "end" ? "right" : ta}`);
const pbg = pp.getAttributeNS(NS.fo, "background-color");
if (pbg && pbg !== "transparent") css.push(`background-color:${pbg}`);
const pborder = pp.getAttributeNS(NS.fo, "border");
if (pborder) css.push(`border:${cssBorder(pborder)}`);
const pad = pp.getAttributeNS(NS.fo, "padding");
if (pad) css.push(`padding:${pad}`);
}
const cp = childElemsNS(st, NS.style, "table-cell-properties")[0];
if (cp) {
const bg = cp.getAttributeNS(NS.fo, "background-color");
if (bg && bg !== "transparent") css.push(`background-color:${bg}`);
const border = cp.getAttributeNS(NS.fo, "border");
if (border) css.push(`border:${cssBorder(border)}`);
const cva = cp.getAttributeNS(NS.style, "vertical-align");
if (cva && cva !== "automatic") css.push(`vertical-align:${cva}`);
}
return css.join(";");
}
function parseStyles(xmlDoc: Document): OdfStyles {
const css = new Map<string, string>();
const colWidth = new Map<string, string>();
const autos = xmlDoc.getElementsByTagNameNS(NS.office, "automatic-styles")[0];
if (!autos) return { css, colWidth };
for (const st of childElemsNS(autos, NS.style, "style")) {
const name = st.getAttributeNS(NS.style, "name");
if (!name) continue;
if (st.getAttributeNS(NS.style, "family") === "table-column") {
const cp = childElemsNS(st, NS.style, "table-column-properties")[0];
const w = cp?.getAttributeNS(NS.style, "column-width");
if (w) colWidth.set(name, w);
} else {
const c = extractCss(st);
if (c) css.set(name, c);
}
}
return { css, colWidth };
}
function cellText(cell: Element): string {
const ps = childElemsNS(cell, NS.text, "p");
if (ps.length === 0) return "";
return ps.map((p) => esc(p.textContent ?? "")).join("<br>");
}
interface GridCell {
css: string;
html: string;
empty: boolean;
}
function renderSpreadsheet(spreadsheet: Element, styles: OdfStyles): string {
const out: string[] = [
"<style>",
"table.ods-sheet{border-collapse:collapse;font-size:12px;margin:0 0 24px;table-layout:fixed}",
"table.ods-sheet td{border:1px solid #e2e2e2;padding:2px 6px;vertical-align:top;word-break:break-word}",
"</style>",
];
const tables = childElemsNS(spreadsheet, NS.table, "table");
for (const table of tables) {
const colWidths: (string | undefined)[] = [];
for (const col of childElemsNS(table, NS.table, "table-column")) {
const rep = clampInt(col.getAttributeNS(NS.table, "number-columns-repeated"), 1);
const sn = col.getAttributeNS(NS.table, "style-name");
const w = sn ? styles.colWidth.get(sn) : undefined;
for (let i = 0; i < rep && colWidths.length < MAX_COLS; i++) colWidths.push(w);
}
const grid: GridCell[][] = [];
for (const row of childElemsNS(table, NS.table, "table-row")) {
if (grid.length >= MAX_ROWS) break;
const rrep = clampInt(row.getAttributeNS(NS.table, "number-rows-repeated"), 1);
const cells: GridCell[] = [];
for (const cell of childElemsNS(row, NS.table, "table-cell")) {
if (cells.length >= MAX_COLS) break;
const crep = clampInt(cell.getAttributeNS(NS.table, "number-columns-repeated"), 1);
const sn = cell.getAttributeNS(NS.table, "style-name");
let css = sn ? (styles.css.get(sn) ?? "") : "";
const html = cellText(cell);
const vtype = cell.getAttributeNS(NS.office, "value-type");
const numeric = vtype === "float" || vtype === "currency" || vtype === "percentage";
if (html && numeric && !css.includes("text-align")) css += `${css ? ";" : ""}text-align:right`;
// Repeated cell: show content once, then empty copies to keep alignment.
cells.push({ css, html, empty: !html });
for (let i = 1; i < crep && cells.length < MAX_COLS; i++) {
cells.push({ css, html: "", empty: true });
}
}
for (let i = 0; i < Math.min(rrep, MAX_ROWS - grid.length); i++) grid.push(cells);
}
// Trim to the used range (drop the huge trailing empty repeats).
let maxCol = -1;
let maxRow = -1;
grid.forEach((r, ri) =>
r.forEach((c, ci) => {
if (!c.empty) {
if (ci > maxCol) maxCol = ci;
if (ri > maxRow) maxRow = ri;
}
}),
);
if (maxCol < 0) continue; // empty sheet
const name = table.getAttributeNS(NS.table, "name");
if (name && tables.length > 1) {
out.push(`<div style="font-weight:600;margin:12px 0 4px">${esc(name)}</div>`);
}
out.push('<table class="ods-sheet"><colgroup>');
for (let ci = 0; ci <= maxCol; ci++) {
const w = colWidths[ci];
out.push(w ? `<col style="width:${w}">` : "<col>");
}
out.push("</colgroup><tbody>");
for (let ri = 0; ri <= maxRow; ri++) {
const r = grid[ri] ?? [];
out.push("<tr>");
for (let ci = 0; ci <= maxCol; ci++) {
const c = r[ci] ?? { css: "", html: "", empty: true };
out.push(`<td${c.css ? ` style="${c.css}"` : ""}>${c.html}</td>`);
}
out.push("</tr>");
}
out.push("</tbody></table>");
}
return out.join("");
}
@@ -0,0 +1,25 @@
// Attachment-preview type helpers. Kept free of pdf.js so `Attachments` can decide
// clickability without pulling the heavy preview bundle (lazy-loaded on open).
const IMAGE_EXT = new Set(["jpg", "jpeg", "png", "gif", "webp"]);
// Office docs converted to HTML client-side (docx via mammoth, ODF via JSZip).
const OFFICE_EXT = new Set(["docx", "xlsx", "odt", "ods", "odp", "odg"]);
export function extOf(name: string): string {
return name.split(".").pop()?.toLowerCase() ?? "";
}
export function isPreviewable(name: string): boolean {
const e = extOf(name);
return e === "pdf" || e === "txt" || IMAGE_EXT.has(e) || OFFICE_EXT.has(e);
}
export type PreviewKind = "image" | "pdf" | "text" | "office";
export function kindOf(name: string): PreviewKind {
const e = extOf(name);
if (e === "pdf") return "pdf";
if (e === "txt") return "text";
if (OFFICE_EXT.has(e)) return "office";
return "image";
}
+9
View File
@@ -49,6 +49,8 @@ export const en = {
"nav.spam": "Spam",
"nav.trash": "Trash",
"nav.search": "Search mail…",
"nav.searchInTrash": "Search in Trash",
"nav.searchInSpam": "Search in Spam",
"search.save": "Save this search",
"search.saved": "Saved searches",
"search.saveNamePrompt": "Name this saved search:",
@@ -61,6 +63,10 @@ export const en = {
"mail.expandAll": "Expand all",
"mail.collapseAll": "Collapse all",
"mail.print": "Print",
"mail.previewPage": "Page {current} / {total}",
"mail.previewResetZoom": "Reset zoom",
"mail.previewPrev": "Previous",
"mail.previewNext": "Next",
"mail.orderOldest": "Oldest first",
"mail.orderNewest": "Newest first",
"filter.all": "All mail",
@@ -120,6 +126,7 @@ export const en = {
"compose.saveDraft": "Save draft",
"compose.closePrompt": "Save this message as a draft?",
"compose.sent": "Message sent.",
"compose.undoIn": "{count} seconds to",
"compose.undo": "Undo",
"compose.sendFailed": "Send failed.",
"compose.savingDraft": "Saving draft…",
@@ -156,6 +163,8 @@ export const en = {
"compose.imageTooLarge": "Image is too large (max 5 MB).",
"compose.attachTooLarge": "File exceeds the 25 MB limit.",
"compose.attachments": "Attachments",
"compose.dropAttach": "Drop to attach",
"compose.dropBody": "Drop image to insert · other files attach",
"wizard.title": "Add mail account",
"wizard.step1": "Your email address",
+4
View File
@@ -230,6 +230,10 @@ export const api = {
apiFetch<AttachmentOut[]>(`/messages/${messageId}/attachments`),
attachmentDownloadUrl: (messageId: string, attachmentId: string) =>
`/api/messages/${messageId}/attachments/${attachmentId}?download=1`,
// Inline URL (no ?download): safe types are served with their real content-type
// for in-app preview (image/pdf/text).
attachmentUrl: (messageId: string, attachmentId: string) =>
`/api/messages/${messageId}/attachments/${attachmentId}`,
attachmentsZipUrl: (messageId: string, ids?: string[]) =>
`/api/messages/${messageId}/attachments.zip${ids && ids.length ? `?ids=${ids.join(",")}` : ""}`,
uploadComposeAttachment: (file: File) => {
+37 -12
View File
@@ -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}
/>
)}
+11
View File
@@ -0,0 +1,11 @@
// xlsx-preview ships types in dist/index.d.ts but doesn't declare them in
// package.json, so TS can't find them. Minimal ambient declaration for the API
// we use: xlsx2Html(ArrayBuffer|Blob|File) -> Promise<HTML string>.
declare module "xlsx-preview" {
export function xlsx2Html(
data: ArrayBuffer | Blob | File,
options?: Record<string, unknown>,
): Promise<string | ArrayBuffer | string[]>;
const _default: { xlsx2Html: typeof xlsx2Html };
export default _default;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB