Productivity features, UI translations, local email translation

- Snooze (with a Snoozed folder), keyboard shortcuts with a Settings
  section, mail templates (Compose > More > Templates), a Subscriptions
  folder with bulk unsubscribe/delete, and tracker blocking with a
  Privacy toggle (on by default)
- Own dialogs instead of browser popups; emoji picker in compose; compose
  windows survive a visit to Settings
- Sentence completion learned from the user's sent mail (no AI)
- Interface in English and Polish (one file per language, English as the
  fallback), picked from the browser on first run, then kept
- Email translation on the server via LibreTranslate: Translate button
  and "Translate to...", messages in several languages translated part
  by part, names/addresses/emoji/file names/phone numbers kept, an
  animated line while translating
- Antispam "Mark as spam" loads with the message, not when the menu opens

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-18 20:00:31 +02:00
co-authored by Claude Opus 5.5
parent 463f96ad57
commit 8a1b9979e9
83 changed files with 8677 additions and 293 deletions
+235 -96
View File
@@ -1,7 +1,8 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
import { useT } from "../../i18n";
import { languageName, needsTranslation, sameLanguage, translate, useLocale, useT } from "../../i18n";
import type { MessageKey } from "../../i18n/en";
import { personHue } from "../../lib/avatarColor";
import { setDarkMessages, useDarkMessages } from "../../lib/darkMessage";
import { api } from "../../lib/api";
@@ -17,6 +18,7 @@ import { Icon } from "../Icon";
import { AvatarImage } from "../SenderAvatar";
import { clearPdfPasswordCache } from "./AttachmentPreview";
import { LabelChips } from "../mail/LabelChips";
import { useSubscriptionActions } from "../list/SubscriptionsList";
import { MessageBody } from "./MessageBody";
// Direction-agnostic thread view (invariant I-5 / SPEC §9.4).
@@ -54,13 +56,13 @@ function attributionFor(msg: MessageEnvelope): string {
day: "numeric",
year: "numeric",
});
const time = d.toLocaleTimeString("en-US", {
const time = d.toLocaleTimeString(getDateLocale(), {
hour: "numeric",
minute: "2-digit",
hour12: getHour12(),
});
const who = msg.from_addr[0]?.name || msg.from_addr[0]?.email || "";
return `On ${date}, at ${time}, ${who} wrote:`;
return translate("compose.attribution", { date, time, who });
}
/** The header block above a forwarded message, as mail programs write it:
@@ -137,16 +139,16 @@ function hue(msg: MessageEnvelope): number {
function fmtRelative(iso: string): string {
const d = new Date(iso);
const base = d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
const base = d.toLocaleDateString(getDateLocale(), { month: "short", day: "numeric" });
const diff = Date.now() - d.getTime();
const mins = Math.round(diff / 60000);
const hrs = Math.round(diff / 3600000);
const days = Math.round(diff / 86400000);
let rel = "";
if (mins < 1) rel = "just now";
else if (mins < 60) rel = `${mins} min ago`;
else if (hrs < 24) rel = `${hrs} hour${hrs === 1 ? "" : "s"} ago`;
else if (days < 30) rel = `${days} day${days === 1 ? "" : "s"} ago`;
if (mins < 1) rel = translate("time.justNow");
else if (mins < 60) rel = translate("time.minAgo", { count: mins });
else if (hrs < 24) rel = translate("time.hoursAgo", { count: hrs });
else if (days < 30) rel = translate("time.daysAgo", { count: days });
return rel ? `${base} (${rel})` : `${base} ${d.getFullYear()}`;
}
@@ -162,23 +164,56 @@ function fmtFull(iso: string): string {
});
}
// "Unsubscribe" beside the sender of bulk mail (its List-Unsubscribe header),
// as Gmail has it; "Unsubscribed" once done.
function UnsubscribeLink({ message, name }: { message: MessageEnvelope; name: string }) {
const t = useT();
const { unsubscribe, dialog } = useSubscriptionActions();
if (message.unsubscribed) {
return <span className="shrink-0 text-xs text-app-faint">{t("subs.unsubscribed")}</span>;
}
return (
<>
{dialog}
<button
className="wm-online-only shrink-0 rounded text-xs font-medium text-app-accent hover:underline disabled:opacity-50"
disabled={unsubscribe.isPending}
onClick={() =>
message.subscription_key &&
message.unsubscribe_via &&
unsubscribe.mutate({
key: message.subscription_key,
name,
via: message.unsubscribe_via,
messageId: message.id,
})
}
>
{t("subs.unsubscribe")}
</button>
</>
);
}
function fmtSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
const LOCATION_LABEL: Record<string, string> = {
inbox: "Inbox",
sent: "Sent",
archive: "Archive",
drafts: "Drafts",
trash: "Trash",
junk: "Spam",
// A message's folder, named as in the sidebar (in the interface language).
const LOCATION_LABEL: Record<string, MessageKey> = {
inbox: "nav.inbox",
sent: "nav.sent",
archive: "nav.archive",
drafts: "nav.drafts",
trash: "nav.trash",
junk: "nav.spam",
};
function ActionsMenu({
messageId,
mailboxId,
onReply,
onPrint,
onDelete,
@@ -187,8 +222,13 @@ function ActionsMenu({
messageLabels,
onToggleLabel,
onCreateLabel,
translateLanguages,
translateTo,
onTranslateTo,
}: {
messageId: string;
/** The message's folder: a move gives plugin items a fresh start. */
mailboxId: string;
onReply: (kind: string) => void;
onPrint: () => void;
onDelete: () => void;
@@ -197,10 +237,16 @@ function ActionsMenu({
messageLabels: string[];
onToggleLabel: (label: string) => void;
onCreateLabel: (name: string) => void;
/** Languages a message can be translated into; empty: translation is off. */
translateLanguages: string[];
translateTo: string | null;
onTranslateTo: (code: string | null) => void;
}) {
const t = useT();
const { locale } = useLocale();
const [open, setOpen] = useState(false);
const [labelsOpen, setLabelsOpen] = useState(false);
const [translateOpen, setTranslateOpen] = useState(false);
const [creating, setCreating] = useState(false);
const [newName, setNewName] = useState("");
const ref = useRef<HTMLDivElement>(null);
@@ -220,7 +266,7 @@ function ActionsMenu({
};
}, [open]);
useEffect(() => {
if (!open) { setLabelsOpen(false); setCreating(false); setNewName(""); }
if (!open) { setLabelsOpen(false); setTranslateOpen(false); setCreating(false); setNewName(""); }
}, [open]);
const item = (label: string, onClick: () => void, disabled = false) => (
@@ -249,79 +295,121 @@ function ActionsMenu({
{t("mail.actions")}
<span className="text-[11px]">▾</span>
</button>
{open && (
<div className="absolute right-0 z-20 mt-1 w-52 overflow-hidden rounded-md border border-app-border bg-app-elevated py-1 shadow-lg">
{item(t("mail.reply"), () => onReply("reply"), replyDisabled)}
{item(t("mail.replyAll"), () => onReply("reply_all"), replyDisabled)}
{item(t("mail.forward"), () => onReply("forward"), replyDisabled)}
{item(t("mail.forwardAsAttachment"), () => onReply("forward"), replyDisabled)}
{item(t("mail.print"), onPrint)}
<div className="my-1 border-t border-app-border" />
{/* Labels submenu: multi-select checkboxes (menu stays open) + create new. */}
<button
className="flex w-full items-center justify-between px-3 py-1.5 text-left text-xs text-app-fg hover:bg-app-hover"
onClick={() => setLabelsOpen((v) => !v)}
>
{t("mail.labels")}
<span className="text-[11px] text-app-muted">{labelsOpen ? "▾" : "▸"}</span>
</button>
{labelsOpen && (
<div className="max-h-52 overflow-auto border-y border-app-border bg-app-surface/40">
{allLabels.map((l) => (
<label
key={l}
className="flex cursor-pointer items-center gap-2 px-3 py-1 text-xs text-app-fg hover:bg-app-hover"
>
<input
type="checkbox"
className="h-3.5 w-3.5 accent-app-accent"
checked={messageLabels.includes(l)}
onChange={() => onToggleLabel(l)}
/>
<span className="truncate">{l}</span>
</label>
))}
{allLabels.length === 0 && (
<div className="px-3 py-1.5 text-xs text-app-faint">{t("mail.noLabels")}</div>
)}
{creating ? (
{/* Always rendered, hidden while closed: plugin items (below) load with
the message, not when the menu opens — on a slow connection their
fetch would otherwise hold them back for seconds. */}
<div
hidden={!open}
className="absolute right-0 z-20 mt-1 w-52 overflow-hidden rounded-md border border-app-border bg-app-elevated py-1 shadow-lg"
>
{item(t("mail.reply"), () => onReply("reply"), replyDisabled)}
{item(t("mail.replyAll"), () => onReply("reply_all"), replyDisabled)}
{item(t("mail.forward"), () => onReply("forward"), replyDisabled)}
{item(t("mail.forwardAsAttachment"), () => onReply("forward"), replyDisabled)}
{item(t("mail.print"), onPrint)}
{translateLanguages.length > 0 && (
<>
{/* Translate to…: any language the server has; ✓ the one shown. */}
<button
className="flex w-full items-center justify-between px-3 py-1.5 text-left text-xs text-app-fg hover:bg-app-hover"
onClick={() => setTranslateOpen((v) => !v)}
>
{t("mail.translateTo")}
<span className="text-[11px] text-app-muted">{translateOpen ? "▾" : "▸"}</span>
</button>
{translateOpen && (
<div className="max-h-56 overflow-auto border-y border-app-border bg-app-surface/40">
{translateTo &&
item(t("mail.showOriginal"), () => onTranslateTo(null))}
{translateLanguages
.map((code) => ({ code, name: languageName(code, locale) }))
.sort((a, b) => a.name.localeCompare(b.name, locale))
.map(({ code, name }) => (
<button
key={code}
className="flex w-full items-center gap-2 px-3 py-1 text-left text-xs text-app-fg hover:bg-app-hover"
onClick={() => {
setOpen(false);
onTranslateTo(code);
}}
>
<span className="w-3 shrink-0 text-app-accent">{translateTo === code ? "✓" : ""}</span>
<span className="truncate first-letter:uppercase">{name}</span>
</button>
))}
</div>
)}
</>
)}
<div className="my-1 border-t border-app-border" />
{/* Labels submenu: multi-select checkboxes (menu stays open) + create new. */}
<button
className="flex w-full items-center justify-between px-3 py-1.5 text-left text-xs text-app-fg hover:bg-app-hover"
onClick={() => setLabelsOpen((v) => !v)}
>
{t("mail.labels")}
<span className="text-[11px] text-app-muted">{labelsOpen ? "▾" : "▸"}</span>
</button>
{labelsOpen && (
<div className="max-h-52 overflow-auto border-y border-app-border bg-app-surface/40">
{allLabels.map((l) => (
<label
key={l}
className="flex cursor-pointer items-center gap-2 px-3 py-1 text-xs text-app-fg hover:bg-app-hover"
>
<input
autoFocus
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && newName.trim()) {
onCreateLabel(newName.trim());
setNewName("");
setCreating(false);
} else if (e.key === "Escape") {
setCreating(false);
setNewName("");
}
}}
placeholder={t("mail.labelName")}
className="m-1 w-[calc(100%-0.5rem)] rounded border border-app-border bg-app-surface px-2 py-1 text-xs outline-none focus:border-app-accent"
type="checkbox"
className="h-3.5 w-3.5 accent-app-accent"
checked={messageLabels.includes(l)}
onChange={() => onToggleLabel(l)}
/>
) : (
<button
className="block w-full px-3 py-1.5 text-left text-xs text-app-accent hover:bg-app-hover"
onClick={() => setCreating(true)}
>
{t("mail.createLabel")}
</button>
)}
</div>
)}
{/* Plugin verbs that belong in the menu rather than on the header
(SPEC §11.3 message.actions.menu). Clicking anything inside closes
the menu, so a bundle only has to perform its action. */}
<div onClick={() => setOpen(false)}>
<PluginSlot name="message.actions.menu" props={{ messageId }} />
<span className="truncate">{l}</span>
</label>
))}
{allLabels.length === 0 && (
<div className="px-3 py-1.5 text-xs text-app-faint">{t("mail.noLabels")}</div>
)}
{creating ? (
<input
autoFocus
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && newName.trim()) {
onCreateLabel(newName.trim());
setNewName("");
setCreating(false);
} else if (e.key === "Escape") {
setCreating(false);
setNewName("");
}
}}
placeholder={t("mail.labelName")}
className="m-1 w-[calc(100%-0.5rem)] rounded border border-app-border bg-app-surface px-2 py-1 text-xs outline-none focus:border-app-accent"
/>
) : (
<button
className="block w-full px-3 py-1.5 text-left text-xs text-app-accent hover:bg-app-hover"
onClick={() => setCreating(true)}
>
{t("mail.createLabel")}
</button>
)}
</div>
<div className="my-1 border-t border-app-border" />
{item(t("mail.delete"), onDelete)}
)}
{/* Plugin verbs that belong in the menu rather than on the header
(SPEC §11.3 message.actions.menu). Clicking anything inside closes
the menu, so a bundle only has to perform its action. */}
<div onClick={() => setOpen(false)}>
<PluginSlot
key={`${messageId}:${mailboxId}`}
name="message.actions.menu"
props={{ messageId }}
/>
</div>
)}
<div className="my-1 border-t border-app-border" />
{item(t("mail.delete"), onDelete)}
</div>
</div>
);
}
@@ -374,7 +462,24 @@ function MessageItem({
ownAddresses: Set<string>;
}) {
const t = useT();
const { locale } = useLocale();
const [showDetails, setShowDetails] = useState(false);
// 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 ?? "");
const [translateTo, setTranslateTo] = useState<string | null>(null);
const translation = useQuery({
queryKey: ["translation-languages"],
queryFn: api.translationLanguages,
staleTime: 10 * 60_000,
});
const translateCodes = translation.data?.available
? translation.data.languages.map((l) => l.code)
: [];
const offerTranslate =
translateCodes.length > 0 &&
(translateTo !== null || needsTranslation(language, locale));
const target = translateCodes.find((c) => sameLanguage(c, locale)) ?? null;
const replyBtn = (kind: string, label: string, icon: string) => (
<button
className="flex items-center gap-1.5 rounded-md border border-app-border px-3 py-1.5 text-xs text-app-fg hover:bg-app-hover"
@@ -435,19 +540,30 @@ function MessageItem({
<div className="flex items-start gap-3 px-4 pb-2 pt-3">
{avatar()}
<div className="min-w-0 flex-1">
{/* line 1: sender + email ············· date (whole row collapses) */}
<button
className="flex w-full items-baseline justify-between gap-2 text-left"
onClick={onToggle}
>
<span className="min-w-0 truncate text-[15px]">
{/* line 1: sender + email · Unsubscribe ··········· date (the row collapses) */}
<div className="flex w-full items-baseline gap-2">
<button className="min-w-0 truncate text-left text-[15px]" onClick={onToggle}>
{senderName}
{message.from_addr[0]?.email && (
<span className="text-app-faint"> &lt;{message.from_addr[0].email}&gt;</span>
)}
</span>
<span className="shrink-0 text-xs text-app-faint">{fmtRelative(message.date)}</span>
</button>
</button>
{message.subscription_key && message.unsubscribe_via && (
<UnsubscribeLink
message={message}
name={message.from_addr[0]?.name || message.from_addr[0]?.email || ""}
/>
)}
<button
className="min-w-4 flex-1 self-stretch"
onClick={onToggle}
tabIndex={-1}
aria-hidden
/>
<button className="shrink-0 text-xs text-app-faint" onClick={onToggle}>
{fmtRelative(message.date)}
</button>
</div>
{/* line 2: to … ▼ ············· location ○ reply actions */}
<div className="mt-0.5 flex items-center justify-between gap-2">
<button
@@ -485,8 +601,24 @@ function MessageItem({
{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}
@@ -533,7 +665,13 @@ function MessageItem({
props={{ messageId: message.id, hasAttachments: message.has_attachments }}
/>
<div className="px-4 pb-3">
<MessageBody message={message} onMailto={onMailto} />
<MessageBody
message={message}
onMailto={onMailto}
translateTo={translateTo}
onLanguage={setLanguage}
onShowOriginal={() => setTranslateTo(null)}
/>
</div>
{showReplyBar && canReply && (
<div className="flex gap-2 border-t border-app-border px-4 py-3">
@@ -658,7 +796,8 @@ export function ThreadView({
}, [mailboxes]);
const locationFor = (msg: MessageEnvelope) => {
const su = specialUse.get(msg.mailbox_id);
return su ? (LOCATION_LABEL[su] ?? su.charAt(0).toUpperCase() + su.slice(1)) : "";
if (!su) return "";
return LOCATION_LABEL[su] ? t(LOCATION_LABEL[su]) : su.charAt(0).toUpperCase() + su.slice(1);
};
const trashFor = (accountId: string) =>
(mailboxes ?? []).find((m) => m.account_id === accountId && m.special_use === "trash")?.id;