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
+17 -9
View File
@@ -4,9 +4,9 @@ import { useEffect, useRef, useState } from "react";
import { useT } from "../../i18n";
import { api, ApiError } from "../../lib/api";
import type { AccountOut, AttachmentOut, DraftInput, DraftOut } from "../../lib/types";
import { Icon } from "../Icon";
import { RecipientChips } from "./RecipientChips";
import { RichTextEditor, type RichTextEditorHandle } from "./RichTextEditor";
import { hasUndoHost, offerUndo } from "./undoSend";
const MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024; // 25 MB per file
@@ -102,6 +102,9 @@ function HeaderButton({
);
}
// Send / Attach / Close icons, half again as big at phone width for the thumb.
const PHONE_ICON = "max-md:h-[22.5px] max-md:w-[22.5px]";
const caret = (
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M4 6l4 4 4-4" strokeLinecap="round" strokeLinejoin="round" />
@@ -437,6 +440,12 @@ export function Composer({
const res = await api.send(draftId.current);
// Drive the countdown + auto-close off the server's undo deadline.
const until = new Date(res.undo_until).getTime();
// With a toast to carry the undo offer, the window closes right away.
if (hasUndoHost()) {
offerUndo({ outboxId: res.outbox_id, draftId: draftId.current, until });
onClose();
return;
}
const remaining = Math.max(0, until - Date.now());
setUndo({ outboxId: res.outbox_id, until });
setUndoLeft(Math.ceil(remaining / 1000));
@@ -609,14 +618,17 @@ export function Composer({
disabled={status === "sending" || (!to && !cc)}
className="flex items-center gap-2 rounded-md bg-app-accent px-3 py-1.5 text-sm font-medium text-app-accent-fg hover:opacity-90 disabled:opacity-50"
>
<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" className={PHONE_ICON}>
<path d="M14.5 1.5L7.2 8.8" />
<path d="M14.5 1.5L9.8 14.5 7.2 8.8 1.5 6.2 14.5 1.5z" />
</svg>
{status === "sending" ? t("compose.sending") : t("compose.send")}
{/* Phone width: the icon alone. */}
<span className="max-md:hidden">
{status === "sending" ? t("compose.sending") : t("compose.send")}
</span>
</button>
<HeaderButton onClick={() => fileInputRef.current?.click()} title={t("compose.attach")}>
<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" className={PHONE_ICON}>
<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>
{/* Phone width: the paperclip alone, so the close button still fits. */}
@@ -724,7 +736,7 @@ export function Composer({
</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">
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" className={PHONE_ICON}>
<path d="M4 4l8 8M12 4l-8 8" />
</svg>
</HeaderButton>
@@ -749,10 +761,6 @@ export function Composer({
<span className="text-app-muted">{fromName ? `<${fromEmail}>` : fromEmail}</span>
{accounts.length > 1 && <span className="text-app-faint">{caret}</span>}
</button>
<div className="ml-auto flex items-center gap-1 text-xs text-app-muted">
<Icon name="reply" size={13} />
{t("compose.replyLabel")}
</div>
{fromOpen && (
<div className="absolute left-12 top-full z-30 mt-1 w-80 overflow-hidden rounded-lg border border-app-border bg-app-elevated py-1 shadow-xl">
{accounts.map((a) => (
@@ -0,0 +1,78 @@
import { useEffect, useState } from "react";
import { useT } from "../../i18n";
import { api } from "../../lib/api";
import { clearUndo, registerUndoHost, usePendingSend } from "./undoSend";
/**
* The "Undo send" offer for the message just sent, shown after its composer has
* closed: bottom right on a desktop, a small "Undo" pill bottom left on a phone
* (clear of the Compose button). `onUndone` gets the draft back to reopen it.
*/
export function UndoSendToast({
mobile,
onUndone,
}: {
mobile: boolean;
onUndone: (draftId: string) => void;
}) {
const t = useT();
const pending = usePendingSend();
const [left, setLeft] = useState(0);
useEffect(registerUndoHost, []);
useEffect(() => {
if (!pending) return;
const tick = () => {
const ms = pending.until - Date.now();
setLeft(Math.max(0, Math.ceil(ms / 1000)));
if (ms <= 0) clearUndo(pending.outboxId);
};
tick();
const id = window.setInterval(tick, 500);
return () => window.clearInterval(id);
}, [pending]);
if (!pending) return null;
const undo = async () => {
clearUndo(pending.outboxId);
try {
await api.undoSend(pending.outboxId);
} catch {
return; // too late: it has already gone
}
onUndone(pending.draftId);
};
if (mobile) {
return (
<button
type="button"
onClick={() => void undo()}
// Level with the Compose button on the other side of the screen.
className="fixed bottom-[calc(4.375rem+env(safe-area-inset-bottom))] left-4 z-40 flex h-11 items-center gap-2 rounded-xl bg-app-fg px-4 text-[15px] font-medium text-app-accent shadow-lg active:opacity-90"
>
{t("compose.undo")}
{left > 0 && <span className="tabular-nums text-app-surface/70">{left}</span>}
</button>
);
}
return (
<div className="fixed bottom-4 right-4 z-50 flex w-80 items-center justify-between rounded-lg bg-app-fg px-4 py-2.5 text-sm text-app-surface shadow-lg">
<span>{t("compose.sending")}</span>
<span className="flex items-center gap-1.5">
{left > 0 && (
<span className="tabular-nums text-app-surface/70">
{t("compose.undoIn", { count: left })}
</span>
)}
<button className="font-medium text-app-accent hover:underline" onClick={() => void undo()}>
{t("compose.undo")}
</button>
</span>
</div>
);
}
@@ -0,0 +1,53 @@
// The pending "Undo send" of the message just sent, held outside any composer so
// the composer can close the moment Send is clicked while the undo offer lives
// on in a toast (`UndoSendToast`).
import { useSyncExternalStore } from "react";
export interface PendingSend {
outboxId: string;
draftId: string;
/** Epoch ms when the server's undo window closes. */
until: number;
}
let pending: PendingSend | null = null;
// How many toasts are mounted to show an offer. With none (the popped-out
// compose window), the composer keeps its own inline undo bar instead.
let hosts = 0;
const listeners = new Set<() => void>();
function emit() {
for (const l of listeners) l();
}
function subscribe(l: () => void) {
listeners.add(l);
return () => listeners.delete(l);
}
export function hasUndoHost(): boolean {
return hosts > 0;
}
export function registerUndoHost(): () => void {
hosts += 1;
return () => {
hosts -= 1;
};
}
export function offerUndo(p: PendingSend) {
pending = p;
emit();
}
export function clearUndo(outboxId?: string) {
if (!pending || (outboxId && pending.outboxId !== outboxId)) return;
pending = null;
emit();
}
export function usePendingSend(): PendingSend | null {
return useSyncExternalStore(subscribe, () => pending);
}