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:
@@ -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"> <{message.from_addr[0].email}></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;
|
||||
|
||||
Reference in New Issue
Block a user