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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user