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:
@@ -4,9 +4,9 @@ import { useEffect, useRef, useState } from "react";
|
|||||||
import { useT } from "../../i18n";
|
import { useT } from "../../i18n";
|
||||||
import { api, ApiError } from "../../lib/api";
|
import { api, ApiError } from "../../lib/api";
|
||||||
import type { AccountOut, AttachmentOut, DraftInput, DraftOut } from "../../lib/types";
|
import type { AccountOut, AttachmentOut, DraftInput, DraftOut } from "../../lib/types";
|
||||||
import { Icon } from "../Icon";
|
|
||||||
import { RecipientChips } from "./RecipientChips";
|
import { RecipientChips } from "./RecipientChips";
|
||||||
import { RichTextEditor, type RichTextEditorHandle } from "./RichTextEditor";
|
import { RichTextEditor, type RichTextEditorHandle } from "./RichTextEditor";
|
||||||
|
import { hasUndoHost, offerUndo } from "./undoSend";
|
||||||
|
|
||||||
const MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024; // 25 MB per file
|
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 = (
|
const caret = (
|
||||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
<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" />
|
<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);
|
const res = await api.send(draftId.current);
|
||||||
// Drive the countdown + auto-close off the server's undo deadline.
|
// Drive the countdown + auto-close off the server's undo deadline.
|
||||||
const until = new Date(res.undo_until).getTime();
|
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());
|
const remaining = Math.max(0, until - Date.now());
|
||||||
setUndo({ outboxId: res.outbox_id, until });
|
setUndo({ outboxId: res.outbox_id, until });
|
||||||
setUndoLeft(Math.ceil(remaining / 1000));
|
setUndoLeft(Math.ceil(remaining / 1000));
|
||||||
@@ -609,14 +618,17 @@ export function Composer({
|
|||||||
disabled={status === "sending" || (!to && !cc)}
|
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"
|
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.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" />
|
<path d="M14.5 1.5L9.8 14.5 7.2 8.8 1.5 6.2 14.5 1.5z" />
|
||||||
</svg>
|
</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>
|
</button>
|
||||||
<HeaderButton onClick={() => fileInputRef.current?.click()} title={t("compose.attach")}>
|
<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" />
|
<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>
|
</svg>
|
||||||
{/* Phone width: the paperclip alone, so the close button still fits. */}
|
{/* Phone width: the paperclip alone, so the close button still fits. */}
|
||||||
@@ -724,7 +736,7 @@ export function Composer({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<HeaderButton onClick={requestClose} title={t("common.close")}>
|
<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" />
|
<path d="M4 4l8 8M12 4l-8 8" />
|
||||||
</svg>
|
</svg>
|
||||||
</HeaderButton>
|
</HeaderButton>
|
||||||
@@ -749,10 +761,6 @@ export function Composer({
|
|||||||
<span className="text-app-muted">{fromName ? `<${fromEmail}>` : fromEmail}</span>
|
<span className="text-app-muted">{fromName ? `<${fromEmail}>` : fromEmail}</span>
|
||||||
{accounts.length > 1 && <span className="text-app-faint">{caret}</span>}
|
{accounts.length > 1 && <span className="text-app-faint">{caret}</span>}
|
||||||
</button>
|
</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 && (
|
{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">
|
<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) => (
|
{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);
|
||||||
|
}
|
||||||
@@ -212,7 +212,6 @@ export const en = {
|
|||||||
"compose.expand": "Expand",
|
"compose.expand": "Expand",
|
||||||
"compose.collapse": "Collapse",
|
"compose.collapse": "Collapse",
|
||||||
"compose.popOut": "Pop out",
|
"compose.popOut": "Pop out",
|
||||||
"compose.replyLabel": "Reply",
|
|
||||||
"compose.addRecipient": "Add recipient…",
|
"compose.addRecipient": "Add recipient…",
|
||||||
"compose.fmtBold": "Bold",
|
"compose.fmtBold": "Bold",
|
||||||
"compose.fmtItalic": "Italic",
|
"compose.fmtItalic": "Italic",
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import signetLogo from "../../icons/ultimo-logo-signet.svg";
|
|||||||
import { AppSwitcher } from "../components/AppSwitcher";
|
import { AppSwitcher } from "../components/AppSwitcher";
|
||||||
import { ComposeDock } from "../components/compose/ComposeDock";
|
import { ComposeDock } from "../components/compose/ComposeDock";
|
||||||
import { Composer, type ComposerInit, draftToInit } from "../components/compose/Composer";
|
import { Composer, type ComposerInit, draftToInit } from "../components/compose/Composer";
|
||||||
|
import { UndoSendToast } from "../components/compose/UndoSendToast";
|
||||||
|
import { usePendingSend } from "../components/compose/undoSend";
|
||||||
import {
|
import {
|
||||||
collapseExpanded,
|
collapseExpanded,
|
||||||
type ComposeWin,
|
type ComposeWin,
|
||||||
@@ -344,6 +346,24 @@ export function MailLayout() {
|
|||||||
if (threadId) navigate("/mail"); // the pane shows one thing at a time
|
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 = () => {
|
const closePaneDraft = () => {
|
||||||
setPaneDraft(null);
|
setPaneDraft(null);
|
||||||
refreshDrafts();
|
refreshDrafts();
|
||||||
@@ -837,6 +857,7 @@ export function MailLayout() {
|
|||||||
mobile ? "gap-2 px-3" : "px-6"
|
mobile ? "gap-2 px-3" : "px-6"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
{mobile && <img src={signetLogo} alt="Ultimo" className="h-7 w-auto shrink-0" />}
|
||||||
{mobile && (
|
{mobile && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -844,7 +865,7 @@ export function MailLayout() {
|
|||||||
aria-label={t("nav.menu")}
|
aria-label={t("nav.menu")}
|
||||||
aria-expanded={drawerOpen}
|
aria-expanded={drawerOpen}
|
||||||
onClick={() => setDrawerOpen(true)}
|
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>
|
<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" />
|
<path d="M4 7h16M4 12h16M4 17h16" />
|
||||||
@@ -1039,18 +1060,21 @@ export function MailLayout() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={`flex items-center justify-between border-t border-app-border py-1.5 text-[13px] text-app-muted ${
|
className={`flex items-center justify-between border-t border-app-border py-1.5 text-app-muted ${
|
||||||
mobile ? "px-4 pb-[max(0.375rem,env(safe-area-inset-bottom))]" : "px-6"
|
// 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
|
<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")}
|
title={t("nav.refresh")}
|
||||||
aria-label={t("nav.refresh")}
|
aria-label={t("nav.refresh")}
|
||||||
onClick={doRefresh}
|
onClick={doRefresh}
|
||||||
disabled={refreshing}
|
disabled={refreshing}
|
||||||
>
|
>
|
||||||
<RefreshIcon spinning={refreshing} />
|
<RefreshIcon spinning={refreshing} size={mobile ? 22.5 : 15} />
|
||||||
</button>
|
</button>
|
||||||
{!isDrafts && total > 0 ? (
|
{!isDrafts && total > 0 ? (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -1063,12 +1087,14 @@ export function MailLayout() {
|
|||||||
onClick={() => setPage(page - 1)}
|
onClick={() => setPage(page - 1)}
|
||||||
label={t("pager.prev")}
|
label={t("pager.prev")}
|
||||||
dir="left"
|
dir="left"
|
||||||
|
big={mobile}
|
||||||
/>
|
/>
|
||||||
<PagerButton
|
<PagerButton
|
||||||
disabled={pageEnd >= total}
|
disabled={pageEnd >= total}
|
||||||
onClick={() => setPage(page + 1)}
|
onClick={() => setPage(page + 1)}
|
||||||
label={t("pager.next")}
|
label={t("pager.next")}
|
||||||
dir="right"
|
dir="right"
|
||||||
|
big={mobile}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1082,7 +1108,7 @@ export function MailLayout() {
|
|||||||
title={t("nav.compose")}
|
title={t("nav.compose")}
|
||||||
aria-label={t("nav.compose")}
|
aria-label={t("nav.compose")}
|
||||||
onClick={() => accounts[0] && openComposer({ accountId: accounts[0].id, kind: "new" })}
|
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} />
|
<Icon name="compose" size={22} />
|
||||||
</button>
|
</button>
|
||||||
@@ -1125,9 +1151,9 @@ export function MailLayout() {
|
|||||||
title={t("common.back")}
|
title={t("common.back")}
|
||||||
aria-label={t("common.back")}
|
aria-label={t("common.back")}
|
||||||
onClick={() => navigate("/mail")}
|
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" />
|
<path d="M15 5l-7 7 7 7" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
@@ -1240,6 +1266,7 @@ export function MailLayout() {
|
|||||||
onClose={closeComposer}
|
onClose={closeComposer}
|
||||||
fullScreen={mobile}
|
fullScreen={mobile}
|
||||||
/>
|
/>
|
||||||
|
<UndoSendToast mobile={mobile} onUndone={reopenUnsent} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1308,32 +1335,37 @@ function PagerButton({
|
|||||||
onClick,
|
onClick,
|
||||||
label,
|
label,
|
||||||
dir,
|
dir,
|
||||||
|
big = false,
|
||||||
}: {
|
}: {
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
label: string;
|
label: string;
|
||||||
dir: "left" | "right";
|
dir: "left" | "right";
|
||||||
|
big?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const size = big ? 21 : 14;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
title={label}
|
title={label}
|
||||||
aria-label={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"} />
|
<path d={dir === "left" ? "M10 4l-4 4 4 4" : "M6 4l4 4-4 4"} />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function RefreshIcon({ spinning }: { spinning: boolean }) {
|
function RefreshIcon({ spinning, size = 15 }: { spinning: boolean; size?: number }) {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
width="15"
|
width={size}
|
||||||
height="15"
|
height={size}
|
||||||
viewBox="0 0 16 16"
|
viewBox="0 0 16 16"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
|
|||||||
Reference in New Issue
Block a user