Mail mark-all-read + iframe fixes; Contacts detail/selection/groups + resizable panels
Mail:
- New "More" (⋯) menu in the list sorting bar with "Mark all as read": POST
/threads/mark-read marks every unread thread in the current view (unified /
folder:<special> / account:<uuid>) as \Seen, pushes + publishes updates.
- Message iframe: no longer shows a spurious vertical scrollbar (display:block,
getBoundingClientRect+ceil sizing, overflow-y:hidden on the doc) and has
rounded corners.
- Deep links from Contacts: /mail?compose=1&to= opens a maximized prefilled
composer; /mail?q= prefills the search box.
Contacts:
- Detail pane shows all populated fields (work subtitle, phones, address, urls,
nickname, birthday). Company name is used as the display name when there's no
personal name (existing rows too). Editable "Add notes".
- Selection: checkbox reveals on avatar hover only; Groups/More bulk menus in
select mode; "N contacts selected" panel; filled yellow VIP star in list +
detail; pagination bottom bar; Lato pinned on form controls.
- Groups: "+" on the GROUPS header and "Create new" in the group menus (bulk
add/remove via POST /groups/{id}/add|remove, ownership-guarded).
- Rounded Mail-style list rows; removed the ⋯ button from the search bar;
Compose→Mail, Find-all-conversations→Mail search; Message button removed;
delete confirmation; resizable sidebar + list/detail split.
Calendar: the right panel (agenda / search) is now resizable.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U72FZiF1W1pb8XxyWBTREA
This commit is contained in:
@@ -49,7 +49,24 @@ export function MessageBody({
|
||||
// Collapse first so scrollHeight reflects the content, not the current frame
|
||||
// height — otherwise the frame can grow but never shrink (e.g. hiding a quote).
|
||||
frame.style.height = "0px";
|
||||
const h = doc.documentElement.scrollHeight;
|
||||
const de = doc.documentElement;
|
||||
const body = doc.body;
|
||||
// Float-accurate content bottom (getBoundingClientRect keeps sub-pixels that
|
||||
// scrollHeight would floor), combined with the integer scroll/offset heights.
|
||||
const rectBottom = body ? body.getBoundingClientRect().bottom : 0;
|
||||
let h = Math.ceil(
|
||||
Math.max(
|
||||
rectBottom,
|
||||
de.scrollHeight,
|
||||
de.offsetHeight,
|
||||
body?.scrollHeight ?? 0,
|
||||
body?.offsetHeight ?? 0,
|
||||
),
|
||||
);
|
||||
// If the email overflows horizontally, its bottom scrollbar steals vertical
|
||||
// space — leave room for it so the content doesn't get a vertical scrollbar too.
|
||||
if (de.scrollWidth > de.clientWidth) h += 18;
|
||||
h += 2; // final guard against any residual rounding
|
||||
frame.style.height = `${h}px`;
|
||||
setHeight(h);
|
||||
}, []);
|
||||
@@ -103,8 +120,16 @@ export function MessageBody({
|
||||
const onLoad = () => {
|
||||
const doc = frameRef.current?.contentDocument;
|
||||
if (!doc) return;
|
||||
// The frame is auto-sized to its content, so it must never scroll itself — the
|
||||
// thread panel is the scroll container. Hiding the document's own vertical
|
||||
// overflow removes any phantom scrollbar left by sub-pixel/line-box rounding
|
||||
// (horizontal stays scrollable for genuinely wide emails).
|
||||
doc.documentElement.style.overflowY = "hidden";
|
||||
injectQuoteToggle(doc);
|
||||
resize();
|
||||
// Re-measure after the first paint settles (fonts/layout can shift the height
|
||||
// by a hair, which is enough to leave a spurious scrollbar).
|
||||
requestAnimationFrame(() => requestAnimationFrame(resize));
|
||||
doc.addEventListener("click", handleFrameClick);
|
||||
// Re-measure as images finish loading (they change the content height).
|
||||
doc.querySelectorAll("img").forEach((img) => {
|
||||
@@ -154,7 +179,7 @@ export function MessageBody({
|
||||
srcDoc={data}
|
||||
onLoad={onLoad}
|
||||
style={{ height }}
|
||||
className="w-full border-0 bg-white"
|
||||
className="block w-full rounded-lg border-0 bg-white"
|
||||
/>
|
||||
)}
|
||||
{message.has_attachments && <Attachments messageId={message.id} />}
|
||||
|
||||
@@ -97,6 +97,8 @@ export const en = {
|
||||
"mail.alsoIn": "Also delivered to",
|
||||
"mail.emptyTrash": "Empty trash",
|
||||
"mail.emptySpam": "Empty spam",
|
||||
"mail.more": "More",
|
||||
"mail.markAllRead": "Mark all as read",
|
||||
"mail.emptyConfirm": "Permanently delete everything in this folder? This can't be undone.",
|
||||
"mail.loadFailed": "Couldn't load this message.",
|
||||
"mail.imagesBlocked": "Images from this sender are blocked.",
|
||||
|
||||
@@ -251,6 +251,11 @@ export const api = {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ add, remove }),
|
||||
}),
|
||||
markAllRead: (view: string) =>
|
||||
apiFetch<{ changed: number }>("/threads/mark-read", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ view }),
|
||||
}),
|
||||
moveMessage: (id: string, mailboxId: string) =>
|
||||
apiFetch<{ id: string; mailbox_id: string }>(`/messages/${id}/move`, {
|
||||
method: "POST",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Link, useMatch, useNavigate } from "react-router-dom";
|
||||
import { Link, useMatch, useNavigate, useSearchParams } from "react-router-dom";
|
||||
|
||||
import { AppSwitcher } from "../components/AppSwitcher";
|
||||
import { Composer, type ComposerInit, draftToInit } from "../components/compose/Composer";
|
||||
@@ -400,6 +400,15 @@ export function MailLayout() {
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
const markAllRead = useMutation({
|
||||
mutationFn: () => api.markAllRead(view),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["threads"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["folder-counts"] });
|
||||
},
|
||||
});
|
||||
|
||||
const openRelative = (delta: number) => {
|
||||
const list = threadsRef.current;
|
||||
if (list.length === 0) return;
|
||||
@@ -423,6 +432,33 @@ export function MailLayout() {
|
||||
|
||||
const accounts = accountsQuery.data ?? [];
|
||||
|
||||
// Deep link (e.g. Contacts "Compose"): /mail?compose=1&to=<email> opens a new,
|
||||
// maximized composer prefilled with the recipient, then strips the params.
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
useEffect(() => {
|
||||
if (searchParams.get("compose") !== "1") return;
|
||||
const first = accountsQuery.data?.[0];
|
||||
if (!first) return; // wait until accounts have loaded
|
||||
openComposer({ accountId: first.id, kind: "new", to: searchParams.get("to") ?? "" }, true);
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.delete("compose");
|
||||
next.delete("to");
|
||||
setSearchParams(next, { replace: true });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [searchParams, accountsQuery.data]);
|
||||
|
||||
// Deep link (e.g. Contacts "Find all conversations"): /mail?q=<text> prefills the
|
||||
// search box (which drives the search), then strips the param.
|
||||
useEffect(() => {
|
||||
const q = searchParams.get("q");
|
||||
if (q == null) return;
|
||||
setTerm(q);
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.delete("q");
|
||||
setSearchParams(next, { replace: true });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [searchParams]);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-app-bg text-app-fg">
|
||||
<BackfillBanner />
|
||||
@@ -700,6 +736,37 @@ export function MailLayout() {
|
||||
>
|
||||
<SortIcon />
|
||||
</button>
|
||||
<div className="relative">
|
||||
<button
|
||||
className="rounded p-1.5 text-app-muted hover:bg-app-hover hover:text-app-fg"
|
||||
title={t("mail.more")}
|
||||
aria-label={t("mail.more")}
|
||||
onClick={() => setMoreOpen((o) => !o)}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
|
||||
<circle cx="5" cy="12" r="1.6" />
|
||||
<circle cx="12" cy="12" r="1.6" />
|
||||
<circle cx="19" cy="12" r="1.6" />
|
||||
</svg>
|
||||
</button>
|
||||
{moreOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setMoreOpen(false)} />
|
||||
<div className="absolute right-0 z-20 mt-1 min-w-[180px] rounded-lg border border-app-border bg-app-surface py-1 shadow-lg">
|
||||
<button
|
||||
className="flex w-full items-center px-3 py-2 text-left text-[13px] text-app-fg hover:bg-app-hover disabled:opacity-50"
|
||||
disabled={markAllRead.isPending}
|
||||
onClick={() => {
|
||||
setMoreOpen(false);
|
||||
markAllRead.mutate();
|
||||
}}
|
||||
>
|
||||
{t("mail.markAllRead")}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user