Extract 515 strings by codemod, and the two bugs only a screenshot caught
Wrapping ~1,000 strings by hand is a thousand chances to mistype the copy
itself, and a parser does not get bored. scripts/i18n-extract.mjs does the
mechanical part -- JSX text and the attributes a person actually reads -- and
refuses the rest rather than guessing. 78% now: 515 wrapped, 143 left.
What it refuses matters as much as what it does. Text split around an
interpolation arrives as separate fragments, and wrapping each on its own
produces "Move " and " messages", which no translator can do anything with;
those are listed for a person to rebuild as sentences. So is anything
containing a double quote, which would end the literal.
Three things it had to be taught, each found by running it:
- <code>, <kbd> and <pre> are not prose. The first run wrapped `label:name`
inside <code> -- a search operator, where translating it breaks the thing it
documents. Subtrees marked translate="no" are skipped for the same reason.
- `t` is a natural name for a callback parameter and several files already use
it, so an import called `t` is shadowed inside those callbacks -- silently,
wherever the local happens to be callable. The name is checked per file now
and aliased to `translate` where it is taken.
- JSX decodes HTML entities and a JS string literal does not, so
`Language & region` moved into t("...") and rendered the entity on screen.
That last one is the one worth remembering. Typecheck passed, 443 tests
passed, and the page said "Language & region" in plain sight. It took
looking at a screenshot, and then a sweep of ten views to find the second
occurrence in a sentence I had written by hand earlier the same day. Nothing
in the toolchain was ever going to catch it: it is valid TypeScript rendering
valid text that happens to be wrong.
The codemod decodes entities now, and checks for a quote after decoding rather
than before.
This commit is contained in:
@@ -8,6 +8,7 @@ import { formatAddress } from "@/lib/address";
|
||||
import { MenuItem, MenuSep, Popover, type Anchor } from "@/ui/popover";
|
||||
import { toast } from "@/ui/toast";
|
||||
import { ContactEditor } from "../contacts/ContactEditor";
|
||||
import { t } from "@/lib/i18n";
|
||||
|
||||
/**
|
||||
* Right-click on anyone named in a message — sender, recipients, Reply-To — to
|
||||
@@ -43,16 +44,16 @@ export function useAddressMenu() {
|
||||
<div className="menu-title truncate">{formatAddress(menu.address)}</div>
|
||||
{contacts.available && (
|
||||
known ? (
|
||||
<MenuItem icon={<Pencil size={16} />} label="Edit contact" onClick={() => { setEditing(known); close(); }} />
|
||||
<MenuItem icon={<Pencil size={16} />} label={t("Edit contact")} onClick={() => { setEditing(known); close(); }} />
|
||||
) : (
|
||||
<MenuItem icon={<UserPlus size={16} />} label="Add to contacts" onClick={() => { setEditing(contactFromAddress(menu.address)); close(); }} />
|
||||
<MenuItem icon={<UserPlus size={16} />} label={t("Add to contacts")} onClick={() => { setEditing(contactFromAddress(menu.address)); close(); }} />
|
||||
)
|
||||
)}
|
||||
<MenuItem icon={<Mail size={16} />} label="New message to this address" onClick={() => { openCompose({ to: [menu.address] }); close(); }} />
|
||||
<MenuItem icon={<Mail size={16} />} label={t("New message to this address")} onClick={() => { openCompose({ to: [menu.address] }); close(); }} />
|
||||
<MenuSep />
|
||||
<MenuItem
|
||||
icon={<Copy size={16} />}
|
||||
label="Copy email address"
|
||||
label={t("Copy email address")}
|
||||
onClick={() => {
|
||||
void navigator.clipboard?.writeText(menu.address.email).then(
|
||||
() => toast.show("Address copied"),
|
||||
@@ -85,7 +86,7 @@ export function AddressList({ list, onContext, empty = "—" }: { list: EmailAdd
|
||||
{list.map((a, i) => (
|
||||
<span key={`${a.email}-${i}`}>
|
||||
{i > 0 && ", "}
|
||||
<span className="addr" onContextMenu={(ev) => onContext(ev, a)} title="Right-click for options">{formatAddress(a)}</span>
|
||||
<span className="addr" onContextMenu={(ev) => onContext(ev, a)} title={t("Right-click for options")}>{formatAddress(a)}</span>
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { RuleDialog } from "../settings/RuleDialog";
|
||||
import { toast } from "@/ui/toast";
|
||||
import { Spinner } from "@/ui/misc";
|
||||
import { Dialog } from "@/ui/dialog";
|
||||
import { t } from "@/lib/i18n";
|
||||
|
||||
/** "Filter messages like this…" — creates a Sieve rule seeded from a message, optionally applying it to the current folder. */
|
||||
export function FilterFromMessageDialog({ email, mailboxId, onClose }: { email: Email; mailboxId: Id | null; onClose: () => void }) {
|
||||
@@ -26,17 +27,17 @@ export function FilterFromMessageDialog({ email, mailboxId, onClose }: { email:
|
||||
|
||||
if (!sieve.available) {
|
||||
return (
|
||||
<Dialog open onClose={onClose} title="Filters unavailable" size="sm" footer={<button className="btn" onClick={onClose}>Close</button>}>
|
||||
<p>Sieve filtering is not enabled for this account.</p>
|
||||
<Dialog open onClose={onClose} title={t("Filters unavailable")} size="sm" footer={<button className="btn" onClick={onClose}>{t("Close")}</button>}>
|
||||
<p>{t("Sieve filtering is not enabled for this account.")}</p>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
if (!ready) return <Dialog open onClose={onClose} title="Create filter" size="sm"><Spinner /></Dialog>;
|
||||
if (!ready) return <Dialog open onClose={onClose} title={t("Create filter")} size="sm"><Spinner /></Dialog>;
|
||||
|
||||
const { rules, loaded, damage } = sieve.rules();
|
||||
if (rules === null) {
|
||||
return (
|
||||
<Dialog open onClose={onClose} title="Create filter" size="sm" footer={<button className="btn" onClick={onClose}>Close</button>}>
|
||||
<Dialog open onClose={onClose} title={t("Create filter")} size="sm" footer={<button className="btn" onClick={onClose}>{t("Close")}</button>}>
|
||||
{/*
|
||||
Three different situations, and telling them apart matters: one is
|
||||
permanent and two are a reload away. Saying "written by hand" when the
|
||||
@@ -46,9 +47,9 @@ export function FilterFromMessageDialog({ email, mailboxId, onClose }: { email:
|
||||
{damage ? (
|
||||
<p>Your filter script {damage}, so only part of it arrived. Adding a rule would write that part back over the whole thing. Reload the page and try again.</p>
|
||||
) : loaded ? (
|
||||
<p>Your active Sieve script was written by hand, so rules can't be added automatically. Open <b>Settings → Filters & rules</b> to edit the script or switch to managed rules.</p>
|
||||
<p>Your active Sieve script was written by hand, so rules can't be added automatically. Open <b>{t("Settings → Filters & rules")}</b> to edit the script or switch to managed rules.</p>
|
||||
) : (
|
||||
<p>Your filter script couldn't be read just now, so adding a rule would risk overwriting it. Reload the page and try again.</p>
|
||||
<p>{t("Your filter script couldn't be read just now, so adding a rule would risk overwriting it. Reload the page and try again.")}</p>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
@@ -57,7 +58,7 @@ export function FilterFromMessageDialog({ email, mailboxId, onClose }: { email:
|
||||
return (
|
||||
<RuleDialog
|
||||
rule={rule}
|
||||
title="Filter messages like this"
|
||||
title={t("Filter messages like this")}
|
||||
saveLabel="Create filter"
|
||||
applyMailbox={mailbox ? { id: mailbox.id, name: mailbox.name } : null}
|
||||
applyByDefault
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { CalendarEvent, Email, EmailBodyPart } from "@/jmap/types";
|
||||
import { useCalendar, toInstance, myParticipantKeys, isAttendee, participantEmail } from "@/store/calendar";
|
||||
import { formatTimeRange } from "@/lib/dates";
|
||||
import { toast } from "@/ui/toast";
|
||||
import { t } from "@/lib/i18n";
|
||||
|
||||
export function InviteCard({ email, part }: { email: Email; part: EmailBodyPart }) {
|
||||
const cal = useCalendar();
|
||||
@@ -110,12 +111,12 @@ export function InviteCard({ email, part }: { email: Email; part: EmailBodyPart
|
||||
) : (
|
||||
!existing && <button className="btn btn-sm" disabled={Boolean(busy)} onClick={() => void addToCalendar()}><Calendar size={14} /> Add to calendar</button>
|
||||
)}
|
||||
{existing && inst && <button className="btn btn-ghost btn-sm" onClick={() => navigate(`/calendar/day/${inst.start.toISOString().slice(0, 10)}`)}>Open in calendar</button>}
|
||||
{existing && inst && <button className="btn btn-ghost btn-sm" onClick={() => navigate(`/calendar/day/${inst.start.toISOString().slice(0, 10)}`)}>{t("Open in calendar")}</button>}
|
||||
</div>
|
||||
)}
|
||||
{method === "CANCEL" && existing && (
|
||||
<div className="rsvp">
|
||||
<button className="btn btn-sm btn-danger" disabled={Boolean(busy)} onClick={async () => { try { await cal.destroyEvent(existing, false, "series"); setExisting(null); toast.success("Removed from calendar"); } catch (err) { toast.error((err as Error).message); } }}>Remove from calendar</button>
|
||||
<button className="btn btn-sm btn-danger" disabled={Boolean(busy)} onClick={async () => { try { await cal.destroyEvent(existing, false, "series"); setExisting(null); toast.success("Removed from calendar"); } catch (err) { toast.error((err as Error).message); } }}>{t("Remove from calendar")}</button>
|
||||
</div>
|
||||
)}
|
||||
<span className="sr-only">{email.id}</span>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useMail } from "@/store/mail";
|
||||
import { Popover } from "@/ui/popover";
|
||||
import type { Id } from "@/jmap/types";
|
||||
import { CALENDAR_COLORS } from "@/ui/misc";
|
||||
import { t } from "@/lib/i18n";
|
||||
|
||||
/** Labels are IMAP keywords on the messages; their names/colors live in settings. */
|
||||
export function LabelPicker({ ids, anchor, onClose, onApplied }: { ids: Id[]; anchor: { x: number; y: number }; onClose: () => void; onApplied?: () => void }) {
|
||||
@@ -33,7 +34,7 @@ export function LabelPicker({ ids, anchor, onClose, onApplied }: { ids: Id[]; an
|
||||
|
||||
return (
|
||||
<Popover anchor={{ x: anchor.x, y: anchor.y, w: 0, h: 0 }} onClose={onClose} width={260} closeOnClick={false}>
|
||||
<div className="menu-title">Label as</div>
|
||||
<div className="menu-title">{t("Label as")}</div>
|
||||
<div className="menu-search">
|
||||
<input
|
||||
className="input sm"
|
||||
@@ -77,7 +78,7 @@ export function LabelPicker({ ids, anchor, onClose, onApplied }: { ids: Id[]; an
|
||||
<span>Create “{q.trim()}”</span>
|
||||
</button>
|
||||
)}
|
||||
{!labels.length && !q && <div className="hint" style={{ padding: "4px 10px 8px" }}>Type a name to create your first label.</div>}
|
||||
{!labels.length && !q && <div className="hint" style={{ padding: "4px 10px 8px" }}>{t("Type a name to create your first label.")}</div>}
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Folder, Inbox } from "lucide-react";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { Dialog } from "@/ui/dialog";
|
||||
import type { Id, Mailbox } from "@/jmap/types";
|
||||
import { t } from "@/lib/i18n";
|
||||
|
||||
export function MailboxPicker({ title, onClose, onPick, exclude }: { title: string; onClose: () => void; onPick: (id: Id) => void; exclude?: Id[] }) {
|
||||
const mailboxes = useMail((s) => s.mailboxes);
|
||||
@@ -23,7 +24,7 @@ export function MailboxPicker({ title, onClose, onPick, exclude }: { title: stri
|
||||
<input
|
||||
className="input"
|
||||
autoFocus
|
||||
placeholder="Type a folder name…"
|
||||
placeholder={t("Type a folder name…")}
|
||||
value={q}
|
||||
onChange={(e) => {
|
||||
setQ(e.target.value);
|
||||
@@ -47,7 +48,7 @@ export function MailboxPicker({ title, onClose, onPick, exclude }: { title: stri
|
||||
{list.map(({ m, path }, i) => (
|
||||
<PickerRow key={m.id} m={m} path={path} active={i === active} onClick={() => onPick(m.id)} onHover={() => setActive(i)} />
|
||||
))}
|
||||
{!list.length && <div className="empty" style={{ padding: 24 }}>No matching folders</div>}
|
||||
{!list.length && <div className="empty" style={{ padding: 24 }}>{t("No matching folders")}</div>}
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -14,6 +14,7 @@ import { ShareDialog } from "../settings/ShareDialog";
|
||||
import { loadRaw, saveJson } from "@/lib/storage";
|
||||
import { canDropFolder, folderColor, movable } from "@/lib/folderMove";
|
||||
import { haptic, useTouchRow } from "@/lib/touch";
|
||||
import { t } from "@/lib/i18n";
|
||||
|
||||
const ROLE_ICONS: Record<string, ReactNode> = {
|
||||
inbox: <Inbox size={20} />,
|
||||
@@ -125,7 +126,7 @@ export function MailboxTree() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav aria-label="Folders" style={{ marginTop: 6 }}>
|
||||
<nav aria-label={t("Folders")} style={{ marginTop: 6 }}>
|
||||
<div
|
||||
className={`nav-section${rootDrop ? " drop-target" : ""}`}
|
||||
onDragOver={(e) => {
|
||||
@@ -143,7 +144,7 @@ export function MailboxTree() {
|
||||
}}
|
||||
>
|
||||
<span>{draggingId && canDropOn(null) ? "Drop here for the top level" : "Folders"}</span>
|
||||
<button className="icon-btn" title="New folder" aria-label="New folder" onClick={() => void createFolder(null)}>
|
||||
<button className="icon-btn" title={t("New folder")} aria-label={t("New folder")} onClick={() => void createFolder(null)}>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -170,8 +171,8 @@ export function MailboxTree() {
|
||||
{labelsSidebar && labels.length > 0 && (
|
||||
<>
|
||||
<div className="nav-section">
|
||||
<span>Labels</span>
|
||||
<Link href="/settings/labels" className="icon-btn" title="Manage labels" aria-label="Manage labels">
|
||||
<span>{t("Labels")}</span>
|
||||
<Link href="/settings/labels" className="icon-btn" title={t("Manage labels")} aria-label={t("Manage labels")}>
|
||||
<Pencil size={14} />
|
||||
</Link>
|
||||
</div>
|
||||
@@ -298,7 +299,7 @@ function FolderRow({ mailbox: m, label, depth, hasChildren, open, hiddenUnread,
|
||||
{count > 0 && <span className="nav-dot" />}
|
||||
<button
|
||||
className="icon-btn nav-more"
|
||||
aria-label="Folder options"
|
||||
aria-label={t("Folder options")}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -362,18 +363,18 @@ function MailboxMenu({ mailbox: m, onClose, onCreateChild, onShare }: { mailbox:
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<MenuItem icon={<CheckCheck size={16} />} label="Mark all as read" onClick={() => void useMail.getState().markMailboxRead(m.id)} disabled={!m.unreadEmails} />
|
||||
<MenuItem icon={<CheckCheck size={16} />} label={t("Mark all as read")} onClick={() => void useMail.getState().markMailboxRead(m.id)} disabled={!m.unreadEmails} />
|
||||
{hasChildren && (
|
||||
<MenuItem
|
||||
icon={<CheckCheck size={16} />}
|
||||
label="Mark all as read, incl. subfolders"
|
||||
label={t("Mark all as read, incl. subfolders")}
|
||||
kbd={m.unreadEmails + subUnread ? String(m.unreadEmails + subUnread) : undefined}
|
||||
onClick={() => void useMail.getState().markMailboxRead(m.id, true)}
|
||||
disabled={!m.unreadEmails && !subUnread}
|
||||
/>
|
||||
)}
|
||||
<MenuItem icon={<FolderPlus size={16} />} label="New subfolder" onClick={onCreateChild} disabled={!m.myRights.mayCreateChild} />
|
||||
<MenuItem icon={<Pencil size={16} />} label="Rename" onClick={() => void rename()} disabled={isSpecial || !m.myRights.mayRename} />
|
||||
<MenuItem icon={<FolderPlus size={16} />} label={t("New subfolder")} onClick={onCreateChild} disabled={!m.myRights.mayCreateChild} />
|
||||
<MenuItem icon={<Pencil size={16} />} label={t("Rename")} onClick={() => void rename()} disabled={isSpecial || !m.myRights.mayRename} />
|
||||
<MenuItem icon={m.isSubscribed ? <EyeOff size={16} /> : <Eye size={16} />} label={m.isSubscribed ? "Hide from list" : "Show in list"} onClick={() => void useMail.getState().updateMailbox(m.id, { isSubscribed: !m.isSubscribed })} disabled={m.role === "inbox"} />
|
||||
{/* Sharing a mail folder is withdrawn, not removed: Stalwart accepts and
|
||||
stores the share, and it never reaches the other account -- its own
|
||||
@@ -381,7 +382,7 @@ function MailboxMenu({ mailbox: m, onClose, onCreateChild, onShare }: { mailbox:
|
||||
folders. Offering it produced shares that looked real and did nothing.
|
||||
One that already exists can still be cleared here, which is the only
|
||||
reason this entry survives at all. */}
|
||||
{shared && <MenuItem icon={<Share2 size={16} />} label="Stop sharing" onClick={onShare} />}
|
||||
{shared && <MenuItem icon={<Share2 size={16} />} label={t("Stop sharing")} onClick={onShare} />}
|
||||
<MenuSep />
|
||||
<MenuTitle><span className="row gap-4"><Palette size={12} /> Colour</span></MenuTitle>
|
||||
<div className="color-grid" style={{ gridTemplateColumns: "repeat(6, 26px)", padding: "4px 10px 8px" }}>
|
||||
@@ -395,10 +396,10 @@ function MailboxMenu({ mailbox: m, onClose, onCreateChild, onShare }: { mailbox:
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{color && <MenuItem icon={<X size={16} />} label="Use the default colour" onClick={() => setColor(null)} />}
|
||||
{color && <MenuItem icon={<X size={16} />} label={t("Use the default colour")} onClick={() => setColor(null)} />}
|
||||
<MenuSep />
|
||||
{canEmpty(m.role) && <MenuItem icon={<Eraser size={16} />} label={emptyLabel(m)} onClick={() => void empty()} danger disabled={!m.totalEmails} />}
|
||||
<MenuItem icon={<Trash2 size={16} />} label="Delete folder" onClick={() => void remove()} danger disabled={isSpecial || !m.myRights.mayDelete} />
|
||||
<MenuItem icon={<Trash2 size={16} />} label={t("Delete folder")} onClick={() => void remove()} danger disabled={isSpecial || !m.myRights.mayDelete} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useCompose } from "@/store/compose";
|
||||
import { haptic, usePullToRefresh, useTouchRow, PULL_TRIGGER } from "@/lib/touch";
|
||||
import { describeSwipe, type SwipeAction, type SwipeDescriptor, type SwipeIcon } from "@/lib/swipe";
|
||||
import { FilterFromMessageDialog } from "./FilterFromMessage";
|
||||
import { t } from "@/lib/i18n";
|
||||
|
||||
/**
|
||||
* The glyph on the strip a swipe reveals. Sized larger than the toolbar's
|
||||
@@ -231,14 +232,14 @@ export function MessageList({ title, list, openThreadId, focusId, setFocusId, on
|
||||
<div className="mail-list-pane">
|
||||
<div className="list-toolbar">
|
||||
{isMobile && isSearch && (
|
||||
<button className="icon-btn" onClick={() => navigate("/mail")} aria-label="Back">
|
||||
<button className="icon-btn" onClick={() => navigate("/mail")} aria-label={t("Back")}>
|
||||
<ArrowLeft size={20} />
|
||||
</button>
|
||||
)}
|
||||
<input
|
||||
type="checkbox"
|
||||
className="select-all"
|
||||
aria-label="Select all"
|
||||
aria-label={t("Select all")}
|
||||
checked={allSelected}
|
||||
ref={(el) => {
|
||||
if (el) el.indeterminate = selCount > 0 && !allSelected;
|
||||
@@ -249,14 +250,14 @@ export function MessageList({ title, list, openThreadId, focusId, setFocusId, on
|
||||
<>
|
||||
<span className="tb-count">{selCount} selected</span>
|
||||
<span className="tb-sep" />
|
||||
<button className="icon-btn" title="Archive (e)" onClick={() => void actions.archive()}><Archive size={19} /></button>
|
||||
<button className="icon-btn" title={t("Archive (e)")} onClick={() => void actions.archive()}><Archive size={19} /></button>
|
||||
<button className="icon-btn" title={isTrashOrJunk ? "Delete forever" : "Delete (#)"} onClick={() => void actions.trash()}><Trash2 size={19} /></button>
|
||||
<button className="icon-btn hide-mobile" title={mailbox?.role === "junk" ? "Not spam" : "Report spam (!)"} onClick={() => void actions.spam()}>{mailbox?.role === "junk" ? <ShieldCheck size={19} /> : <AlertOctagon size={19} />}</button>
|
||||
<span className="tb-sep" />
|
||||
<button className="icon-btn" title="Mark as read (Shift+I)" onClick={() => void actions.read(true)}><MailOpen size={19} /></button>
|
||||
<button className="icon-btn hide-mobile" title="Mark as unread (Shift+U)" onClick={() => void actions.read(false)}><Mail size={19} /></button>
|
||||
<button className="icon-btn" title="Move to (v)" onClick={() => actions.move()}><FolderInput size={19} /></button>
|
||||
<button className="icon-btn hide-mobile" title="Labels (l)" onClick={(e) => actions.label(undefined, { x: e.clientX, y: e.clientY })}><Tag size={19} /></button>
|
||||
<button className="icon-btn" title={t("Mark as read (Shift+I)")} onClick={() => void actions.read(true)}><MailOpen size={19} /></button>
|
||||
<button className="icon-btn hide-mobile" title={t("Mark as unread (Shift+U)")} onClick={() => void actions.read(false)}><Mail size={19} /></button>
|
||||
<button className="icon-btn" title={t("Move to (v)")} onClick={() => actions.move()}><FolderInput size={19} /></button>
|
||||
<button className="icon-btn hide-mobile" title={t("Labels (l)")} onClick={(e) => actions.label(undefined, { x: e.clientX, y: e.clientY })}><Tag size={19} /></button>
|
||||
{/*
|
||||
The three buttons above marked hide-mobile have nowhere to go on
|
||||
a phone, and used to simply not exist there: selecting mail on a
|
||||
@@ -267,18 +268,18 @@ export function MessageList({ title, list, openThreadId, focusId, setFocusId, on
|
||||
{isMobile && (
|
||||
<>
|
||||
<span className="spacer" />
|
||||
<button className="icon-btn" onClick={selMenu.open} aria-label="More actions"><MoreVertical size={19} /></button>
|
||||
<button className="icon-btn" onClick={selMenu.open} aria-label={t("More actions")}><MoreVertical size={19} /></button>
|
||||
<Popover anchor={selMenu.anchor} onClose={selMenu.close} align="end" width={240}>
|
||||
<MenuItem
|
||||
icon={mailbox?.role === "junk" ? <ShieldCheck size={16} /> : <AlertOctagon size={16} />}
|
||||
label={mailbox?.role === "junk" ? "Not spam" : "Report spam"}
|
||||
onClick={() => void actions.spam()}
|
||||
/>
|
||||
<MenuItem icon={<Mail size={16} />} label="Mark as unread" onClick={() => void actions.read(false)} />
|
||||
<MenuItem icon={<Tag size={16} />} label="Label…" onClick={() => actions.label(undefined, { x: window.innerWidth / 2, y: 100 })} />
|
||||
<MenuItem icon={<Mail size={16} />} label={t("Mark as unread")} onClick={() => void actions.read(false)} />
|
||||
<MenuItem icon={<Tag size={16} />} label={t("Label…")} onClick={() => actions.label(undefined, { x: window.innerWidth / 2, y: 100 })} />
|
||||
<MenuSep />
|
||||
<MenuItem icon={<CheckSquare size={16} />} label="Select all" onClick={selectAll} />
|
||||
<MenuItem icon={<X size={16} />} label="Clear selection" onClick={clearSelection} />
|
||||
<MenuItem icon={<CheckSquare size={16} />} label={t("Select all")} onClick={selectAll} />
|
||||
<MenuItem icon={<X size={16} />} label={t("Clear selection")} onClick={clearSelection} />
|
||||
</Popover>
|
||||
</>
|
||||
)}
|
||||
@@ -288,20 +289,20 @@ export function MessageList({ title, list, openThreadId, focusId, setFocusId, on
|
||||
<span className="tb-title">{title}</span>
|
||||
{list && !list.loading && <span className="tb-count">{list.total.toLocaleString()}</span>}
|
||||
<span className="spacer" />
|
||||
<button className={`icon-btn ${refreshing ? "active" : ""}`} title="Refresh" onClick={() => void doRefresh()} aria-label="Refresh">
|
||||
<button className={`icon-btn ${refreshing ? "active" : ""}`} title={t("Refresh")} onClick={() => void doRefresh()} aria-label={t("Refresh")}>
|
||||
<RefreshCw size={18} className={refreshing ? "spin" : ""} style={refreshing ? { animation: "spin .8s linear infinite" } : undefined} />
|
||||
</button>
|
||||
<button className="icon-btn" onClick={moreMenu.open} aria-label="More">
|
||||
<button className="icon-btn" onClick={moreMenu.open} aria-label={t("More")}>
|
||||
<MoreVertical size={18} />
|
||||
</button>
|
||||
<Popover anchor={moreMenu.anchor} onClose={moreMenu.close} align="end" width={240}>
|
||||
<MenuTitle>Reading pane</MenuTitle>
|
||||
<MenuItem icon={<PanelRight size={16} />} label="Right of the list" checked={settings.readingPane === "right"} onClick={() => updateSettings({ readingPane: "right" })} />
|
||||
<MenuItem icon={<PanelBottom size={16} />} label="Below the list" checked={settings.readingPane === "bottom"} onClick={() => updateSettings({ readingPane: "bottom" })} />
|
||||
<MenuItem icon={<PanelTop size={16} />} label="Hidden (open full width)" checked={settings.readingPane === "off"} onClick={() => updateSettings({ readingPane: "off" })} />
|
||||
<MenuTitle>{t("Reading pane")}</MenuTitle>
|
||||
<MenuItem icon={<PanelRight size={16} />} label={t("Right of the list")} checked={settings.readingPane === "right"} onClick={() => updateSettings({ readingPane: "right" })} />
|
||||
<MenuItem icon={<PanelBottom size={16} />} label={t("Below the list")} checked={settings.readingPane === "bottom"} onClick={() => updateSettings({ readingPane: "bottom" })} />
|
||||
<MenuItem icon={<PanelTop size={16} />} label={t("Hidden (open full width)")} checked={settings.readingPane === "off"} onClick={() => updateSettings({ readingPane: "off" })} />
|
||||
<MenuSep />
|
||||
<MenuItem icon={<CheckSquare size={16} />} label="Select all" onClick={selectAll} />
|
||||
<MenuItem icon={<MailOpen size={16} />} label="Mark all as read" onClick={() => mailboxId && void useMail.getState().markMailboxRead(mailboxId)} disabled={!mailboxId} />
|
||||
<MenuItem icon={<CheckSquare size={16} />} label={t("Select all")} onClick={selectAll} />
|
||||
<MenuItem icon={<MailOpen size={16} />} label={t("Mark all as read")} onClick={() => mailboxId && void useMail.getState().markMailboxRead(mailboxId)} disabled={!mailboxId} />
|
||||
{mailbox && canEmpty(mailbox.role) && (
|
||||
<>
|
||||
<MenuSep />
|
||||
@@ -321,7 +322,7 @@ export function MessageList({ title, list, openThreadId, focusId, setFocusId, on
|
||||
{list?.error && (
|
||||
<div className="list-hint">
|
||||
<span className="grow" style={{ color: "var(--danger)" }}>{list.error}</span>
|
||||
<button onClick={() => void doRefresh()}>Retry</button>
|
||||
<button onClick={() => void doRefresh()}>{t("Retry")}</button>
|
||||
</div>
|
||||
)}
|
||||
{/*
|
||||
@@ -336,9 +337,10 @@ export function MessageList({ title, list, openThreadId, focusId, setFocusId, on
|
||||
{mailbox?.role === "junk" && !!mailbox.totalEmails && !selCount && (
|
||||
<div className="list-hint">
|
||||
<span className="grow">
|
||||
Deleting spam is permanent — it does not go to Deleted Items first.
|
||||
|
||||
{t("Deleting spam is permanent — it does not go to Deleted Items first.")}
|
||||
</span>
|
||||
<button onClick={() => void confirmAndEmpty(mailbox)}>Delete all spam now</button>
|
||||
<button onClick={() => void confirmAndEmpty(mailbox)}>{t("Delete all spam now")}</button>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
@@ -457,19 +459,19 @@ export function MessageList({ title, list, openThreadId, focusId, setFocusId, on
|
||||
</div>
|
||||
</div>
|
||||
<Popover anchor={ctxMenu.anchor} onClose={ctxMenu.close} width={250}>
|
||||
<MenuItem icon={<Reply size={16} />} label="Reply" onClick={() => { const e = ctxRow ? emails[ctxRow] : undefined; if (e) void useCompose.getState().reply(e, "reply"); }} />
|
||||
<MenuItem icon={<Forward size={16} />} label="Forward" onClick={() => { const e = ctxRow ? emails[ctxRow] : undefined; if (e) void useCompose.getState().reply(e, "forward"); }} />
|
||||
<MenuItem icon={<Reply size={16} />} label={t("Reply")} onClick={() => { const e = ctxRow ? emails[ctxRow] : undefined; if (e) void useCompose.getState().reply(e, "reply"); }} />
|
||||
<MenuItem icon={<Forward size={16} />} label={t("Forward")} onClick={() => { const e = ctxRow ? emails[ctxRow] : undefined; if (e) void useCompose.getState().reply(e, "forward"); }} />
|
||||
<MenuSep />
|
||||
<MenuItem icon={<Archive size={16} />} label="Archive" kbd="e" onClick={() => void actions.archive(ctxTargets)} />
|
||||
<MenuItem icon={<Trash2 size={16} />} label="Delete" kbd="#" onClick={() => void actions.trash(ctxTargets)} />
|
||||
<MenuItem icon={<Archive size={16} />} label={t("Archive")} kbd="e" onClick={() => void actions.archive(ctxTargets)} />
|
||||
<MenuItem icon={<Trash2 size={16} />} label={t("Delete")} kbd="#" onClick={() => void actions.trash(ctxTargets)} />
|
||||
<MenuItem icon={<AlertOctagon size={16} />} label={mailbox?.role === "junk" ? "Not spam" : "Report spam"} kbd="!" onClick={() => void actions.spam(ctxTargets)} />
|
||||
<MenuSep />
|
||||
<MenuItem icon={someUnread ? <MailOpen size={16} /> : <Mail size={16} />} label={someUnread ? "Mark as read" : "Mark as unread"} onClick={() => void actions.read(someUnread, ctxTargets)} />
|
||||
<MenuItem icon={<Star size={16} />} label={someUnstarred ? "Add star" : "Remove star"} kbd="s" onClick={() => void actions.star(someUnstarred, ctxTargets)} />
|
||||
<MenuItem icon={<FolderInput size={16} />} label="Move to…" kbd="v" onClick={() => actions.move(ctxTargets)} />
|
||||
<MenuItem icon={<Tag size={16} />} label="Label…" kbd="l" onClick={() => actions.label(ctxTargets, ctxMenu.anchor ?? { x: 0, y: 0 })} />
|
||||
<MenuItem icon={<FolderInput size={16} />} label={t("Move to…")} kbd="v" onClick={() => actions.move(ctxTargets)} />
|
||||
<MenuItem icon={<Tag size={16} />} label={t("Label…")} kbd="l" onClick={() => actions.label(ctxTargets, ctxMenu.anchor ?? { x: 0, y: 0 })} />
|
||||
<MenuSep />
|
||||
<MenuItem icon={<Filter size={16} />} label="Filter messages like this…" onClick={() => { const e = ctxRow ? emails[ctxRow] : undefined; if (e) setFilterFrom(e); }} />
|
||||
<MenuItem icon={<Filter size={16} />} label={t("Filter messages like this…")} onClick={() => { const e = ctxRow ? emails[ctxRow] : undefined; if (e) setFilterFrom(e); }} />
|
||||
</Popover>
|
||||
{filterFrom && <FilterFromMessageDialog email={filterFrom} mailboxId={mailboxId} onClose={() => setFilterFrom(null)} />}
|
||||
</div>
|
||||
@@ -636,7 +638,7 @@ const Row = memo(function Row({ email: e, threadEmails, top, height, selected, f
|
||||
role="row"
|
||||
aria-selected={selected}
|
||||
>
|
||||
<input type="checkbox" className="msg-check" checked={selected} onClick={(ev) => ev.stopPropagation()} onChange={(ev) => onSelect(e.id, ev.target.checked)} aria-label="Select" />
|
||||
<input type="checkbox" className="msg-check" checked={selected} onClick={(ev) => ev.stopPropagation()} onChange={(ev) => onSelect(e.id, ev.target.checked)} aria-label={t("Select")} />
|
||||
{!twoLine && (
|
||||
<button className={`msg-star ${starred ? "on" : ""}`} onClick={(ev) => { ev.stopPropagation(); onStar(e.id, !starred); }} aria-label={starred ? "Unstar" : "Star"}>
|
||||
<Star size={18} fill={starred ? "currentColor" : "none"} />
|
||||
@@ -656,10 +658,10 @@ const Row = memo(function Row({ email: e, threadEmails, top, height, selected, f
|
||||
</span>
|
||||
</div>
|
||||
<div className="msg-main">
|
||||
{isDrafts && <span style={{ color: "var(--danger)" }}>Draft</span>}
|
||||
{isDrafts && <span style={{ color: "var(--danger)" }}>{t("Draft")}</span>}
|
||||
<span className="msg-subject">{e.subject || "(no subject)"}</span>
|
||||
{showPreview && <span className="msg-preview">{latest.preview}</span>}
|
||||
<button className={`msg-star ${starred ? "on" : ""}`} style={{ marginLeft: "auto" }} onClick={(ev) => { ev.stopPropagation(); onStar(e.id, !starred); }} aria-label="Star">
|
||||
<button className={`msg-star ${starred ? "on" : ""}`} style={{ marginLeft: "auto" }} onClick={(ev) => { ev.stopPropagation(); onStar(e.id, !starred); }} aria-label={t("Star")}>
|
||||
<Star size={16} fill={starred ? "currentColor" : "none"} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -672,7 +674,7 @@ const Row = memo(function Row({ email: e, threadEmails, top, height, selected, f
|
||||
{count > 1 && <span className="thread-count">{count}</span>}
|
||||
</span>
|
||||
<span className="msg-main">
|
||||
{isDrafts && <span style={{ color: "var(--danger)", flex: "0 0 auto" }}>Draft</span>}
|
||||
{isDrafts && <span style={{ color: "var(--danger)", flex: "0 0 auto" }}>{t("Draft")}</span>}
|
||||
{rowLabels.length > 0 && <span className="msg-labels">{rowLabels.map((l) => <span key={l.keyword} className="tag" style={{ background: l.color }}>{l.name}</span>)}</span>}
|
||||
<span className="msg-subject">{e.subject || "(no subject)"}</span>
|
||||
{showPreview && <span className="msg-preview">{latest.preview}</span>}
|
||||
@@ -682,8 +684,8 @@ const Row = memo(function Row({ email: e, threadEmails, top, height, selected, f
|
||||
{hasAtt && <Paperclip size={14} className="msg-attach" />}
|
||||
<span className="msg-date">{formatListDate(latest.receivedAt)}</span>
|
||||
<span className="msg-actions">
|
||||
<button className="icon-btn sm" title="Archive" onClick={(ev) => { ev.stopPropagation(); onArchive(e.id); }}><Archive size={16} /></button>
|
||||
<button className="icon-btn sm" title="Delete" onClick={(ev) => { ev.stopPropagation(); onTrash(e.id); }}><Trash2 size={16} /></button>
|
||||
<button className="icon-btn sm" title={t("Archive")} onClick={(ev) => { ev.stopPropagation(); onArchive(e.id); }}><Archive size={16} /></button>
|
||||
<button className="icon-btn sm" title={t("Delete")} onClick={(ev) => { ev.stopPropagation(); onTrash(e.id); }}><Trash2 size={16} /></button>
|
||||
<button className="icon-btn sm" title={unread ? "Mark as read" : "Mark as unread"} onClick={(ev) => { ev.stopPropagation(); onRead(e.id, unread); }}>{unread ? <MailOpen size={16} /> : <Mail size={16} />}</button>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -24,6 +24,7 @@ import { useScheduled } from "@/store/scheduled";
|
||||
import { formatScheduleTime } from "@/lib/schedule";
|
||||
import { mdnDecision, refusalText } from "@/lib/mdn";
|
||||
import { sendReadReceipt } from "@/store/mdn";
|
||||
import { t as translate } from "@/lib/i18n";
|
||||
|
||||
interface Props {
|
||||
email: Email;
|
||||
@@ -150,13 +151,13 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
|
||||
<div className="from" onContextMenu={(ev) => from && addrMenu.open(ev, from)}>
|
||||
<span className="addr">{displayName(from)}</span>
|
||||
{expanded && from && <span className="email addr"><{from.email}></span>}
|
||||
{isHighPriority && <span className="tag" style={{ background: "var(--danger)" }}>Important</span>}
|
||||
{isHighPriority && <span className="tag" style={{ background: "var(--danger)" }}>{translate("Important")}</span>}
|
||||
{authFailed && <span className="tag" style={{ background: "var(--warn)" }} title={e["header:Authentication-Results:asText"] ?? ""}><ShieldAlert size={12} /> Unverified</span>}
|
||||
</div>
|
||||
{expanded ? (
|
||||
<div className="to">
|
||||
<span className="truncate">to {summarizeRecipients(e)}</span>
|
||||
<button onClick={(ev) => { ev.stopPropagation(); setDetails((v) => !v); }} aria-label="Show details" title="Show details">
|
||||
<button onClick={(ev) => { ev.stopPropagation(); setDetails((v) => !v); }} aria-label={translate("Show details")} title={translate("Show details")}>
|
||||
{details ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
@@ -167,30 +168,30 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
|
||||
<div className="meta">
|
||||
{e.hasAttachment && !expanded && <Paperclip size={14} />}
|
||||
<span className="date" title={formatFullDate(e.receivedAt)}>{expanded ? formatFullDate(e.receivedAt) : formatListDate(e.receivedAt)}</span>
|
||||
<button className={`icon-btn sm ${e.keywords.$flagged ? "active" : ""}`} style={e.keywords.$flagged ? { color: "var(--star)", background: "transparent" } : undefined} title="Star" onClick={(ev) => { ev.stopPropagation(); void actions.star(!e.keywords.$flagged, [e.id]); }}>
|
||||
<button className={`icon-btn sm ${e.keywords.$flagged ? "active" : ""}`} style={e.keywords.$flagged ? { color: "var(--star)", background: "transparent" } : undefined} title={translate("Star")} onClick={(ev) => { ev.stopPropagation(); void actions.star(!e.keywords.$flagged, [e.id]); }}>
|
||||
<Star size={17} fill={e.keywords.$flagged ? "currentColor" : "none"} />
|
||||
</button>
|
||||
{expanded && (
|
||||
<>
|
||||
<button className="icon-btn sm hide-mobile" title="Reply (r)" onClick={(ev) => { ev.stopPropagation(); void reply(e, "reply"); }}><Reply size={17} /></button>
|
||||
<button className="icon-btn sm" onClick={(ev) => { ev.stopPropagation(); moreMenu.open(ev); }} aria-label="More"><MoreVertical size={17} /></button>
|
||||
<button className="icon-btn sm hide-mobile" title={translate("Reply (r)")} onClick={(ev) => { ev.stopPropagation(); void reply(e, "reply"); }}><Reply size={17} /></button>
|
||||
<button className="icon-btn sm" onClick={(ev) => { ev.stopPropagation(); moreMenu.open(ev); }} aria-label={translate("More")}><MoreVertical size={17} /></button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
<Popover anchor={moreMenu.anchor} onClose={moreMenu.close} align="end" width={240}>
|
||||
<MenuItem icon={<Reply size={16} />} label="Reply" onClick={() => void reply(e, "reply")} />
|
||||
<MenuItem icon={<ReplyAll size={16} />} label="Reply all" onClick={() => void reply(e, "replyAll")} />
|
||||
<MenuItem icon={<Forward size={16} />} label="Forward" onClick={() => void reply(e, "forward")} />
|
||||
<MenuItem icon={<Reply size={16} />} label={translate("Reply")} onClick={() => void reply(e, "reply")} />
|
||||
<MenuItem icon={<ReplyAll size={16} />} label={translate("Reply all")} onClick={() => void reply(e, "replyAll")} />
|
||||
<MenuItem icon={<Forward size={16} />} label={translate("Forward")} onClick={() => void reply(e, "forward")} />
|
||||
<MenuSep />
|
||||
<MenuItem icon={<Mail size={16} />} label={e.keywords.$seen ? "Mark as unread" : "Mark as read"} onClick={() => void useMail.getState().markRead([e.id], !e.keywords.$seen)} />
|
||||
<MenuItem icon={<Trash2 size={16} />} label="Delete this message" onClick={() => void useMail.getState().trash([e.id])} />
|
||||
<MenuItem icon={<Trash2 size={16} />} label={translate("Delete this message")} onClick={() => void useMail.getState().trash([e.id])} />
|
||||
<MenuSep />
|
||||
<MenuItem icon={<Eye size={16} />} label="Show original" onClick={() => void openSource()} />
|
||||
<MenuItem icon={<Code size={16} />} label="Show headers" onClick={() => setShowHeaders(true)} />
|
||||
<MenuItem icon={<Download size={16} />} label="Download (.eml)" onClick={downloadEml} />
|
||||
<MenuItem icon={<Printer size={16} />} label="Print" onClick={() => window.print()} />
|
||||
<MenuItem icon={<Filter size={16} />} label="Filter messages like this…" onClick={() => setFilterOpen(true)} />
|
||||
<MenuItem icon={<Eye size={16} />} label={translate("Show original")} onClick={() => void openSource()} />
|
||||
<MenuItem icon={<Code size={16} />} label={translate("Show headers")} onClick={() => setShowHeaders(true)} />
|
||||
<MenuItem icon={<Download size={16} />} label={translate("Download (.eml)")} onClick={downloadEml} />
|
||||
<MenuItem icon={<Printer size={16} />} label={translate("Print")} onClick={() => window.print()} />
|
||||
<MenuItem icon={<Filter size={16} />} label={translate("Filter messages like this…")} onClick={() => setFilterOpen(true)} />
|
||||
{from && (
|
||||
<>
|
||||
<MenuSep />
|
||||
@@ -203,18 +204,18 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
|
||||
<>
|
||||
{details && (
|
||||
<dl className="message-details" onClick={(ev) => ev.stopPropagation()}>
|
||||
<dt>From</dt><dd><AddressList list={e.from} onContext={addrMenu.open} /></dd>
|
||||
{e.sender?.length && !(e.sender.length === 1 && e.from?.some((f) => f.email === e.sender![0]!.email)) ? <><dt>Sender</dt><dd><AddressList list={e.sender} onContext={addrMenu.open} /></dd></> : null}
|
||||
{e.replyTo?.length ? <><dt>Reply-To</dt><dd><AddressList list={e.replyTo} onContext={addrMenu.open} /></dd></> : null}
|
||||
<dt>To</dt><dd><AddressList list={e.to} onContext={addrMenu.open} /></dd>
|
||||
{e.cc?.length ? <><dt>Cc</dt><dd><AddressList list={e.cc} onContext={addrMenu.open} /></dd></> : null}
|
||||
{e.bcc?.length ? <><dt>Bcc</dt><dd><AddressList list={e.bcc} onContext={addrMenu.open} /></dd></> : null}
|
||||
<dt>Date</dt><dd>{formatFullDate(e.sentAt ?? e.receivedAt)}</dd>
|
||||
<dt>Subject</dt><dd>{e.subject || "(no subject)"}</dd>
|
||||
{e.messageId?.[0] && <><dt>Message-ID</dt><dd className="mono small">{e.messageId[0]}</dd></>}
|
||||
{e["header:List-Id:asText"] && <><dt>List</dt><dd>{e["header:List-Id:asText"]}</dd></>}
|
||||
<dt>Size</dt><dd>{formatSize(e.size)}</dd>
|
||||
{receiptRequested && <><dt>Receipt</dt><dd>{receipt.offer ? `Requested, to ${receipt.to!.email}. Never sent automatically.` : refusalText(receipt.refusal!)}</dd></>}
|
||||
<dt>{translate("From")}</dt><dd><AddressList list={e.from} onContext={addrMenu.open} /></dd>
|
||||
{e.sender?.length && !(e.sender.length === 1 && e.from?.some((f) => f.email === e.sender![0]!.email)) ? <><dt>{translate("Sender")}</dt><dd><AddressList list={e.sender} onContext={addrMenu.open} /></dd></> : null}
|
||||
{e.replyTo?.length ? <><dt>{translate("Reply-To")}</dt><dd><AddressList list={e.replyTo} onContext={addrMenu.open} /></dd></> : null}
|
||||
<dt>{translate("To")}</dt><dd><AddressList list={e.to} onContext={addrMenu.open} /></dd>
|
||||
{e.cc?.length ? <><dt>{translate("Cc")}</dt><dd><AddressList list={e.cc} onContext={addrMenu.open} /></dd></> : null}
|
||||
{e.bcc?.length ? <><dt>{translate("Bcc")}</dt><dd><AddressList list={e.bcc} onContext={addrMenu.open} /></dd></> : null}
|
||||
<dt>{translate("Date")}</dt><dd>{formatFullDate(e.sentAt ?? e.receivedAt)}</dd>
|
||||
<dt>{translate("Subject")}</dt><dd>{e.subject || "(no subject)"}</dd>
|
||||
{e.messageId?.[0] && <><dt>{translate("Message-ID")}</dt><dd className="mono small">{e.messageId[0]}</dd></>}
|
||||
{e["header:List-Id:asText"] && <><dt>{translate("List")}</dt><dd>{e["header:List-Id:asText"]}</dd></>}
|
||||
<dt>{translate("Size")}</dt><dd>{formatSize(e.size)}</dd>
|
||||
{receiptRequested && <><dt>{translate("Receipt")}</dt><dd>{receipt.offer ? `Requested, to ${receipt.to!.email}. Never sent automatically.` : refusalText(receipt.refusal!)}</dd></>}
|
||||
</dl>
|
||||
)}
|
||||
{receipt.offer && settings.readReceiptPolicy !== "never" && receiptDone !== "dismissed" && (
|
||||
@@ -241,7 +242,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
|
||||
>
|
||||
{receiptDone === "sending" ? "Sending…" : "Send receipt"}
|
||||
</button>
|
||||
<button onClick={() => setReceiptDone("dismissed")}>Not this time</button>
|
||||
<button onClick={() => setReceiptDone("dismissed")}>{translate("Not this time")}</button>
|
||||
</div>
|
||||
)}
|
||||
{scheduled && (
|
||||
@@ -258,15 +259,16 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
|
||||
}
|
||||
}}
|
||||
>
|
||||
Cancel send
|
||||
|
||||
{translate("Cancel send")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{rendered && rendered.remoteCount > 0 && !remoteAllowed && (
|
||||
<div className="remote-banner" style={{ margin: "0 16px 8px" }}>
|
||||
<ImageIcon size={16} />
|
||||
<span className="grow">Remote images are blocked to protect your privacy.</span>
|
||||
<button onClick={() => setAllowRemote(true)}>Show images</button>
|
||||
<span className="grow">{translate("Remote images are blocked to protect your privacy.")}</span>
|
||||
<button onClick={() => setAllowRemote(true)}>{translate("Show images")}</button>
|
||||
{from && <button onClick={() => updateSettings({ trustedImageSenders: [...settings.trustedImageSenders, from.email.toLowerCase()] })}>Always from {from.email}</button>}
|
||||
</div>
|
||||
)}
|
||||
@@ -278,18 +280,18 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
|
||||
{attachments.length > 0 && <AttachmentList attachments={attachments} accountId={accountId} email={e} />}
|
||||
{unsubscribe && (
|
||||
<div className="unsubscribe-row">
|
||||
<span>This looks like a mailing list.</span>
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => void onUnsubscribe()}>Unsubscribe</button>
|
||||
<span>{translate("This looks like a mailing list.")}</span>
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => void onUnsubscribe()}>{translate("Unsubscribe")}</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{addrMenu.node}
|
||||
{filterOpen && <FilterFromMessageDialog email={e} mailboxId={Object.keys(e.mailboxIds)[0] ?? null} onClose={() => setFilterOpen(false)} />}
|
||||
<Dialog open={showSource} onClose={() => setShowSource(false)} title="Original message" size="xl">
|
||||
<Dialog open={showSource} onClose={() => setShowSource(false)} title={translate("Original message")} size="xl">
|
||||
{source === null ? <div className="center"><span className="spinner" /></div> : <pre className="code notranslate" translate="no" style={{ minHeight: 300, maxHeight: "65vh" }}>{source}</pre>}
|
||||
</Dialog>
|
||||
<Dialog open={showHeaders} onClose={() => setShowHeaders(false)} title="Message headers" size="lg">
|
||||
<Dialog open={showHeaders} onClose={() => setShowHeaders(false)} title={translate("Message headers")} size="lg">
|
||||
<dl className="message-details" style={{ margin: 0 }}>
|
||||
{Object.entries(e).filter(([k]) => k.startsWith("header:")).map(([k, v]) => (
|
||||
<>
|
||||
@@ -297,11 +299,11 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
|
||||
<dd key={`${k}-d`} className="mono small">{Array.isArray(v) ? v.map((x: unknown) => (typeof x === "object" && x ? formatAddress(x as EmailAddress) : String(x))).join(", ") : String(v ?? "—")}</dd>
|
||||
</>
|
||||
))}
|
||||
<dt>Received</dt><dd>{formatFullDate(e.receivedAt)}</dd>
|
||||
{e.inReplyTo?.length ? <><dt>In-Reply-To</dt><dd className="mono small">{e.inReplyTo.join(" ")}</dd></> : null}
|
||||
{e.references?.length ? <><dt>References</dt><dd className="mono small">{e.references.join(" ")}</dd></> : null}
|
||||
<dt>{translate("Received")}</dt><dd>{formatFullDate(e.receivedAt)}</dd>
|
||||
{e.inReplyTo?.length ? <><dt>{translate("In-Reply-To")}</dt><dd className="mono small">{e.inReplyTo.join(" ")}</dd></> : null}
|
||||
{e.references?.length ? <><dt>{translate("References")}</dt><dd className="mono small">{e.references.join(" ")}</dd></> : null}
|
||||
</dl>
|
||||
<p className="hint">Use “Show original” for the complete raw message.</p>
|
||||
<p className="hint">{translate("Use “Show original” for the complete raw message.")}</p>
|
||||
</Dialog>
|
||||
</article>
|
||||
);
|
||||
@@ -449,7 +451,7 @@ function HtmlBody({ html, bodyStyle, themed, onShowImages }: { html: string; bod
|
||||
<div ref={hostRef} className="body-host notranslate" translate="no" />
|
||||
{hasQuote && (
|
||||
<button className="quote-toggle" onClick={() => setQuoteOpen((v) => !v)} title={quoteOpen ? "Hide quoted text" : "Show quoted text"}>
|
||||
{quoteOpen ? <ChevronUp size={12} /> : <span style={{ letterSpacing: 2 }}>•••</span>}
|
||||
{quoteOpen ? <ChevronUp size={12} /> : <span style={{ letterSpacing: 2 }}>{translate("•••")}</span>}
|
||||
{quoteOpen ? "Hide quoted text" : ""}
|
||||
</button>
|
||||
)}
|
||||
@@ -491,7 +493,7 @@ function TextBody({ text }: { text: string }) {
|
||||
<div ref={hostRef} className="body-host notranslate" translate="no" />
|
||||
{quoted && (
|
||||
<button className="quote-toggle" onClick={() => setQuoteOpen((v) => !v)}>
|
||||
{quoteOpen ? <ChevronUp size={12} /> : <span style={{ letterSpacing: 2 }}>•••</span>}
|
||||
{quoteOpen ? <ChevronUp size={12} /> : <span style={{ letterSpacing: 2 }}>{translate("•••")}</span>}
|
||||
{quoteOpen ? "Hide quoted text" : ""}
|
||||
</button>
|
||||
)}
|
||||
@@ -532,8 +534,8 @@ function AttachmentList({ attachments, accountId, email }: { attachments: EmailB
|
||||
<span className="att-name">{a.name ?? "(unnamed)"}</span>
|
||||
<span className="att-size">{formatSize(a.size)}</span>
|
||||
<span className="att-actions">
|
||||
<button className="icon-btn xs" title="Download" onClick={(ev) => { ev.preventDefault(); ev.stopPropagation(); const l = document.createElement("a"); l.href = url; l.download = a.name ?? ""; l.click(); }}><Download size={14} /></button>
|
||||
{viewable(a) && <button className="icon-btn xs" title="Open in new tab" onClick={(ev) => { ev.preventDefault(); ev.stopPropagation(); window.open(inlineUrl, "_blank", "noopener"); }}><ExternalLink size={14} /></button>}
|
||||
<button className="icon-btn xs" title={translate("Download")} onClick={(ev) => { ev.preventDefault(); ev.stopPropagation(); const l = document.createElement("a"); l.href = url; l.download = a.name ?? ""; l.click(); }}><Download size={14} /></button>
|
||||
{viewable(a) && <button className="icon-btn xs" title={translate("Open in new tab")} onClick={(ev) => { ev.preventDefault(); ev.stopPropagation(); window.open(inlineUrl, "_blank", "noopener"); }}><ExternalLink size={14} /></button>}
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
@@ -547,7 +549,7 @@ function AttachmentList({ attachments, accountId, email }: { attachments: EmailB
|
||||
</div>
|
||||
<Dialog open={Boolean(preview)} onClose={() => setPreview(null)} title={preview?.name ?? "Preview"} size="xl" footer={preview && <a className="btn" href={client.downloadUrl(accountId, preview.blobId!, preview.name ?? "file", preview.type)} download><Download size={16} /> Download</a>}>
|
||||
{preview?.type.startsWith("image/") && <img src={client.downloadUrl(accountId, preview.blobId!, preview.name ?? "image", preview.type, true)} alt={preview.name ?? ""} style={{ maxHeight: "70vh", display: "block", margin: "0 auto" }} />}
|
||||
{preview?.type === "application/pdf" && <iframe title="PDF" src={client.downloadUrl(accountId, preview.blobId!, preview.name ?? "file.pdf", preview.type, true)} style={{ width: "100%", height: "70vh", border: 0 }} />}
|
||||
{preview?.type === "application/pdf" && <iframe title={translate("PDF")} src={client.downloadUrl(accountId, preview.blobId!, preview.name ?? "file.pdf", preview.type, true)} style={{ width: "100%", height: "70vh", border: 0 }} />}
|
||||
{preview?.type === "text/plain" && <TextAttachment url={client.downloadUrl(accountId, preview.blobId!, preview.name ?? "file.txt", preview.type, true)} />}
|
||||
<p className="hint" style={{ marginTop: 8 }}>From: {displayName(email.from?.[0])}</p>
|
||||
</Dialog>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { client } from "@/jmap/client";
|
||||
import { LabelPicker } from "./LabelPicker";
|
||||
import { threadScrollTarget } from "@/lib/threadScroll";
|
||||
import { useEdgeBack } from "@/lib/touch";
|
||||
import { t } from "@/lib/i18n";
|
||||
|
||||
/** How long the opening scroll keeps its place while bodies and images land. */
|
||||
const HOLD_MS = 2000;
|
||||
@@ -230,30 +231,30 @@ export function ThreadView({ threadId, mailboxId, onBack, actions, onNavigate, h
|
||||
return (
|
||||
<div className="thread-view" ref={setViewEl}>
|
||||
<div className="thread-toolbar">
|
||||
<button className="icon-btn" onClick={onBack} aria-label="Back to list" title="Back (u)">
|
||||
<button className="icon-btn" onClick={onBack} aria-label={t("Back to list")} title={t("Back (u)")}>
|
||||
<ArrowLeft size={20} />
|
||||
</button>
|
||||
<button className="icon-btn" title="Archive (e)" onClick={() => void actions.archive(rowIds)}><Archive size={19} /></button>
|
||||
<button className="icon-btn" title={t("Archive (e)")} onClick={() => void actions.archive(rowIds)}><Archive size={19} /></button>
|
||||
<button className="icon-btn" title={inJunk ? "Not spam" : "Report spam (!)"} onClick={() => void actions.spam(rowIds)}>{inJunk ? <ShieldCheck size={19} /> : <AlertOctagon size={19} />}</button>
|
||||
<button className="icon-btn" title="Delete (#)" onClick={() => void actions.trash(rowIds)}><Trash2 size={19} /></button>
|
||||
<button className="icon-btn" title={t("Delete (#)")} onClick={() => void actions.trash(rowIds)}><Trash2 size={19} /></button>
|
||||
<span className="tb-sep hide-mobile" />
|
||||
<button className="icon-btn hide-mobile" title={anyUnread ? "Mark as read" : "Mark as unread"} onClick={() => void actions.read(anyUnread, rowIds)}>{anyUnread ? <MailOpen size={19} /> : <Mail size={19} />}</button>
|
||||
<button className="icon-btn hide-mobile" title="Move to (v)" onClick={() => actions.move(rowIds)}><FolderInput size={19} /></button>
|
||||
<button className="icon-btn hide-mobile" title="Labels (l)" onClick={(e) => setLabelAnchor({ x: e.clientX, y: e.clientY })}><Tag size={19} /></button>
|
||||
<button className="icon-btn" onClick={moreMenu.open} aria-label="More"><MoreVertical size={19} /></button>
|
||||
<button className="icon-btn hide-mobile" title={t("Move to (v)")} onClick={() => actions.move(rowIds)}><FolderInput size={19} /></button>
|
||||
<button className="icon-btn hide-mobile" title={t("Labels (l)")} onClick={(e) => setLabelAnchor({ x: e.clientX, y: e.clientY })}><Tag size={19} /></button>
|
||||
<button className="icon-btn" onClick={moreMenu.open} aria-label={t("More")}><MoreVertical size={19} /></button>
|
||||
<Popover anchor={moreMenu.anchor} onClose={moreMenu.close} align="start" width={240}>
|
||||
<MenuItem icon={<Star size={16} />} label={anyStarred ? "Remove star" : "Add star"} onClick={() => void actions.star(!anyStarred, rowIds)} />
|
||||
<MenuItem icon={<Tag size={16} />} label="Label…" onClick={() => setLabelAnchor({ x: window.innerWidth / 2, y: 100 })} />
|
||||
<MenuItem icon={<Tag size={16} />} label={t("Label…")} onClick={() => setLabelAnchor({ x: window.innerWidth / 2, y: 100 })} />
|
||||
<MenuItem icon={allExpanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />} label={allExpanded ? "Collapse all" : "Expand all"} onClick={() => { setAllExpanded((v) => !v); setExpanded({}); }} />
|
||||
<MenuSep />
|
||||
<MenuItem icon={<Printer size={16} />} label="Print conversation" onClick={() => window.print()} />
|
||||
<MenuItem icon={<Printer size={16} />} label={t("Print conversation")} onClick={() => window.print()} />
|
||||
{last && accountId && (
|
||||
<MenuItem icon={<Download size={16} />} label="Download latest as .eml" onClick={() => { const a = document.createElement("a"); a.href = client.downloadUrl(accountId, last.blobId, `${(last.subject || "message").replace(/[^\w.-]+/g, "_")}.eml`, "message/rfc822"); a.download = ""; a.click(); }} />
|
||||
<MenuItem icon={<Download size={16} />} label={t("Download latest as .eml")} onClick={() => { const a = document.createElement("a"); a.href = client.downloadUrl(accountId, last.blobId, `${(last.subject || "message").replace(/[^\w.-]+/g, "_")}.eml`, "message/rfc822"); a.download = ""; a.click(); }} />
|
||||
)}
|
||||
</Popover>
|
||||
<div className="thread-nav hide-mobile">
|
||||
<button className="icon-btn sm" disabled={!hasPrev} onClick={() => onNavigate(-1)} title="Newer (k)"><ChevronUp size={18} /></button>
|
||||
<button className="icon-btn sm" disabled={!hasNext} onClick={() => onNavigate(1)} title="Older (j)"><ChevronDown size={18} /></button>
|
||||
<button className="icon-btn sm" disabled={!hasPrev} onClick={() => onNavigate(-1)} title={t("Newer (k)")}><ChevronUp size={18} /></button>
|
||||
<button className="icon-btn sm" disabled={!hasNext} onClick={() => onNavigate(1)} title={t("Older (j)")}><ChevronDown size={18} /></button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="thread-scroll" ref={scrollRef}>
|
||||
@@ -270,7 +271,7 @@ export function ThreadView({ threadId, mailboxId, onBack, actions, onNavigate, h
|
||||
{messages.length > 1 && <span className="muted small nowrap" style={{ marginTop: 6 }}>{messages.length} messages</span>}
|
||||
</div>
|
||||
{error && <div className="error-box" style={{ margin: 16 }}>{error}</div>}
|
||||
{loading && !messages.length && <Spinner label="Loading conversation…" />}
|
||||
{loading && !messages.length && <Spinner label={t("Loading conversation…")} />}
|
||||
{messages.map((e, i) => (
|
||||
<MessageView
|
||||
key={e.id}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { EmailBodyPart, Id } from "@/jmap/types";
|
||||
import { useContacts } from "@/store/contacts";
|
||||
import { client } from "@/jmap/client";
|
||||
import { toast } from "@/ui/toast";
|
||||
import { t } from "@/lib/i18n";
|
||||
|
||||
export function VCardCard({ part, accountId }: { part: EmailBodyPart; accountId: Id }) {
|
||||
const contacts = useContacts();
|
||||
@@ -30,7 +31,7 @@ export function VCardCard({ part, accountId }: { part: EmailBodyPart; accountId:
|
||||
<UserPlus size={20} style={{ color: "var(--accent)" }} />
|
||||
<div className="grow">
|
||||
<div style={{ fontWeight: 600 }}>{part.name ?? "Contact card"}</div>
|
||||
<div className="hint">vCard attachment</div>
|
||||
<div className="hint">{t("vCard attachment")}</div>
|
||||
</div>
|
||||
<button className="btn btn-sm" disabled={busy || done} onClick={() => void add()}>{done ? "Added" : "Add to contacts"}</button>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user