Compose: close on Send and offer Undo from a toast; phone-size touch targets

Clicking Send now closes the compose window right away. The undo offer moved
out of the composer into a toast: bottom right on a desktop, a small "Undo"
pill with the countdown bottom left on a phone, where sending also returns to
the message list. An undone send reopens the draft in a composer. The
popped-out compose window keeps its inline undo bar, since closing it would
lose the offer.

Also: drop the "Reply" label from the From row; on a phone show the Send
button as its icon alone, enlarge the Send/Attach/Close icons by half, put the
logo beside the menu button, make the reading view's back arrow bolder, and
make the list's pagination bar half again as big.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 19:37:54 +02:00
co-authored by Claude Opus 5.5
parent fbca6cb728
commit 6e65664766
5 changed files with 193 additions and 23 deletions
+45 -13
View File
@@ -6,6 +6,8 @@ import signetLogo from "../../icons/ultimo-logo-signet.svg";
import { AppSwitcher } from "../components/AppSwitcher";
import { ComposeDock } from "../components/compose/ComposeDock";
import { Composer, type ComposerInit, draftToInit } from "../components/compose/Composer";
import { UndoSendToast } from "../components/compose/UndoSendToast";
import { usePendingSend } from "../components/compose/undoSend";
import {
collapseExpanded,
type ComposeWin,
@@ -344,6 +346,24 @@ export function MailLayout() {
if (threadId) navigate("/mail"); // the pane shows one thing at a time
};
// Sent from a phone: back to the list, where the Undo offer waits.
const pendingSend = usePendingSend();
useEffect(() => {
if (pendingSend && mobile && threadId) navigate("/mail");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pendingSend?.outboxId]);
// An undone send is a draft again: open it back up where it can be edited.
const reopenUnsent = async (draftId: string) => {
refreshDrafts();
try {
const draft = (await api.listDrafts()).find((d) => d.id === draftId);
if (draft) openComposer(draftToInit(draft));
} catch {
/* it is still in Drafts */
}
};
const closePaneDraft = () => {
setPaneDraft(null);
refreshDrafts();
@@ -837,6 +857,7 @@ export function MailLayout() {
mobile ? "gap-2 px-3" : "px-6"
}`}
>
{mobile && <img src={signetLogo} alt="Ultimo" className="h-7 w-auto shrink-0" />}
{mobile && (
<button
type="button"
@@ -844,7 +865,7 @@ export function MailLayout() {
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"
className="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" />
@@ -1039,18 +1060,21 @@ export function MailLayout() {
)}
</div>
<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"
className={`flex items-center justify-between border-t border-app-border py-1.5 text-app-muted ${
// Phone: everything half again as big, for the thumb.
mobile
? "px-4 pb-[max(0.375rem,env(safe-area-inset-bottom))] text-[19.5px]"
: "px-6 text-[13px]"
}`}
>
<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 text-app-muted ${mobile ? "p-1.5" : "p-1"} hover:bg-app-hover hover:text-app-fg disabled:opacity-60`}
title={t("nav.refresh")}
aria-label={t("nav.refresh")}
onClick={doRefresh}
disabled={refreshing}
>
<RefreshIcon spinning={refreshing} />
<RefreshIcon spinning={refreshing} size={mobile ? 22.5 : 15} />
</button>
{!isDrafts && total > 0 ? (
<div className="flex items-center gap-3">
@@ -1063,12 +1087,14 @@ export function MailLayout() {
onClick={() => setPage(page - 1)}
label={t("pager.prev")}
dir="left"
big={mobile}
/>
<PagerButton
disabled={pageEnd >= total}
onClick={() => setPage(page + 1)}
label={t("pager.next")}
dir="right"
big={mobile}
/>
</div>
</div>
@@ -1082,7 +1108,7 @@ export function MailLayout() {
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"
className="absolute bottom-[calc(4rem+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>
@@ -1125,9 +1151,9 @@ export function MailLayout() {
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"
className="-ml-1 mr-1 rounded p-1.5 text-app-fg hover:bg-app-hover"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M15 5l-7 7 7 7" />
</svg>
</button>
@@ -1240,6 +1266,7 @@ export function MailLayout() {
onClose={closeComposer}
fullScreen={mobile}
/>
<UndoSendToast mobile={mobile} onUndone={reopenUnsent} />
</div>
);
}
@@ -1308,32 +1335,37 @@ function PagerButton({
onClick,
label,
dir,
big = false,
}: {
disabled: boolean;
onClick: () => void;
label: string;
dir: "left" | "right";
big?: boolean;
}) {
const size = big ? 21 : 14;
return (
<button
disabled={disabled}
onClick={onClick}
title={label}
aria-label={label}
className="rounded p-1 text-app-muted hover:bg-app-hover hover:text-app-fg disabled:pointer-events-none disabled:text-app-faint disabled:opacity-40"
className={`rounded text-app-muted hover:bg-app-hover hover:text-app-fg disabled:pointer-events-none disabled:text-app-faint disabled:opacity-40 ${
big ? "p-1.5" : "p-1"
}`}
>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<svg width={size} height={size} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d={dir === "left" ? "M10 4l-4 4 4 4" : "M6 4l4 4-4 4"} />
</svg>
</button>
);
}
function RefreshIcon({ spinning }: { spinning: boolean }) {
function RefreshIcon({ spinning, size = 15 }: { spinning: boolean; size?: number }) {
return (
<svg
width="15"
height="15"
width={size}
height={size}
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"