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 uuid
import zipfile import zipfile
from typing import Annotated from typing import Annotated
from urllib.parse import quote
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from fastapi.responses import HTMLResponse, Response from fastapi.responses import HTMLResponse, Response
@@ -146,6 +147,15 @@ async def message_cid(
return Response(content=data, media_type=media_type) 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]: async def _real_attachments(db: AsyncSession, message_id: uuid.UUID) -> list[Attachment]:
"""Downloadable (non-inline) attachments for a message, in a stable order.""" """Downloadable (non-inline) attachments for a message, in a stable order."""
rows = await db.scalars( rows = await db.scalars(
@@ -234,5 +244,5 @@ async def message_attachment(
return Response( return Response(
content=data, content=data,
media_type=media_type, 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: for cond in conditions:
stmt = stmt.where(cond) 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) decoded = _decode_cursor(cursor)
if decoded is not None: if decoded is not None:
cur_hit, cur_date, cur_id = decoded cur_hit, cur_date, cur_id = decoded
@@ -291,6 +302,23 @@ class PgSearchBackend(SearchBackend):
return hits, next_cursor 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]: def _positive_text_terms(groups: list[list[dict[str, object]]]) -> list[str]:
terms: list[str] = [] terms: list[str] = []
for group in groups: 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" 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: async def test_quota_reports_usage_and_default(api: ApiCtx) -> None:
resp = await api.client.get("/api/settings/quota") resp = await api.client.get("/api/settings/quota")
assert resp.status_code == 200 assert resp.status_code == 200
+23 -2
View File
@@ -86,6 +86,8 @@ async def _ingest(
body: str, body: str,
flags: frozenset[str] = frozenset(), flags: frozenset[str] = frozenset(),
attach: bool = False, attach: bool = False,
mailbox_id: uuid.UUID | None = None,
special: str = "inbox",
) -> None: ) -> None:
ctx.uid += 1 ctx.uid += 1
m = EmailMessage() m = EmailMessage()
@@ -101,8 +103,8 @@ async def _ingest(
ctx.session, ctx.session,
user_id=ctx.user_id, user_id=ctx.user_id,
account_id=ctx.account_id, account_id=ctx.account_id,
mailbox_id=ctx.mailbox_id, mailbox_id=mailbox_id or ctx.mailbox_id,
mailbox_special_use="inbox", mailbox_special_use=special,
remote=RemoteMessage( remote=RemoteMessage(
uid=ctx.uid, raw=m.as_bytes(), flags=flags, internal_date=datetime.now(UTC) 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 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: async def test_free_text(seeded: Ctx) -> None:
assert await _search(seeded, "revenue") == 1 assert await _search(seeded, "revenue") == 1
assert await _search(seeded, "lunch") == 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": { "dependencies": {
"@tanstack/react-query": "~5.51.0", "@tanstack/react-query": "~5.51.0",
"@tanstack/react-virtual": "~3.8.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", "qrcode.react": "~4.1.0",
"react": "~18.3.1", "react": "~18.3.1",
"react-dom": "~18.3.1", "react-dom": "~18.3.1",
"react-router-dom": "~6.26.0", "react-router-dom": "~6.26.0",
"xlsx-preview": "^1.0.5",
"zod": "~3.23.8" "zod": "~3.23.8"
}, },
"devDependencies": { "devDependencies": {
+124 -22
View File
@@ -142,7 +142,17 @@ export function Composer({
const [attachments, setAttachments] = useState<AttachmentOut[]>([]); const [attachments, setAttachments] = useState<AttachmentOut[]>([]);
const [attachError, setAttachError] = useState(""); const [attachError, setAttachError] = useState("");
const [status, setStatus] = useState<"" | "saving" | "saved" | "sending" | "error">(""); 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 [showClosePrompt, setShowClosePrompt] = useState(false);
const draftId = useRef<string | null>(init.draftId ?? null); const draftId = useRef<string | null>(init.draftId ?? null);
const sentRef = useRef(false); // send initiated -> don't mirror to Drafts on close 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 // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
const addFiles = async (files: FileList) => { const addFiles = async (files: FileList | File[]) => {
markDirty(); markDirty();
setAttachError(""); setAttachError("");
for (const file of Array.from(files)) { for (const file of Array.from(files)) {
@@ -246,6 +256,77 @@ export function Composer({
setAttachments((prev) => prev.filter((a) => a.id !== id)); 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 // 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 // lines above it); replies keep the quoted original in a marked block so the
// signature can later be inserted above it. // signature can later be inserted above it.
@@ -325,8 +406,12 @@ export function Composer({
if (!draftId.current) draftId.current = (await api.createDraft(payload())).id; if (!draftId.current) draftId.current = (await api.createDraft(payload())).id;
else await api.updateDraft(draftId.current, payload()); else await api.updateDraft(draftId.current, payload());
const res = await api.send(draftId.current); const res = await api.send(draftId.current);
setUndo({ outboxId: res.outbox_id }); // Drive the countdown + auto-close off the server's undo deadline.
undoTimer.current = window.setTimeout(onClose, 10000); 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) { } catch (e) {
sentRef.current = false; sentRef.current = false;
setStatus("error"); setStatus("error");
@@ -403,22 +488,37 @@ export function Composer({
if (undo) { if (undo) {
return ( 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"> <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> <span>{t("compose.sending")}</span>
<button className="font-medium text-app-accent" onClick={doUndo}> <span className="flex items-center gap-1.5">
{t("compose.undo")} {undoLeft > 0 && (
</button> <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> </div>
); );
} }
return ( return (
<div <div
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
className={`relative flex flex-col overflow-hidden border-app-border bg-app-elevated ${ className={`relative flex flex-col overflow-hidden border-app-border bg-app-elevated ${
fullWindow fullWindow
? "h-full" ? "h-full"
: `rounded-xl border shadow-2xl ${expanded ? "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 && ( {showClosePrompt && (
<div <div
className="absolute inset-0 z-40 flex items-center justify-center bg-black/40 p-4" className="absolute inset-0 z-40 flex items-center justify-center bg-black/40 p-4"
@@ -672,20 +772,22 @@ export function Composer({
</div> </div>
)} )}
{/* Body */} {/* Body — wrapped so a drop here is detected as the writing area. */}
{bodyReady ? ( <div ref={editorAreaRef} className="flex min-h-0 flex-1 flex-col">
<RichTextEditor {bodyReady ? (
ref={editorRef} <RichTextEditor
initialHtml={initialHtml} ref={editorRef}
onInput={(html) => { initialHtml={initialHtml}
markDirty(); onInput={(html) => {
setBodyHtml(html); markDirty();
}} setBodyHtml(html);
showToolbar={showToolbar} }}
/> showToolbar={showToolbar}
) : ( />
<div className="min-h-[250px] flex-1 px-3 py-3 text-sm text-app-faint">…</div> ) : (
)} <div className="min-h-[250px] flex-1 px-3 py-3 text-sm text-app-faint">…</div>
)}
</div>
</div> </div>
); );
} }
@@ -8,6 +8,8 @@ export interface RichTextEditorHandle {
replaceSignature: (html: string) => void; replaceSignature: (html: string) => void;
// Insert the signature below the body but above quoted content (reply menu). // Insert the signature below the body but above quoted content (reply menu).
insertSignature: (html: string) => void; 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 // 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 paletteRef = useRef<HTMLDivElement>(null);
const [active, setActive] = useState<Record<string, boolean>>({}); const [active, setActive] = useState<Record<string, boolean>>({});
const [colorMenu, setColorMenu] = useState<null | "fore" | "hilite">(null); 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. // Seed the editable region exactly once.
useEffect(() => { useEffect(() => {
@@ -194,6 +199,19 @@ export const RichTextEditor = forwardRef<
el.insertBefore(spacerAfter, anchor); el.insertBefore(spacerAfter, anchor);
onInput(el.innerHTML); 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 // Remember the caret/selection so native pickers (color/file) that steal focus
@@ -294,6 +312,75 @@ export const RichTextEditor = forwardRef<
reader.readAsDataURL(file); 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 = ({ const Btn = ({
onClick, onClick,
title, title,
@@ -452,18 +539,44 @@ export const RichTextEditor = forwardRef<
{ICONS.clear} {ICONS.clear}
</Btn> </Btn>
</div> </div>
<div <div className="relative flex min-h-0 flex-1 flex-col">
ref={ref} <div
contentEditable ref={ref}
suppressContentEditableWarning contentEditable
role="textbox" suppressContentEditableWarning
aria-multiline="true" role="textbox"
data-testid="rich-editor" aria-multiline="true"
onInput={(e) => onInput((e.target as HTMLDivElement).innerHTML)} data-testid="rich-editor"
onKeyUp={refreshActive} onInput={(e) => onInput((e.target as HTMLDivElement).innerHTML)}
onMouseUp={refreshActive} onKeyUp={refreshActive}
className={`wmc-editor ${minHeight} flex-1 overflow-y-auto px-3 py-3 text-sm text-app-fg outline-none`} 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> </div>
); );
}); });
+10 -4
View File
@@ -61,6 +61,7 @@ export function MessageList({
onDeletePermanent, onDeletePermanent,
onTogglePin, onTogglePin,
checkedIds, checkedIds,
dimmedIds,
onToggleCheck, onToggleCheck,
}: { }: {
threads: ThreadSummary[]; threads: ThreadSummary[];
@@ -73,6 +74,7 @@ export function MessageList({
onDeletePermanent: (id: string) => void; onDeletePermanent: (id: string) => void;
onTogglePin: (id: string, pinned: boolean) => void; onTogglePin: (id: string, pinned: boolean) => void;
checkedIds: Set<string>; checkedIds: Set<string>;
dimmedIds?: Set<string>;
onToggleCheck: (id: string) => void; onToggleCheck: (id: string) => void;
}) { }) {
const t = useT(); const t = useT();
@@ -103,6 +105,7 @@ export function MessageList({
const unread = thread.unread_count > 0; const unread = thread.unread_count > 0;
const selected = selectedId === thread.id; const selected = selectedId === thread.id;
const checked = checkedIds.has(thread.id); const checked = checkedIds.has(thread.id);
const dimmed = dimmedIds?.has(thread.id) ?? false;
const primary = thread.participants[0]; const primary = thread.participants[0];
const names = const names =
thread.participants.map((p) => p.name || p.email).join(", ") || "(no one)"; thread.participants.map((p) => p.name || p.email).join(", ") || "(no one)";
@@ -116,10 +119,11 @@ export function MessageList({
<div <div
key={thread.id} key={thread.id}
role="button" role="button"
tabIndex={0} tabIndex={dimmed ? -1 : 0}
onClick={() => onSelect(thread.id)} aria-disabled={dimmed}
onClick={() => !dimmed && onSelect(thread.id)}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") onSelect(thread.id); if (!dimmed && (e.key === "Enter" || e.key === " ")) onSelect(thread.id);
}} }}
style={{ style={{
position: "absolute", position: "absolute",
@@ -129,7 +133,9 @@ export function MessageList({
height: item.size, height: item.size,
transform: `translateY(${item.start}px)`, 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 <div
className={`flex h-full items-center gap-3 rounded-lg px-3 ${ 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" />;
}
+118 -14
View File
@@ -1,9 +1,22 @@
import { useQuery } from "@tanstack/react-query"; 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 { useT } from "../../i18n";
import { api } from "../../lib/api"; import { api } from "../../lib/api";
import type { AttachmentOut } from "../../lib/types"; 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 { function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`; if (bytes < 1024) return `${bytes} B`;
@@ -11,7 +24,9 @@ function formatSize(bytes: number): string {
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; 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 ( return (
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" aria-hidden> <svg width="26" height="26" viewBox="0 0 24 24" fill="none" aria-hidden>
<path <path
@@ -19,16 +34,77 @@ function FileGlyph() {
fill="currentColor" fill="currentColor"
opacity="0.15" opacity="0.15"
/> />
<path <path d="M13 2.5L19 8h-4.5A1.5 1.5 0 0 1 13 6.5V2.5z" fill="currentColor" opacity="0.4" />
d="M13 2.5L19 8h-4.5A1.5 1.5 0 0 1 13 6.5V2.5z" {label ? (
fill="currentColor" <text
opacity="0.4" x="11.5"
/> y="18"
<path d="M8 12.5h8M8 15.5h8M8 18.5h5" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" /> textAnchor="middle"
fontSize="6.5"
fontWeight="700"
fill="currentColor"
>
{label}
</text>
) : (
<path
d="M8 12.5h8M8 15.5h8M8 18.5h5"
stroke="currentColor"
strokeWidth="1.3"
strokeLinecap="round"
/>
)}
</svg> </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() { function DownloadGlyph() {
return ( return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden> <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 }) { export function Attachments({ messageId }: { messageId: string }) {
const t = useT(); const t = useT();
const [selected, setSelected] = useState<Set<string>>(new Set()); 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({ const { data } = useQuery({
queryKey: ["attachments", messageId], queryKey: ["attachments", messageId],
@@ -49,6 +127,12 @@ export function Attachments({ messageId }: { messageId: string }) {
const attachments = data ?? []; const attachments = data ?? [];
if (attachments.length === 0) return null; 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) => const toggle = (id: string) =>
setSelected((prev) => { setSelected((prev) => {
const next = new Set(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" className="h-3.5 w-3.5 shrink-0 accent-app-accent"
aria-label={att.filename} aria-label={att.filename}
/> />
<span className="shrink-0 text-app-accent"> <AttachmentIcon filename={att.filename} />
<FileGlyph />
</span>
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-app-fg" title={att.filename}> {isPreviewable(att.filename) ? (
{att.filename} <button
</span> 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 className="block text-xs text-app-faint">{formatSize(att.size_bytes)}</span>
</span> </span>
<a <a
@@ -114,6 +207,17 @@ export function Attachments({ messageId }: { messageId: string }) {
{t("mail.downloadAll", { count: attachments.length })} {t("mail.downloadAll", { count: attachments.length })}
</a> </a>
</div> </div>
{previewIdx !== null && (
<Suspense fallback={null}>
<AttachmentPreview
messageId={messageId}
attachments={previewable}
index={previewIdx}
onIndex={setPreviewIdx}
onClose={() => setPreviewIdx(null)}
/>
</Suspense>
)}
</div> </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.spam": "Spam",
"nav.trash": "Trash", "nav.trash": "Trash",
"nav.search": "Search mail…", "nav.search": "Search mail…",
"nav.searchInTrash": "Search in Trash",
"nav.searchInSpam": "Search in Spam",
"search.save": "Save this search", "search.save": "Save this search",
"search.saved": "Saved searches", "search.saved": "Saved searches",
"search.saveNamePrompt": "Name this saved search:", "search.saveNamePrompt": "Name this saved search:",
@@ -61,6 +63,10 @@ export const en = {
"mail.expandAll": "Expand all", "mail.expandAll": "Expand all",
"mail.collapseAll": "Collapse all", "mail.collapseAll": "Collapse all",
"mail.print": "Print", "mail.print": "Print",
"mail.previewPage": "Page {current} / {total}",
"mail.previewResetZoom": "Reset zoom",
"mail.previewPrev": "Previous",
"mail.previewNext": "Next",
"mail.orderOldest": "Oldest first", "mail.orderOldest": "Oldest first",
"mail.orderNewest": "Newest first", "mail.orderNewest": "Newest first",
"filter.all": "All mail", "filter.all": "All mail",
@@ -120,6 +126,7 @@ export const en = {
"compose.saveDraft": "Save draft", "compose.saveDraft": "Save draft",
"compose.closePrompt": "Save this message as a draft?", "compose.closePrompt": "Save this message as a draft?",
"compose.sent": "Message sent.", "compose.sent": "Message sent.",
"compose.undoIn": "{count} seconds to",
"compose.undo": "Undo", "compose.undo": "Undo",
"compose.sendFailed": "Send failed.", "compose.sendFailed": "Send failed.",
"compose.savingDraft": "Saving draft…", "compose.savingDraft": "Saving draft…",
@@ -156,6 +163,8 @@ export const en = {
"compose.imageTooLarge": "Image is too large (max 5 MB).", "compose.imageTooLarge": "Image is too large (max 5 MB).",
"compose.attachTooLarge": "File exceeds the 25 MB limit.", "compose.attachTooLarge": "File exceeds the 25 MB limit.",
"compose.attachments": "Attachments", "compose.attachments": "Attachments",
"compose.dropAttach": "Drop to attach",
"compose.dropBody": "Drop image to insert · other files attach",
"wizard.title": "Add mail account", "wizard.title": "Add mail account",
"wizard.step1": "Your email address", "wizard.step1": "Your email address",
+4
View File
@@ -230,6 +230,10 @@ export const api = {
apiFetch<AttachmentOut[]>(`/messages/${messageId}/attachments`), apiFetch<AttachmentOut[]>(`/messages/${messageId}/attachments`),
attachmentDownloadUrl: (messageId: string, attachmentId: string) => attachmentDownloadUrl: (messageId: string, attachmentId: string) =>
`/api/messages/${messageId}/attachments/${attachmentId}?download=1`, `/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[]) => attachmentsZipUrl: (messageId: string, ids?: string[]) =>
`/api/messages/${messageId}/attachments.zip${ids && ids.length ? `?ids=${ids.join(",")}` : ""}`, `/api/messages/${messageId}/attachments.zip${ids && ids.length ? `?ids=${ids.join(",")}` : ""}`,
uploadComposeAttachment: (file: File) => { 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 // Mail-list selection (checkboxes). Lives here so the reading pane can show the
// bulk-action bar / count while any conversation is selected. // bulk-action bar / count while any conversation is selected.
const [checkedIds, setCheckedIds] = useState<Set<string>>(new Set()); 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) => const toggleCheck = (id: string) =>
setCheckedIds((prev) => { setCheckedIds((prev) => {
const next = new Set(prev); const next = new Set(prev);
@@ -116,9 +119,16 @@ export function MailLayout() {
queryFn: api.remoteDrafts, queryFn: api.remoteDrafts,
enabled: isDrafts, 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({ const searchQuery = useQuery({
queryKey: ["search", debouncedTerm], queryKey: ["search", effectiveQuery],
queryFn: () => api.search(debouncedTerm), queryFn: () => api.search(effectiveQuery),
enabled: searching, enabled: searching,
}); });
const savedSearchesQuery = useQuery({ queryKey: ["saved-searches"], queryFn: api.savedSearches }); const savedSearchesQuery = useQuery({ queryKey: ["saved-searches"], queryFn: api.savedSearches });
@@ -192,6 +202,13 @@ export function MailLayout() {
setTerm(s.query); setTerm(s.query);
navigate("/mail"); 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 // 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. // 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"] : [], detail.pinned ? ["\\Flagged"] : [],
); );
queryClient.invalidateQueries({ queryKey: ["threads"] }); queryClient.invalidateQueries({ queryKey: ["threads"] });
queryClient.invalidateQueries({ queryKey: ["search"] });
queryClient.invalidateQueries({ queryKey: ["thread", detail.id] }); queryClient.invalidateQueries({ queryKey: ["thread", detail.id] });
}; };
// Per-thread actions for the mail-list hover buttons (act on a given id, not the // 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. // currently-open thread). Close the reading pane only if the acted thread is open.
const listAfterMove = (id: string) => { 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: ["threads"] });
queryClient.invalidateQueries({ queryKey: ["search"] }); // keep search results in sync
queryClient.invalidateQueries({ queryKey: ["quota"] }); queryClient.invalidateQueries({ queryKey: ["quota"] });
queryClient.invalidateQueries({ queryKey: ["folder-counts"] }); queryClient.invalidateQueries({ queryKey: ["folder-counts"] });
if (id === threadId) navigate("/mail"); if (id === threadId) navigate("/mail");
@@ -319,11 +341,13 @@ export function MailLayout() {
const listTogglePin = async (id: string, pinned: boolean) => { const listTogglePin = async (id: string, pinned: boolean) => {
await api.threadFlags(id, pinned ? [] : ["\\Flagged"], pinned ? ["\\Flagged"] : []); await api.threadFlags(id, pinned ? [] : ["\\Flagged"], pinned ? ["\\Flagged"] : []);
queryClient.invalidateQueries({ queryKey: ["threads"] }); queryClient.invalidateQueries({ queryKey: ["threads"] });
queryClient.invalidateQueries({ queryKey: ["search"] }); // pin state on search results
queryClient.invalidateQueries({ queryKey: ["thread", id] }); queryClient.invalidateQueries({ queryKey: ["thread", id] });
}; };
// Selection resets when you leave the folder. // Selection resets when you leave the folder.
useEffect(() => setCheckedIds(new Set()), [view]); useEffect(() => setCheckedIds(new Set()), [view]);
useEffect(() => setDimmedIds(new Set()), [view, debouncedTerm]);
// Bulk actions run the given operation over every selected thread, then clear the // 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). // 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() }), onReplyAll: () => setReplySignal({ kind: "reply_all", seq: Date.now() }),
onForward: () => setReplySignal({ kind: "forward", seq: Date.now() }), onForward: () => setReplySignal({ kind: "forward", seq: Date.now() }),
onArchive: archive, onArchive: archive,
onGoInbox: () => { onGoInbox: () => selectView("folder:inbox"),
setView("folder:inbox");
navigate("/mail");
},
onEscape: () => setComposing(null), onEscape: () => setComposing(null),
onNext: () => openRelative(1), onNext: () => openRelative(1),
onPrev: () => openRelative(-1), onPrev: () => openRelative(-1),
@@ -447,10 +468,7 @@ export function MailLayout() {
active={view === f.view} active={view === f.view}
collapsed={collapsed} collapsed={collapsed}
title={collapsed ? t(f.label) : undefined} title={collapsed ? t(f.label) : undefined}
onClick={() => { onClick={() => selectView(f.view)}
setView(f.view);
navigate("/mail");
}}
> >
<Icon name={f.icon} size={f.icon === "trash" ? 20 : 18} /> <Icon name={f.icon} size={f.icon === "trash" ? 20 : 18} />
{!collapsed && <span className="flex-1 truncate">{t(f.label)}</span>} {!collapsed && <span className="flex-1 truncate">{t(f.label)}</span>}
@@ -470,7 +488,7 @@ export function MailLayout() {
<SidebarItem <SidebarItem
key={a.id} key={a.id}
active={view === `account:${a.id}`} active={view === `account:${a.id}`}
onClick={() => { setView(`account:${a.id}`); navigate("/mail"); }} onClick={() => selectView(`account:${a.id}`)}
> >
<AccountDot <AccountDot
color={a.color} 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 ${ 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" 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} value={term}
onChange={(e) => setTerm(e.target.value)} onChange={(e) => setTerm(e.target.value)}
/> />
@@ -683,6 +707,7 @@ export function MailLayout() {
onDeletePermanent={listDeletePermanent} onDeletePermanent={listDeletePermanent}
onTogglePin={listTogglePin} onTogglePin={listTogglePin}
checkedIds={checkedIds} checkedIds={checkedIds}
dimmedIds={dimmedIds}
onToggleCheck={toggleCheck} 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