From 6e65664766b9d9de8a0e80e5105a515215a0f242 Mon Sep 17 00:00:00 2001 From: Lukas Remis Date: Wed, 30 Sep 2026 19:37:54 +0200 Subject: [PATCH] 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 --- frontend/src/components/compose/Composer.tsx | 26 ++++--- .../src/components/compose/UndoSendToast.tsx | 78 +++++++++++++++++++ frontend/src/components/compose/undoSend.ts | 53 +++++++++++++ frontend/src/i18n/en.ts | 1 - frontend/src/routes/MailLayout.tsx | 58 ++++++++++---- 5 files changed, 193 insertions(+), 23 deletions(-) create mode 100644 frontend/src/components/compose/UndoSendToast.tsx create mode 100644 frontend/src/components/compose/undoSend.ts diff --git a/frontend/src/components/compose/Composer.tsx b/frontend/src/components/compose/Composer.tsx index 72b4bae..cfe143b 100644 --- a/frontend/src/components/compose/Composer.tsx +++ b/frontend/src/components/compose/Composer.tsx @@ -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 = ( @@ -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" > - + - {status === "sending" ? t("compose.sending") : t("compose.send")} + {/* Phone width: the icon alone. */} + + {status === "sending" ? t("compose.sending") : t("compose.send")} + fileInputRef.current?.click()} title={t("compose.attach")}> - + {/* Phone width: the paperclip alone, so the close button still fits. */} @@ -724,7 +736,7 @@ export function Composer({ )} - + @@ -749,10 +761,6 @@ export function Composer({ {fromName ? `<${fromEmail}>` : fromEmail} {accounts.length > 1 && {caret}} -
- - {t("compose.replyLabel")} -
{fromOpen && (
{accounts.map((a) => ( diff --git a/frontend/src/components/compose/UndoSendToast.tsx b/frontend/src/components/compose/UndoSendToast.tsx new file mode 100644 index 0000000..0d76aae --- /dev/null +++ b/frontend/src/components/compose/UndoSendToast.tsx @@ -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 ( + + ); + } + + return ( +
+ {t("compose.sending")} + + {left > 0 && ( + + {t("compose.undoIn", { count: left })} + + )} + + +
+ ); +} diff --git a/frontend/src/components/compose/undoSend.ts b/frontend/src/components/compose/undoSend.ts new file mode 100644 index 0000000..421893f --- /dev/null +++ b/frontend/src/components/compose/undoSend.ts @@ -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); +} diff --git a/frontend/src/i18n/en.ts b/frontend/src/i18n/en.ts index 4a241fe..02d6e9f 100644 --- a/frontend/src/i18n/en.ts +++ b/frontend/src/i18n/en.ts @@ -212,7 +212,6 @@ export const en = { "compose.expand": "Expand", "compose.collapse": "Collapse", "compose.popOut": "Pop out", - "compose.replyLabel": "Reply", "compose.addRecipient": "Add recipient…", "compose.fmtBold": "Bold", "compose.fmtItalic": "Italic", diff --git a/frontend/src/routes/MailLayout.tsx b/frontend/src/routes/MailLayout.tsx index 3fe45cd..1ac3350 100644 --- a/frontend/src/routes/MailLayout.tsx +++ b/frontend/src/routes/MailLayout.tsx @@ -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 && Ultimo} {mobile && (
{!isDrafts && total > 0 ? (
@@ -1063,12 +1087,14 @@ export function MailLayout() { onClick={() => setPage(page - 1)} label={t("pager.prev")} dir="left" + big={mobile} /> = total} onClick={() => setPage(page + 1)} label={t("pager.next")} dir="right" + big={mobile} />
@@ -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" > @@ -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" > - + @@ -1240,6 +1266,7 @@ export function MailLayout() { onClose={closeComposer} fullScreen={mobile} /> + ); } @@ -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 ( ); } -function RefreshIcon({ spinning }: { spinning: boolean }) { +function RefreshIcon({ spinning, size = 15 }: { spinning: boolean; size?: number }) { return (