Phone reading pane: icon-only top bar, roomier header; dark app icon
- Top bar: icons only (names kept for screen readers and as tooltips), 20% larger, twice the room between them; Archive 2 px smaller - Subject wraps to two lines; subject-bar icons twice as far apart - Reply / Translate / Actions on their own row with more room around it; the ▼ for the full header can no longer be squeezed out, and the full header spans the card's width - Maskable app icons on the app's dark grey, so Android's launch screen shows the logo without a white circle Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 7.0 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 23 KiB |
@@ -464,6 +464,8 @@ function MessageItem({
|
||||
const t = useT();
|
||||
const { locale } = useLocale();
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
// On a phone the reply / translate / actions buttons get a row of their own.
|
||||
const mobile = useIsMobile();
|
||||
// Translation (app/translation.py): the message's languages, as the server
|
||||
// found them ("zh-Hans,pl,en"), and the language it is shown in (null: as sent).
|
||||
const [language, setLanguage] = useState(message.content_language ?? "");
|
||||
@@ -511,6 +513,73 @@ function MessageItem({
|
||||
const allTargets = replyTargets(message, ownAddresses, "reply_all");
|
||||
const multiRecipient = allTargets.to.length + allTargets.cc.length > 1;
|
||||
|
||||
// The full header (From, To, Cc, Subject, Date, Size), opened with ▼.
|
||||
const details = (
|
||||
<dl className="mt-2 grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 border-t border-app-border pt-2 text-xs text-app-muted">
|
||||
<dt className="text-app-faint">{t("mail.from")}</dt>
|
||||
<dd className="truncate">{formatList(message.from_addr)}</dd>
|
||||
<dt className="text-app-faint">{t("mail.to")}</dt>
|
||||
<dd className="truncate">{formatList(message.to_addr)}</dd>
|
||||
{message.cc_addr.length > 0 && (
|
||||
<>
|
||||
<dt className="text-app-faint">Cc</dt>
|
||||
<dd className="truncate">{formatList(message.cc_addr)}</dd>
|
||||
</>
|
||||
)}
|
||||
<dt className="text-app-faint">{t("mail.subjectLabel")}</dt>
|
||||
<dd className="truncate">{message.subject || "(no subject)"}</dd>
|
||||
<dt className="text-app-faint">{t("mail.dateLabel")}</dt>
|
||||
<dd>{fmtFull(message.date)}</dd>
|
||||
<dt className="text-app-faint">{t("mail.sizeLabel")}</dt>
|
||||
<dd>{fmtSize(message.size_bytes)}</dd>
|
||||
</dl>
|
||||
);
|
||||
|
||||
// Reply, Translate, Actions and plugin verbs: beside the recipients, or on
|
||||
// a phone on a row below them.
|
||||
const actions = (
|
||||
<>
|
||||
{canReply && (
|
||||
<button
|
||||
className="flex items-center gap-1 text-app-muted hover:text-app-fg"
|
||||
onClick={() => onReply(multiRecipient ? "reply_all" : "reply")}
|
||||
>
|
||||
<Icon name={multiRecipient ? "reply-all" : "reply"} size={14} />
|
||||
{multiRecipient ? t("mail.replyAll") : t("mail.reply")}
|
||||
</button>
|
||||
)}
|
||||
{offerTranslate && (target || translateTo) && (
|
||||
<button
|
||||
className="wm-online-only flex items-center gap-1 text-app-muted hover:text-app-fg"
|
||||
onClick={() => setTranslateTo(translateTo ? null : target)}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M4 5h9M8.5 3v2M6 5c.6 3.4 2.6 6 6 7.5M11 5c-.6 3.4-2.8 6.3-7 8" />
|
||||
<path d="M13 21l4-10 4 10M14.5 17.5h5" />
|
||||
</svg>
|
||||
{translateTo ? t("mail.showOriginal") : t("mail.translate")}
|
||||
</button>
|
||||
)}
|
||||
<ActionsMenu
|
||||
messageId={message.id}
|
||||
mailboxId={message.mailbox_id}
|
||||
translateLanguages={translateCodes}
|
||||
translateTo={translateTo}
|
||||
onTranslateTo={setTranslateTo}
|
||||
onReply={onReply}
|
||||
onPrint={onPrint}
|
||||
onDelete={onDelete}
|
||||
replyDisabled={!canReply}
|
||||
allLabels={allLabels}
|
||||
messageLabels={messageLabels}
|
||||
onToggleLabel={onToggleLabel}
|
||||
onCreateLabel={onCreateLabel}
|
||||
/>
|
||||
{/* Plugin-contributed per-message actions (SPEC §11.3 message.actions). */}
|
||||
<PluginSlot name="message.actions" props={{ messageId: message.id }} />
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="message"
|
||||
@@ -574,7 +643,7 @@ function MessageItem({
|
||||
<span className="truncate">
|
||||
{t("mail.to")} {recipientSummary(message.to_addr)}
|
||||
</span>
|
||||
<span className="text-[11px] text-app-faint">{showDetails ? "▲" : "▼"}</span>
|
||||
<span className="shrink-0 text-[11px] text-app-faint">{showDetails ? "▲" : "▼"}</span>
|
||||
</button>
|
||||
<div className="flex shrink-0 items-center gap-3 text-xs">
|
||||
{location && <span className="font-semibold text-app-fg">{location}</span>}
|
||||
@@ -592,73 +661,22 @@ function MessageItem({
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
{canReply && (
|
||||
<button
|
||||
className="flex items-center gap-1 text-app-muted hover:text-app-fg"
|
||||
onClick={() => onReply(multiRecipient ? "reply_all" : "reply")}
|
||||
>
|
||||
<Icon name={multiRecipient ? "reply-all" : "reply"} size={14} />
|
||||
{multiRecipient ? t("mail.replyAll") : t("mail.reply")}
|
||||
</button>
|
||||
)}
|
||||
{offerTranslate && (target || translateTo) && (
|
||||
<button
|
||||
className="wm-online-only flex items-center gap-1 text-app-muted hover:text-app-fg"
|
||||
onClick={() => setTranslateTo(translateTo ? null : target)}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M4 5h9M8.5 3v2M6 5c.6 3.4 2.6 6 6 7.5M11 5c-.6 3.4-2.8 6.3-7 8" />
|
||||
<path d="M13 21l4-10 4 10M14.5 17.5h5" />
|
||||
</svg>
|
||||
{translateTo ? t("mail.showOriginal") : t("mail.translate")}
|
||||
</button>
|
||||
)}
|
||||
<ActionsMenu
|
||||
messageId={message.id}
|
||||
mailboxId={message.mailbox_id}
|
||||
translateLanguages={translateCodes}
|
||||
translateTo={translateTo}
|
||||
onTranslateTo={setTranslateTo}
|
||||
onReply={onReply}
|
||||
onPrint={onPrint}
|
||||
onDelete={onDelete}
|
||||
replyDisabled={!canReply}
|
||||
allLabels={allLabels}
|
||||
messageLabels={messageLabels}
|
||||
onToggleLabel={onToggleLabel}
|
||||
onCreateLabel={onCreateLabel}
|
||||
/>
|
||||
{/* Plugin-contributed per-message actions (SPEC §11.3 message.actions). */}
|
||||
<PluginSlot name="message.actions" props={{ messageId: message.id }} />
|
||||
{!mobile && actions}
|
||||
</div>
|
||||
</div>
|
||||
{mobile && (
|
||||
<div className="mb-2 mt-3 flex items-center justify-end gap-4 text-xs">{actions}</div>
|
||||
)}
|
||||
{alsoIn.length > 0 && (
|
||||
<div className="mt-0.5 truncate text-xs text-app-faint">
|
||||
{t("mail.alsoIn")} {alsoIn.join(", ")}
|
||||
</div>
|
||||
)}
|
||||
{showDetails && (
|
||||
<dl className="mt-2 grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 border-t border-app-border pt-2 text-xs text-app-muted">
|
||||
<dt className="text-app-faint">{t("mail.from")}</dt>
|
||||
<dd className="truncate">{formatList(message.from_addr)}</dd>
|
||||
<dt className="text-app-faint">{t("mail.to")}</dt>
|
||||
<dd className="truncate">{formatList(message.to_addr)}</dd>
|
||||
{message.cc_addr.length > 0 && (
|
||||
<>
|
||||
<dt className="text-app-faint">Cc</dt>
|
||||
<dd className="truncate">{formatList(message.cc_addr)}</dd>
|
||||
</>
|
||||
)}
|
||||
<dt className="text-app-faint">{t("mail.subjectLabel")}</dt>
|
||||
<dd className="truncate">{message.subject || "(no subject)"}</dd>
|
||||
<dt className="text-app-faint">{t("mail.dateLabel")}</dt>
|
||||
<dd>{fmtFull(message.date)}</dd>
|
||||
<dt className="text-app-faint">{t("mail.sizeLabel")}</dt>
|
||||
<dd>{fmtSize(message.size_bytes)}</dd>
|
||||
</dl>
|
||||
)}
|
||||
{showDetails && !mobile && details}
|
||||
</div>
|
||||
</div>
|
||||
{/* On a phone the full header takes the card's width, not the column beside the avatar. */}
|
||||
{showDetails && mobile && <div className="px-4 pb-2">{details}</div>}
|
||||
{/* Plugin-contributed per-message banner (SPEC §11.3 message.banner). */}
|
||||
<PluginSlot
|
||||
name="message.banner"
|
||||
@@ -891,12 +909,23 @@ export function ThreadView({
|
||||
|
||||
return (
|
||||
<div data-testid="thread-view" className="flex h-full flex-col overflow-y-auto bg-app-bg">
|
||||
<div className="sticky top-0 z-10 flex h-[52px] shrink-0 items-center justify-between gap-2 border-b border-app-border bg-app-surface px-4">
|
||||
<div
|
||||
className={`sticky top-0 z-10 flex shrink-0 items-center justify-between gap-2 border-b border-app-border bg-app-surface px-4 ${
|
||||
mobile ? "min-h-[52px] py-2" : "h-[52px]" // on a phone the subject may take two lines
|
||||
}`}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<h2 className="truncate text-[17px] font-semibold text-app-fg">{subject || "(no subject)"}</h2>
|
||||
<h2
|
||||
className={`text-[17px] font-semibold text-app-fg ${
|
||||
mobile ? "line-clamp-2 break-words leading-snug" : "truncate"
|
||||
}`}
|
||||
>
|
||||
{subject || "(no subject)"}
|
||||
</h2>
|
||||
<LabelChips labels={threadLabels} onRemove={(l) => removeThreadLabel.mutate(l)} />
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{/* On a phone the icons get twice the room between them. */}
|
||||
<div className={`flex shrink-0 items-center ${mobile ? "gap-5" : "gap-1"}`}>
|
||||
{/* Dark message mode (lib/darkMessage.ts) — only the dark theme has it. */}
|
||||
{resolvedTheme === "dark" && (
|
||||
<button
|
||||
|
||||
@@ -128,6 +128,8 @@ export function MailLayout() {
|
||||
// Smartphone layout: one column at a time — the list, or the reading pane full
|
||||
// screen — with the sidebar as a slide-in drawer. Desktop is untouched by it.
|
||||
const mobile = useIsMobile();
|
||||
// The reading pane's top bar: icons only on a phone, 20% larger.
|
||||
const paneIcon = mobile ? 19 : 16;
|
||||
// Desktop arrangement of list and reading pane (Settings → Appearance, or the
|
||||
// list's ⋯ menu → Mailbox display). A phone always uses its own single panel.
|
||||
const readingPane = useReadingPane();
|
||||
@@ -1923,55 +1925,78 @@ export function MailLayout() {
|
||||
)}
|
||||
{currentFolder === "archive" ? (
|
||||
<>
|
||||
<ToolbarButton onClick={restore}>
|
||||
<Icon name="inbox" size={16} />
|
||||
{t("mail.restore")}
|
||||
</ToolbarButton>
|
||||
<ToolbarButton onClick={trash}>
|
||||
<Icon name="trash" size={16} />
|
||||
{t("mail.delete")}
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={restore}
|
||||
iconOnly={mobile}
|
||||
icon={<Icon name="inbox" size={paneIcon} />}
|
||||
label={t("mail.restore")}
|
||||
/>
|
||||
<ToolbarButton
|
||||
onClick={trash}
|
||||
iconOnly={mobile}
|
||||
icon={<Icon name="trash" size={paneIcon} />}
|
||||
label={t("mail.delete")}
|
||||
/>
|
||||
</>
|
||||
) : currentFolder === "trash" ? (
|
||||
<>
|
||||
<ToolbarButton onClick={restore}>
|
||||
<Icon name="inbox" size={16} />
|
||||
{t("mail.restore")}
|
||||
</ToolbarButton>
|
||||
<ToolbarButton onClick={deletePermanent}>
|
||||
<Icon name="trash" size={16} />
|
||||
{t("mail.delete")}
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={restore}
|
||||
iconOnly={mobile}
|
||||
icon={<Icon name="inbox" size={paneIcon} />}
|
||||
label={t("mail.restore")}
|
||||
/>
|
||||
<ToolbarButton
|
||||
onClick={deletePermanent}
|
||||
iconOnly={mobile}
|
||||
icon={<Icon name="trash" size={paneIcon} />}
|
||||
label={t("mail.delete")}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ToolbarButton onClick={archive}>
|
||||
<Icon name="archive" size={16} />
|
||||
{t("mail.archive")}
|
||||
</ToolbarButton>
|
||||
<ToolbarButton onClick={togglePin} active={detailQuery.data.pinned}>
|
||||
<ToolbarButton
|
||||
onClick={archive}
|
||||
iconOnly={mobile}
|
||||
icon={<Icon name="archive" size={mobile ? paneIcon - 2 : paneIcon} />}
|
||||
label={t("mail.archive")}
|
||||
/>
|
||||
<ToolbarButton
|
||||
onClick={togglePin}
|
||||
active={detailQuery.data.pinned}
|
||||
iconOnly={mobile}
|
||||
icon={
|
||||
<Icon
|
||||
name="pin"
|
||||
size={16}
|
||||
size={paneIcon}
|
||||
className={detailQuery.data.pinned ? "text-[rgb(220_129_143)]" : ""}
|
||||
/>
|
||||
{detailQuery.data.pinned ? t("mail.unpin") : t("mail.pin")}
|
||||
</ToolbarButton>
|
||||
<ToolbarButton onClick={trash}>
|
||||
<Icon name="trash" size={16} />
|
||||
{t("mail.delete")}
|
||||
</ToolbarButton>
|
||||
}
|
||||
label={detailQuery.data.pinned ? t("mail.unpin") : t("mail.pin")}
|
||||
/>
|
||||
<ToolbarButton
|
||||
onClick={trash}
|
||||
iconOnly={mobile}
|
||||
icon={<Icon name="trash" size={paneIcon} />}
|
||||
label={t("mail.delete")}
|
||||
/>
|
||||
{detailQuery.data.snoozed_until ? (
|
||||
<ToolbarButton onClick={unsnooze} active>
|
||||
<Clock size={16} />
|
||||
{t("snooze.unsnooze")}
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={unsnooze}
|
||||
active
|
||||
iconOnly={mobile}
|
||||
icon={<Clock size={paneIcon} />}
|
||||
label={t("snooze.unsnooze")}
|
||||
/>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<ToolbarButton onClick={() => setSnoozeOpen((v) => !v)} active={snoozeOpen}>
|
||||
<Clock size={16} />
|
||||
{t("snooze.action")}
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={() => setSnoozeOpen((v) => !v)}
|
||||
active={snoozeOpen}
|
||||
iconOnly={mobile}
|
||||
icon={<Clock size={paneIcon} />}
|
||||
label={t("snooze.action")}
|
||||
/>
|
||||
{snoozeOpen && <SnoozeMenu onPick={snoozeThread} onClose={closeSnooze} />}
|
||||
</div>
|
||||
)}
|
||||
@@ -2338,18 +2363,29 @@ function ToolbarButton({
|
||||
onClick,
|
||||
active = false,
|
||||
children,
|
||||
icon,
|
||||
label,
|
||||
iconOnly = false,
|
||||
}: {
|
||||
onClick: () => void;
|
||||
active?: boolean;
|
||||
children: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
/** With ``label``: the icon and its name, the name only for screen readers
|
||||
* and as a tooltip when ``iconOnly`` (the phone layout). */
|
||||
icon?: React.ReactNode;
|
||||
label?: string;
|
||||
iconOnly?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className={`wm-online-only flex items-center gap-1.5 rounded px-2.5 py-1 hover:bg-app-hover hover:text-app-fg ${
|
||||
active ? "text-app-fg" : "text-app-muted"
|
||||
}`}
|
||||
className={`wm-online-only flex items-center gap-1.5 rounded py-1 hover:bg-app-hover hover:text-app-fg ${
|
||||
iconOnly ? "px-[18px]" : "px-2.5" // icons only (phone): twice the room between them
|
||||
} ${active ? "text-app-fg" : "text-app-muted"}`}
|
||||
onClick={onClick}
|
||||
title={iconOnly ? label : undefined}
|
||||
>
|
||||
{icon}
|
||||
{label !== undefined && <span className={iconOnly ? "sr-only" : undefined}>{label}</span>}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user