Compare commits

...
4 Commits
Author SHA1 Message Date
lukasandClaude Opus 5.5 fbca6cb728 Compose: typed text became a recipient without being an address
Blurring the To/Cc/Bcc field (or Enter, or a comma) committed whatever was
typed — a name like "John" became a recipient that could never be
delivered. Only valid addresses ("a@x.com" or "Name <a@x.com>") now become
chips; anything else stays in the input, marked red, to fix or complete
from the suggestions. A pasted list keeps just its invalid parts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:09:09 +02:00
lukasandClaude Opus 5.5 caed8543ea Avatars: a cached avatar or miss failed on every later request
The shared Redis client decodes replies as UTF-8 text, but the avatar cache
holds raw image bytes and a b"none" miss marker: an image could not be read
back at all, and a miss came back as str and crashed on .partition(b"\n").
Only a sender's first request worked; after that, a 500. The cache now goes
through a binary-safe client, get_redis_bytes().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:09:09 +02:00
lukasandClaude Opus 5.5 37bb6ef78d Compose: fold the quoted original behind a "•••" pill
A reply or forward now opens with the quote hidden, as the reading pane
shows it; the pill toggles it. The pill lives in the editor but is stripped
from everything reported out, and the quote is hidden by a class on the
editor rather than in its markup, so drafts and sent mail carry the full
quote. Only a quote the pill directly precedes is hidden, so deleting the
pill cannot strand it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:09:09 +02:00
lukasandClaude Opus 5.5 fe3fd94165 Mail: a smartphone layout
Below 768px (or a touch screen under 500px tall) the mail view becomes one
column at a time: the list full screen, with a menu button beside the search
box that slides the sidebar in as a 70%-wide drawer (tap outside, pick a
folder or press Esc to close), and a floating compose button. Opening a
conversation or a draft shows the reading pane full screen with a back
arrow; composers, including reply/reply-all/forward, open full screen.

List rows stack sender/date, subject and snippet; tapping an avatar selects
a row and the bulk actions sit above the list. The app switcher, Settings
link, select-all box, resizers and right panel are hidden on a phone, and
the composer header drops the Attach label and pop-out at phone width.

Everything is gated on useIsMobile(); the desktop layout renders as before
(checked by screenshot comparison at 1440x900).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:09:09 +02:00
14 changed files with 597 additions and 80 deletions
+3 -2
View File
@@ -17,7 +17,7 @@ from fastapi import APIRouter, Query, Response
from app.accounts.ssrf import SsrfError, guarded_request
from app.config import get_settings
from app.identity.deps import CurrentUser
from app.redis_client import get_redis
from app.redis_client import get_redis_bytes
router = APIRouter(prefix="/avatar", tags=["mail"])
@@ -66,7 +66,8 @@ async def sender_avatar(_user: CurrentUser, email: str = Query(...)) -> Response
if not get_settings().sender_avatars_enabled or "@" not in email:
return Response(status_code=404)
redis = get_redis()
# Binary-safe client: the cache holds raw image bytes (and a b"none" marker).
redis = get_redis_bytes()
md5 = _email_md5(email)
key = f"avatar:{md5}"
headers = {"Cache-Control": "private, max-age=86400"}
+19 -2
View File
@@ -20,6 +20,7 @@ def aw(result: Any) -> Awaitable[Any]:
_client: aioredis.Redis | None = None
_bytes_client: aioredis.Redis | None = None
def get_redis() -> aioredis.Redis:
@@ -34,9 +35,25 @@ def get_redis() -> aioredis.Redis:
return _client
def get_redis_bytes() -> aioredis.Redis:
"""Return the process-wide Redis client for binary values.
``get_redis()`` decodes every reply as UTF-8 text, which fails on (or
silently changes the type of) raw bytes such as a cached image. Anything that
stores bytes goes through this one instead.
"""
global _bytes_client
if _bytes_client is None:
_bytes_client = aioredis.Redis.from_url(get_settings().redis_url, decode_responses=False)
return _bytes_client
async def close_redis() -> None:
"""Close the Redis client (used on app shutdown)."""
global _client
"""Close the Redis clients (used on app shutdown)."""
global _client, _bytes_client
if _client is not None:
await _client.aclose()
_client = None
if _bytes_client is not None:
await _bytes_client.aclose()
_bytes_client = None
+85
View File
@@ -0,0 +1,85 @@
"""Sender avatar cache (SPEC §9.1): a cached hit or miss must be served again.
The shared Redis client decodes replies as text, so the cache of raw image
bytes broke on the second request for any sender — a 500 instead of the image
(or instead of the 404 that means "draw initials").
"""
from __future__ import annotations
import uuid
from collections.abc import AsyncIterator
import httpx
import pytest
import pytest_asyncio
from httpx import ASGITransport
from sqlalchemy import delete
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app.api import avatar
from app.config import get_settings
from app.identity.passwords import hash_password
from app.identity.sessions import create_session
from app.main import create_app
from app.models import User
# A PNG signature: not valid UTF-8, exactly what a text-decoding client chokes on.
PNG = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\xff\xfe"
@pytest_asyncio.fixture
async def client() -> AsyncIterator[httpx.AsyncClient]:
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
session = async_sessionmaker(engine, expire_on_commit=False)()
user = User(handle=f"av{uuid.uuid4().hex[:8]}@test.local", password_hash=hash_password("pw"))
session.add(user)
await session.commit()
token = await create_session(user.id, "pytest")
async with httpx.AsyncClient(
transport=ASGITransport(app=create_app()),
base_url="http://testserver",
cookies={"session": token},
) as c:
yield c
await session.execute(delete(User).where(User.id == user.id))
await session.commit()
await session.close()
await engine.dispose()
async def test_cached_avatar_is_served_again(
client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
calls = 0
async def fake_resolve(email: str, md5: str) -> tuple[str, bytes] | None:
nonlocal calls
calls += 1
return "image/png", PNG
monkeypatch.setattr(avatar, "_resolve", fake_resolve)
for _ in range(2):
resp = await client.get("/api/avatar", params={"email": "alice@example.com"})
assert resp.status_code == 200
assert resp.content == PNG
assert resp.headers["content-type"] == "image/png"
assert calls == 1 # the second came from the cache
async def test_cached_miss_is_a_404_again(
client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
calls = 0
async def fake_resolve(email: str, md5: str) -> tuple[str, bytes] | None:
nonlocal calls
calls += 1
return None
monkeypatch.setattr(avatar, "_resolve", fake_resolve)
for _ in range(2):
resp = await client.get("/api/avatar", params={"email": "nobody@example.com"})
assert resp.status_code == 404
assert calls == 1
@@ -36,6 +36,7 @@ export function ComposeDock({
onRestore,
onToggleExpand,
onClose,
fullScreen = false,
}: {
windows: ComposeWin[];
/** Live geometry of the reading pane; an expanded window is laid over it. */
@@ -44,6 +45,8 @@ export function ComposeDock({
onRestore: (id: string) => void;
onToggleExpand: (id: string) => void;
onClose: (id: string) => void;
/** Phone layout: the newest window fills the screen; the rest wait, mounted. */
fullScreen?: boolean;
}) {
const t = useT();
const { vw, vh } = useViewportSize();
@@ -62,6 +65,29 @@ export function ComposeDock({
if (windows.length === 0) return null;
if (fullScreen) {
// One window at a time, the most recently activated on top. The others stay
// mounted (hidden) for the same reason as below — an unmount loses the
// body — and come forward as the one above them is sent or closed.
const top = windows.reduce((a, b) => (b.activated > a.activated ? b : a)).id;
return (
<>
{windows.map((win) => (
<div
key={win.id}
className={
win.id === top
? "fixed inset-0 z-50 bg-app-elevated pb-[env(safe-area-inset-bottom)] pt-[env(safe-area-inset-top)]"
: "hidden"
}
>
<Composer init={win.init} onClose={() => onClose(win.id)} fullWindow />
</div>
))}
</>
);
}
const { items, overflow } = layoutDock(vw, vh, windows, pane.width);
const title = (w: ComposeWin) =>
+5 -1
View File
@@ -619,7 +619,8 @@ export function Composer({
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
<path d="M12.5 6.5l-5.2 5.2a2.5 2.5 0 0 1-3.5-3.5l5.4-5.4a1.6 1.6 0 0 1 2.3 2.3l-5.3 5.3a0.8 0.8 0 0 1-1.1-1.1l4.9-4.9" />
</svg>
{t("compose.attach")}
{/* Phone width: the paperclip alone, so the close button still fits. */}
<span className="max-md:hidden">{t("compose.attach")}</span>
</HeaderButton>
<input
ref={fileInputRef}
@@ -713,11 +714,14 @@ export function Composer({
</HeaderButton>
)}
{!fullWindow && (
// No second browser window to pop out into on a phone.
<div className="contents max-md:hidden">
<HeaderButton onClick={popOut} title={t("compose.popOut")}>
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 3H3v10h10v-3M9 3h4v4M13 3L7 9" />
</svg>
</HeaderButton>
</div>
)}
<HeaderButton onClick={requestClose} title={t("common.close")}>
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
@@ -17,6 +17,13 @@ function parseAddr(token: string): { name: string; email: string } {
return { name: "", email: token };
}
// Something that can actually be sent to: a bare address or "Name <address>",
// with a dotted domain. A typed name alone ("John") is not one.
const EMAIL = /^[^\s@<>,;]+@[^\s@<>,;]+\.[^\s@<>,;]+$/;
export function isValidRecipient(token: string): boolean {
return EMAIL.test(parseAddr(token.trim()).email.trim());
}
// A recipient field rendering committed addresses as removable chips with an
// inline autocomplete input (Fastmail-style). The value stays a comma-joined
// string ("Name <email>" or bare email) so the draft payload is unchanged.
@@ -38,16 +45,34 @@ export function RecipientChips({
const [draft, setDraft] = useState("");
const [suggestions, setSuggestions] = useState<RecipientSuggestion[]>([]);
const [open, setOpen] = useState(false);
// Typed text that is not an address, left in the input (red) rather than
// turned into a recipient that could never be delivered.
const [invalid, setInvalid] = useState(false);
const boxRef = useRef<HTMLDivElement>(null);
const commit = (token: string) => {
const tok = token.trim().replace(/,+$/, "").trim();
if (!tok) return;
onChange([...chips, tok].join(", "));
// A chosen suggestion is an address by construction.
const pick = (token: string) => {
onChange([...chips, token].join(", "));
setDraft("");
setInvalid(false);
setOpen(false);
setSuggestions([]);
};
// Typed text becomes chips only where it is a valid address; a paste of
// several ("a@x.com, John") keeps the rest in the input to fix or complete.
const commit = (text: string) => {
const tokens = splitList(text);
const good = tokens.filter(isValidRecipient);
const bad = tokens.filter((tok) => !isValidRecipient(tok));
if (good.length) onChange([...chips, ...good].join(", "));
setDraft(bad.join(", "));
setInvalid(bad.length > 0);
if (!bad.length) {
setOpen(false);
setSuggestions([]);
}
};
const removeAt = (i: number) => onChange(chips.filter((_, idx) => idx !== i).join(", "));
useEffect(() => {
@@ -106,12 +131,18 @@ export function RecipientChips({
);
})}
<input
className="min-w-[8rem] flex-1 bg-transparent py-1 text-sm text-app-fg outline-none placeholder:text-app-faint"
className={`min-w-[8rem] flex-1 bg-transparent py-1 text-sm outline-none placeholder:text-app-faint ${
invalid ? "text-red-500" : "text-app-fg"
}`}
aria-invalid={invalid}
value={draft}
placeholder={chips.length === 0 ? t("compose.addRecipient") : ""}
autoFocus={autoFocus}
autoComplete="off"
onChange={(e) => setDraft(e.target.value)}
onChange={(e) => {
setDraft(e.target.value);
setInvalid(false);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
@@ -134,7 +165,7 @@ export function RecipientChips({
className="flex w-full flex-col px-3 py-1.5 text-left hover:bg-app-hover"
onMouseDown={(e) => {
e.preventDefault();
commit(s.name ? `${s.name} <${s.email}>` : s.email);
pick(s.name ? `${s.name} <${s.email}>` : s.email);
}}
>
{s.name && <span className="text-sm text-app-fg">{s.name}</span>}
@@ -53,6 +53,33 @@ const PALETTE: string[] = [
"#5b0f00", "#660000", "#783f04", "#7f6000", "#274e13", "#0c343d", "#1c4587", "#073763", "#20124d", "#4c1130",
];
// The "•••" pill that folds a reply's quoted original, as the reading pane
// does. It lives in the editable DOM (so it sits where the quote is) but is
// never part of the message: `contentHtml` strips it from everything reported
// out, and the quote is hidden by a class on the editor, not by its own markup,
// so the sent and saved HTML always carry the full quote.
const QUOTE_TOGGLE = "wmc-quote-toggle";
function addQuoteToggle(el: HTMLElement, title: string) {
const quote = el.querySelector(".wmc-quoted > blockquote");
if (!quote || el.querySelector(`.${QUOTE_TOGGLE}`)) return;
const btn = document.createElement("button");
btn.type = "button";
btn.className = QUOTE_TOGGLE;
btn.contentEditable = "false";
btn.title = title;
btn.setAttribute("aria-label", title);
btn.textContent = "•••";
quote.parentNode?.insertBefore(btn, quote);
}
function contentHtml(el: HTMLElement): string {
if (!el.querySelector(`.${QUOTE_TOGGLE}`)) return el.innerHTML;
const copy = el.cloneNode(true) as HTMLElement;
copy.querySelectorAll(`.${QUOTE_TOGGLE}`).forEach((n) => n.remove());
return copy.innerHTML;
}
function Svg({ children }: { children: React.ReactNode }) {
return (
<svg
@@ -159,11 +186,15 @@ export const RichTextEditor = forwardRef<
const [selImg, setSelImg] = useState<HTMLImageElement | null>(null);
const [box, setBox] = useState<{ left: number; top: number; width: number; height: number } | null>(null);
// A reply's quoted original starts folded away behind the "•••" pill.
const [quoteOpen, setQuoteOpen] = useState(false);
// Seed the editable region exactly once.
useEffect(() => {
const el = ref.current;
if (!el) return;
el.innerHTML = initialHtml;
addQuoteToggle(el, t("compose.showQuoted"));
onReady?.(el);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -178,7 +209,7 @@ export const RichTextEditor = forwardRef<
} else if (sig) {
sig.remove();
}
onInput(el.innerHTML);
onInput(contentHtml(el));
},
insertSignature: (html: string) => {
const el = ref.current;
@@ -197,7 +228,7 @@ export const RichTextEditor = forwardRef<
el.insertBefore(spacerBefore, anchor);
el.insertBefore(block, anchor);
el.insertBefore(spacerAfter, anchor);
onInput(el.innerHTML);
onInput(contentHtml(el));
},
insertHtml: (html: string) => {
const el = ref.current;
@@ -210,7 +241,7 @@ export const RichTextEditor = forwardRef<
block.innerHTML = html;
el.insertBefore(block, el.querySelector(".wmc-signature, .wmc-quoted"));
}
onInput(el.innerHTML);
onInput(contentHtml(el));
},
}));
@@ -269,7 +300,7 @@ export const RichTextEditor = forwardRef<
}, [colorMenu]);
const emit = () => {
if (ref.current) onInput(ref.current.innerHTML);
if (ref.current) onInput(contentHtml(ref.current));
};
const exec = (command: string, value?: string) => {
@@ -369,6 +400,11 @@ export const RichTextEditor = forwardRef<
const onEditorClick = (e: React.MouseEvent) => {
const el = e.target as HTMLElement;
if (el.closest(`.${QUOTE_TOGGLE}`)) {
e.preventDefault();
setQuoteOpen((v) => !v);
return;
}
setSelImg(el.tagName === "IMG" ? (el as HTMLImageElement) : null);
};
@@ -547,13 +583,13 @@ export const RichTextEditor = forwardRef<
role="textbox"
aria-multiline="true"
data-testid="rich-editor"
onInput={(e) => onInput((e.target as HTMLDivElement).innerHTML)}
onInput={(e) => onInput(contentHtml(e.currentTarget))}
onKeyUp={refreshActive}
onMouseUp={refreshActive}
onClick={onEditorClick}
onKeyDown={onEditorKeyDown}
onScroll={() => setBox(computeBox())}
className={`wmc-editor ${minHeight} flex-1 overflow-y-auto px-3 py-3 text-sm text-app-fg outline-none`}
className={`wmc-editor ${quoteOpen ? "" : "wmc-quote-collapsed"} ${minHeight} flex-1 overflow-y-auto px-3 py-3 text-sm text-app-fg outline-none`}
/>
{selImg && box && (
<>
+54 -2
View File
@@ -98,6 +98,7 @@ export function MessageList({
checkedIds,
dimmedIds,
onToggleCheck,
compact = false,
}: {
threads: ThreadSummary[];
/** Which page of the list this is. Changing it scrolls back to the top. */
@@ -113,6 +114,8 @@ export function MessageList({
checkedIds: Set<string>;
dimmedIds?: Set<string>;
onToggleCheck: (id: string) => void;
/** Phone layout: sender, subject and snippet stacked; no hover-only actions. */
compact?: boolean;
}) {
const t = useT();
const parentRef = useRef<HTMLDivElement>(null);
@@ -214,7 +217,11 @@ export function MessageList({
className="absolute -left-[21px] top-1/2 h-2 w-2 -translate-y-1/2 rounded-full bg-app-accent"
/>
)}
<div className={checkMode ? "hidden" : "contents group-hover/av:hidden"}>
<div
className={
checkMode ? "hidden" : compact ? "contents" : "contents group-hover/av:hidden"
}
>
<span
className="relative flex h-[33px] w-[33px] items-center justify-center overflow-hidden rounded-full text-[13px] font-semibold text-white"
style={{ backgroundColor: avatarBg }}
@@ -233,8 +240,10 @@ export function MessageList({
aria-label={t("mail.select")}
aria-pressed={checked}
onClick={stop(() => onToggleCheck(thread.id))}
// No hover on a touch screen: the avatar itself is the (invisible)
// selection target until selection mode shows the boxes.
className={`absolute inset-0 items-center justify-center ${
checkMode ? "flex" : "hidden group-hover/av:flex"
checkMode ? "flex" : compact ? "flex opacity-0" : "hidden group-hover/av:flex"
}`}
>
<span
@@ -253,6 +262,47 @@ export function MessageList({
</button>
</div>
{compact ? (
<div className="flex min-w-0 flex-1 flex-col justify-center">
<div className="flex min-w-0 items-center gap-1.5">
<span
className={`min-w-0 truncate text-[15px] ${
unread ? "font-black text-app-fg" : "text-app-fg"
}`}
>
{names}
</span>
{thread.message_count > 1 && (
<span className="shrink-0 rounded-full bg-app-hover px-1.5 text-[12px] text-app-muted">
{thread.message_count}
</span>
)}
<span className="ml-auto flex shrink-0 items-center gap-1.5 pl-2 text-app-faint">
{thread.has_attachments && <Paperclip />}
{thread.pinned && (
<Icon name="pin" size={15} className="text-[rgb(220_129_143)]" />
)}
<span
className={`whitespace-nowrap text-[13px] tabular-nums ${
unread ? "font-bold" : ""
}`}
>
{timeLabel(thread.last_message_at)}
</span>
</span>
</div>
<div
className={`flex min-w-0 items-center gap-1.5 text-[14px] ${
unread ? "font-black text-app-fg" : "text-app-fg"
}`}
>
<LabelChips labels={labelMap[thread.id] ?? []} />
<span className="truncate">{thread.subject}</span>
</div>
<div className="truncate text-xs text-app-faint">{thread.snippet}</div>
</div>
) : (
<>
{/* Sender column */}
<div className="flex w-[200px] min-w-0 shrink items-center gap-1.5">
<span
@@ -347,6 +397,8 @@ export function MessageList({
{timeLabel(thread.last_message_at)}
</span>
</div>
</>
)}
</div>
</div>
);
+16 -5
View File
@@ -4,6 +4,7 @@ import { Fragment, useEffect, useMemo, useRef, useState } from "react";
import { useT } from "../../i18n";
import { api } from "../../lib/api";
import { printMessages } from "../../lib/print";
import { useIsMobile } from "../../lib/useIsMobile";
import { getDateLocale, getHour12 } from "../../lib/timeFormat";
import type { AccountOut, MailboxOut, MessageEnvelope } from "../../lib/types";
import { PluginSlot } from "../../plugins/PluginSlot";
@@ -684,16 +685,26 @@ export function ThreadView({
);
const composerRef = useRef<HTMLDivElement | null>(null);
// On a phone, reply/reply-all/forward open full screen rather than inline.
const mobile = useIsMobile();
// Bring the inline composer into view when it opens (reply/reply-all/forward).
useEffect(() => {
if (composing) composerRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
}, [composing]);
if (composing && !mobile) {
composerRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
}
}, [composing, mobile]);
const composerFor = (id: string) =>
composing?.anchorId === id ? (
<div ref={composerRef} className="px-4 py-3">
<Composer init={composing.init} onClose={() => setComposing(null)} />
</div>
mobile ? (
<div className="fixed inset-0 z-50 bg-app-elevated pb-[env(safe-area-inset-bottom)] pt-[env(safe-area-inset-top)]">
<Composer init={composing.init} onClose={() => setComposing(null)} fullWindow />
</div>
) : (
<div ref={composerRef} className="px-4 py-3">
<Composer init={composing.init} onClose={() => setComposing(null)} />
</div>
)
) : null;
return (
+2
View File
@@ -68,6 +68,7 @@ export const en = {
"mail.noConversations": "No conversations.",
"mail.me": "me",
"nav.menu": "Menu",
"mail.selectConversation": "Select a conversation.",
"mail.loadingThread": "Loading thread…",
"mail.expandAll": "Expand all",
@@ -182,6 +183,7 @@ export const en = {
"mail.download": "Download",
"compose.newMessage": "New message",
"compose.showQuoted": "Show or hide the quoted message",
"compose.to": "To",
"compose.cc": "Cc",
"compose.bcc": "Bcc",
+24
View File
@@ -161,6 +161,30 @@ body {
background: rgba(180, 150, 100, 0.06);
}
/* The reply's quoted original folds behind a "•••" pill, as in the reading
pane. Only a quote the pill directly precedes is hidden, so deleting the pill
can never leave a quote hidden with no way back to it. */
.wmc-editor .wmc-quote-toggle {
display: inline-block;
margin: 6px 0;
padding: 1px 9px;
border: none;
border-radius: 8px;
background: rgb(var(--app-hover));
color: rgb(var(--app-muted));
cursor: pointer;
font: inherit;
font-size: 15px;
line-height: 1.5;
user-select: none;
}
.wmc-editor .wmc-quote-toggle:hover {
color: rgb(var(--app-fg));
}
.wmc-editor.wmc-quote-collapsed .wmc-quote-toggle + blockquote {
display: none;
}
/* Thin, unobtrusive scrollbars that match the theme. */
* {
scrollbar-width: thin;
+25
View File
@@ -0,0 +1,25 @@
import { useEffect, useState } from "react";
// Phones: narrower than Tailwind's `md`, or a touch screen too short to be
// anything but a phone on its side. Tablets and desktops keep the full layout.
const MOBILE_QUERY = "(max-width: 767px), (pointer: coarse) and (max-height: 500px)";
function matches(): boolean {
return typeof window !== "undefined" && typeof window.matchMedia === "function"
? window.matchMedia(MOBILE_QUERY).matches
: false;
}
/** Whether the smartphone layout applies; follows rotation and window resizes. */
export function useIsMobile(): boolean {
const [mobile, setMobile] = useState(matches);
useEffect(() => {
if (typeof window.matchMedia !== "function") return;
const mq = window.matchMedia(MOBILE_QUERY);
const onChange = () => setMobile(mq.matches);
onChange();
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}, []);
return mobile;
}
+172 -55
View File
@@ -29,6 +29,7 @@ import type { MessageKey } from "../i18n/en";
import { api } from "../lib/api";
import { setTimeFormat } from "../lib/timeFormat";
import type { QuotaOut, SavedSearch, ThreadSummary } from "../lib/types";
import { useIsMobile } from "../lib/useIsMobile";
import { useKeyboardShortcuts } from "../lib/useKeyboardShortcuts";
import { PluginSlot } from "../plugins/PluginSlot";
@@ -63,6 +64,10 @@ export function MailLayout() {
const match = useMatch("/mail/t/:threadId");
const threadId = match?.params.threadId;
const [view, setView] = useState<string>("folder:inbox");
// Smartphone layout: one column at a time — the list, or the reading pane full
// screen — with the sidebar as a slide-in drawer. Desktop is untouched by it.
const mobile = useIsMobile();
const [drawerOpen, setDrawerOpen] = useState(false);
const [term, setTerm] = useState("");
// Debounced copy of the search box for search-as-you-type (avoids a request per key).
const [debouncedTerm, setDebouncedTerm] = useState("");
@@ -126,8 +131,11 @@ export function MailLayout() {
// Resizable panel widths (persisted). Drag the dividers to adjust.
const [sidebarW, setSidebarW] = useState(() => Number(localStorage.getItem("panel.sidebar")) || 240);
const [listW, setListW] = useState(() => Number(localStorage.getItem("panel.list")) || 384);
// Collapsed sidebar shows icons only (persisted).
const [collapsed, setCollapsed] = useState(() => localStorage.getItem("panel.collapsed") === "1");
// Collapsed sidebar shows icons only (persisted). The mobile drawer is always full.
const [collapsedPref, setCollapsed] = useState(
() => localStorage.getItem("panel.collapsed") === "1",
);
const collapsed = collapsedPref && !mobile;
// Right info panel (Calendar / People / Attachments) — hidden by default.
const [rightOpen, setRightOpen] = useState(() => localStorage.getItem("panel.right") === "1");
const [rightTab, setRightTab] = useState<PanelTab>(() => (localStorage.getItem("panel.rightTab") as PanelTab) || "calendar");
@@ -138,7 +146,10 @@ export function MailLayout() {
const clampRight = (w: number) => Math.min(Math.max(w, 280), 560);
useEffect(() => localStorage.setItem("panel.sidebar", String(sidebarW)), [sidebarW]);
useEffect(() => localStorage.setItem("panel.list", String(listW)), [listW]);
useEffect(() => localStorage.setItem("panel.collapsed", collapsed ? "1" : "0"), [collapsed]);
useEffect(
() => localStorage.setItem("panel.collapsed", collapsedPref ? "1" : "0"),
[collapsedPref],
);
useEffect(() => localStorage.setItem("panel.right", rightOpen ? "1" : "0"), [rightOpen]);
useEffect(() => localStorage.setItem("panel.rightTab", rightTab), [rightTab]);
useEffect(() => localStorage.setItem("panel.rightW", String(rightW)), [rightW]);
@@ -269,6 +280,7 @@ export function MailLayout() {
};
const runSavedSearch = (s: SavedSearch) => {
setTerm(s.query);
setDrawerOpen(false);
navigate("/mail");
};
// Switch folder/account view. Clears any active search so the folder actually
@@ -276,8 +288,18 @@ export function MailLayout() {
const selectView = (v: string) => {
setTerm("");
setView(v);
setDrawerOpen(false);
navigate("/mail");
};
// Anything else that switches the view (a plugin's sidebar label link) or
// leaves the phone layout also puts the drawer away.
useEffect(() => setDrawerOpen(false), [view, mobile]);
useEffect(() => {
if (!drawerOpen) return;
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setDrawerOpen(false);
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [drawerOpen]);
// 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.
@@ -473,6 +495,37 @@ export function MailLayout() {
const bulkRestore = () => runBulk((id) => api.restoreThread(id));
const bulkDeletePermanent = () => runBulk((id) => api.deleteThreadPermanent(id));
// Bulk-action buttons over the selected conversations: in the reading pane on
// the desktop, above the list on a phone.
const bulkButtons =
currentFolder === "archive" || currentFolder === "trash" || currentFolder === "junk" ? (
<>
<ToolbarButton onClick={bulkRestore}>
<Icon name="restore" size={16} />
{t("mail.restore")}
</ToolbarButton>
<ToolbarButton onClick={currentFolder === "trash" ? bulkDeletePermanent : bulkTrash}>
<Icon name="trash" size={16} />
{t("mail.delete")}
</ToolbarButton>
</>
) : (
<>
<ToolbarButton onClick={bulkArchive}>
<Icon name="archive" size={16} />
{t("mail.archive")}
</ToolbarButton>
<ToolbarButton onClick={bulkPin}>
<Icon name="pin" size={16} />
{t("mail.pin")}
</ToolbarButton>
<ToolbarButton onClick={bulkTrash}>
<Icon name="trash" size={16} />
{t("mail.delete")}
</ToolbarButton>
</>
);
// "Empty" a Trash/Spam/Archive folder: permanently delete everything in it. The
// work runs in a worker; refresh the views after it and via the WS message.deleted.
const [emptying, setEmptying] = useState(false);
@@ -576,13 +629,32 @@ export function MailLayout() {
return () => window.removeEventListener("webmail:labels-changed", onChanged);
}, [queryClient]);
// On a phone the reading pane replaces the list rather than sitting beside it.
const paneShown = Boolean(paneDraft || threadId);
return (
<div className="flex h-screen bg-app-bg text-app-fg">
<div className={`flex bg-app-bg text-app-fg ${mobile ? "h-[100dvh]" : "h-screen"}`}>
<BackfillBanner />
{/* Sidebar */}
{mobile && (
// Tapping outside the drawer closes it.
<div
aria-hidden
onClick={() => setDrawerOpen(false)}
className={`fixed inset-0 z-40 bg-black/40 transition-opacity duration-200 ${
drawerOpen ? "opacity-100" : "pointer-events-none opacity-0"
}`}
/>
)}
{/* Sidebar (a slide-in drawer on phones) */}
<aside
style={{ width: collapsed ? COLLAPSED_W : sidebarW }}
className="flex shrink-0 flex-col border-r border-app-border bg-app-bg"
style={mobile ? undefined : { width: collapsed ? COLLAPSED_W : sidebarW }}
className={
mobile
? `fixed inset-y-0 left-0 z-50 flex w-[70vw] flex-col border-r border-app-border bg-app-bg shadow-2xl transition-transform duration-200 ease-out ${
drawerOpen ? "translate-x-0" : "-translate-x-full"
}`
: "flex shrink-0 flex-col border-r border-app-border bg-app-bg"
}
>
<div
className={`flex pb-1 pt-[11px] ${
@@ -594,10 +666,12 @@ export function MailLayout() {
) : (
<div className="flex items-center gap-2">
<img src={signetLogo} alt="Ultimo" className="h-7 w-auto shrink-0" />
<AppSwitcher />
{/* Calendar/Contacts have no phone layout yet, so no way into them there. */}
{!mobile && <AppSwitcher />}
</div>
)}
<div className={`flex items-center gap-1 ${collapsed ? "flex-col" : ""}`}>
{!mobile && (
<button
onClick={() => setCollapsed((v) => !v)}
title={collapsed ? t("nav.expandSidebar") : t("nav.collapseSidebar")}
@@ -606,10 +680,11 @@ export function MailLayout() {
>
<Icon name={collapsed ? "show" : "hide"} size={16} />
</button>
)}
<ThemeToggle />
</div>
</div>
<div className={`pb-2 pt-2.5 ${collapsed ? "px-2" : "px-3"}`}>
<div className={`pb-2 pt-2.5 ${collapsed ? "px-2" : "px-3"} ${mobile ? "hidden" : ""}`}>
<button
className="flex w-full items-center justify-center gap-2 rounded-lg bg-app-accent py-2 text-[15px] font-medium text-app-accent-fg shadow-sm hover:opacity-90"
title={collapsed ? t("nav.compose") : undefined}
@@ -708,10 +783,11 @@ export function MailLayout() {
{!collapsed && <PluginSlot name="sidebar.section" />}
</nav>
<div className="border-t border-app-border p-2 text-[15px]">
{/* Settings has no phone layout yet either. */}
<Link
className={`flex items-center gap-2.5 rounded-lg py-2 text-app-muted hover:bg-app-hover hover:text-app-fg ${
collapsed ? "justify-center px-0" : "px-3"
}`}
} ${mobile ? "hidden" : ""}`}
title={collapsed ? t("nav.settings") : undefined}
to="/settings"
>
@@ -743,14 +819,38 @@ export function MailLayout() {
</div>
</aside>
{!collapsed && <Resizer onDelta={(dx) => setSidebarW((w) => clampSidebar(w + dx))} />}
{!collapsed && !mobile && (
<Resizer onDelta={(dx) => setSidebarW((w) => clampSidebar(w + dx))} />
)}
{/* Message list */}
{/* Message list (the whole screen on a phone, until a thread opens) */}
<div
style={{ width: listW }}
className="flex shrink-0 flex-col border-r border-app-border bg-app-surface"
style={mobile ? undefined : { width: listW }}
className={
mobile
? `relative min-w-0 flex-1 flex-col bg-app-surface ${paneShown ? "hidden" : "flex"}`
: "flex shrink-0 flex-col border-r border-app-border bg-app-surface"
}
>
<div className="flex h-[52px] shrink-0 items-center border-b border-app-border px-6">
<div
className={`flex h-[52px] shrink-0 items-center border-b border-app-border ${
mobile ? "gap-2 px-3" : "px-6"
}`}
>
{mobile && (
<button
type="button"
title={t("nav.menu")}
aria-label={t("nav.menu")}
aria-expanded={drawerOpen}
onClick={() => setDrawerOpen(true)}
className="-ml-1 shrink-0 rounded p-1.5 text-app-muted hover:bg-app-hover hover:text-app-fg"
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" aria-hidden>
<path d="M4 7h16M4 12h16M4 17h16" />
</svg>
</button>
)}
<div className="relative w-full">
<input
ref={searchRef}
@@ -799,13 +899,17 @@ export function MailLayout() {
</div>
</div>
{!isDrafts && (
<div className="flex h-[52px] shrink-0 items-center gap-5 border-b border-app-border bg-app-bg px-6 text-[15px]">
<div
className={`flex h-[52px] shrink-0 items-center gap-5 border-b border-app-border bg-app-bg text-[15px] ${
mobile ? "px-3" : "px-6"
}`}
>
<button
type="button"
aria-label={t("mail.selectAll")}
title={t("mail.selectAll")}
onClick={toggleSelectAll}
className={`ml-[28px] flex h-[17px] w-[17px] shrink-0 items-center justify-center rounded-md border-2 transition-colors ${
className={`${mobile ? "hidden" : "flex"} ml-[28px] h-[17px] w-[17px] shrink-0 items-center justify-center rounded-md border-2 transition-colors ${
allSelected || someSelected
? "border-app-accent bg-app-accent text-white"
: "border-app-faint hover:border-app-muted"
@@ -892,6 +996,14 @@ export function MailLayout() {
</div>
</div>
)}
{mobile && checkedIds.size > 0 && (
// The desktop shows bulk actions in the reading pane; a phone has none
// beside the list, so they sit above it.
<div className="flex h-[48px] shrink-0 items-center gap-1 border-b border-app-border bg-app-surface px-2 text-[15px]">
<span className="px-2 text-[13px] text-app-muted">{checkedIds.size}</span>
{bulkButtons}
</div>
)}
<div className="min-h-0 flex-1">
{isDrafts && !term.trim() ? (
<DraftsList
@@ -922,10 +1034,15 @@ export function MailLayout() {
checkedIds={checkedIds}
dimmedIds={dimmedIds}
onToggleCheck={toggleCheck}
compact={mobile}
/>
)}
</div>
<div className="flex items-center justify-between border-t border-app-border px-6 py-1.5 text-[13px] text-app-muted">
<div
className={`flex items-center justify-between border-t border-app-border py-1.5 text-[13px] text-app-muted ${
mobile ? "px-4 pb-[max(0.375rem,env(safe-area-inset-bottom))]" : "px-6"
}`}
>
<button
className="-ml-1 rounded p-1 text-app-muted hover:bg-app-hover hover:text-app-fg disabled:opacity-60"
title={t("nav.refresh")}
@@ -959,12 +1076,26 @@ export function MailLayout() {
<span />
)}
</div>
{mobile && (
<button
type="button"
title={t("nav.compose")}
aria-label={t("nav.compose")}
onClick={() => accounts[0] && openComposer({ accountId: accounts[0].id, kind: "new" })}
className="absolute bottom-[calc(3.25rem+env(safe-area-inset-bottom))] right-4 z-30 flex h-14 w-14 items-center justify-center rounded-2xl bg-app-accent text-app-accent-fg shadow-lg active:opacity-90"
>
<Icon name="compose" size={22} />
</button>
)}
</div>
<Resizer onDelta={(dx) => setListW((w) => clampList(w + dx))} />
{!mobile && <Resizer onDelta={(dx) => setListW((w) => clampList(w + dx))} />}
{/* Thread pane */}
<main ref={paneRef} className="relative min-w-0 flex-1 bg-app-surface">
{/* Thread pane (full screen on a phone, shown only with something open) */}
<main
ref={paneRef}
className={`relative min-w-0 flex-1 bg-app-surface ${mobile && !paneShown ? "hidden" : ""}`}
>
{paneDraft ? (
// Keyed by draft: picking a different one must build a fresh editor,
// because RichTextEditor seeds its body exactly once.
@@ -974,40 +1105,10 @@ export function MailLayout() {
onClose={closePaneDraft}
fullWindow
/>
) : checkedIds.size > 0 ? (
) : checkedIds.size > 0 && !mobile ? (
<div className="flex h-full flex-col">
<div className="flex h-[52px] shrink-0 items-center gap-1 border-b border-app-border bg-app-surface px-3 text-[15px]">
{currentFolder === "archive" ||
currentFolder === "trash" ||
currentFolder === "junk" ? (
<>
<ToolbarButton onClick={bulkRestore}>
<Icon name="restore" size={16} />
{t("mail.restore")}
</ToolbarButton>
<ToolbarButton
onClick={currentFolder === "trash" ? bulkDeletePermanent : bulkTrash}
>
<Icon name="trash" size={16} />
{t("mail.delete")}
</ToolbarButton>
</>
) : (
<>
<ToolbarButton onClick={bulkArchive}>
<Icon name="archive" size={16} />
{t("mail.archive")}
</ToolbarButton>
<ToolbarButton onClick={bulkPin}>
<Icon name="pin" size={16} />
{t("mail.pin")}
</ToolbarButton>
<ToolbarButton onClick={bulkTrash}>
<Icon name="trash" size={16} />
{t("mail.delete")}
</ToolbarButton>
</>
)}
{bulkButtons}
</div>
<div className="flex flex-1 items-center justify-center text-3xl text-app-faint">
{checkedIds.size === 1
@@ -1018,6 +1119,19 @@ export function MailLayout() {
) : threadId && detailQuery.data ? (
<div className="flex h-full flex-col">
<div className="flex h-[52px] shrink-0 items-center gap-1 border-b border-app-border bg-app-surface px-3 text-[15px]">
{mobile && (
<button
type="button"
title={t("common.back")}
aria-label={t("common.back")}
onClick={() => navigate("/mail")}
className="-ml-1 mr-1 rounded p-1.5 text-app-muted hover:bg-app-hover hover:text-app-fg"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M15 5l-7 7 7 7" />
</svg>
</button>
)}
{currentFolder === "archive" ? (
<>
<ToolbarButton onClick={restore}>
@@ -1060,9 +1174,10 @@ export function MailLayout() {
</ToolbarButton>
</>
)}
<span className="ml-auto text-[13px] text-app-faint">
<span className={`ml-auto text-[13px] text-app-faint ${mobile ? "hidden" : ""}`}>
{t("mail.messageCount", { count: detailQuery.data.messages.length })}
</span>
{!mobile && (
<button
className={`rounded p-1.5 hover:bg-app-hover hover:text-app-fg ${rightOpen ? "text-app-fg" : "text-app-muted"}`}
title={t("panel.toggle")}
@@ -1075,6 +1190,7 @@ export function MailLayout() {
<path d="M15 4v16" />
</svg>
</button>
)}
</div>
<div className="min-h-0 flex-1 overflow-hidden">
<ThreadView
@@ -1107,7 +1223,7 @@ export function MailLayout() {
</div>
)}
</main>
{rightOpen && (
{rightOpen && !mobile && (
<>
<Resizer onDelta={(dx) => setRightW((w) => clampRight(w - dx))} />
<div style={{ width: rightW }} className="shrink-0 border-l border-app-border">
@@ -1122,6 +1238,7 @@ export function MailLayout() {
onRestore={restoreComposer}
onToggleExpand={toggleComposerExpand}
onClose={closeComposer}
fullScreen={mobile}
/>
</div>
);
+86
View File
@@ -0,0 +1,86 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { useState } from "react";
import { describe, expect, it, vi } from "vitest";
import {
isValidRecipient,
RecipientChips,
} from "../components/compose/RecipientChips";
import { I18nProvider } from "../i18n";
vi.mock("../lib/api", () => ({
api: { suggestRecipients: vi.fn().mockResolvedValue([]) },
}));
// A controlled field, reporting every value it is given.
function Field({ seen }: { seen: string[] }) {
const [value, setValue] = useState("");
return (
<I18nProvider>
<RecipientChips
label="To"
value={value}
onChange={(v) => {
seen.push(v);
setValue(v);
}}
/>
</I18nProvider>
);
}
function type(text: string) {
const input = screen.getByRole("textbox");
fireEvent.change(input, { target: { value: text } });
return input as HTMLInputElement;
}
describe("RecipientChips", () => {
it("leaves typed text that is not an address in the input on blur", () => {
const seen: string[] = [];
render(<Field seen={seen} />);
const input = type("John");
fireEvent.blur(input);
expect(seen).toEqual([]);
expect(input.value).toBe("John");
expect(input.getAttribute("aria-invalid")).toBe("true");
});
it("does not turn a non-address into a recipient on Enter either", () => {
const seen: string[] = [];
render(<Field seen={seen} />);
const input = type("john@");
fireEvent.keyDown(input, { key: "Enter" });
expect(seen).toEqual([]);
expect(input.value).toBe("john@");
});
it("commits a valid address on blur", () => {
const seen: string[] = [];
render(<Field seen={seen} />);
const input = type("john@example.com");
fireEvent.blur(input);
expect(seen).toEqual(["john@example.com"]);
expect(input.value).toBe("");
});
it("keeps only the invalid part of a pasted list", () => {
const seen: string[] = [];
render(<Field seen={seen} />);
const input = type("a@x.com, John, Bob <bob@y.org>");
fireEvent.keyDown(input, { key: "Enter" });
expect(seen).toEqual(["a@x.com, Bob <bob@y.org>"]);
expect(input.value).toBe("John");
});
});
describe("isValidRecipient", () => {
it.each([
["john@example.com", true],
["John Smith <john@example.com>", true],
["John", false],
["john@", false],
["john@localhost", false],
["John <not an address>", false],
])("%s -> %s", (token, ok) => expect(isValidRecipient(token)).toBe(ok));
});