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:
2026-10-19 07:59:56 +02:00
co-authored by Claude Opus 5.5
parent 21cfaa07ed
commit 1aa0673720
4 changed files with 171 additions and 106 deletions
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

+91 -62
View File
@@ -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
+76 -40
View File
@@ -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>
);