Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbca6cb728 | ||
|
|
caed8543ea | ||
|
|
37bb6ef78d | ||
|
|
fe3fd94165 |
@@ -17,7 +17,7 @@ from fastapi import APIRouter, Query, Response
|
|||||||
from app.accounts.ssrf import SsrfError, guarded_request
|
from app.accounts.ssrf import SsrfError, guarded_request
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.identity.deps import CurrentUser
|
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"])
|
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:
|
if not get_settings().sender_avatars_enabled or "@" not in email:
|
||||||
return Response(status_code=404)
|
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)
|
md5 = _email_md5(email)
|
||||||
key = f"avatar:{md5}"
|
key = f"avatar:{md5}"
|
||||||
headers = {"Cache-Control": "private, max-age=86400"}
|
headers = {"Cache-Control": "private, max-age=86400"}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ def aw(result: Any) -> Awaitable[Any]:
|
|||||||
|
|
||||||
|
|
||||||
_client: aioredis.Redis | None = None
|
_client: aioredis.Redis | None = None
|
||||||
|
_bytes_client: aioredis.Redis | None = None
|
||||||
|
|
||||||
|
|
||||||
def get_redis() -> aioredis.Redis:
|
def get_redis() -> aioredis.Redis:
|
||||||
@@ -34,9 +35,25 @@ def get_redis() -> aioredis.Redis:
|
|||||||
return _client
|
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:
|
async def close_redis() -> None:
|
||||||
"""Close the Redis client (used on app shutdown)."""
|
"""Close the Redis clients (used on app shutdown)."""
|
||||||
global _client
|
global _client, _bytes_client
|
||||||
if _client is not None:
|
if _client is not None:
|
||||||
await _client.aclose()
|
await _client.aclose()
|
||||||
_client = None
|
_client = None
|
||||||
|
if _bytes_client is not None:
|
||||||
|
await _bytes_client.aclose()
|
||||||
|
_bytes_client = None
|
||||||
|
|||||||
@@ -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,
|
onRestore,
|
||||||
onToggleExpand,
|
onToggleExpand,
|
||||||
onClose,
|
onClose,
|
||||||
|
fullScreen = false,
|
||||||
}: {
|
}: {
|
||||||
windows: ComposeWin[];
|
windows: ComposeWin[];
|
||||||
/** Live geometry of the reading pane; an expanded window is laid over it. */
|
/** Live geometry of the reading pane; an expanded window is laid over it. */
|
||||||
@@ -44,6 +45,8 @@ export function ComposeDock({
|
|||||||
onRestore: (id: string) => void;
|
onRestore: (id: string) => void;
|
||||||
onToggleExpand: (id: string) => void;
|
onToggleExpand: (id: string) => void;
|
||||||
onClose: (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 t = useT();
|
||||||
const { vw, vh } = useViewportSize();
|
const { vw, vh } = useViewportSize();
|
||||||
@@ -62,6 +65,29 @@ export function ComposeDock({
|
|||||||
|
|
||||||
if (windows.length === 0) return null;
|
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 { items, overflow } = layoutDock(vw, vh, windows, pane.width);
|
||||||
|
|
||||||
const title = (w: ComposeWin) =>
|
const title = (w: ComposeWin) =>
|
||||||
|
|||||||
@@ -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">
|
<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" />
|
<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>
|
</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>
|
</HeaderButton>
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
@@ -713,11 +714,14 @@ export function Composer({
|
|||||||
</HeaderButton>
|
</HeaderButton>
|
||||||
)}
|
)}
|
||||||
{!fullWindow && (
|
{!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")}>
|
<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">
|
<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" />
|
<path d="M6 3H3v10h10v-3M9 3h4v4M13 3L7 9" />
|
||||||
</svg>
|
</svg>
|
||||||
</HeaderButton>
|
</HeaderButton>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<HeaderButton onClick={requestClose} title={t("common.close")}>
|
<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">
|
<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 };
|
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
|
// A recipient field rendering committed addresses as removable chips with an
|
||||||
// inline autocomplete input (Fastmail-style). The value stays a comma-joined
|
// inline autocomplete input (Fastmail-style). The value stays a comma-joined
|
||||||
// string ("Name <email>" or bare email) so the draft payload is unchanged.
|
// string ("Name <email>" or bare email) so the draft payload is unchanged.
|
||||||
@@ -38,16 +45,34 @@ export function RecipientChips({
|
|||||||
const [draft, setDraft] = useState("");
|
const [draft, setDraft] = useState("");
|
||||||
const [suggestions, setSuggestions] = useState<RecipientSuggestion[]>([]);
|
const [suggestions, setSuggestions] = useState<RecipientSuggestion[]>([]);
|
||||||
const [open, setOpen] = useState(false);
|
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 boxRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const commit = (token: string) => {
|
// A chosen suggestion is an address by construction.
|
||||||
const tok = token.trim().replace(/,+$/, "").trim();
|
const pick = (token: string) => {
|
||||||
if (!tok) return;
|
onChange([...chips, token].join(", "));
|
||||||
onChange([...chips, tok].join(", "));
|
|
||||||
setDraft("");
|
setDraft("");
|
||||||
|
setInvalid(false);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
setSuggestions([]);
|
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(", "));
|
const removeAt = (i: number) => onChange(chips.filter((_, idx) => idx !== i).join(", "));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -106,12 +131,18 @@ export function RecipientChips({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<input
|
<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}
|
value={draft}
|
||||||
placeholder={chips.length === 0 ? t("compose.addRecipient") : ""}
|
placeholder={chips.length === 0 ? t("compose.addRecipient") : ""}
|
||||||
autoFocus={autoFocus}
|
autoFocus={autoFocus}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
onChange={(e) => setDraft(e.target.value)}
|
onChange={(e) => {
|
||||||
|
setDraft(e.target.value);
|
||||||
|
setInvalid(false);
|
||||||
|
}}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter" || e.key === ",") {
|
if (e.key === "Enter" || e.key === ",") {
|
||||||
e.preventDefault();
|
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"
|
className="flex w-full flex-col px-3 py-1.5 text-left hover:bg-app-hover"
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
e.preventDefault();
|
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>}
|
{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",
|
"#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 }) {
|
function Svg({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
@@ -159,11 +186,15 @@ export const RichTextEditor = forwardRef<
|
|||||||
const [selImg, setSelImg] = useState<HTMLImageElement | null>(null);
|
const [selImg, setSelImg] = useState<HTMLImageElement | null>(null);
|
||||||
const [box, setBox] = useState<{ left: number; top: number; width: number; height: number } | 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.
|
// Seed the editable region exactly once.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = ref.current;
|
const el = ref.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
el.innerHTML = initialHtml;
|
el.innerHTML = initialHtml;
|
||||||
|
addQuoteToggle(el, t("compose.showQuoted"));
|
||||||
onReady?.(el);
|
onReady?.(el);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
@@ -178,7 +209,7 @@ export const RichTextEditor = forwardRef<
|
|||||||
} else if (sig) {
|
} else if (sig) {
|
||||||
sig.remove();
|
sig.remove();
|
||||||
}
|
}
|
||||||
onInput(el.innerHTML);
|
onInput(contentHtml(el));
|
||||||
},
|
},
|
||||||
insertSignature: (html: string) => {
|
insertSignature: (html: string) => {
|
||||||
const el = ref.current;
|
const el = ref.current;
|
||||||
@@ -197,7 +228,7 @@ export const RichTextEditor = forwardRef<
|
|||||||
el.insertBefore(spacerBefore, anchor);
|
el.insertBefore(spacerBefore, anchor);
|
||||||
el.insertBefore(block, anchor);
|
el.insertBefore(block, anchor);
|
||||||
el.insertBefore(spacerAfter, anchor);
|
el.insertBefore(spacerAfter, anchor);
|
||||||
onInput(el.innerHTML);
|
onInput(contentHtml(el));
|
||||||
},
|
},
|
||||||
insertHtml: (html: string) => {
|
insertHtml: (html: string) => {
|
||||||
const el = ref.current;
|
const el = ref.current;
|
||||||
@@ -210,7 +241,7 @@ export const RichTextEditor = forwardRef<
|
|||||||
block.innerHTML = html;
|
block.innerHTML = html;
|
||||||
el.insertBefore(block, el.querySelector(".wmc-signature, .wmc-quoted"));
|
el.insertBefore(block, el.querySelector(".wmc-signature, .wmc-quoted"));
|
||||||
}
|
}
|
||||||
onInput(el.innerHTML);
|
onInput(contentHtml(el));
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -269,7 +300,7 @@ export const RichTextEditor = forwardRef<
|
|||||||
}, [colorMenu]);
|
}, [colorMenu]);
|
||||||
|
|
||||||
const emit = () => {
|
const emit = () => {
|
||||||
if (ref.current) onInput(ref.current.innerHTML);
|
if (ref.current) onInput(contentHtml(ref.current));
|
||||||
};
|
};
|
||||||
|
|
||||||
const exec = (command: string, value?: string) => {
|
const exec = (command: string, value?: string) => {
|
||||||
@@ -369,6 +400,11 @@ export const RichTextEditor = forwardRef<
|
|||||||
|
|
||||||
const onEditorClick = (e: React.MouseEvent) => {
|
const onEditorClick = (e: React.MouseEvent) => {
|
||||||
const el = e.target as HTMLElement;
|
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);
|
setSelImg(el.tagName === "IMG" ? (el as HTMLImageElement) : null);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -547,13 +583,13 @@ export const RichTextEditor = forwardRef<
|
|||||||
role="textbox"
|
role="textbox"
|
||||||
aria-multiline="true"
|
aria-multiline="true"
|
||||||
data-testid="rich-editor"
|
data-testid="rich-editor"
|
||||||
onInput={(e) => onInput((e.target as HTMLDivElement).innerHTML)}
|
onInput={(e) => onInput(contentHtml(e.currentTarget))}
|
||||||
onKeyUp={refreshActive}
|
onKeyUp={refreshActive}
|
||||||
onMouseUp={refreshActive}
|
onMouseUp={refreshActive}
|
||||||
onClick={onEditorClick}
|
onClick={onEditorClick}
|
||||||
onKeyDown={onEditorKeyDown}
|
onKeyDown={onEditorKeyDown}
|
||||||
onScroll={() => setBox(computeBox())}
|
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 && (
|
{selImg && box && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ export function MessageList({
|
|||||||
checkedIds,
|
checkedIds,
|
||||||
dimmedIds,
|
dimmedIds,
|
||||||
onToggleCheck,
|
onToggleCheck,
|
||||||
|
compact = false,
|
||||||
}: {
|
}: {
|
||||||
threads: ThreadSummary[];
|
threads: ThreadSummary[];
|
||||||
/** Which page of the list this is. Changing it scrolls back to the top. */
|
/** Which page of the list this is. Changing it scrolls back to the top. */
|
||||||
@@ -113,6 +114,8 @@ export function MessageList({
|
|||||||
checkedIds: Set<string>;
|
checkedIds: Set<string>;
|
||||||
dimmedIds?: Set<string>;
|
dimmedIds?: Set<string>;
|
||||||
onToggleCheck: (id: string) => void;
|
onToggleCheck: (id: string) => void;
|
||||||
|
/** Phone layout: sender, subject and snippet stacked; no hover-only actions. */
|
||||||
|
compact?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const parentRef = useRef<HTMLDivElement>(null);
|
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"
|
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
|
<span
|
||||||
className="relative flex h-[33px] w-[33px] items-center justify-center overflow-hidden rounded-full text-[13px] font-semibold text-white"
|
className="relative flex h-[33px] w-[33px] items-center justify-center overflow-hidden rounded-full text-[13px] font-semibold text-white"
|
||||||
style={{ backgroundColor: avatarBg }}
|
style={{ backgroundColor: avatarBg }}
|
||||||
@@ -233,8 +240,10 @@ export function MessageList({
|
|||||||
aria-label={t("mail.select")}
|
aria-label={t("mail.select")}
|
||||||
aria-pressed={checked}
|
aria-pressed={checked}
|
||||||
onClick={stop(() => onToggleCheck(thread.id))}
|
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 ${
|
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
|
<span
|
||||||
@@ -253,6 +262,47 @@ export function MessageList({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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 */}
|
{/* Sender column */}
|
||||||
<div className="flex w-[200px] min-w-0 shrink items-center gap-1.5">
|
<div className="flex w-[200px] min-w-0 shrink items-center gap-1.5">
|
||||||
<span
|
<span
|
||||||
@@ -347,6 +397,8 @@ export function MessageList({
|
|||||||
{timeLabel(thread.last_message_at)}
|
{timeLabel(thread.last_message_at)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Fragment, useEffect, useMemo, useRef, useState } from "react";
|
|||||||
import { useT } from "../../i18n";
|
import { useT } from "../../i18n";
|
||||||
import { api } from "../../lib/api";
|
import { api } from "../../lib/api";
|
||||||
import { printMessages } from "../../lib/print";
|
import { printMessages } from "../../lib/print";
|
||||||
|
import { useIsMobile } from "../../lib/useIsMobile";
|
||||||
import { getDateLocale, getHour12 } from "../../lib/timeFormat";
|
import { getDateLocale, getHour12 } from "../../lib/timeFormat";
|
||||||
import type { AccountOut, MailboxOut, MessageEnvelope } from "../../lib/types";
|
import type { AccountOut, MailboxOut, MessageEnvelope } from "../../lib/types";
|
||||||
import { PluginSlot } from "../../plugins/PluginSlot";
|
import { PluginSlot } from "../../plugins/PluginSlot";
|
||||||
@@ -684,16 +685,26 @@ export function ThreadView({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const composerRef = useRef<HTMLDivElement | null>(null);
|
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).
|
// Bring the inline composer into view when it opens (reply/reply-all/forward).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (composing) composerRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
|
if (composing && !mobile) {
|
||||||
}, [composing]);
|
composerRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
|
}
|
||||||
|
}, [composing, mobile]);
|
||||||
|
|
||||||
const composerFor = (id: string) =>
|
const composerFor = (id: string) =>
|
||||||
composing?.anchorId === id ? (
|
composing?.anchorId === id ? (
|
||||||
<div ref={composerRef} className="px-4 py-3">
|
mobile ? (
|
||||||
<Composer init={composing.init} onClose={() => setComposing(null)} />
|
<div className="fixed inset-0 z-50 bg-app-elevated pb-[env(safe-area-inset-bottom)] pt-[env(safe-area-inset-top)]">
|
||||||
</div>
|
<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;
|
) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ export const en = {
|
|||||||
|
|
||||||
"mail.noConversations": "No conversations.",
|
"mail.noConversations": "No conversations.",
|
||||||
"mail.me": "me",
|
"mail.me": "me",
|
||||||
|
"nav.menu": "Menu",
|
||||||
"mail.selectConversation": "Select a conversation.",
|
"mail.selectConversation": "Select a conversation.",
|
||||||
"mail.loadingThread": "Loading thread…",
|
"mail.loadingThread": "Loading thread…",
|
||||||
"mail.expandAll": "Expand all",
|
"mail.expandAll": "Expand all",
|
||||||
@@ -182,6 +183,7 @@ export const en = {
|
|||||||
"mail.download": "Download",
|
"mail.download": "Download",
|
||||||
|
|
||||||
"compose.newMessage": "New message",
|
"compose.newMessage": "New message",
|
||||||
|
"compose.showQuoted": "Show or hide the quoted message",
|
||||||
"compose.to": "To",
|
"compose.to": "To",
|
||||||
"compose.cc": "Cc",
|
"compose.cc": "Cc",
|
||||||
"compose.bcc": "Bcc",
|
"compose.bcc": "Bcc",
|
||||||
|
|||||||
@@ -161,6 +161,30 @@ body {
|
|||||||
background: rgba(180, 150, 100, 0.06);
|
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. */
|
/* Thin, unobtrusive scrollbars that match the theme. */
|
||||||
* {
|
* {
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ import type { MessageKey } from "../i18n/en";
|
|||||||
import { api } from "../lib/api";
|
import { api } from "../lib/api";
|
||||||
import { setTimeFormat } from "../lib/timeFormat";
|
import { setTimeFormat } from "../lib/timeFormat";
|
||||||
import type { QuotaOut, SavedSearch, ThreadSummary } from "../lib/types";
|
import type { QuotaOut, SavedSearch, ThreadSummary } from "../lib/types";
|
||||||
|
import { useIsMobile } from "../lib/useIsMobile";
|
||||||
import { useKeyboardShortcuts } from "../lib/useKeyboardShortcuts";
|
import { useKeyboardShortcuts } from "../lib/useKeyboardShortcuts";
|
||||||
import { PluginSlot } from "../plugins/PluginSlot";
|
import { PluginSlot } from "../plugins/PluginSlot";
|
||||||
|
|
||||||
@@ -63,6 +64,10 @@ export function MailLayout() {
|
|||||||
const match = useMatch("/mail/t/:threadId");
|
const match = useMatch("/mail/t/:threadId");
|
||||||
const threadId = match?.params.threadId;
|
const threadId = match?.params.threadId;
|
||||||
const [view, setView] = useState<string>("folder:inbox");
|
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("");
|
const [term, setTerm] = useState("");
|
||||||
// Debounced copy of the search box for search-as-you-type (avoids a request per key).
|
// Debounced copy of the search box for search-as-you-type (avoids a request per key).
|
||||||
const [debouncedTerm, setDebouncedTerm] = useState("");
|
const [debouncedTerm, setDebouncedTerm] = useState("");
|
||||||
@@ -126,8 +131,11 @@ export function MailLayout() {
|
|||||||
// Resizable panel widths (persisted). Drag the dividers to adjust.
|
// Resizable panel widths (persisted). Drag the dividers to adjust.
|
||||||
const [sidebarW, setSidebarW] = useState(() => Number(localStorage.getItem("panel.sidebar")) || 240);
|
const [sidebarW, setSidebarW] = useState(() => Number(localStorage.getItem("panel.sidebar")) || 240);
|
||||||
const [listW, setListW] = useState(() => Number(localStorage.getItem("panel.list")) || 384);
|
const [listW, setListW] = useState(() => Number(localStorage.getItem("panel.list")) || 384);
|
||||||
// Collapsed sidebar shows icons only (persisted).
|
// Collapsed sidebar shows icons only (persisted). The mobile drawer is always full.
|
||||||
const [collapsed, setCollapsed] = useState(() => localStorage.getItem("panel.collapsed") === "1");
|
const [collapsedPref, setCollapsed] = useState(
|
||||||
|
() => localStorage.getItem("panel.collapsed") === "1",
|
||||||
|
);
|
||||||
|
const collapsed = collapsedPref && !mobile;
|
||||||
// Right info panel (Calendar / People / Attachments) — hidden by default.
|
// Right info panel (Calendar / People / Attachments) — hidden by default.
|
||||||
const [rightOpen, setRightOpen] = useState(() => localStorage.getItem("panel.right") === "1");
|
const [rightOpen, setRightOpen] = useState(() => localStorage.getItem("panel.right") === "1");
|
||||||
const [rightTab, setRightTab] = useState<PanelTab>(() => (localStorage.getItem("panel.rightTab") as PanelTab) || "calendar");
|
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);
|
const clampRight = (w: number) => Math.min(Math.max(w, 280), 560);
|
||||||
useEffect(() => localStorage.setItem("panel.sidebar", String(sidebarW)), [sidebarW]);
|
useEffect(() => localStorage.setItem("panel.sidebar", String(sidebarW)), [sidebarW]);
|
||||||
useEffect(() => localStorage.setItem("panel.list", String(listW)), [listW]);
|
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.right", rightOpen ? "1" : "0"), [rightOpen]);
|
||||||
useEffect(() => localStorage.setItem("panel.rightTab", rightTab), [rightTab]);
|
useEffect(() => localStorage.setItem("panel.rightTab", rightTab), [rightTab]);
|
||||||
useEffect(() => localStorage.setItem("panel.rightW", String(rightW)), [rightW]);
|
useEffect(() => localStorage.setItem("panel.rightW", String(rightW)), [rightW]);
|
||||||
@@ -269,6 +280,7 @@ export function MailLayout() {
|
|||||||
};
|
};
|
||||||
const runSavedSearch = (s: SavedSearch) => {
|
const runSavedSearch = (s: SavedSearch) => {
|
||||||
setTerm(s.query);
|
setTerm(s.query);
|
||||||
|
setDrawerOpen(false);
|
||||||
navigate("/mail");
|
navigate("/mail");
|
||||||
};
|
};
|
||||||
// Switch folder/account view. Clears any active search so the folder actually
|
// Switch folder/account view. Clears any active search so the folder actually
|
||||||
@@ -276,8 +288,18 @@ export function MailLayout() {
|
|||||||
const selectView = (v: string) => {
|
const selectView = (v: string) => {
|
||||||
setTerm("");
|
setTerm("");
|
||||||
setView(v);
|
setView(v);
|
||||||
|
setDrawerOpen(false);
|
||||||
navigate("/mail");
|
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
|
// 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.
|
||||||
@@ -473,6 +495,37 @@ export function MailLayout() {
|
|||||||
const bulkRestore = () => runBulk((id) => api.restoreThread(id));
|
const bulkRestore = () => runBulk((id) => api.restoreThread(id));
|
||||||
const bulkDeletePermanent = () => runBulk((id) => api.deleteThreadPermanent(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
|
// "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.
|
// work runs in a worker; refresh the views after it and via the WS message.deleted.
|
||||||
const [emptying, setEmptying] = useState(false);
|
const [emptying, setEmptying] = useState(false);
|
||||||
@@ -576,13 +629,32 @@ export function MailLayout() {
|
|||||||
return () => window.removeEventListener("webmail:labels-changed", onChanged);
|
return () => window.removeEventListener("webmail:labels-changed", onChanged);
|
||||||
}, [queryClient]);
|
}, [queryClient]);
|
||||||
|
|
||||||
|
// On a phone the reading pane replaces the list rather than sitting beside it.
|
||||||
|
const paneShown = Boolean(paneDraft || threadId);
|
||||||
|
|
||||||
return (
|
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 />
|
<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
|
<aside
|
||||||
style={{ width: collapsed ? COLLAPSED_W : sidebarW }}
|
style={mobile ? undefined : { width: collapsed ? COLLAPSED_W : sidebarW }}
|
||||||
className="flex shrink-0 flex-col border-r border-app-border bg-app-bg"
|
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
|
<div
|
||||||
className={`flex pb-1 pt-[11px] ${
|
className={`flex pb-1 pt-[11px] ${
|
||||||
@@ -594,10 +666,12 @@ export function MailLayout() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<img src={signetLogo} alt="Ultimo" className="h-7 w-auto shrink-0" />
|
<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>
|
||||||
)}
|
)}
|
||||||
<div className={`flex items-center gap-1 ${collapsed ? "flex-col" : ""}`}>
|
<div className={`flex items-center gap-1 ${collapsed ? "flex-col" : ""}`}>
|
||||||
|
{!mobile && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setCollapsed((v) => !v)}
|
onClick={() => setCollapsed((v) => !v)}
|
||||||
title={collapsed ? t("nav.expandSidebar") : t("nav.collapseSidebar")}
|
title={collapsed ? t("nav.expandSidebar") : t("nav.collapseSidebar")}
|
||||||
@@ -606,10 +680,11 @@ export function MailLayout() {
|
|||||||
>
|
>
|
||||||
<Icon name={collapsed ? "show" : "hide"} size={16} />
|
<Icon name={collapsed ? "show" : "hide"} size={16} />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
</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
|
<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"
|
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}
|
title={collapsed ? t("nav.compose") : undefined}
|
||||||
@@ -708,10 +783,11 @@ export function MailLayout() {
|
|||||||
{!collapsed && <PluginSlot name="sidebar.section" />}
|
{!collapsed && <PluginSlot name="sidebar.section" />}
|
||||||
</nav>
|
</nav>
|
||||||
<div className="border-t border-app-border p-2 text-[15px]">
|
<div className="border-t border-app-border p-2 text-[15px]">
|
||||||
|
{/* Settings has no phone layout yet either. */}
|
||||||
<Link
|
<Link
|
||||||
className={`flex items-center gap-2.5 rounded-lg py-2 text-app-muted hover:bg-app-hover hover:text-app-fg ${
|
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"
|
collapsed ? "justify-center px-0" : "px-3"
|
||||||
}`}
|
} ${mobile ? "hidden" : ""}`}
|
||||||
title={collapsed ? t("nav.settings") : undefined}
|
title={collapsed ? t("nav.settings") : undefined}
|
||||||
to="/settings"
|
to="/settings"
|
||||||
>
|
>
|
||||||
@@ -743,14 +819,38 @@ export function MailLayout() {
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</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
|
<div
|
||||||
style={{ width: listW }}
|
style={mobile ? undefined : { width: listW }}
|
||||||
className="flex shrink-0 flex-col border-r border-app-border bg-app-surface"
|
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">
|
<div className="relative w-full">
|
||||||
<input
|
<input
|
||||||
ref={searchRef}
|
ref={searchRef}
|
||||||
@@ -799,13 +899,17 @@ export function MailLayout() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!isDrafts && (
|
{!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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={t("mail.selectAll")}
|
aria-label={t("mail.selectAll")}
|
||||||
title={t("mail.selectAll")}
|
title={t("mail.selectAll")}
|
||||||
onClick={toggleSelectAll}
|
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
|
allSelected || someSelected
|
||||||
? "border-app-accent bg-app-accent text-white"
|
? "border-app-accent bg-app-accent text-white"
|
||||||
: "border-app-faint hover:border-app-muted"
|
: "border-app-faint hover:border-app-muted"
|
||||||
@@ -892,6 +996,14 @@ export function MailLayout() {
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="min-h-0 flex-1">
|
||||||
{isDrafts && !term.trim() ? (
|
{isDrafts && !term.trim() ? (
|
||||||
<DraftsList
|
<DraftsList
|
||||||
@@ -922,10 +1034,15 @@ export function MailLayout() {
|
|||||||
checkedIds={checkedIds}
|
checkedIds={checkedIds}
|
||||||
dimmedIds={dimmedIds}
|
dimmedIds={dimmedIds}
|
||||||
onToggleCheck={toggleCheck}
|
onToggleCheck={toggleCheck}
|
||||||
|
compact={mobile}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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
|
<button
|
||||||
className="-ml-1 rounded p-1 text-app-muted hover:bg-app-hover hover:text-app-fg disabled:opacity-60"
|
className="-ml-1 rounded p-1 text-app-muted hover:bg-app-hover hover:text-app-fg disabled:opacity-60"
|
||||||
title={t("nav.refresh")}
|
title={t("nav.refresh")}
|
||||||
@@ -959,12 +1076,26 @@ export function MailLayout() {
|
|||||||
<span />
|
<span />
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<Resizer onDelta={(dx) => setListW((w) => clampList(w + dx))} />
|
{!mobile && <Resizer onDelta={(dx) => setListW((w) => clampList(w + dx))} />}
|
||||||
|
|
||||||
{/* Thread pane */}
|
{/* 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">
|
<main
|
||||||
|
ref={paneRef}
|
||||||
|
className={`relative min-w-0 flex-1 bg-app-surface ${mobile && !paneShown ? "hidden" : ""}`}
|
||||||
|
>
|
||||||
{paneDraft ? (
|
{paneDraft ? (
|
||||||
// Keyed by draft: picking a different one must build a fresh editor,
|
// Keyed by draft: picking a different one must build a fresh editor,
|
||||||
// because RichTextEditor seeds its body exactly once.
|
// because RichTextEditor seeds its body exactly once.
|
||||||
@@ -974,40 +1105,10 @@ export function MailLayout() {
|
|||||||
onClose={closePaneDraft}
|
onClose={closePaneDraft}
|
||||||
fullWindow
|
fullWindow
|
||||||
/>
|
/>
|
||||||
) : checkedIds.size > 0 ? (
|
) : checkedIds.size > 0 && !mobile ? (
|
||||||
<div className="flex h-full flex-col">
|
<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]">
|
<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" ||
|
{bulkButtons}
|
||||||
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>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-1 items-center justify-center text-3xl text-app-faint">
|
<div className="flex flex-1 items-center justify-center text-3xl text-app-faint">
|
||||||
{checkedIds.size === 1
|
{checkedIds.size === 1
|
||||||
@@ -1018,6 +1119,19 @@ export function MailLayout() {
|
|||||||
) : threadId && detailQuery.data ? (
|
) : threadId && detailQuery.data ? (
|
||||||
<div className="flex h-full flex-col">
|
<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]">
|
<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" ? (
|
{currentFolder === "archive" ? (
|
||||||
<>
|
<>
|
||||||
<ToolbarButton onClick={restore}>
|
<ToolbarButton onClick={restore}>
|
||||||
@@ -1060,9 +1174,10 @@ export function MailLayout() {
|
|||||||
</ToolbarButton>
|
</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 })}
|
{t("mail.messageCount", { count: detailQuery.data.messages.length })}
|
||||||
</span>
|
</span>
|
||||||
|
{!mobile && (
|
||||||
<button
|
<button
|
||||||
className={`rounded p-1.5 hover:bg-app-hover hover:text-app-fg ${rightOpen ? "text-app-fg" : "text-app-muted"}`}
|
className={`rounded p-1.5 hover:bg-app-hover hover:text-app-fg ${rightOpen ? "text-app-fg" : "text-app-muted"}`}
|
||||||
title={t("panel.toggle")}
|
title={t("panel.toggle")}
|
||||||
@@ -1075,6 +1190,7 @@ export function MailLayout() {
|
|||||||
<path d="M15 4v16" />
|
<path d="M15 4v16" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-h-0 flex-1 overflow-hidden">
|
<div className="min-h-0 flex-1 overflow-hidden">
|
||||||
<ThreadView
|
<ThreadView
|
||||||
@@ -1107,7 +1223,7 @@ export function MailLayout() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
{rightOpen && (
|
{rightOpen && !mobile && (
|
||||||
<>
|
<>
|
||||||
<Resizer onDelta={(dx) => setRightW((w) => clampRight(w - dx))} />
|
<Resizer onDelta={(dx) => setRightW((w) => clampRight(w - dx))} />
|
||||||
<div style={{ width: rightW }} className="shrink-0 border-l border-app-border">
|
<div style={{ width: rightW }} className="shrink-0 border-l border-app-border">
|
||||||
@@ -1122,6 +1238,7 @@ export function MailLayout() {
|
|||||||
onRestore={restoreComposer}
|
onRestore={restoreComposer}
|
||||||
onToggleExpand={toggleComposerExpand}
|
onToggleExpand={toggleComposerExpand}
|
||||||
onClose={closeComposer}
|
onClose={closeComposer}
|
||||||
|
fullScreen={mobile}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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));
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user