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:
@@ -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)},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 |
@@ -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 |
@@ -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 |
Generated
+1103
-15
File diff suppressed because it is too large
Load Diff
@@ -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": {
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">");
|
||||||
|
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" />;
|
||||||
|
}
|
||||||
@@ -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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
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(" ");
|
||||||
|
if (local === "tab") return void sb.push(" ");
|
||||||
|
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";
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Vendored
+11
@@ -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 |
Reference in New Issue
Block a user