Attachment previews, forwarded attachments, row dividers
Attachments in the reading pane are tiles with a preview: images and a PDF's
first page, rendered on the server (Pillow, pypdfium2) as WebP of at most
480 px, cached beside the blob store and in the browser. Tiles are all one
size — at most 230 px wide, narrower so three still fit in a row — and frame
each preview by its shape: close to the tile's it fills it, very tall / wide /
square ones show whole over a blurred copy, small ones are not enlarged, PDFs
show the top of the page. The name bar (checkbox, type, name, size, download)
slides in on hover and adapts to the tile's width; files without a preview
get a large type icon. Previews of opened conversations are kept offline.
Forward now carries the original's attachments: new compose attachments
sharing the stored blobs (POST /messages/{id}/forward-attachments).
Mailbox display / Settings → Appearance: "Show a dividing line between
rows" (off by default, per device), with a new switch. Settings drop the
"On a phone…" note, and "Language" reads "Spellchecking language".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import html as html_lib
|
import html as html_lib
|
||||||
import io
|
import io
|
||||||
import uuid
|
import uuid
|
||||||
@@ -19,10 +20,11 @@ from app.api.schemas import AttachmentOut
|
|||||||
from app.api.settings import add_allowed_image_sender, allowed_image_senders
|
from app.api.settings import add_allowed_image_sender, allowed_image_senders
|
||||||
from app.db import get_session
|
from app.db import get_session
|
||||||
from app.identity.deps import CurrentUser, require_csrf
|
from app.identity.deps import CurrentUser, require_csrf
|
||||||
from app.indexer import blobstore
|
from app.indexer import blobstore, thumbs
|
||||||
from app.indexer.mime import parse_message
|
from app.indexer.mime import parse_message
|
||||||
from app.indexer.sanitize import SANITIZER_VERSION, sanitize_html
|
from app.indexer.sanitize import SANITIZER_VERSION, sanitize_html
|
||||||
from app.models import Attachment, BodyPart, Message
|
from app.indexer.thumbs import thumb_type
|
||||||
|
from app.models import Attachment, BodyPart, ComposeAttachment, Message
|
||||||
|
|
||||||
router = APIRouter(prefix="/messages", tags=["mail"])
|
router = APIRouter(prefix="/messages", tags=["mail"])
|
||||||
|
|
||||||
@@ -190,6 +192,37 @@ async def list_attachments(
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{message_id}/forward-attachments",
|
||||||
|
response_model=list[AttachmentOut],
|
||||||
|
dependencies=[Depends(require_csrf)],
|
||||||
|
)
|
||||||
|
async def forward_attachments(
|
||||||
|
message_id: uuid.UUID, user: CurrentUser, db: DbSession
|
||||||
|
) -> list[AttachmentOut]:
|
||||||
|
"""Attach a message's files to a forward of it: one compose attachment per
|
||||||
|
real (non-inline) attachment, sharing the stored blob — nothing is copied.
|
||||||
|
Returns them as the composer lists its attachments."""
|
||||||
|
await _owned_message(db, user.id, message_id)
|
||||||
|
rows = [
|
||||||
|
ComposeAttachment(
|
||||||
|
user_id=user.id,
|
||||||
|
filename=a.filename,
|
||||||
|
mime_type=a.mime_type,
|
||||||
|
size_bytes=a.size_bytes,
|
||||||
|
blob_sha256=a.blob_sha256,
|
||||||
|
)
|
||||||
|
for a in await _real_attachments(db, message_id)
|
||||||
|
if blobstore.exists(user.id, a.blob_sha256)
|
||||||
|
]
|
||||||
|
db.add_all(rows)
|
||||||
|
await db.commit()
|
||||||
|
return [
|
||||||
|
AttachmentOut(id=r.id, filename=r.filename, mime_type=r.mime_type, size_bytes=r.size_bytes)
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{message_id}/attachments.zip")
|
@router.get("/{message_id}/attachments.zip")
|
||||||
async def download_attachments_zip(
|
async def download_attachments_zip(
|
||||||
message_id: uuid.UUID,
|
message_id: uuid.UUID,
|
||||||
@@ -232,6 +265,33 @@ async def download_attachments_zip(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{message_id}/attachments/{attachment_id}/thumb")
|
||||||
|
async def attachment_thumbnail(
|
||||||
|
message_id: uuid.UUID, attachment_id: uuid.UUID, user: CurrentUser, db: DbSession
|
||||||
|
) -> Response:
|
||||||
|
"""A small preview of an image or a PDF's first page (WebP, at most
|
||||||
|
480 px, aspect kept). 404 when the file cannot have one: the reading pane
|
||||||
|
then shows its type icon instead."""
|
||||||
|
msg = await _owned_message(db, user.id, message_id)
|
||||||
|
att = await db.scalar(
|
||||||
|
select(Attachment).where(
|
||||||
|
Attachment.id == attachment_id, Attachment.message_id == message_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
kind = thumb_type(att.mime_type, att.filename) if att is not None else None
|
||||||
|
if att is None or kind is None:
|
||||||
|
raise_api_error(404, "not_found", "no thumbnail")
|
||||||
|
data = await asyncio.to_thread(thumbs.thumbnail, msg.user_id, att.blob_sha256, kind)
|
||||||
|
if data is None:
|
||||||
|
raise_api_error(404, "not_found", "no thumbnail")
|
||||||
|
# An attachment never changes under its id: the browser may keep this.
|
||||||
|
return Response(
|
||||||
|
content=data,
|
||||||
|
media_type="image/webp",
|
||||||
|
headers={"Cache-Control": "private, max-age=31536000, immutable"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{message_id}/attachments/{attachment_id}")
|
@router.get("/{message_id}/attachments/{attachment_id}")
|
||||||
async def message_attachment(
|
async def message_attachment(
|
||||||
message_id: uuid.UUID,
|
message_id: uuid.UUID,
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
"""Attachment thumbnails for the reading pane: images and a PDF's first page.
|
||||||
|
|
||||||
|
A thumbnail fits within THUMB_MAX × THUMB_MAX with its aspect ratio kept (the
|
||||||
|
browser decides how to frame it), as WebP. Made once and kept beside the blob
|
||||||
|
store — ``{BLOB_ROOT}/{user}/thumbs/{sha}-{THUMB_MAX}.webp`` — with a ``.none``
|
||||||
|
marker for files that cannot have one (a broken image, an encrypted PDF, HEIC),
|
||||||
|
so neither is attempted twice.
|
||||||
|
|
||||||
|
Attachments are hostile input: decoding runs in a worker thread, oversized
|
||||||
|
files and decompression bombs are refused.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.indexer import blobstore
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
THUMB_MAX = 480 # px: sharp on a 2× screen for a tile of up to 230 px
|
||||||
|
_MAX_INPUT_BYTES = 40 * 1024 * 1024
|
||||||
|
_MAX_PIXELS = 64_000_000 # a larger image is refused, not decoded
|
||||||
|
_IMAGE_MIME = frozenset(
|
||||||
|
{"image/jpeg", "image/png", "image/gif", "image/webp", "image/bmp", "image/tiff"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_EXT_MIME = {
|
||||||
|
"jpg": "image/jpeg",
|
||||||
|
"jpeg": "image/jpeg",
|
||||||
|
"png": "image/png",
|
||||||
|
"gif": "image/gif",
|
||||||
|
"webp": "image/webp",
|
||||||
|
"bmp": "image/bmp",
|
||||||
|
"tif": "image/tiff",
|
||||||
|
"tiff": "image/tiff",
|
||||||
|
"pdf": "application/pdf",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def thumb_type(mime_type: str, filename: str) -> str | None:
|
||||||
|
"""The type to render a thumbnail as, or None. Senders often label files
|
||||||
|
application/octet-stream, so the extension stands in for a missing type."""
|
||||||
|
mime = mime_type.lower()
|
||||||
|
if mime in _IMAGE_MIME or mime == "application/pdf":
|
||||||
|
return mime
|
||||||
|
return _EXT_MIME.get(filename.rsplit(".", 1)[-1].lower()) if "." in filename else None
|
||||||
|
|
||||||
|
|
||||||
|
def can_thumbnail(mime_type: str) -> bool:
|
||||||
|
return mime_type in _IMAGE_MIME or mime_type == "application/pdf"
|
||||||
|
|
||||||
|
|
||||||
|
def _path(user_id: uuid.UUID, sha: str, suffix: str) -> Path:
|
||||||
|
return blobstore.blob_path(user_id, sha).parent.parent / "thumbs" / f"{sha}-{THUMB_MAX}{suffix}"
|
||||||
|
|
||||||
|
|
||||||
|
def _render(data: bytes, mime_type: str) -> bytes | None:
|
||||||
|
from PIL import Image, ImageOps
|
||||||
|
|
||||||
|
Image.MAX_IMAGE_PIXELS = _MAX_PIXELS # beyond: DecompressionBombError
|
||||||
|
if mime_type == "application/pdf":
|
||||||
|
import pypdfium2 as pdfium # type: ignore[import-untyped]
|
||||||
|
|
||||||
|
doc = pdfium.PdfDocument(data)
|
||||||
|
try:
|
||||||
|
page = doc[0]
|
||||||
|
width, height = page.get_size()
|
||||||
|
scale = THUMB_MAX / max(width, height, 1)
|
||||||
|
img = page.render(scale=scale, draw_annots=True).to_pil()
|
||||||
|
finally:
|
||||||
|
doc.close()
|
||||||
|
else:
|
||||||
|
img = Image.open(io.BytesIO(data))
|
||||||
|
img.seek(0) # an animation: its first frame
|
||||||
|
img = ImageOps.exif_transpose(img) # phone photos: upright
|
||||||
|
img.thumbnail((THUMB_MAX, THUMB_MAX))
|
||||||
|
if img.mode not in ("RGB", "RGBA"):
|
||||||
|
img = img.convert("RGBA" if "A" in img.getbands() or img.mode == "P" else "RGB")
|
||||||
|
out = io.BytesIO()
|
||||||
|
img.save(out, "WEBP", quality=80, method=4)
|
||||||
|
return out.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def thumbnail(user_id: uuid.UUID, sha: str, mime_type: str) -> bytes | None:
|
||||||
|
"""The thumbnail of a stored attachment, or None if it cannot have one.
|
||||||
|
|
||||||
|
Blocking (decoding is CPU work): call it in a thread.
|
||||||
|
"""
|
||||||
|
done = _path(user_id, sha, ".webp")
|
||||||
|
if done.exists():
|
||||||
|
return done.read_bytes()
|
||||||
|
none = _path(user_id, sha, ".none")
|
||||||
|
if none.exists() or not can_thumbnail(mime_type):
|
||||||
|
return None
|
||||||
|
path = blobstore.blob_path(user_id, sha)
|
||||||
|
if not path.exists() or path.stat().st_size > _MAX_INPUT_BYTES:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
thumb = _render(path.read_bytes(), mime_type)
|
||||||
|
except Exception as exc: # noqa: BLE001 - any undecodable file: no thumbnail
|
||||||
|
logger.info("no thumbnail for blob %s (%s): %s", sha, mime_type, exc)
|
||||||
|
thumb = None
|
||||||
|
done.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if thumb is None:
|
||||||
|
none.touch()
|
||||||
|
return None
|
||||||
|
tmp = done.with_suffix(".tmp")
|
||||||
|
tmp.write_bytes(thumb)
|
||||||
|
tmp.replace(done)
|
||||||
|
return thumb
|
||||||
@@ -36,6 +36,11 @@ dependencies = [
|
|||||||
# DEPENDENCY: autoconfig/ISPDB return XML from untrusted servers; defusedxml
|
# DEPENDENCY: autoconfig/ISPDB return XML from untrusted servers; defusedxml
|
||||||
# blocks XXE/billion-laughs that stdlib ElementTree is vulnerable to (§10 hostile input).
|
# blocks XXE/billion-laughs that stdlib ElementTree is vulnerable to (§10 hostile input).
|
||||||
"defusedxml~=0.7",
|
"defusedxml~=0.7",
|
||||||
|
# DEPENDENCY: thumbnails of attachments in the reading pane — Pillow scales
|
||||||
|
# images; pypdfium2 renders a PDF's first page (a self-contained wheel, no
|
||||||
|
# system libraries, so the slim runtime image needs nothing extra).
|
||||||
|
"pillow~=11.0",
|
||||||
|
"pypdfium2~=4.30",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
@@ -475,3 +475,133 @@ async def test_thread_pagination_offset(api: ApiCtx) -> None:
|
|||||||
assert len(body2["threads"]) == 1
|
assert len(body2["threads"]) == 1
|
||||||
assert body2["total"] == body["total"] # grand total is stable across pages
|
assert body2["total"] == body["total"] # grand total is stable across pages
|
||||||
assert body2["threads"][0]["id"] != body["threads"][0]["id"]
|
assert body2["threads"][0]["id"] != body["threads"][0]["id"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_forward_carries_the_attachments(api: ApiCtx) -> None:
|
||||||
|
# Forwarding attaches the original's files (not its inline images), sharing
|
||||||
|
# the stored blob — and sending the forward then really includes them.
|
||||||
|
from app.indexer import blobstore
|
||||||
|
from app.models import Attachment, ComposeAttachment, Draft
|
||||||
|
from app.outbox.attachments import load_attachments
|
||||||
|
|
||||||
|
assert api.session is not None
|
||||||
|
mid = api.message_ids[-1]
|
||||||
|
sha = blobstore.store(api.user_id, b"%PDF-1.4 quarterly numbers")
|
||||||
|
logo = blobstore.store(api.user_id, b"\x89PNG logo")
|
||||||
|
api.session.add_all(
|
||||||
|
[
|
||||||
|
Attachment(
|
||||||
|
message_id=mid,
|
||||||
|
user_id=api.user_id,
|
||||||
|
filename="numbers.pdf",
|
||||||
|
mime_type="application/pdf",
|
||||||
|
size_bytes=26,
|
||||||
|
content_id=None,
|
||||||
|
blob_sha256=sha,
|
||||||
|
),
|
||||||
|
Attachment(
|
||||||
|
message_id=mid,
|
||||||
|
user_id=api.user_id,
|
||||||
|
filename="logo.png",
|
||||||
|
mime_type="image/png",
|
||||||
|
size_bytes=9,
|
||||||
|
content_id="logo@x", # inline: part of the body, not a file
|
||||||
|
blob_sha256=logo,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
await api.session.commit()
|
||||||
|
|
||||||
|
api.client.cookies.set("csrf", "t") # a POST: double-submit CSRF
|
||||||
|
resp = await api.client.post(
|
||||||
|
f"/api/messages/{mid}/forward-attachments", headers={"X-CSRF": "t"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert [(a["filename"], a["size_bytes"]) for a in body] == [("numbers.pdf", 26)]
|
||||||
|
row = await api.session.get(ComposeAttachment, uuid.UUID(body[0]["id"]))
|
||||||
|
assert row is not None and row.blob_sha256 == sha and row.user_id == api.user_id
|
||||||
|
|
||||||
|
draft = Draft(
|
||||||
|
user_id=api.user_id,
|
||||||
|
account_id=api.account_id,
|
||||||
|
kind="forward",
|
||||||
|
payload={
|
||||||
|
"to": ["bob@example.com"],
|
||||||
|
"subject": "Fwd: Lunch",
|
||||||
|
"attachment_ids": [body[0]["id"]],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert await load_attachments(api.session, api.user_id, draft) == [
|
||||||
|
("numbers.pdf", "application/pdf", b"%PDF-1.4 quarterly numbers")
|
||||||
|
]
|
||||||
|
|
||||||
|
# Someone else's message: not found.
|
||||||
|
other = await api.client.post(
|
||||||
|
f"/api/messages/{uuid.uuid4()}/forward-attachments", headers={"X-CSRF": "t"}
|
||||||
|
)
|
||||||
|
assert other.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
async def test_attachment_thumbnails(api: ApiCtx) -> None:
|
||||||
|
# Images and a PDF's first page get a WebP preview (aspect kept, at most
|
||||||
|
# 480 px, browser-cacheable); other files, and broken ones, get a 404.
|
||||||
|
import io
|
||||||
|
|
||||||
|
import pypdfium2 as pdfium # type: ignore[import-untyped]
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from app.indexer import blobstore
|
||||||
|
from app.models import Attachment
|
||||||
|
|
||||||
|
assert api.session is not None
|
||||||
|
mid = api.message_ids[-1]
|
||||||
|
|
||||||
|
def png(w: int, h: int) -> bytes:
|
||||||
|
out = io.BytesIO()
|
||||||
|
Image.new("RGB", (w, h), (200, 30, 30)).save(out, "PNG")
|
||||||
|
return out.getvalue()
|
||||||
|
|
||||||
|
pdf = pdfium.PdfDocument.new()
|
||||||
|
pdf.new_page(595, 842)
|
||||||
|
pdf_bytes = io.BytesIO()
|
||||||
|
pdf.save(pdf_bytes)
|
||||||
|
|
||||||
|
files = {
|
||||||
|
"wide.png": ("image/png", png(1600, 400)),
|
||||||
|
"scan.pdf": ("application/octet-stream", pdf_bytes.getvalue()), # mislabelled
|
||||||
|
"notes.txt": ("text/plain", b"hello"),
|
||||||
|
"broken.jpg": ("image/jpeg", b"not really a jpeg"),
|
||||||
|
}
|
||||||
|
ids: dict[str, uuid.UUID] = {}
|
||||||
|
for name, (mime, data) in files.items():
|
||||||
|
att = Attachment(
|
||||||
|
message_id=mid,
|
||||||
|
user_id=api.user_id,
|
||||||
|
filename=name,
|
||||||
|
mime_type=mime,
|
||||||
|
size_bytes=len(data),
|
||||||
|
content_id=None,
|
||||||
|
blob_sha256=blobstore.store(api.user_id, data),
|
||||||
|
)
|
||||||
|
api.session.add(att)
|
||||||
|
await api.session.flush()
|
||||||
|
ids[name] = att.id
|
||||||
|
await api.session.commit()
|
||||||
|
|
||||||
|
def url(name: str) -> str:
|
||||||
|
return f"/api/messages/{mid}/attachments/{ids[name]}/thumb"
|
||||||
|
|
||||||
|
wide = await api.client.get(url("wide.png"))
|
||||||
|
assert wide.status_code == 200 and wide.headers["content-type"] == "image/webp"
|
||||||
|
assert "immutable" in wide.headers["cache-control"]
|
||||||
|
assert Image.open(io.BytesIO(wide.content)).size == (480, 120) # aspect kept
|
||||||
|
assert (await api.client.get(url("wide.png"))).content == wide.content # cached
|
||||||
|
|
||||||
|
page = await api.client.get(url("scan.pdf"))
|
||||||
|
assert page.status_code == 200
|
||||||
|
w, h = Image.open(io.BytesIO(page.content)).size
|
||||||
|
assert h == 480 and 330 < w < 350 # an A4 page, upright
|
||||||
|
|
||||||
|
assert (await api.client.get(url("notes.txt"))).status_code == 404
|
||||||
|
assert (await api.client.get(url("broken.jpg"))).status_code == 404
|
||||||
|
|||||||
@@ -51,7 +51,9 @@ const META = new Set([
|
|||||||
]);
|
]);
|
||||||
const ID = "[0-9a-fA-F-]{36}";
|
const ID = "[0-9a-fA-F-]{36}";
|
||||||
const THREAD_RE = new RegExp(`^/api/threads/(${ID})$`);
|
const THREAD_RE = new RegExp(`^/api/threads/(${ID})$`);
|
||||||
const MESSAGE_RE = new RegExp(`^/api/messages/(${ID})/(?:html|attachments|cid/.+)$`);
|
const MESSAGE_RE = new RegExp(
|
||||||
|
`^/api/messages/(${ID})/(?:html|attachments|attachments/${ID}/thumb|cid/.+)$`,
|
||||||
|
);
|
||||||
const ID_IN_PATH = new RegExp(ID);
|
const ID_IN_PATH = new RegExp(ID);
|
||||||
const PLUGIN_UI_RE = /^\/api\/plugins\/[^/]+\/ui\//; // plugin bundles (Labels' sidebar…)
|
const PLUGIN_UI_RE = /^\/api\/plugins\/[^/]+\/ui\//; // plugin bundles (Labels' sidebar…)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// An on/off switch: off shows a dark knob with ✕ on a grey track, on a light
|
||||||
|
// knob with ✓ on the accent colour.
|
||||||
|
export function Switch({
|
||||||
|
checked,
|
||||||
|
onChange,
|
||||||
|
label,
|
||||||
|
disabled = false,
|
||||||
|
}: {
|
||||||
|
checked: boolean;
|
||||||
|
onChange: (value: boolean) => void;
|
||||||
|
/** Accessible name, when no visible <label> points at the switch. */
|
||||||
|
label?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={checked}
|
||||||
|
aria-label={label}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => onChange(!checked)}
|
||||||
|
className={`relative h-6 w-11 shrink-0 rounded-full border transition-colors disabled:opacity-50 ${
|
||||||
|
checked ? "border-app-accent bg-app-accent" : "border-app-border bg-app-hover"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className={`absolute top-1/2 flex h-[18px] w-[18px] -translate-y-1/2 items-center justify-center rounded-full transition-all ${
|
||||||
|
checked ? "left-[21px] bg-white text-app-accent" : "left-[2px] bg-app-fg text-app-bg"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
{checked ? <path d="M2 5.2l2 2 4-4.4" /> : <path d="M2.8 2.8l4.4 4.4M7.2 2.8l-4.4 4.4" />}
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -40,6 +40,8 @@ export interface ComposerInit {
|
|||||||
// A reply/forward quotes the original: this attribution line sits above the
|
// A reply/forward quotes the original: this attribution line sits above the
|
||||||
// quoted message body (fetched from the original message's sanitized HTML).
|
// quoted message body (fetched from the original message's sanitized HTML).
|
||||||
quote?: { attribution: string; messageId: string };
|
quote?: { attribution: string; messageId: string };
|
||||||
|
// A forward: the message whose attachments go along with it.
|
||||||
|
forwardFrom?: string;
|
||||||
// A message kept on this device (written offline; lib/scheduled.ts).
|
// A message kept on this device (written offline; lib/scheduled.ts).
|
||||||
localId?: string;
|
localId?: string;
|
||||||
localState?: LocalState; // "scheduled" when reopened from Scheduled
|
localState?: LocalState; // "scheduled" when reopened from Scheduled
|
||||||
@@ -336,6 +338,25 @@ export function Composer({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// A forward carries the original's attachments (shared on the server, not
|
||||||
|
// re-uploaded). Once per window: StrictMode runs effects twice in dev, so a
|
||||||
|
// ref — not a cancel flag — keeps it from attaching them twice.
|
||||||
|
const forwardedFrom = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
const source = init.forwardFrom;
|
||||||
|
if (!source || init.draftId || forwardedFrom.current) return;
|
||||||
|
forwardedFrom.current = true;
|
||||||
|
void api
|
||||||
|
.forwardAttachments(source)
|
||||||
|
.then((rows) => {
|
||||||
|
if (rows.length) setAttachments((prev) => [...rows, ...prev]);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (!isOfflineError(e)) setAttachError(t("compose.forwardAttachFailed"));
|
||||||
|
});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
const addFiles = async (files: FileList | File[]) => {
|
const addFiles = async (files: FileList | File[]) => {
|
||||||
markDirty();
|
markDirty();
|
||||||
setAttachError("");
|
setAttachError("");
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useEffect, useRef } from "react";
|
|||||||
|
|
||||||
import { useT } from "../../i18n";
|
import { useT } from "../../i18n";
|
||||||
import { api } from "../../lib/api";
|
import { api } from "../../lib/api";
|
||||||
|
import { useListDividers } from "../../lib/listDividers";
|
||||||
import { useSavedThreads } from "../../lib/offline";
|
import { useSavedThreads } from "../../lib/offline";
|
||||||
import { formatClock, getDateLocale } from "../../lib/timeFormat";
|
import { formatClock, getDateLocale } from "../../lib/timeFormat";
|
||||||
import type { Address, ThreadSummary } from "../../lib/types";
|
import type { Address, ThreadSummary } from "../../lib/types";
|
||||||
@@ -140,6 +141,8 @@ export function MessageList({
|
|||||||
const labelMap = labelsQuery.data ?? {};
|
const labelMap = labelsQuery.data ?? {};
|
||||||
// Offline: conversations not saved on this device are shown faded.
|
// Offline: conversations not saved on this device are shown faded.
|
||||||
const saved = useSavedThreads();
|
const saved = useSavedThreads();
|
||||||
|
// Optional thin line between rows (Mailbox display).
|
||||||
|
const dividers = useListDividers();
|
||||||
|
|
||||||
// The user's own addresses, shown as "me" in the sender column.
|
// The user's own addresses, shown as "me" in the sender column.
|
||||||
const accounts = useQuery({ queryKey: ["accounts"], queryFn: api.accounts });
|
const accounts = useQuery({ queryKey: ["accounts"], queryFn: api.accounts });
|
||||||
@@ -212,6 +215,15 @@ export function MessageList({
|
|||||||
dimmed ? "pointer-events-none opacity-40" : "cursor-pointer"
|
dimmed ? "pointer-events-none opacity-40" : "cursor-pointer"
|
||||||
} ${unsaved ? "opacity-45" : ""}`}
|
} ${unsaved ? "opacity-45" : ""}`}
|
||||||
>
|
>
|
||||||
|
{dividers && item.index > 0 && (
|
||||||
|
// Midway through the gap above the row, lined up with its content.
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className={`pointer-events-none absolute inset-x-5 h-px bg-app-border ${
|
||||||
|
compact ? "top-2.5" : "top-[3px]"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<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 ${
|
||||||
selected || checked ? "bg-app-active" : "group-hover:bg-app-hover"
|
selected || checked ? "bg-app-active" : "group-hover:bg-app-hover"
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { useT } from "../../i18n";
|
||||||
|
import { setListDividers, useListDividers } from "../../lib/listDividers";
|
||||||
|
import { Switch } from "../Switch";
|
||||||
|
|
||||||
|
/** "Show a dividing line between rows" — under the layout choice, in the ⋯
|
||||||
|
* menu's Mailbox display and in Settings → Appearance. Applies at once. */
|
||||||
|
export function ListDividersOption() {
|
||||||
|
const t = useT();
|
||||||
|
const on = useListDividers();
|
||||||
|
return (
|
||||||
|
<label className="flex cursor-pointer items-center justify-between gap-3 text-[14px] text-app-fg">
|
||||||
|
<span>{t("display.dividers")}</span>
|
||||||
|
<Switch checked={on} onChange={setListDividers} label={t("display.dividers")} />
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
import { useT } from "../../i18n";
|
import { useT } from "../../i18n";
|
||||||
|
import { ListDividersOption } from "./ListDividersOption";
|
||||||
import { ReadingPanePicker } from "./ReadingPanePicker";
|
import { ReadingPanePicker } from "./ReadingPanePicker";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* "Mailbox display": the reading-pane choice, opened from the list's ⋯ menu.
|
* "Mailbox display": the reading-pane choice and the row dividers, opened from
|
||||||
* Anchored below that menu. Picking a layout applies it and closes the popover;
|
* the list's ⋯ menu. Anchored below that menu. Picking a layout applies it and
|
||||||
* ✕, Escape or a click elsewhere closes it without a change.
|
* closes the popover; the dividers switch applies at once and leaves it open;
|
||||||
|
* ✕, Escape or a click elsewhere closes it.
|
||||||
*/
|
*/
|
||||||
export function MailboxDisplay({ onClose }: { onClose: () => void }) {
|
export function MailboxDisplay({ onClose }: { onClose: () => void }) {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
@@ -48,6 +50,9 @@ export function MailboxDisplay({ onClose }: { onClose: () => void }) {
|
|||||||
</div>
|
</div>
|
||||||
<p className="mb-2 text-[13px] font-medium text-app-fg">{t("display.readingPane")}</p>
|
<p className="mb-2 text-[13px] font-medium text-app-fg">{t("display.readingPane")}</p>
|
||||||
<ReadingPanePicker onPick={onClose} />
|
<ReadingPanePicker onPick={onClose} />
|
||||||
|
<div className="mt-4 border-t border-app-border pt-3">
|
||||||
|
<ListDividersOption />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,6 +113,172 @@ function DownloadGlyph() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- tiles ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Every tile is the same size: at most 230 px wide, and narrower when the
|
||||||
|
// reading column is, so at least three still fit in a row (gap-2: 2 × 8 px).
|
||||||
|
const TILE_WIDTH = "min(230px, calc((100% - 16px) / 3))";
|
||||||
|
const TILE_RATIO = 230 / 146;
|
||||||
|
|
||||||
|
const THUMB_EXT = new Set(["jpg", "jpeg", "png", "gif", "webp", "bmp", "tif", "tiff", "pdf"]);
|
||||||
|
|
||||||
|
/** How a thumbnail sits in its tile, decided once it has loaded. */
|
||||||
|
type Fit =
|
||||||
|
| "loading"
|
||||||
|
| "cover" // close to the tile's shape: fill it, trimming a little
|
||||||
|
| "whole" // very tall / wide / square-ish: all of it, over a blurred copy
|
||||||
|
| "small" // smaller than the tile: as is, not blown up
|
||||||
|
| "page" // a PDF: the top of its first page, like a document
|
||||||
|
| "none"; // no thumbnail: the type icon instead
|
||||||
|
|
||||||
|
function fitFor(img: HTMLImageElement, pdf: boolean, tileWidth: number): Fit {
|
||||||
|
if (pdf) return "page";
|
||||||
|
const w = img.naturalWidth;
|
||||||
|
const h = img.naturalHeight;
|
||||||
|
if (!w || !h) return "none";
|
||||||
|
// The server keeps the original size when it is smaller than its 480 px
|
||||||
|
// limit: smaller than the tile itself means a small picture (an icon, a logo).
|
||||||
|
if (w < tileWidth && h < tileWidth / TILE_RATIO) return "small";
|
||||||
|
const ratio = w / h;
|
||||||
|
return ratio >= TILE_RATIO / 1.5 && ratio <= TILE_RATIO * 1.5 ? "cover" : "whole";
|
||||||
|
}
|
||||||
|
|
||||||
|
function AttachmentTile({
|
||||||
|
messageId,
|
||||||
|
att,
|
||||||
|
checked,
|
||||||
|
onToggle,
|
||||||
|
onOpen,
|
||||||
|
}: {
|
||||||
|
messageId: string;
|
||||||
|
att: AttachmentOut;
|
||||||
|
checked: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
/** Open the in-app preview; absent for types it cannot show (they download). */
|
||||||
|
onOpen?: () => void;
|
||||||
|
}) {
|
||||||
|
const t = useT();
|
||||||
|
const ext = extOf(att.filename);
|
||||||
|
const pdf = ext === "pdf";
|
||||||
|
const [fit, setFit] = useState<Fit>(THUMB_EXT.has(ext) ? "loading" : "none");
|
||||||
|
const thumb = fit !== "none";
|
||||||
|
const src = api.attachmentThumbUrl(messageId, att.id);
|
||||||
|
const download = api.attachmentDownloadUrl(messageId, att.id);
|
||||||
|
|
||||||
|
const open = () => {
|
||||||
|
if (onOpen) onOpen();
|
||||||
|
else window.location.href = download;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={att.filename}
|
||||||
|
title={att.filename}
|
||||||
|
onClick={open}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
open();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{ width: TILE_WIDTH, aspectRatio: `${TILE_RATIO}` }}
|
||||||
|
className={`wm-tile group/tile relative cursor-pointer overflow-hidden rounded-lg border bg-app-surface focus:outline-none focus-visible:ring-2 focus-visible:ring-app-accent ${
|
||||||
|
checked ? "border-app-accent ring-1 ring-app-accent" : "border-app-border"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{thumb ? (
|
||||||
|
<div className={`absolute inset-0 ${pdf ? "bg-white" : "bg-app-hover"}`}>
|
||||||
|
{fit === "whole" && (
|
||||||
|
// The same picture, blurred, filling the space beside it.
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt=""
|
||||||
|
aria-hidden
|
||||||
|
className="absolute inset-0 h-full w-full scale-110 object-cover opacity-60 blur-lg"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
draggable={false}
|
||||||
|
onLoad={(e) => {
|
||||||
|
const tile = e.currentTarget.closest<HTMLElement>("[role=button]");
|
||||||
|
setFit(fitFor(e.currentTarget, pdf, tile?.clientWidth ?? 230));
|
||||||
|
}}
|
||||||
|
onError={() => setFit("none")}
|
||||||
|
className={`relative h-full w-full transition-opacity ${
|
||||||
|
fit === "loading" ? "opacity-0" : "opacity-100"
|
||||||
|
} ${
|
||||||
|
fit === "cover"
|
||||||
|
? "object-cover"
|
||||||
|
: fit === "page"
|
||||||
|
? "object-cover object-top"
|
||||||
|
: fit === "small"
|
||||||
|
? "object-scale-down"
|
||||||
|
: "object-contain"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// No picture: the file type, large, above the name bar.
|
||||||
|
<div className="wm-tile-art absolute inset-x-0 top-0 bottom-[46px] flex items-center justify-center bg-app-hover/60 [&_svg]:h-11 [&_svg]:w-11">
|
||||||
|
<AttachmentIcon filename={att.filename} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Name bar: over a picture it shows on hover (or when ticked / focused);
|
||||||
|
a tile without one always shows it. */}
|
||||||
|
<div
|
||||||
|
className={`wm-tile-bar absolute inset-x-0 bottom-0 flex h-[46px] items-center gap-2 px-2.5 transition-opacity ${
|
||||||
|
thumb
|
||||||
|
? `bg-black/70 text-white backdrop-blur-sm ${
|
||||||
|
checked ? "opacity-100" : "opacity-0 group-hover/tile:opacity-100 group-focus-within/tile:opacity-100"
|
||||||
|
}`
|
||||||
|
: "border-t border-app-border bg-app-surface text-app-fg"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={onToggle}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="h-3.5 w-3.5 shrink-0 accent-app-accent"
|
||||||
|
aria-label={att.filename}
|
||||||
|
/>
|
||||||
|
<span className="wm-tile-icon shrink-0 [&_svg]:h-5 [&_svg]:w-5">
|
||||||
|
<AttachmentIcon filename={att.filename} />
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1 leading-tight">
|
||||||
|
<span
|
||||||
|
className={`block truncate text-[13px] font-medium ${
|
||||||
|
onOpen ? (thumb ? "text-sky-300" : "text-app-accent") : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{att.filename}
|
||||||
|
</span>
|
||||||
|
<span className={`wm-tile-size block text-[11px] ${thumb ? "text-white/70" : "text-app-faint"}`}>
|
||||||
|
{formatSize(att.size_bytes)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<a
|
||||||
|
href={download}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
title={t("mail.download")}
|
||||||
|
aria-label={`${t("mail.download")} ${att.filename}`}
|
||||||
|
className={`shrink-0 rounded p-1 ${
|
||||||
|
thumb ? "text-white/80 hover:bg-white/15 hover:text-white" : "text-app-muted hover:bg-app-hover hover:text-app-fg"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<DownloadGlyph />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
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());
|
||||||
@@ -158,44 +324,14 @@ export function Attachments({ messageId }: { messageId: string }) {
|
|||||||
)}
|
)}
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{attachments.map((att: AttachmentOut) => (
|
{attachments.map((att: AttachmentOut) => (
|
||||||
<div
|
<AttachmentTile
|
||||||
key={att.id}
|
key={att.id}
|
||||||
className="flex min-w-[13rem] max-w-[18rem] items-center gap-2 rounded-md border border-app-border bg-app-surface px-2.5 py-2"
|
messageId={messageId}
|
||||||
>
|
att={att}
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={selected.has(att.id)}
|
checked={selected.has(att.id)}
|
||||||
onChange={() => toggle(att.id)}
|
onToggle={() => toggle(att.id)}
|
||||||
className="h-3.5 w-3.5 shrink-0 accent-app-accent"
|
onOpen={isPreviewable(att.filename) ? () => openPreview(att.id) : undefined}
|
||||||
aria-label={att.filename}
|
|
||||||
/>
|
/>
|
||||||
<AttachmentIcon filename={att.filename} />
|
|
||||||
<span className="min-w-0 flex-1">
|
|
||||||
{isPreviewable(att.filename) ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => openPreview(att.id)}
|
|
||||||
title={att.filename}
|
|
||||||
className="block w-full truncate text-left text-sm font-medium text-app-accent hover:underline"
|
|
||||||
>
|
|
||||||
{att.filename}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<span className="block truncate text-sm font-medium text-app-fg" title={att.filename}>
|
|
||||||
{att.filename}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="block text-xs text-app-faint">{formatSize(att.size_bytes)}</span>
|
|
||||||
</span>
|
|
||||||
<a
|
|
||||||
href={api.attachmentDownloadUrl(messageId, att.id)}
|
|
||||||
title={t("mail.download")}
|
|
||||||
aria-label={`${t("mail.download")} ${att.filename}`}
|
|
||||||
className="shrink-0 rounded p-1.5 text-app-muted hover:bg-app-hover hover:text-app-fg"
|
|
||||||
>
|
|
||||||
<DownloadGlyph />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ function buildReplyInit(subject: string, msg: MessageEnvelope, kind: string): Co
|
|||||||
kind,
|
kind,
|
||||||
subject: `Fwd: ${norm}`,
|
subject: `Fwd: ${norm}`,
|
||||||
quote,
|
quote,
|
||||||
|
forwardFrom: msg.id,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const to = kind === "reply_all" ? [from, ...toEmails].filter(Boolean).join(", ") : from;
|
const to = kind === "reply_all" ? [from, ...toEmails].filter(Boolean).join(", ") : from;
|
||||||
|
|||||||
@@ -362,7 +362,7 @@ export const en = {
|
|||||||
"settings.spellcheck": "Spelling and grammar check",
|
"settings.spellcheck": "Spelling and grammar check",
|
||||||
"settings.on": "On",
|
"settings.on": "On",
|
||||||
"settings.off": "Off",
|
"settings.off": "Off",
|
||||||
"settings.spellcheckLanguage": "Language",
|
"settings.spellcheckLanguage": "Spellchecking language",
|
||||||
"settings.spellcheckAuto": "Auto-detect (each paragraph)",
|
"settings.spellcheckAuto": "Auto-detect (each paragraph)",
|
||||||
"settings.spellcheckUnavailable": "Not available: this server has no spell-checking engine set up.",
|
"settings.spellcheckUnavailable": "Not available: this server has no spell-checking engine set up.",
|
||||||
"settings.recoveryEmailHint":
|
"settings.recoveryEmailHint":
|
||||||
@@ -484,7 +484,7 @@ export const en = {
|
|||||||
"display.side": "Side",
|
"display.side": "Side",
|
||||||
"display.bottom": "Bottom",
|
"display.bottom": "Bottom",
|
||||||
"display.off": "Off",
|
"display.off": "Off",
|
||||||
"display.phoneNote": "On a phone, mail always opens in a single panel.",
|
"display.dividers": "Show a dividing line between rows",
|
||||||
"admin.mail": "Outgoing system mail",
|
"admin.mail": "Outgoing system mail",
|
||||||
"admin.mailHelp":
|
"admin.mailHelp":
|
||||||
"The mail account the server sends password-reset links from. Without it, “Forgot password?” sends nothing.",
|
"The mail account the server sends password-reset links from. Without it, “Forgot password?” sends nothing.",
|
||||||
@@ -561,6 +561,7 @@ export const en = {
|
|||||||
"offline.draftsNotSaved": "Only drafts written on this device are available offline.",
|
"offline.draftsNotSaved": "Only drafts written on this device are available offline.",
|
||||||
"compose.sendLater": "Schedule",
|
"compose.sendLater": "Schedule",
|
||||||
"compose.savedOnDevice": "Saved on this device",
|
"compose.savedOnDevice": "Saved on this device",
|
||||||
|
"compose.forwardAttachFailed": "Couldn't attach the original message's files.",
|
||||||
"compose.attachOnDevice": "On this device — uploaded when you're back online",
|
"compose.attachOnDevice": "On this device — uploaded when you're back online",
|
||||||
"compose.onDevice": "On this device",
|
"compose.onDevice": "On this device",
|
||||||
"nav.scheduled": "Scheduled",
|
"nav.scheduled": "Scheduled",
|
||||||
|
|||||||
@@ -245,3 +245,29 @@ html[data-offline] .wm-online-only:hover {
|
|||||||
transform: translate(-50%, 0);
|
transform: translate(-50%, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Attachment tiles (thread/Attachments.tsx) shrink with the reading column so
|
||||||
|
three fit in a row; their name bar adapts to the tile's own width. */
|
||||||
|
.wm-tile {
|
||||||
|
container-type: inline-size;
|
||||||
|
}
|
||||||
|
@container (max-width: 160px) {
|
||||||
|
.wm-tile-icon,
|
||||||
|
.wm-tile-size {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.wm-tile-bar {
|
||||||
|
height: 34px;
|
||||||
|
gap: 6px;
|
||||||
|
padding-inline: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@container (max-width: 80px) {
|
||||||
|
/* Too small for a name: the picture (or icon) alone; a click still opens it. */
|
||||||
|
.wm-tile-bar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.wm-tile-art {
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -191,6 +191,9 @@ export const api = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
messageHtmlUrl: (id: string) => `/api/messages/${id}/html`,
|
messageHtmlUrl: (id: string) => `/api/messages/${id}/html`,
|
||||||
|
// A forward's attachments: the original's files, as compose attachments.
|
||||||
|
forwardAttachments: (id: string) =>
|
||||||
|
apiFetch<AttachmentOut[]>(`/messages/${id}/forward-attachments`, { method: "POST" }),
|
||||||
|
|
||||||
// --- account wizard ---
|
// --- account wizard ---
|
||||||
discover: (email: string) =>
|
discover: (email: string) =>
|
||||||
@@ -411,6 +414,9 @@ export const api = {
|
|||||||
// --- attachments ---
|
// --- attachments ---
|
||||||
messageAttachments: (messageId: string) =>
|
messageAttachments: (messageId: string) =>
|
||||||
apiFetch<AttachmentOut[]>(`/messages/${messageId}/attachments`),
|
apiFetch<AttachmentOut[]>(`/messages/${messageId}/attachments`),
|
||||||
|
// A small preview (images, a PDF's first page); 404 when there is none.
|
||||||
|
attachmentThumbUrl: (messageId: string, attachmentId: string) =>
|
||||||
|
`/api/messages/${messageId}/attachments/${attachmentId}/thumb`,
|
||||||
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
|
// Inline URL (no ?download): safe types are served with their real content-type
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// Whether the mail list draws a thin line between its rows (Mailbox display /
|
||||||
|
// Settings → Appearance). Off by default; per device, in localStorage, like the
|
||||||
|
// reading-pane layout it sits under.
|
||||||
|
|
||||||
|
import { useSyncExternalStore } from "react";
|
||||||
|
|
||||||
|
const KEY = "mail.listDividers";
|
||||||
|
const EVENT = "webmail:list-dividers";
|
||||||
|
|
||||||
|
function read(): boolean {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(KEY) === "1";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribe(onChange: () => void): () => void {
|
||||||
|
// Our own writes (same tab) and other tabs' writes (the storage event).
|
||||||
|
window.addEventListener(EVENT, onChange);
|
||||||
|
window.addEventListener("storage", onChange);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener(EVENT, onChange);
|
||||||
|
window.removeEventListener("storage", onChange);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useListDividers(): boolean {
|
||||||
|
return useSyncExternalStore(subscribe, read, () => false);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setListDividers(on: boolean): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(KEY, on ? "1" : "0");
|
||||||
|
} catch {
|
||||||
|
/* private mode: the choice simply does not stick */
|
||||||
|
}
|
||||||
|
window.dispatchEvent(new Event(EVENT));
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { Link, useLocation, useNavigate, useParams, useSearchParams } from "reac
|
|||||||
|
|
||||||
import { RichTextEditor } from "../../components/compose/RichTextEditor";
|
import { RichTextEditor } from "../../components/compose/RichTextEditor";
|
||||||
import { TypedPasswordInput } from "../../components/TypedPasswordInput";
|
import { TypedPasswordInput } from "../../components/TypedPasswordInput";
|
||||||
|
import { ListDividersOption } from "../../components/mail/ListDividersOption";
|
||||||
import { ReadingPanePicker } from "../../components/mail/ReadingPanePicker";
|
import { ReadingPanePicker } from "../../components/mail/ReadingPanePicker";
|
||||||
import { useT } from "../../i18n";
|
import { useT } from "../../i18n";
|
||||||
import { api, ApiError } from "../../lib/api";
|
import { api, ApiError } from "../../lib/api";
|
||||||
@@ -926,8 +927,9 @@ function AppearanceSection() {
|
|||||||
<div className="max-w-md">
|
<div className="max-w-md">
|
||||||
<ReadingPanePicker />
|
<ReadingPanePicker />
|
||||||
</div>
|
</div>
|
||||||
{/* Saved per device, like the theme: the right choice depends on the screen. */}
|
<div className="max-w-md pt-2">
|
||||||
<p className="text-xs text-app-faint">{t("display.phoneNote")}</p>
|
<ListDividersOption />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<SpellcheckSetting />
|
<SpellcheckSetting />
|
||||||
<label className="flex items-center justify-between border-b border-app-border py-2 text-sm">
|
<label className="flex items-center justify-between border-b border-app-border py-2 text-sm">
|
||||||
|
|||||||
Reference in New Issue
Block a user