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:
Generated
+14
@@ -8,9 +8,11 @@
|
||||
"name": "webmail-frontend",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@emoji-mart/data": "~1.2.1",
|
||||
"@tanstack/react-query": "~5.51.0",
|
||||
"@tanstack/react-virtual": "~3.8.0",
|
||||
"docx-preview": "^0.4.0",
|
||||
"emoji-mart": "~5.6.0",
|
||||
"jszip": "^3.10.1",
|
||||
"pdfjs-dist": "^4.10.38",
|
||||
"qrcode.react": "~4.1.0",
|
||||
@@ -524,6 +526,12 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@emoji-mart/data": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@emoji-mart/data/-/data-1.2.1.tgz",
|
||||
"integrity": "sha512-no2pQMWiBy6gpBEiqGeU77/bFejDqUTRY7KX+0+iur13op3bqUsXdnwoZs6Xb1zbv0gAj5VvS1PWoUUckSr5Dw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
|
||||
@@ -3457,6 +3465,12 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/emoji-mart": {
|
||||
"version": "5.6.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-mart/-/emoji-mart-5.6.0.tgz",
|
||||
"integrity": "sha512-eJp3QRe79pjwa+duv+n7+5YsNhRcMl812EcFVwrnRvYKoNPoQb5qxU8DG6Bgwji0akHdp6D4Ln6tYLG58MFSow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/end-of-stream": {
|
||||
"version": "1.4.5",
|
||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||
|
||||
@@ -13,9 +13,11 @@
|
||||
"gen:api": "openapi-typescript http://127.0.0.1:8000/openapi.json -o src/api/schema.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emoji-mart/data": "~1.2.1",
|
||||
"@tanstack/react-query": "~5.51.0",
|
||||
"@tanstack/react-virtual": "~3.8.0",
|
||||
"docx-preview": "^0.4.0",
|
||||
"emoji-mart": "~5.6.0",
|
||||
"jszip": "^3.10.1",
|
||||
"pdfjs-dist": "^4.10.38",
|
||||
"qrcode.react": "~4.1.0",
|
||||
|
||||
+32
-4
@@ -1,11 +1,11 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect } from "react";
|
||||
import { Navigate, Route, Routes, useLocation } from "react-router-dom";
|
||||
|
||||
import { OfflineController } from "./components/Offline";
|
||||
import { PushController } from "./components/PushController";
|
||||
import { useT } from "./i18n";
|
||||
import { isOfflineError } from "./lib/api";
|
||||
import { detectLanguage, supportedLanguage, useLocale, useT } from "./i18n";
|
||||
import { api, isOfflineError } from "./lib/api";
|
||||
import { useMe } from "./lib/auth";
|
||||
import { connectWs } from "./lib/ws";
|
||||
import { PluginPage } from "./plugins/PluginPage";
|
||||
@@ -17,6 +17,33 @@ import { Register } from "./routes/Register";
|
||||
import { ResetPassword } from "./routes/ResetPassword";
|
||||
import { Settings } from "./routes/settings/Settings";
|
||||
|
||||
/** The interface language follows the account (Settings → Appearance), on
|
||||
* every device. An account that has never chosen one gets the browser's or
|
||||
* system's language (English if we have no catalog for it), saved right away
|
||||
* — so it stays put on a device set to another language later. */
|
||||
function LanguageSync() {
|
||||
const { locale, setLocale } = useLocale();
|
||||
const queryClient = useQueryClient();
|
||||
const settings = useQuery({ queryKey: ["settings"], queryFn: api.settings });
|
||||
const saved = settings.data?.ui_language;
|
||||
useEffect(() => {
|
||||
if (saved === undefined) return; // not loaded yet
|
||||
if (saved) {
|
||||
const code = supportedLanguage(saved) ?? "en";
|
||||
if (code !== locale) setLocale(code);
|
||||
return;
|
||||
}
|
||||
const chosen = detectLanguage();
|
||||
setLocale(chosen);
|
||||
void api
|
||||
.patchSettings({ ui_language: chosen })
|
||||
.then((data) => queryClient.setQueryData(["settings"], data))
|
||||
.catch(() => undefined); // tried again on the next visit
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [saved]);
|
||||
return null;
|
||||
}
|
||||
|
||||
function RequireAuth({ children }: { children: JSX.Element }) {
|
||||
const { data, isLoading, isError, error, refetch } = useMe();
|
||||
const t = useT();
|
||||
@@ -29,7 +56,7 @@ function RequireAuth({ children }: { children: JSX.Element }) {
|
||||
}, [data, queryClient]);
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="p-8 text-gray-500">Loading…</div>;
|
||||
return <div className="p-8 text-gray-500">{t("common.loading")}</div>;
|
||||
}
|
||||
if (isError && isOfflineError(error)) {
|
||||
// No connection and nothing saved on this device: not a reason to sign out.
|
||||
@@ -50,6 +77,7 @@ function RequireAuth({ children }: { children: JSX.Element }) {
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<LanguageSync />
|
||||
<OfflineController />
|
||||
<PushController />
|
||||
{children}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useT } from "../i18n";
|
||||
import { api, ApiError } from "../lib/api";
|
||||
import { useMe } from "../lib/auth";
|
||||
import type { AdminMail, AdminUser } from "../lib/types";
|
||||
import { useConfirm } from "../components/AppDialog";
|
||||
|
||||
const GIB = 1024 ** 3;
|
||||
|
||||
@@ -519,6 +520,7 @@ function InvitationEmailDialog({ onClose }: { onClose: () => void }) {
|
||||
* placeholders; previewed with sample names, and sent as a test. */
|
||||
function InvitationEmail() {
|
||||
const t = useT();
|
||||
const [confirm, confirmDialog] = useConfirm();
|
||||
const queryClient = useQueryClient();
|
||||
const tpl = useQuery({ queryKey: ["adminInviteTemplate"], queryFn: api.adminInviteTemplate });
|
||||
// Sender and subject are drafts until Save; null shows the server's value.
|
||||
@@ -566,6 +568,7 @@ function InvitationEmail() {
|
||||
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
{confirmDialog}
|
||||
<h2 className="text-lg font-semibold">{t("admin.inviteMail")}</h2>
|
||||
<p className="text-xs text-app-faint">{t("admin.inviteMailHelp")}</p>
|
||||
<form
|
||||
@@ -634,7 +637,14 @@ function InvitationEmail() {
|
||||
type="button"
|
||||
className="rounded border border-app-border px-3 py-1 hover:bg-app-hover disabled:opacity-50"
|
||||
disabled={save.isPending}
|
||||
onClick={() => confirm(t("admin.inviteResetConfirm")) && save.mutate({ html: "" })}
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: t("admin.inviteReset"),
|
||||
message: t("admin.inviteResetConfirm"),
|
||||
confirmLabel: t("admin.inviteResetAction"),
|
||||
});
|
||||
if (ok) save.mutate({ html: "" });
|
||||
}}
|
||||
>
|
||||
{t("admin.inviteReset")}
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import { useT } from "../i18n";
|
||||
|
||||
// The app's own dialog, in place of the browser's alert/confirm/prompt boxes.
|
||||
// Looks like Settings' confirmations. Drawn on <body>, so a compose window
|
||||
// (or any other clipped, transformed box) it is opened from can't cut it off.
|
||||
// Escape cancels; Enter in a form submits it.
|
||||
export function AppDialog({
|
||||
title,
|
||||
children,
|
||||
onClose,
|
||||
}: {
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
// Capture: before the app's own Escape handling (minimizing compose windows).
|
||||
window.addEventListener("keydown", onKey, true);
|
||||
return () => window.removeEventListener("keydown", onKey, true);
|
||||
}, [onClose]);
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 p-4" onMouseDown={onClose}>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
className="w-full max-w-sm rounded-lg border border-app-border bg-app-surface p-5 text-app-fg shadow-xl"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3 className="text-base font-semibold">{title}</h3>
|
||||
{children}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
export function DialogButtons({
|
||||
confirmLabel,
|
||||
onCancel,
|
||||
danger = false,
|
||||
disabled = false,
|
||||
submit = false,
|
||||
onConfirm,
|
||||
}: {
|
||||
confirmLabel: string;
|
||||
onCancel: () => void;
|
||||
danger?: boolean;
|
||||
disabled?: boolean;
|
||||
/** The confirm button submits the surrounding form (Enter works too). */
|
||||
submit?: boolean;
|
||||
onConfirm?: () => void;
|
||||
}) {
|
||||
const t = useT();
|
||||
return (
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<button type="button" className="rounded bg-app-hover px-4 py-1.5 text-sm hover:opacity-90" onClick={onCancel}>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type={submit ? "submit" : "button"}
|
||||
autoFocus={!submit}
|
||||
disabled={disabled}
|
||||
onClick={submit ? undefined : onConfirm}
|
||||
className={`rounded px-4 py-1.5 text-sm font-medium hover:opacity-90 disabled:opacity-50 ${
|
||||
danger ? "bg-red-500 text-white" : "bg-app-accent text-app-accent-fg"
|
||||
}`}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConfirmDialog({
|
||||
title,
|
||||
message,
|
||||
detail,
|
||||
confirmLabel,
|
||||
danger = false,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: {
|
||||
title: string;
|
||||
message?: string;
|
||||
/** A second, smaller paragraph (what exactly will happen). */
|
||||
detail?: string;
|
||||
confirmLabel: string;
|
||||
danger?: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
return (
|
||||
<AppDialog title={title} onClose={onCancel}>
|
||||
{message && <p className="mt-2 text-sm text-app-muted">{message}</p>}
|
||||
{detail && <p className="mt-2 text-xs text-app-faint">{detail}</p>}
|
||||
<DialogButtons confirmLabel={confirmLabel} danger={danger} onConfirm={onConfirm} onCancel={onCancel} />
|
||||
</AppDialog>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ConfirmOptions {
|
||||
title: string;
|
||||
message?: string;
|
||||
detail?: string;
|
||||
confirmLabel: string;
|
||||
danger?: boolean;
|
||||
}
|
||||
|
||||
/** `await confirm({...})` in place of window.confirm: true when confirmed.
|
||||
* Render the returned element somewhere in the component. */
|
||||
export function useConfirm(): [(options: ConfirmOptions) => Promise<boolean>, ReactNode] {
|
||||
const [request, setRequest] = useState<(ConfirmOptions & { resolve: (ok: boolean) => void }) | null>(
|
||||
null,
|
||||
);
|
||||
const confirm = useCallback(
|
||||
(options: ConfirmOptions) => new Promise<boolean>((resolve) => setRequest({ ...options, resolve })),
|
||||
[],
|
||||
);
|
||||
const finish = (ok: boolean) => {
|
||||
request?.resolve(ok);
|
||||
setRequest(null);
|
||||
};
|
||||
const element = request ? (
|
||||
<ConfirmDialog
|
||||
title={request.title}
|
||||
message={request.message}
|
||||
detail={request.detail}
|
||||
confirmLabel={request.confirmLabel}
|
||||
danger={request.danger}
|
||||
onConfirm={() => finish(true)}
|
||||
onCancel={() => finish(false)}
|
||||
/>
|
||||
) : null;
|
||||
return [confirm, element];
|
||||
}
|
||||
|
||||
export interface PromptOptions {
|
||||
title: string;
|
||||
message?: string;
|
||||
label: string;
|
||||
initial?: string;
|
||||
placeholder?: string;
|
||||
confirmLabel: string;
|
||||
maxLength?: number;
|
||||
}
|
||||
|
||||
function PromptDialog({
|
||||
options,
|
||||
onDone,
|
||||
}: {
|
||||
options: PromptOptions;
|
||||
onDone: (value: string | null) => void;
|
||||
}) {
|
||||
const [value, setValue] = useState(options.initial ?? "");
|
||||
const cancel = useCallback(() => onDone(null), [onDone]);
|
||||
return (
|
||||
<AppDialog title={options.title} onClose={cancel}>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (value.trim()) onDone(value.trim());
|
||||
}}
|
||||
>
|
||||
{options.message && <p className="mt-2 text-sm text-app-muted">{options.message}</p>}
|
||||
<label className="mt-4 block text-sm">
|
||||
<span className="text-app-muted">{options.label}</span>
|
||||
<input
|
||||
className="mt-1 w-full rounded-lg border border-app-border bg-app-bg px-3 py-1.5 text-app-fg placeholder:text-app-faint focus:border-app-accent focus:outline-none"
|
||||
value={value}
|
||||
maxLength={options.maxLength}
|
||||
placeholder={options.placeholder}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
<DialogButtons submit confirmLabel={options.confirmLabel} disabled={!value.trim()} onCancel={cancel} />
|
||||
</form>
|
||||
</AppDialog>
|
||||
);
|
||||
}
|
||||
|
||||
/** `await prompt({...})` in place of window.prompt: the text, or null if cancelled. */
|
||||
export function usePrompt(): [(options: PromptOptions) => Promise<string | null>, ReactNode] {
|
||||
const [request, setRequest] = useState<
|
||||
(PromptOptions & { resolve: (value: string | null) => void }) | null
|
||||
>(null);
|
||||
const prompt = useCallback(
|
||||
(options: PromptOptions) =>
|
||||
new Promise<string | null>((resolve) => setRequest({ ...options, resolve })),
|
||||
[],
|
||||
);
|
||||
const onDone = useCallback(
|
||||
(value: string | null) => {
|
||||
request?.resolve(value);
|
||||
setRequest(null);
|
||||
},
|
||||
[request],
|
||||
);
|
||||
const element = request ? <PromptDialog options={request} onDone={onDone} /> : null;
|
||||
return [prompt, element];
|
||||
}
|
||||
|
||||
/** A short notice in the corner (components/Toast.tsx), in place of window.alert. */
|
||||
export function notify(text: string, ms?: number): void {
|
||||
window.dispatchEvent(new CustomEvent("webmail:toast", { detail: { text, ms } }));
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useT } from "../i18n";
|
||||
import { SHORTCUT_GROUPS } from "../lib/shortcuts";
|
||||
|
||||
/** Every shortcut in groups, keys drawn as keycaps. Settings and the "?" overlay. */
|
||||
export function ShortcutList() {
|
||||
const t = useT();
|
||||
return (
|
||||
<div className="grid gap-x-10 gap-y-6 sm:grid-cols-2">
|
||||
{SHORTCUT_GROUPS.map((group) => (
|
||||
<div key={group.title}>
|
||||
<h3 className="mb-2 text-[12px] font-semibold uppercase tracking-wide text-app-faint">
|
||||
{t(group.title)}
|
||||
</h3>
|
||||
<ul className="space-y-1.5 text-sm">
|
||||
{group.items.map((item) => (
|
||||
<li key={item.label} className="flex items-center justify-between gap-4">
|
||||
<span className="text-app-fg">{t(item.label)}</span>
|
||||
<span className="flex shrink-0 items-center gap-1 text-app-faint">
|
||||
{item.keys.map((k, i) => (
|
||||
<span key={k} className="flex items-center gap-1">
|
||||
{i > 0 && (
|
||||
<span className="text-[11px]">{item.keys[0] === "Shift" ? "+" : t("shortcuts.then")}</span>
|
||||
)}
|
||||
<kbd className="min-w-[1.6rem] rounded border border-app-border bg-app-surface px-1.5 py-0.5 text-center font-mono text-[12px] text-app-fg shadow-[0_1px_0_rgb(var(--app-border))]">
|
||||
{k}
|
||||
</kbd>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The "?" overlay. */
|
||||
export function ShortcutsHelp({ onClose }: { onClose: () => void }) {
|
||||
const t = useT();
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onMouseDown={onClose}>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("shortcuts.title")}
|
||||
className="max-h-[85vh] w-full max-w-3xl overflow-y-auto rounded-xl border border-app-border bg-app-elevated p-6 shadow-2xl"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="mb-5 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">{t("shortcuts.title")}</h2>
|
||||
<button className="rounded p-1 text-app-muted hover:bg-app-hover hover:text-app-fg" onClick={onClose} aria-label={t("common.close")}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<ShortcutList />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -82,7 +82,7 @@ export function ComposeDock({
|
||||
: "hidden"
|
||||
}
|
||||
>
|
||||
<Composer init={win.init} onClose={() => onClose(win.id)} fullWindow />
|
||||
<Composer init={win.init} snapshotKey={win.id} onClose={() => onClose(win.id)} fullWindow />
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
@@ -162,6 +162,7 @@ export function ComposeDock({
|
||||
>
|
||||
<Composer
|
||||
init={win.init}
|
||||
snapshotKey={win.id}
|
||||
onTitle={(s) => noteTitle(win.id, s)}
|
||||
minimized={!open}
|
||||
expanded={open && win.expanded}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { useT } from "../../i18n";
|
||||
@@ -16,11 +16,14 @@ import {
|
||||
putLocal,
|
||||
uploadFiles,
|
||||
} from "../../lib/scheduled";
|
||||
import type { AccountOut, AttachmentOut, DraftInput, DraftOut } from "../../lib/types";
|
||||
import type { AccountOut, AttachmentOut, DraftInput, DraftOut, MailTemplate } from "../../lib/types";
|
||||
import { showOfflineNotice } from "../../lib/offlineNotice";
|
||||
import { RecipientChips } from "./RecipientChips";
|
||||
import { RichTextEditor, type RichTextEditorHandle } from "./RichTextEditor";
|
||||
import { hasUndoHost, offerUndo } from "./undoSend";
|
||||
import { ConfirmDialog } from "../AppDialog";
|
||||
import { SaveTemplateDialog } from "./SaveTemplateDialog";
|
||||
import { noteSnapshotDraft, saveSnapshot } from "./restore";
|
||||
|
||||
const MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024; // 25 MB per file
|
||||
|
||||
@@ -48,6 +51,9 @@ export interface ComposerInit {
|
||||
localState?: LocalState; // "scheduled" when reopened from Scheduled
|
||||
attachments?: AttachmentOut[]; // known already: no need to ask the server
|
||||
localFiles?: LocalFile[];
|
||||
// Brought back after leaving the mail view (./restore.ts) with changes that
|
||||
// may not have been saved yet: save again.
|
||||
restoredDirty?: boolean;
|
||||
}
|
||||
|
||||
// Build the composer seed from a saved draft (used to reopen it in place or in a
|
||||
@@ -161,6 +167,7 @@ const caret = (
|
||||
|
||||
export function Composer({
|
||||
init,
|
||||
snapshotKey,
|
||||
onClose,
|
||||
expanded = false,
|
||||
onToggleExpand,
|
||||
@@ -171,6 +178,9 @@ export function Composer({
|
||||
fullWindow = false,
|
||||
}: {
|
||||
init: ComposerInit;
|
||||
/** Keep this window's content when it goes away for any reason but
|
||||
* Send/Discard (restore.ts), under this key. */
|
||||
snapshotKey?: string;
|
||||
onClose: () => void;
|
||||
expanded?: boolean;
|
||||
onToggleExpand?: () => void;
|
||||
@@ -211,9 +221,21 @@ export function Composer({
|
||||
const [spellLang, setSpellLang] = useState<string | null>(null);
|
||||
const [spellMenuOpen, setSpellMenuOpen] = useState(false);
|
||||
const settingsQuery = useQuery({ queryKey: ["settings"], queryFn: api.settings });
|
||||
// The language list folds away whenever the More menu closes.
|
||||
// More → Templates: saved replies (api/templates.py).
|
||||
const [templatesOpen, setTemplatesOpen] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
const templatesQuery = useQuery({
|
||||
queryKey: ["templates"],
|
||||
queryFn: api.templates,
|
||||
enabled: moreOpen,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
// The language and template lists fold away whenever the More menu closes.
|
||||
useEffect(() => {
|
||||
if (!moreOpen) setSpellMenuOpen(false);
|
||||
if (!moreOpen) {
|
||||
setSpellMenuOpen(false);
|
||||
setTemplatesOpen(false);
|
||||
}
|
||||
}, [moreOpen]);
|
||||
// What the engine can check — and whether this server has one at all.
|
||||
const spellLanguages = useQuery({
|
||||
@@ -250,6 +272,9 @@ export function Composer({
|
||||
onTitleRef.current?.(subject);
|
||||
}, [subject]);
|
||||
const draftId = useRef<string | null>(init.draftId ?? null);
|
||||
// Where this window keeps its content when it goes away (restore.ts).
|
||||
const snapshotKeyRef = useRef(snapshotKey);
|
||||
snapshotKeyRef.current = snapshotKey;
|
||||
// This message's copy on this device, while there is one (lib/scheduled.ts).
|
||||
const localId = useRef<string | null>(init.localId ?? null);
|
||||
const discardedRef = useRef(false);
|
||||
@@ -258,7 +283,7 @@ export function Composer({
|
||||
const sentRef = useRef(false); // send initiated -> don't mirror to Drafts on close
|
||||
// Whether the user actually edited anything. Merely opening a draft to view it
|
||||
// must not re-save/re-mirror it (that spawned duplicate IMAP copies).
|
||||
const dirtyRef = useRef(false);
|
||||
const dirtyRef = useRef(Boolean(init.restoredDirty));
|
||||
const markDirty = () => {
|
||||
dirtyRef.current = true;
|
||||
};
|
||||
@@ -278,6 +303,41 @@ export function Composer({
|
||||
return () => document.removeEventListener("mousedown", onDown);
|
||||
}, [moreOpen, fromOpen, minimized]);
|
||||
|
||||
// A template holds what was written: not the signature, not the quoted mail.
|
||||
const templateContent = (): string => {
|
||||
const box = document.createElement("div");
|
||||
box.innerHTML = bodyHtml;
|
||||
box.querySelectorAll(".wmc-signature, .wmc-quoted, .wmc-quote-toggle").forEach((n) => n.remove());
|
||||
// Blank lines at either end (the editor keeps one above the signature).
|
||||
const blank = (n: ChildNode | null) =>
|
||||
n !== null &&
|
||||
(n.nodeType === Node.TEXT_NODE
|
||||
? !n.textContent?.trim()
|
||||
: n instanceof HTMLElement && !n.textContent?.trim() && !n.querySelector("img"));
|
||||
while (blank(box.firstChild)) box.firstChild?.remove();
|
||||
while (blank(box.lastChild)) box.lastChild?.remove();
|
||||
return box.textContent?.trim() || box.querySelector("img") ? box.innerHTML.trim() : "";
|
||||
};
|
||||
// Our own dialogs (components/AppDialog.tsx), not the browser's prompt/confirm.
|
||||
const [savingTemplate, setSavingTemplate] = useState<string | null>(null); // the content
|
||||
const [deletingTemplate, setDeletingTemplate] = useState<MailTemplate | null>(null);
|
||||
const saveTemplate = async (name: string) => {
|
||||
if (savingTemplate === null) return;
|
||||
await api.saveTemplate(name, subject, savingTemplate);
|
||||
void queryClient.invalidateQueries({ queryKey: ["templates"] });
|
||||
};
|
||||
const insertTemplate = (tpl: MailTemplate) => {
|
||||
setMoreOpen(false);
|
||||
markDirty();
|
||||
if (!subject.trim() && tpl.subject) setSubject(tpl.subject);
|
||||
editorRef.current?.insertHtml(tpl.body_html);
|
||||
};
|
||||
const deleteTemplate = async (tpl: MailTemplate) => {
|
||||
setDeletingTemplate(null);
|
||||
await api.deleteTemplate(tpl.id).catch(() => undefined);
|
||||
void queryClient.invalidateQueries({ queryKey: ["templates"] });
|
||||
};
|
||||
|
||||
const account = accounts.find((a) => a.id === accountId);
|
||||
const fromName = account?.display_name || "";
|
||||
const fromEmail = account?.email ?? "";
|
||||
@@ -533,7 +593,11 @@ export function Composer({
|
||||
setLocalFiles([]);
|
||||
}
|
||||
if (draftId.current) await api.updateDraft(draftId.current, payload(atts));
|
||||
else draftId.current = (await api.createDraft(payload(atts))).id;
|
||||
else {
|
||||
draftId.current = (await api.createDraft(payload(atts))).id;
|
||||
// This window may have gone away while the draft was being made.
|
||||
if (snapshotKeyRef.current) noteSnapshotDraft(snapshotKeyRef.current, draftId.current);
|
||||
}
|
||||
if (localId.current) {
|
||||
await deleteLocal(localId.current);
|
||||
localId.current = null;
|
||||
@@ -586,8 +650,44 @@ export function Composer({
|
||||
const saveLocalRef = useRef(saveLocal);
|
||||
saveLocalRef.current = saveLocal;
|
||||
|
||||
// What this window holds right now, to bring it back as it is (restore.ts).
|
||||
// A ref: the unmount below sees only the first render's values otherwise.
|
||||
const snapshotRef = useRef<() => ComposerInit>(() => init);
|
||||
snapshotRef.current = () => ({
|
||||
...init,
|
||||
accountId,
|
||||
to,
|
||||
cc,
|
||||
bcc,
|
||||
subject,
|
||||
html: bodyHtml || initialHtml,
|
||||
text: undefined,
|
||||
quote: undefined, // inside html now
|
||||
forwardFrom: undefined, // its attachments are in `attachments` now
|
||||
draftId: draftId.current ?? undefined,
|
||||
localId: localId.current ?? undefined,
|
||||
localState: localState.current,
|
||||
attachments,
|
||||
localFiles,
|
||||
restoredDirty: dirtyRef.current,
|
||||
});
|
||||
|
||||
const keepSnapshot = () => {
|
||||
const key = snapshotKeyRef.current;
|
||||
if (key && !sentRef.current && !discardedRef.current) saveSnapshot(key, snapshotRef.current());
|
||||
};
|
||||
const keepSnapshotRef = useRef(keepSnapshot);
|
||||
keepSnapshotRef.current = keepSnapshot;
|
||||
// A reload or a closed tab unmounts nothing: keep the content then too.
|
||||
useEffect(() => {
|
||||
const onPageHide = () => keepSnapshotRef.current();
|
||||
window.addEventListener("pagehide", onPageHide);
|
||||
return () => window.removeEventListener("pagehide", onPageHide);
|
||||
}, []);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
keepSnapshotRef.current(); // leaving the mail view (Settings…): back as it was
|
||||
if (undoTimer.current) window.clearTimeout(undoTimer.current);
|
||||
// On close, mirror the draft to the IMAP Drafts folder so it can be
|
||||
// continued on any mail client — but only if the user actually edited it
|
||||
@@ -773,6 +873,24 @@ export function Composer({
|
||||
{dragKind === "body" ? t("compose.dropBody") : t("compose.dropAttach")}
|
||||
</div>
|
||||
)}
|
||||
{savingTemplate !== null && (
|
||||
<SaveTemplateDialog
|
||||
initialName={subject.trim()}
|
||||
existing={templatesQuery.data ?? []}
|
||||
onSave={saveTemplate}
|
||||
onClose={() => setSavingTemplate(null)}
|
||||
/>
|
||||
)}
|
||||
{deletingTemplate && (
|
||||
<ConfirmDialog
|
||||
title={t("compose.deleteTemplate")}
|
||||
message={t("compose.templateDelete", { name: deletingTemplate.name })}
|
||||
confirmLabel={t("compose.deleteTemplateAction")}
|
||||
danger
|
||||
onConfirm={() => void deleteTemplate(deletingTemplate)}
|
||||
onCancel={() => setDeletingTemplate(null)}
|
||||
/>
|
||||
)}
|
||||
{showClosePrompt && (
|
||||
<div
|
||||
className="absolute inset-0 z-40 flex items-center justify-center bg-black/40 p-4"
|
||||
@@ -884,6 +1002,63 @@ export function Composer({
|
||||
>
|
||||
{t("compose.insertSignature")}
|
||||
</button>
|
||||
<button
|
||||
className="flex w-full items-center justify-between gap-2 px-3 py-1.5 text-left text-app-fg hover:bg-app-hover"
|
||||
aria-expanded={templatesOpen}
|
||||
onClick={() => setTemplatesOpen((v) => !v)}
|
||||
>
|
||||
<span>{t("compose.templates")}</span>
|
||||
<span className="shrink-0 text-app-faint">{templatesOpen ? "▾" : "›"}</span>
|
||||
</button>
|
||||
{templatesOpen && (
|
||||
<div className="max-h-64 overflow-y-auto border-y border-app-border bg-app-surface py-1">
|
||||
{(() => {
|
||||
const content = templateContent();
|
||||
return (
|
||||
<button
|
||||
className="block w-full px-3 py-1 text-left text-[13px] font-medium text-app-accent hover:bg-app-hover disabled:cursor-default disabled:text-app-faint disabled:hover:bg-transparent"
|
||||
disabled={!content}
|
||||
title={content ? undefined : t("compose.templateEmpty")}
|
||||
onClick={() => {
|
||||
setMoreOpen(false);
|
||||
setSavingTemplate(content);
|
||||
}}
|
||||
>
|
||||
{t("compose.saveAsTemplate")}
|
||||
{!content && (
|
||||
<span className="block text-[11px] font-normal">{t("compose.templateEmptyShort")}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
<div className="my-1 border-t border-app-border" />
|
||||
{templatesQuery.data?.length === 0 && (
|
||||
<div className="px-3 py-1 text-[13px] text-app-faint">{t("compose.noTemplates")}</div>
|
||||
)}
|
||||
{templatesQuery.data?.map((tpl) => (
|
||||
<div key={tpl.id} className="group flex items-center hover:bg-app-hover">
|
||||
<button
|
||||
className="min-w-0 flex-1 truncate px-3 py-1 text-left text-[13px] text-app-fg"
|
||||
title={tpl.subject || tpl.name}
|
||||
onClick={() => insertTemplate(tpl)}
|
||||
>
|
||||
{tpl.name}
|
||||
</button>
|
||||
<button
|
||||
className="mr-1 shrink-0 rounded px-1.5 text-app-faint opacity-0 hover:text-red-500 focus:opacity-100 group-hover:opacity-100"
|
||||
title={t("compose.deleteTemplate")}
|
||||
aria-label={t("compose.deleteTemplate")}
|
||||
onClick={() => {
|
||||
setMoreOpen(false);
|
||||
setDeletingTemplate(tpl);
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{spellLanguages.data && (() => {
|
||||
// The language in force: this message's choice, else Settings.
|
||||
const fallback = settingsQuery.data?.spellcheck_enabled
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import { useT } from "../../i18n";
|
||||
import { useTheme } from "../../lib/theme";
|
||||
|
||||
// emoji-mart, loaded on first use: its emoji data is a few hundred KB that the
|
||||
// rest of the app does without. The data is bundled — nothing is fetched from
|
||||
// outside this server.
|
||||
const loadData = async () => (await import("@emoji-mart/data")).default;
|
||||
|
||||
const PICKER_W = 352;
|
||||
const PICKER_H = 435;
|
||||
|
||||
/** emoji-mart's picker (a web component), made and removed here: its React
|
||||
* wrapper builds it twice under StrictMode, doubling sections. */
|
||||
function PickerHost({
|
||||
theme,
|
||||
onSelect,
|
||||
loading,
|
||||
}: {
|
||||
theme: "light" | "dark";
|
||||
onSelect: (native: string) => void;
|
||||
loading: string;
|
||||
}) {
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const onSelectRef = useRef(onSelect);
|
||||
onSelectRef.current = onSelect;
|
||||
const [ready, setReady] = useState(false);
|
||||
useEffect(() => {
|
||||
let picker: HTMLElement | null = null;
|
||||
let alive = true;
|
||||
void import("emoji-mart").then(({ Picker }) => {
|
||||
if (!alive || !hostRef.current) return;
|
||||
picker = new Picker({
|
||||
data: loadData,
|
||||
theme,
|
||||
previewPosition: "none",
|
||||
skinTonePosition: "search",
|
||||
maxFrequentRows: 2,
|
||||
onEmojiSelect: (emoji: { native?: string }) => {
|
||||
if (emoji.native) onSelectRef.current(emoji.native);
|
||||
},
|
||||
}) as unknown as HTMLElement;
|
||||
hostRef.current.appendChild(picker);
|
||||
setReady(true);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
picker?.remove();
|
||||
};
|
||||
}, [theme]);
|
||||
return (
|
||||
<div ref={hostRef}>
|
||||
{!ready && (
|
||||
<div
|
||||
className="flex items-center justify-center border border-app-border bg-app-elevated text-sm text-app-faint"
|
||||
style={{ width: PICKER_W, height: PICKER_H }}
|
||||
>
|
||||
{loading}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The formatting bar's emoji button and its picker. ``onOpen`` runs before
|
||||
* the picker takes focus (the editor keeps its caret); ``onPick`` inserts. */
|
||||
export function EmojiButton({
|
||||
onOpen,
|
||||
onPick,
|
||||
className,
|
||||
}: {
|
||||
onOpen: () => void;
|
||||
onPick: (emoji: string) => void;
|
||||
className: string;
|
||||
}) {
|
||||
const t = useT();
|
||||
const { resolved } = useTheme();
|
||||
const btnRef = useRef<HTMLButtonElement>(null);
|
||||
const popRef = useRef<HTMLDivElement>(null);
|
||||
const [pos, setPos] = useState<{ left: number; top: number } | null>(null);
|
||||
|
||||
const open = () => {
|
||||
const r = btnRef.current?.getBoundingClientRect();
|
||||
if (!r) return;
|
||||
onOpen();
|
||||
// Below the button, or above it when the window ends first; inside the viewport.
|
||||
const below = r.bottom + 6 + PICKER_H <= window.innerHeight;
|
||||
setPos({
|
||||
left: Math.max(8, Math.min(r.left, window.innerWidth - PICKER_W - 8)),
|
||||
top: below ? r.bottom + 6 : Math.max(8, r.top - 6 - PICKER_H),
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!pos) return;
|
||||
const onDown = (e: MouseEvent) => {
|
||||
const target = e.target as Node;
|
||||
if (!popRef.current?.contains(target) && !btnRef.current?.contains(target)) setPos(null);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.stopPropagation(); // the picker, not the compose window
|
||||
setPos(null);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", onDown);
|
||||
window.addEventListener("keydown", onKey, true);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDown);
|
||||
window.removeEventListener("keydown", onKey, true);
|
||||
};
|
||||
}, [pos]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={btnRef}
|
||||
type="button"
|
||||
title={t("compose.fmtEmoji")}
|
||||
aria-label={t("compose.fmtEmoji")}
|
||||
aria-expanded={pos !== null}
|
||||
onMouseDown={(e) => e.preventDefault()} // the editor keeps focus and caret
|
||||
onClick={() => (pos ? setPos(null) : open())}
|
||||
className={className}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M8.5 14.5c.9 1.3 2.1 2 3.5 2s2.6-.7 3.5-2" />
|
||||
<path d="M9 9.5h.01M15 9.5h.01" strokeWidth="2.6" />
|
||||
</svg>
|
||||
</button>
|
||||
{pos &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={popRef}
|
||||
className="fixed z-[70] overflow-hidden rounded-xl shadow-2xl"
|
||||
style={{ left: pos.left, top: pos.top }}
|
||||
>
|
||||
<PickerHost
|
||||
theme={resolved}
|
||||
loading={t("compose.emojiLoading")}
|
||||
onSelect={(native) => {
|
||||
onPick(native);
|
||||
setPos(null);
|
||||
}}
|
||||
/>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,17 @@ import {
|
||||
|
||||
import { useT } from "../../i18n";
|
||||
import { api, ApiError } from "../../lib/api";
|
||||
import { notify } from "../AppDialog";
|
||||
import { EmojiButton } from "./EmojiButton";
|
||||
import {
|
||||
GHOST,
|
||||
acceptGhost,
|
||||
caretContext,
|
||||
ghostEl,
|
||||
removeGhost,
|
||||
showGhost,
|
||||
typedIntoGhost,
|
||||
} from "./completion";
|
||||
import {
|
||||
canHighlight,
|
||||
clearPaint,
|
||||
@@ -94,9 +105,10 @@ function addQuoteToggle(el: HTMLElement, title: string) {
|
||||
}
|
||||
|
||||
function contentHtml(el: HTMLElement): string {
|
||||
if (!el.querySelector(`.${QUOTE_TOGGLE}`)) return el.innerHTML;
|
||||
// The quote pill and a suggestion's grey text are the editor's, not the message's.
|
||||
if (!el.querySelector(`.${QUOTE_TOGGLE}, .${GHOST}`)) return el.innerHTML;
|
||||
const copy = el.cloneNode(true) as HTMLElement;
|
||||
copy.querySelectorAll(`.${QUOTE_TOGGLE}`).forEach((n) => n.remove());
|
||||
copy.querySelectorAll(`.${QUOTE_TOGGLE}, .${GHOST}`).forEach((n) => n.remove());
|
||||
return copy.innerHTML;
|
||||
}
|
||||
|
||||
@@ -247,6 +259,70 @@ export const RichTextEditor = forwardRef<
|
||||
const [spellPos, setSpellPos] = useState<{ left: number; top: number } | null>(null);
|
||||
const spellPopupRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Sentence completion (./completion.ts): grey text after the caret, Tab takes it.
|
||||
const completionOn = settings.data?.completion_enabled ?? true;
|
||||
const completionOnRef = useRef(completionOn);
|
||||
completionOnRef.current = completionOn;
|
||||
const completionSeq = useRef(0);
|
||||
const completionTimer = useRef<number | null>(null);
|
||||
const composing = useRef(false); // an input method is building a character
|
||||
const scheduleCompletion = () => {
|
||||
const el = ref.current;
|
||||
if (completionTimer.current) window.clearTimeout(completionTimer.current);
|
||||
const seq = ++completionSeq.current;
|
||||
if (!el || !completionOnRef.current || ghostEl(el)) return;
|
||||
completionTimer.current = window.setTimeout(async () => {
|
||||
if (composing.current || document.activeElement !== el) return;
|
||||
const at = caretContext(el);
|
||||
if (!at) return;
|
||||
let suggestion: string | null = null;
|
||||
try {
|
||||
suggestion = (await api.complete(at.before)).suggestion;
|
||||
} catch {
|
||||
return; // offline, or the server says no: simply no suggestion
|
||||
}
|
||||
// Still where it was asked for? (The user may have typed on, or moved.)
|
||||
const now = caretContext(el);
|
||||
if (!suggestion || seq !== completionSeq.current || !now || now.before !== at.before) return;
|
||||
removeGhost(el);
|
||||
showGhost(now.range, suggestion, "Tab");
|
||||
}, 300);
|
||||
};
|
||||
const dropGhost = () => {
|
||||
completionSeq.current++;
|
||||
if (ref.current) removeGhost(ref.current);
|
||||
};
|
||||
|
||||
// Native input events the ghost answers to: typing over it, leaving, IME.
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const onBeforeInput = (e: InputEvent) => {
|
||||
if (typedIntoGhost(el, e)) completionSeq.current++; // kept: no new request
|
||||
};
|
||||
const onCompositionStart = () => {
|
||||
composing.current = true;
|
||||
dropGhost();
|
||||
};
|
||||
const onCompositionEnd = () => {
|
||||
composing.current = false;
|
||||
};
|
||||
el.addEventListener("beforeinput", onBeforeInput);
|
||||
el.addEventListener("blur", dropGhost);
|
||||
el.addEventListener("mousedown", dropGhost);
|
||||
el.addEventListener("compositionstart", onCompositionStart);
|
||||
el.addEventListener("compositionend", onCompositionEnd);
|
||||
return () => {
|
||||
el.removeEventListener("beforeinput", onBeforeInput);
|
||||
el.removeEventListener("blur", dropGhost);
|
||||
el.removeEventListener("mousedown", dropGhost);
|
||||
el.removeEventListener("compositionstart", onCompositionStart);
|
||||
el.removeEventListener("compositionend", onCompositionEnd);
|
||||
if (completionTimer.current) window.clearTimeout(completionTimer.current);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// A reply's quoted original starts folded away behind the "•••" pill.
|
||||
const [quoteOpen, setQuoteOpen] = useState(false);
|
||||
|
||||
@@ -274,6 +350,7 @@ export const RichTextEditor = forwardRef<
|
||||
replaceSignature: (html: string) => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
removeGhost(el);
|
||||
const sig = el.querySelector(".wmc-signature");
|
||||
if (html) {
|
||||
if (sig) sig.innerHTML = html;
|
||||
@@ -285,6 +362,7 @@ export const RichTextEditor = forwardRef<
|
||||
insertSignature: (html: string) => {
|
||||
const el = ref.current;
|
||||
if (!el || !html) return;
|
||||
removeGhost(el);
|
||||
el.querySelector(".wmc-signature")?.remove();
|
||||
const spacerBefore = document.createElement("div");
|
||||
spacerBefore.innerHTML = "<br>";
|
||||
@@ -304,6 +382,7 @@ export const RichTextEditor = forwardRef<
|
||||
insertHtml: (html: string) => {
|
||||
const el = ref.current;
|
||||
if (!el || !html) return;
|
||||
removeGhost(el);
|
||||
el.focus();
|
||||
// Insert at the current caret; if that fails (no selection in the editor),
|
||||
// append before the signature/quoted block.
|
||||
@@ -430,6 +509,7 @@ export const RichTextEditor = forwardRef<
|
||||
};
|
||||
|
||||
const exec = (command: string, value?: string) => {
|
||||
dropGhost();
|
||||
ref.current?.focus();
|
||||
document.execCommand(command, false, value);
|
||||
emit();
|
||||
@@ -481,7 +561,7 @@ export const RichTextEditor = forwardRef<
|
||||
|
||||
const insertImage = (file: File) => {
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
window.alert(t("compose.imageTooLarge"));
|
||||
notify(t("compose.imageTooLarge"), 5000);
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
@@ -771,6 +851,23 @@ export const RichTextEditor = forwardRef<
|
||||
};
|
||||
|
||||
const onEditorKeyDown = (e: React.KeyboardEvent) => {
|
||||
const el = ref.current;
|
||||
if (el && ghostEl(el)) {
|
||||
if (e.key === "Tab" && !e.shiftKey && !e.altKey && !e.ctrlKey && !e.metaKey) {
|
||||
e.preventDefault();
|
||||
if (acceptGhost(el)) emit();
|
||||
return;
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation(); // the suggestion, not the compose window
|
||||
dropGhost();
|
||||
return;
|
||||
}
|
||||
// Moving, deleting, Enter…: the suggestion no longer fits. Typing is
|
||||
// handled by beforeinput (it may shorten the suggestion instead).
|
||||
if (e.key.length > 1 && e.key !== "Shift" && e.key !== "Dead") dropGhost();
|
||||
}
|
||||
// Typing (or Escape) in the message puts the link popup away. Escape stops
|
||||
// here: the mail view's own Escape would otherwise minimise the whole window.
|
||||
if (spellIssue) {
|
||||
@@ -926,6 +1023,15 @@ export const RichTextEditor = forwardRef<
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<EmojiButton
|
||||
className="flex h-7 w-7 items-center justify-center rounded text-app-muted hover:bg-app-hover hover:text-app-fg"
|
||||
onOpen={saveSelection}
|
||||
onPick={(emoji) => {
|
||||
ref.current?.focus();
|
||||
restoreSelection();
|
||||
exec("insertText", emoji);
|
||||
}}
|
||||
/>
|
||||
<Sep />
|
||||
<Btn
|
||||
onClick={() => exec("justifyLeft")}
|
||||
@@ -964,6 +1070,7 @@ export const RichTextEditor = forwardRef<
|
||||
onInput={(e) => {
|
||||
onInput(contentHtml(e.currentTarget));
|
||||
scheduleSpellcheck();
|
||||
scheduleCompletion();
|
||||
}}
|
||||
// Our own checker replaces the browser's, rather than doubling it up.
|
||||
spellCheck={!spellOn}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { useT } from "../../i18n";
|
||||
import { ApiError } from "../../lib/api";
|
||||
import type { MailTemplate } from "../../lib/types";
|
||||
import { AppDialog, DialogButtons } from "../AppDialog";
|
||||
|
||||
/** Compose → More → Templates → Save as template: name it, save it. A name
|
||||
* already taken turns the button into "Replace" rather than asking again. */
|
||||
export function SaveTemplateDialog({
|
||||
initialName,
|
||||
existing,
|
||||
onSave,
|
||||
onClose,
|
||||
}: {
|
||||
initialName: string;
|
||||
existing: MailTemplate[];
|
||||
onSave: (name: string) => Promise<void>;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const t = useT();
|
||||
const [name, setName] = useState(initialName);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const trimmed = name.trim();
|
||||
const taken = existing.some((x) => x.name.toLowerCase() === trimmed.toLowerCase());
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!trimmed || saving) return;
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
await onSave(trimmed);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : t("compose.templateSaveFailed"));
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AppDialog title={t("compose.saveTemplateTitle")} onClose={onClose}>
|
||||
<form onSubmit={submit}>
|
||||
<p className="mt-2 text-sm text-app-muted">{t("compose.saveTemplateHint")}</p>
|
||||
<label className="mt-4 block text-sm">
|
||||
<span className="text-app-muted">{t("compose.templateName")}</span>
|
||||
<input
|
||||
className="mt-1 w-full rounded-lg border border-app-border bg-app-bg px-3 py-1.5 text-app-fg placeholder:text-app-faint focus:border-app-accent focus:outline-none"
|
||||
value={name}
|
||||
maxLength={80}
|
||||
placeholder={t("compose.templateNamePlaceholder")}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
autoFocus
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
/>
|
||||
</label>
|
||||
{taken && <p className="mt-2 text-xs text-amber-600 dark:text-amber-400">{t("compose.templateReplaceNote")}</p>}
|
||||
{error && <p className="mt-2 text-xs text-red-500">{error}</p>}
|
||||
<DialogButtons
|
||||
submit
|
||||
confirmLabel={saving ? t("compose.templateSaving") : taken ? t("compose.templateReplaceAction") : t("common.save")}
|
||||
disabled={!trimmed || saving}
|
||||
onCancel={onClose}
|
||||
/>
|
||||
</form>
|
||||
</AppDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
// Sentence completion in the compose editor: the grey "ghost" text after the
|
||||
// caret (suggested by /api/completion, app/completion.py) that Tab accepts.
|
||||
//
|
||||
// The ghost is a non-editable span right after the caret, so it wraps with the
|
||||
// paragraph like real text. It is never part of the message: contentHtml()
|
||||
// leaves it out, the spell checker skips it, and it goes on any other key.
|
||||
|
||||
export const GHOST = "wmc-ghost";
|
||||
|
||||
// Where a suggestion would not belong.
|
||||
const NO_GHOST = ".wmc-signature, .wmc-quoted, .wmc-quote-toggle, blockquote, a, pre, code";
|
||||
const BLOCKS = "div, p, li, h1, h2, h3";
|
||||
|
||||
export function ghostEl(root: HTMLElement): HTMLElement | null {
|
||||
return root.querySelector<HTMLElement>(`.${GHOST}`);
|
||||
}
|
||||
|
||||
export function removeGhost(root: HTMLElement): void {
|
||||
root.querySelectorAll(`.${GHOST}`).forEach((n) => n.remove());
|
||||
}
|
||||
|
||||
/** The current line's text up to the caret, when a suggestion may be shown
|
||||
* there: a collapsed caret at the end of its line, outside signature, quote
|
||||
* and links. Null otherwise. */
|
||||
export function caretContext(root: HTMLElement): { before: string; range: Range } | null {
|
||||
const sel = window.getSelection();
|
||||
if (!sel || sel.rangeCount === 0 || !sel.isCollapsed) return null;
|
||||
const range = sel.getRangeAt(0);
|
||||
if (!root.contains(range.startContainer)) return null;
|
||||
const node = range.startContainer;
|
||||
const el = node instanceof Element ? node : node.parentElement;
|
||||
if (!el || el.closest(NO_GHOST)) return null;
|
||||
const block = el.closest(BLOCKS);
|
||||
const scope = block && block !== root && root.contains(block) ? block : root;
|
||||
|
||||
// Only at the end of the line: nothing but whitespace after the caret.
|
||||
const after = document.createRange();
|
||||
after.selectNodeContents(scope);
|
||||
after.setStart(range.startContainer, range.startOffset);
|
||||
if (after.toString().replace(/[\s\u200b]/g, "")) return null;
|
||||
|
||||
// The text before the caret, a <br> counting as a line break.
|
||||
let text = "";
|
||||
const walker = document.createTreeWalker(scope, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT);
|
||||
for (let n: Node | null = walker.nextNode(); n; n = walker.nextNode()) {
|
||||
if (n === range.startContainer && n.nodeType === Node.TEXT_NODE) {
|
||||
text += (n as Text).data.slice(0, range.startOffset);
|
||||
break;
|
||||
}
|
||||
if (n.nodeType === Node.ELEMENT_NODE && n === range.startContainer) {
|
||||
// The caret sits between children: take those before it.
|
||||
const kids = Array.from(n.childNodes).slice(0, range.startOffset);
|
||||
text += kids.map((k) => k.textContent ?? "").join("");
|
||||
break;
|
||||
}
|
||||
if (range.comparePoint(n, 0) > 0) break; // past the caret
|
||||
if (n.nodeType === Node.TEXT_NODE) text += (n as Text).data;
|
||||
else if ((n as Element).tagName === "BR") text += "\n";
|
||||
}
|
||||
const line = (text.split("\n").pop() ?? "").replace(/\u00a0/g, " ");
|
||||
if (!line.trim()) return null;
|
||||
return { before: line, range: range.cloneRange() };
|
||||
}
|
||||
|
||||
/** Show ``text`` in grey right after the caret (``range``), the caret kept before it. */
|
||||
export function showGhost(range: Range, text: string, hint: string): void {
|
||||
const span = document.createElement("span");
|
||||
span.className = GHOST;
|
||||
span.setAttribute("contenteditable", "false");
|
||||
span.setAttribute("aria-hidden", "true");
|
||||
span.dataset.hint = hint; // "Tab", drawn after the text by CSS
|
||||
span.textContent = text;
|
||||
range.insertNode(span);
|
||||
const caret = document.createRange();
|
||||
caret.setStartBefore(span);
|
||||
caret.collapse(true);
|
||||
const sel = window.getSelection();
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(caret);
|
||||
}
|
||||
|
||||
/** Take the suggestion: real, undoable text where the ghost was. */
|
||||
export function acceptGhost(root: HTMLElement): boolean {
|
||||
const ghost = ghostEl(root);
|
||||
const text = ghost?.textContent ?? "";
|
||||
if (!ghost || !text) return false;
|
||||
const caret = document.createRange();
|
||||
caret.setStartBefore(ghost);
|
||||
caret.collapse(true);
|
||||
ghost.remove();
|
||||
const sel = window.getSelection();
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(caret);
|
||||
document.execCommand("insertText", false, text);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Typing what the ghost shows shortens it ("hea" → "ring from you") rather
|
||||
* than throwing it away. True when the ghost stays. */
|
||||
export function typedIntoGhost(root: HTMLElement, e: InputEvent): boolean {
|
||||
const ghost = ghostEl(root);
|
||||
if (!ghost) return false;
|
||||
const shown = ghost.textContent ?? "";
|
||||
if (e.inputType === "insertText" && e.data && shown.length > e.data.length && shown.startsWith(e.data)) {
|
||||
ghost.textContent = shown.slice(e.data.length);
|
||||
return true;
|
||||
}
|
||||
ghost.remove();
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// Open compose windows outlive leaving the mail view (Settings, a plugin page)
|
||||
// and a reload: kept for this tab in sessionStorage, and put back when the mail
|
||||
// view opens again.
|
||||
//
|
||||
// Two parts: the window list (which windows, minimized or expanded), written as
|
||||
// it changes; and each window's content, written by its Composer as it goes
|
||||
// away (with the latest text, which autosave may not have reached yet). Only a
|
||||
// window with content comes back: one that was sent or discarded leaves none.
|
||||
|
||||
import type { ComposerInit } from "./Composer";
|
||||
import type { ComposeWin } from "./windows";
|
||||
|
||||
const LIST = "compose.windows";
|
||||
const SNAP = "compose.snap.";
|
||||
|
||||
type Listed = Pick<ComposeWin, "id" | "minimized" | "expanded" | "activated">;
|
||||
|
||||
function read<T>(key: string): T | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(key);
|
||||
return raw ? (JSON.parse(raw) as T) : null;
|
||||
} catch {
|
||||
return null; // storage blocked or garbled: nothing to restore
|
||||
}
|
||||
}
|
||||
|
||||
function write(key: string, value: unknown): void {
|
||||
try {
|
||||
sessionStorage.setItem(key, JSON.stringify(value));
|
||||
} catch {
|
||||
/* storage blocked or full: the windows just won't come back */
|
||||
}
|
||||
}
|
||||
|
||||
export function saveWindowList(wins: ComposeWin[]): void {
|
||||
write(
|
||||
LIST,
|
||||
wins.map(({ id, minimized, expanded, activated }) => ({ id, minimized, expanded, activated })),
|
||||
);
|
||||
}
|
||||
|
||||
/** A window's content as it goes away (called by its Composer). */
|
||||
export function saveSnapshot(id: string, init: ComposerInit): void {
|
||||
write(SNAP + id, init);
|
||||
}
|
||||
|
||||
/** A save that finished after its window went away: the kept content learns
|
||||
* its draft id, so the window brought back updates that draft instead of
|
||||
* starting a second one. */
|
||||
export function noteSnapshotDraft(id: string, draftId: string): void {
|
||||
const kept = read<ComposerInit>(SNAP + id);
|
||||
if (kept && !kept.draftId) write(SNAP + id, { ...kept, draftId });
|
||||
}
|
||||
|
||||
/** The windows to reopen, each with its last content. Reads only: React may
|
||||
* call a state initializer twice (StrictMode), and both must see the same. */
|
||||
export function restoredWindows(): ComposeWin[] {
|
||||
const listed = read<Listed[]>(LIST) ?? [];
|
||||
const out: ComposeWin[] = [];
|
||||
for (const w of listed) {
|
||||
const init = read<ComposerInit>(SNAP + w.id);
|
||||
if (init) out.push({ ...w, init });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Drop the kept contents once the windows are open again (they keep their own). */
|
||||
export function clearSnapshots(): void {
|
||||
try {
|
||||
for (let i = sessionStorage.length - 1; i >= 0; i--) {
|
||||
const key = sessionStorage.key(i);
|
||||
if (key?.startsWith(SNAP)) sessionStorage.removeItem(key);
|
||||
}
|
||||
} catch {
|
||||
/* nothing to clear */
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ export interface PlacedIssue extends SpellIssue {
|
||||
}
|
||||
|
||||
// The signature and the quoted original are not the user's writing to check.
|
||||
const SKIP = ".wmc-signature, .wmc-quoted, .wmc-quote-toggle";
|
||||
const SKIP = ".wmc-signature, .wmc-quoted, .wmc-quote-toggle, .wmc-ghost";
|
||||
const BLOCK = /^(DIV|P|LI|UL|OL|BLOCKQUOTE|H[1-6]|TABLE|TR|PRE|SECTION)$/;
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useSavedThreads } from "../../lib/offline";
|
||||
import { formatClock, getDateLocale } from "../../lib/timeFormat";
|
||||
import type { Address, ThreadSummary } from "../../lib/types";
|
||||
import { Icon } from "../Icon";
|
||||
import { Clock } from "../Offline";
|
||||
import { AvatarImage } from "../SenderAvatar";
|
||||
import { LabelChips } from "../mail/LabelChips";
|
||||
|
||||
@@ -22,6 +23,17 @@ function timeLabel(iso: string): string {
|
||||
return d.toLocaleDateString(getDateLocale(), { year: "2-digit", month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
// A row's date: when the conversation comes back, while it is snoozed.
|
||||
function RowDate({ thread }: { thread: ThreadSummary }) {
|
||||
if (!thread.snoozed_until) return <>{timeLabel(thread.last_message_at)}</>;
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-amber-600 dark:text-amber-400">
|
||||
<Clock size={13} />
|
||||
{timeLabel(thread.snoozed_until)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Stable per-sender hue so a sender's avatar keeps a consistent colour.
|
||||
function initials(p: Address | undefined): string {
|
||||
const name = (p?.name || p?.email || "?").trim();
|
||||
@@ -354,7 +366,7 @@ export function MessageList({
|
||||
unread ? "font-bold" : ""
|
||||
}`}
|
||||
>
|
||||
{timeLabel(thread.last_message_at)}
|
||||
<RowDate thread={thread} />
|
||||
</span>
|
||||
</span>
|
||||
{/* A narrow desktop list: the actions take the date's place on hover. */}
|
||||
@@ -505,7 +517,7 @@ export function MessageList({
|
||||
unread ? "font-bold" : ""
|
||||
}`}
|
||||
>
|
||||
{timeLabel(thread.last_message_at)}
|
||||
<RowDate thread={thread} />
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,527 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
|
||||
import { useT } from "../../i18n";
|
||||
import { api } from "../../lib/api";
|
||||
import { avatarColor } from "../../lib/avatarColor";
|
||||
import { useListDividers } from "../../lib/listDividers";
|
||||
import { formatClock, getDateLocale } from "../../lib/timeFormat";
|
||||
import type { Subscription, SubscriptionList } from "../../lib/types";
|
||||
import { useConfirm } from "../AppDialog";
|
||||
import { EmptyIllustration } from "../EmptyIllustration";
|
||||
import { Icon } from "../Icon";
|
||||
import { AvatarImage } from "../SenderAvatar";
|
||||
|
||||
export function dateLabel(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
const now = new Date();
|
||||
if (d.toDateString() === now.toDateString()) return formatClock(d);
|
||||
if (d.getFullYear() === now.getFullYear())
|
||||
return d.toLocaleDateString(getDateLocale(), { month: "short", day: "numeric" });
|
||||
return d.toLocaleDateString(getDateLocale(), { year: "2-digit", month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
export function initialsOf(name: string): string {
|
||||
const words = name.trim().split(/\s+/).filter(Boolean);
|
||||
if (words.length >= 2) return (words[0][0] + words[words.length - 1][0]).toUpperCase();
|
||||
return (name.trim() || "?").slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
function toast(text: string) {
|
||||
window.dispatchEvent(new CustomEvent("webmail:toast", { detail: { text, ms: 5000 } }));
|
||||
}
|
||||
|
||||
/** What to unsubscribe from: a sender group, or one message's own header. */
|
||||
export interface UnsubscribeTarget {
|
||||
key: string;
|
||||
name: string;
|
||||
via: "one_click" | "mailto" | "web";
|
||||
messageId?: string;
|
||||
}
|
||||
|
||||
export function targetOf(sub: Subscription): UnsubscribeTarget {
|
||||
return {
|
||||
key: sub.key,
|
||||
name: sub.name,
|
||||
via: sub.one_click ? "one_click" : sub.has_mailto ? "mailto" : "web",
|
||||
};
|
||||
}
|
||||
|
||||
/** Unsubscribe and delete-all, with their confirmations, for the list, the
|
||||
* sender pane and a message in the reading pane. */
|
||||
export function useSubscriptionActions() {
|
||||
const t = useT();
|
||||
const queryClient = useQueryClient();
|
||||
const [confirm, dialog] = useConfirm();
|
||||
const refresh = () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["subscriptions"] });
|
||||
};
|
||||
|
||||
const unsubscribeRun = useMutation({
|
||||
mutationFn: async (target: UnsubscribeTarget) => {
|
||||
// Opened now, while the click on Unsubscribe still counts as the user's:
|
||||
// a page opened after the request returns would be stopped as a pop-up.
|
||||
const page = target.via === "web" ? window.open("", "_blank") : null;
|
||||
const result = await api.unsubscribe(target.key, target.messageId);
|
||||
if (result.status === "open" && result.url) {
|
||||
if (page) page.location.href = result.url;
|
||||
else window.open(result.url, "_blank", "noopener");
|
||||
} else page?.close();
|
||||
return result;
|
||||
},
|
||||
onSuccess: (result, sub) => {
|
||||
if (!result) return;
|
||||
refresh();
|
||||
void queryClient.invalidateQueries({ queryKey: ["thread"] }); // the reading pane's link
|
||||
toast(
|
||||
result.method === "web"
|
||||
? t("subs.doneWeb")
|
||||
: result.method === "mailto"
|
||||
? t("subs.doneMailto", { name: sub.name })
|
||||
: t("subs.doneOneClick", { name: sub.name }),
|
||||
);
|
||||
},
|
||||
onError: () => toast(t("subs.failed")),
|
||||
});
|
||||
|
||||
const trashRun = useMutation({
|
||||
mutationFn: (sub: Subscription) => api.trashSubscription(sub.key),
|
||||
onSuccess: (result) => {
|
||||
if (!result) return;
|
||||
toast(t("subs.trashed", { count: result.moved }));
|
||||
// The moves land one by one in the worker; look again once they have.
|
||||
window.setTimeout(() => {
|
||||
refresh();
|
||||
void queryClient.invalidateQueries({ queryKey: ["threads"] });
|
||||
}, 2500);
|
||||
},
|
||||
});
|
||||
|
||||
// Asked first, in our own dialog; the request (and its busy state) only after.
|
||||
const unsubscribe = {
|
||||
...unsubscribeRun,
|
||||
mutate: async (target: UnsubscribeTarget) => {
|
||||
const ok = await confirm({
|
||||
title: t("subs.confirm", { name: target.name }),
|
||||
message:
|
||||
target.via === "one_click"
|
||||
? t("subs.confirmOneClick")
|
||||
: target.via === "mailto"
|
||||
? t("subs.confirmMailto")
|
||||
: t("subs.confirmWeb"),
|
||||
confirmLabel: t("subs.unsubscribe"),
|
||||
});
|
||||
if (ok) unsubscribeRun.mutate(target);
|
||||
},
|
||||
};
|
||||
const trash = {
|
||||
...trashRun,
|
||||
mutate: async (sub: Subscription) => {
|
||||
const ok = await confirm({
|
||||
title: t("subs.trashAll"),
|
||||
message: t("subs.confirmTrash", { name: sub.name }),
|
||||
confirmLabel: t("subs.trashAllAction"),
|
||||
danger: true,
|
||||
});
|
||||
if (ok) trashRun.mutate(sub);
|
||||
},
|
||||
};
|
||||
|
||||
/** Render `dialog` wherever the hook is used: it is the confirmation. */
|
||||
return { unsubscribe, trash, dialog };
|
||||
}
|
||||
|
||||
// Requests in flight at once during a bulk unsubscribe: a one-click request can
|
||||
// take seconds, and twenty of them one after another would be slow.
|
||||
const BULK_PARALLEL = 4;
|
||||
|
||||
async function eachLimited<T>(items: T[], run: (item: T) => Promise<void>): Promise<void> {
|
||||
let next = 0;
|
||||
const worker = async () => {
|
||||
while (next < items.length) await run(items[next++]);
|
||||
};
|
||||
await Promise.all(Array.from({ length: Math.min(BULK_PARALLEL, items.length) }, worker));
|
||||
}
|
||||
|
||||
const isDone = (sub: Subscription) => sub.unsubscribed_at !== null && !sub.still_sending;
|
||||
|
||||
/** Unsubscribe from / delete the mail of several senders at once (the
|
||||
* Subscriptions folder's checkboxes). Senders that can only be left on their
|
||||
* own web page are collected for the user to open one by one: a browser opens
|
||||
* one new tab per click, not twenty. */
|
||||
export function useBulkSubscriptionActions(onDone: () => void) {
|
||||
const t = useT();
|
||||
const queryClient = useQueryClient();
|
||||
const [confirm, dialog] = useConfirm();
|
||||
const [running, setRunning] = useState(false);
|
||||
const [finishOnPage, setFinishOnPage] = useState<{ name: string; url: string }[]>([]);
|
||||
|
||||
const unsubscribeMany = async (subs: Subscription[]) => {
|
||||
const todo = subs.filter((s) => !isDone(s));
|
||||
if (todo.length === 0) {
|
||||
toast(t("subs.bulkNothing"));
|
||||
return;
|
||||
}
|
||||
const ok = await confirm({
|
||||
title: t("subs.bulkConfirm", { count: todo.length }),
|
||||
message: t("subs.bulkHow"),
|
||||
confirmLabel: t("subs.unsubscribe"),
|
||||
});
|
||||
if (!ok) return;
|
||||
setRunning(true);
|
||||
let done = 0;
|
||||
let failed = 0;
|
||||
const pages: { name: string; url: string }[] = [];
|
||||
await eachLimited(todo, async (sub) => {
|
||||
try {
|
||||
const r = await api.unsubscribe(sub.key);
|
||||
if (r.status === "open" && r.url) pages.push({ name: sub.name, url: r.url });
|
||||
else done += 1;
|
||||
} catch {
|
||||
failed += 1;
|
||||
}
|
||||
});
|
||||
setRunning(false);
|
||||
void queryClient.invalidateQueries({ queryKey: ["subscriptions"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["thread"] });
|
||||
toast(
|
||||
[
|
||||
t("subs.bulkDone", { count: done }),
|
||||
pages.length ? t("subs.bulkPages", { count: pages.length }) : "",
|
||||
failed ? t("subs.bulkFailed", { count: failed }) : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · "),
|
||||
);
|
||||
setFinishOnPage(pages);
|
||||
onDone();
|
||||
};
|
||||
|
||||
const trashMany = async (subs: Subscription[]) => {
|
||||
if (subs.length === 0) return;
|
||||
const ok = await confirm({
|
||||
title: t("subs.trashAll"),
|
||||
message: t("subs.bulkTrashConfirm", { count: subs.length }),
|
||||
confirmLabel: t("subs.trashAllAction"),
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
setRunning(true);
|
||||
let moved = 0;
|
||||
await eachLimited(subs, async (sub) => {
|
||||
try {
|
||||
moved += (await api.trashSubscription(sub.key)).moved;
|
||||
} catch {
|
||||
/* the others still go */
|
||||
}
|
||||
});
|
||||
setRunning(false);
|
||||
toast(t("subs.trashed", { count: moved }));
|
||||
onDone();
|
||||
// The moves land one by one in the worker; look again once they have.
|
||||
window.setTimeout(() => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["subscriptions"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["threads"] });
|
||||
}, 2500);
|
||||
};
|
||||
|
||||
return {
|
||||
unsubscribeMany,
|
||||
trashMany,
|
||||
running,
|
||||
finishOnPage,
|
||||
closeFinish: () => setFinishOnPage([]),
|
||||
dialog, // render it: the confirmation
|
||||
};
|
||||
}
|
||||
|
||||
/** After a bulk unsubscribe: the senders to finish on their own page. */
|
||||
export function FinishOnPageDialog({
|
||||
items,
|
||||
onClose,
|
||||
}: {
|
||||
items: { name: string; url: string }[];
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const t = useT();
|
||||
const [opened, setOpened] = useState<Set<string>>(new Set());
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onMouseDown={onClose}>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("subs.finishTitle")}
|
||||
className="max-h-[80vh] w-full max-w-md overflow-y-auto rounded-xl border border-app-border bg-app-elevated p-5 shadow-2xl"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2 className="text-lg font-semibold">{t("subs.finishTitle")}</h2>
|
||||
<p className="mt-1 text-sm text-app-muted">{t("subs.finishHint")}</p>
|
||||
<ul className="mt-4 divide-y divide-app-border rounded-lg border border-app-border">
|
||||
{items.map((item) => (
|
||||
<li key={item.url} className="flex items-center justify-between gap-3 px-3 py-2 text-sm">
|
||||
<span className="min-w-0 truncate text-app-fg">{item.name}</span>
|
||||
<a
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => setOpened((prev) => new Set(prev).add(item.url))}
|
||||
className={`shrink-0 font-medium ${opened.has(item.url) ? "text-app-faint" : "text-app-accent hover:underline"}`}
|
||||
>
|
||||
{opened.has(item.url) ? t("subs.pageOpened") : t("subs.openPage")}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-4 flex justify-end">
|
||||
<button className="rounded-lg bg-app-accent px-4 py-1.5 text-sm font-medium text-app-accent-fg" onClick={onClose}>
|
||||
{t("subs.finishDone")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The two bulk buttons, for the reading pane's bar (or above the list on a phone). */
|
||||
export function SubscriptionBulkButtons({
|
||||
selected,
|
||||
bulk,
|
||||
}: {
|
||||
selected: Subscription[];
|
||||
bulk: ReturnType<typeof useBulkSubscriptionActions>;
|
||||
}) {
|
||||
const t = useT();
|
||||
const cls =
|
||||
"wm-online-only flex items-center gap-1.5 rounded px-2.5 py-1 text-app-muted hover:bg-app-hover hover:text-app-fg disabled:opacity-50";
|
||||
return (
|
||||
<>
|
||||
<button className={cls} disabled={bulk.running} onClick={() => void bulk.unsubscribeMany(selected)}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M5.6 5.6l12.8 12.8" />
|
||||
</svg>
|
||||
{t("subs.unsubscribe")}
|
||||
</button>
|
||||
<button className={cls} disabled={bulk.running} onClick={() => void bulk.trashMany(selected)}>
|
||||
<Icon name="trash" size={16} />
|
||||
{t("subs.trashAll")}
|
||||
</button>
|
||||
{bulk.running && (
|
||||
<span className="ml-1 h-3.5 w-3.5 animate-spin rounded-full border-2 border-app-faint border-t-transparent" />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckBox({ checked, partial = false }: { checked: boolean; partial?: boolean }) {
|
||||
return (
|
||||
<span
|
||||
className={`flex h-[17px] w-[17px] items-center justify-center rounded-md border-2 transition-colors ${
|
||||
checked || partial ? "border-app-accent bg-app-accent text-white" : "border-app-faint hover:border-app-muted"
|
||||
}`}
|
||||
>
|
||||
{checked ? (
|
||||
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3.5 8.5l3 3 6-7" />
|
||||
</svg>
|
||||
) : partial ? (
|
||||
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round">
|
||||
<path d="M4 8h8" />
|
||||
</svg>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** One line of figures about a sender: how much, how often, how much is read. */
|
||||
export function SubscriptionStats({ sub }: { sub: Subscription }) {
|
||||
const t = useT();
|
||||
return (
|
||||
<span>
|
||||
{t(sub.total === 1 ? "subs.email" : "subs.emails", { count: sub.total })}
|
||||
{" · "}
|
||||
{t("subs.perMonth", { count: sub.per_month })}
|
||||
{sub.unread > 0 && ` · ${t("subs.unread", { count: sub.unread })}`}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function SubscriptionStatus({ sub }: { sub: Subscription }) {
|
||||
const t = useT();
|
||||
if (sub.still_sending)
|
||||
return (
|
||||
<span className="shrink-0 rounded-full bg-amber-500/15 px-2 text-[12px] font-medium text-amber-700 dark:text-amber-300">
|
||||
{t("subs.stillSending")}
|
||||
</span>
|
||||
);
|
||||
if (sub.unsubscribed_at)
|
||||
return (
|
||||
<span className="shrink-0 rounded-full bg-app-hover px-2 text-[12px] text-app-muted">
|
||||
{t("subs.unsubscribed")}
|
||||
</span>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The Subscriptions folder in the list panel: one row per bulk sender. */
|
||||
export function SubscriptionsList({
|
||||
data,
|
||||
loading,
|
||||
selectedKey,
|
||||
onSelect,
|
||||
checkedKeys,
|
||||
onToggleCheck,
|
||||
busyKeys,
|
||||
compact = false,
|
||||
}: {
|
||||
data: SubscriptionList | undefined;
|
||||
loading: boolean;
|
||||
selectedKey: string | null;
|
||||
onSelect: (key: string) => void;
|
||||
/** Checked rows (bulk actions); a row's avatar turns into its checkbox. */
|
||||
checkedKeys: Set<string>;
|
||||
onToggleCheck: (key: string) => void;
|
||||
/** Rows a bulk action is working on (dimmed). */
|
||||
busyKeys?: Set<string>;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const t = useT();
|
||||
const { unsubscribe, trash, dialog } = useSubscriptionActions();
|
||||
const subs = data?.subscriptions ?? [];
|
||||
const checkMode = checkedKeys.size > 0;
|
||||
const dividers = useListDividers(); // Mailbox display → dividing lines, as in the mail list
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto pb-2">
|
||||
{dialog}
|
||||
{data && data.pending > 0 && (
|
||||
<div className="mx-3 mt-2 flex items-center gap-2 rounded-lg bg-app-hover px-3 py-2 text-[13px] text-app-muted">
|
||||
<span className="h-3 w-3 shrink-0 animate-spin rounded-full border-2 border-app-faint border-t-transparent" />
|
||||
{t("subs.scanning", { count: data.pending })}
|
||||
</div>
|
||||
)}
|
||||
{!loading && subs.length === 0 && (data?.pending ?? 0) === 0 && (
|
||||
<div className="flex flex-col items-center gap-4 px-6 pt-16 text-center text-app-faint">
|
||||
<EmptyIllustration className="w-40 max-w-[60%]" />
|
||||
<p className="text-[15px]">{t("subs.none")}</p>
|
||||
<p className="max-w-xs text-[13px]">{t("subs.noneHint")}</p>
|
||||
</div>
|
||||
)}
|
||||
{subs.map((sub, index) => {
|
||||
const selected = sub.key === selectedKey;
|
||||
const checked = checkedKeys.has(sub.key);
|
||||
const busy =
|
||||
busyKeys?.has(sub.key) ||
|
||||
(unsubscribe.isPending && unsubscribe.variables?.key === sub.key) ||
|
||||
(trash.isPending && trash.variables?.key === sub.key);
|
||||
const done = sub.unsubscribed_at !== null && !sub.still_sending;
|
||||
return (
|
||||
<div
|
||||
key={sub.key}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onSelect(sub.key)}
|
||||
onKeyDown={(e) => (e.key === "Enter" || e.key === " ") && onSelect(sub.key)}
|
||||
className={`group relative cursor-pointer px-2 ${compact ? "pt-3" : "pt-[7px]"} ${busy ? "opacity-50" : ""}`}
|
||||
>
|
||||
{dividers && index > 0 && (
|
||||
// Midway through the gap above the row, lined up with its content.
|
||||
<div
|
||||
aria-hidden
|
||||
className={`pointer-events-none absolute inset-x-5 h-px bg-app-border ${
|
||||
compact ? "top-1.5" : "top-[3px]"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={`flex items-center gap-3 rounded-lg px-3 py-2 ${
|
||||
selected || checked ? "bg-app-active" : "group-hover:bg-app-hover"
|
||||
}`}
|
||||
>
|
||||
<div className="group/av relative ml-5 h-[33px] w-[33px] shrink-0">
|
||||
{sub.unread > 0 && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute -left-[21px] top-1/2 h-2 w-2 -translate-y-1/2 rounded-full bg-app-accent"
|
||||
/>
|
||||
)}
|
||||
{/* The avatar gives way to a checkbox on hover, and everywhere once
|
||||
anything is checked — as in the mail list. */}
|
||||
<span
|
||||
className={`relative h-[33px] w-[33px] items-center justify-center overflow-hidden rounded-full text-[13px] font-semibold text-white ${
|
||||
checkMode ? "hidden" : compact ? "flex" : "flex group-hover/av:hidden"
|
||||
}`}
|
||||
style={{ backgroundColor: avatarColor(sub.email, sub.name, undefined) }}
|
||||
>
|
||||
{initialsOf(sub.name)}
|
||||
<AvatarImage email={sub.email} />
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("subs.select")}
|
||||
aria-pressed={checked}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleCheck(sub.key);
|
||||
}}
|
||||
className={`absolute inset-0 items-center justify-center ${
|
||||
checkMode ? "flex" : compact ? "flex opacity-0" : "hidden group-hover/av:flex"
|
||||
}`}
|
||||
>
|
||||
<CheckBox checked={checked} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span className={`min-w-0 truncate text-[15px] text-app-fg ${sub.unread > 0 ? "font-black" : ""}`}>
|
||||
{sub.name}
|
||||
</span>
|
||||
<SubscriptionStatus sub={sub} />
|
||||
<span
|
||||
className={`ml-auto shrink-0 whitespace-nowrap pl-2 text-[13px] tabular-nums text-app-faint ${
|
||||
compact ? "" : "group-hover:hidden"
|
||||
}`}
|
||||
>
|
||||
{dateLabel(sub.last_at)}
|
||||
</span>
|
||||
{!compact && (
|
||||
<span className="ml-auto hidden shrink-0 items-center gap-2 pl-2 group-hover:flex">
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || done}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
unsubscribe.mutate(targetOf(sub));
|
||||
}}
|
||||
className="wm-online-only rounded px-1.5 text-[13px] font-medium text-app-accent hover:underline disabled:text-app-faint disabled:no-underline"
|
||||
>
|
||||
{done ? t("subs.unsubscribed") : t("subs.unsubscribe")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title={t("subs.trashAll")}
|
||||
aria-label={t("subs.trashAll")}
|
||||
disabled={busy}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
trash.mutate(sub);
|
||||
}}
|
||||
className="wm-online-only text-app-muted hover:text-app-fg"
|
||||
>
|
||||
<Icon name="trash" size={16} />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="truncate text-[14px] text-app-fg">{sub.last_subject || t("subs.noSubject")}</div>
|
||||
<div className="truncate text-xs text-app-faint">
|
||||
<SubscriptionStats sub={sub} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { translate } from "../../i18n";
|
||||
|
||||
// Small muted pills for a message/thread's labels, shown before the subject in the
|
||||
// list and after the subject in the reading pane. With `onRemove`, each pill gets a
|
||||
// clickable ✕ (used in the reading pane to drop a label from the thread).
|
||||
@@ -23,7 +25,7 @@ export function LabelChips({
|
||||
{onRemove && (
|
||||
<button
|
||||
className="text-app-faint hover:text-app-fg"
|
||||
title="Remove label"
|
||||
title={translate("labels.remove")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove(l);
|
||||
|
||||
@@ -14,6 +14,7 @@ import { api, apiFetch } from "../../lib/api";
|
||||
import type { AttachmentOut, MessageEnvelope, ThreadDetail } from "../../lib/types";
|
||||
import { AvatarImage } from "../SenderAvatar";
|
||||
import { useIsMobile } from "../../lib/useIsMobile";
|
||||
import { getDateLocale } from "../../lib/timeFormat";
|
||||
import { loadBundle } from "../../plugins/bundleLoader";
|
||||
import { usePluginContext } from "../../plugins/context";
|
||||
import { isPreviewable } from "../thread/previewKind";
|
||||
@@ -433,7 +434,7 @@ function AttachmentsTab({ thread }: { thread: ThreadDetail | undefined }) {
|
||||
if (groups.length === 0) return <div className="px-4 py-6 text-sm text-app-faint">{t("panel.noAttachments")}</div>;
|
||||
|
||||
const senderName = (m: MessageEnvelope) => m.from_addr[0]?.name || m.from_addr[0]?.email || "";
|
||||
const shortDate = (iso: string) => new Date(iso).toLocaleDateString("en-GB", { month: "short", day: "numeric" });
|
||||
const shortDate = (iso: string) => new Date(iso).toLocaleDateString(getDateLocale(), { month: "short", day: "numeric" });
|
||||
|
||||
return (
|
||||
<div className="px-3 py-2">
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { useT } from "../../i18n";
|
||||
import type { MessageKey } from "../../i18n/en";
|
||||
import { formatClock, getDateLocale } from "../../lib/timeFormat";
|
||||
|
||||
const MORNING = 8; // the hour "tomorrow", "this weekend" and "next week" start at
|
||||
|
||||
function at(day: Date, hour: number): Date {
|
||||
const d = new Date(day);
|
||||
d.setHours(hour, 0, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
function addDays(day: Date, n: number): Date {
|
||||
const d = new Date(day);
|
||||
d.setDate(d.getDate() + n);
|
||||
return d;
|
||||
}
|
||||
|
||||
/** The quick choices, in the order shown. Ones already past are left out. */
|
||||
export function snoozePresets(now: Date): { label: MessageKey; when: Date }[] {
|
||||
const out: { label: MessageKey; when: Date }[] = [];
|
||||
// Three hours on, rounded up to the hour, while that is still this evening.
|
||||
const later = new Date(now);
|
||||
later.setHours(later.getHours() + 3, 0, 0, 0);
|
||||
if (later.getDate() === now.getDate() && later.getHours() <= 21) {
|
||||
out.push({ label: "snooze.laterToday", when: later });
|
||||
}
|
||||
out.push({ label: "snooze.tomorrow", when: at(addDays(now, 1), MORNING) });
|
||||
const day = now.getDay(); // 0 = Sunday
|
||||
if (day >= 1 && day <= 4) {
|
||||
out.push({ label: "snooze.weekend", when: at(addDays(now, 6 - day), MORNING) });
|
||||
}
|
||||
// The coming Monday — or, on a Sunday, the one after (tomorrow is offered already).
|
||||
const toMonday = ((8 - day) % 7) || 7;
|
||||
out.push({ label: "snooze.nextWeek", when: at(addDays(now, toMonday === 1 ? 8 : toMonday), MORNING) });
|
||||
return out;
|
||||
}
|
||||
|
||||
function whenLabel(d: Date, now: Date): string {
|
||||
const sameDay = d.toDateString() === now.toDateString();
|
||||
const day = sameDay
|
||||
? ""
|
||||
: d.toLocaleDateString(getDateLocale(), { weekday: "short", day: "numeric", month: "short" }) + ", ";
|
||||
return day + formatClock(d);
|
||||
}
|
||||
|
||||
// "YYYY-MM-DDTHH:MM" in local time, for <input type="datetime-local">.
|
||||
function localInput(d: Date): string {
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
/** Popover: when should this conversation come back? */
|
||||
export function SnoozeMenu({
|
||||
onPick,
|
||||
onClose,
|
||||
align = "left",
|
||||
}: {
|
||||
onPick: (when: Date) => void;
|
||||
onClose: () => void;
|
||||
align?: "left" | "right";
|
||||
}) {
|
||||
const t = useT();
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const now = new Date();
|
||||
const [custom, setCustom] = useState(false);
|
||||
const [value, setValue] = useState(() => localInput(at(addDays(now, 1), MORNING)));
|
||||
const picked = new Date(value);
|
||||
const valid = !Number.isNaN(picked.getTime()) && picked.getTime() > Date.now();
|
||||
|
||||
useEffect(() => {
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
||||
document.addEventListener("mousedown", onDown);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDown);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="menu"
|
||||
className={`absolute top-full z-40 mt-1 w-72 overflow-hidden rounded-lg border border-app-border bg-app-elevated py-1 text-sm shadow-xl ${
|
||||
align === "right" ? "right-0" : "left-0"
|
||||
}`}
|
||||
>
|
||||
<div className="px-3 pb-1 pt-1.5 text-[12px] font-semibold uppercase tracking-wide text-app-faint">
|
||||
{t("snooze.until")}
|
||||
</div>
|
||||
{snoozePresets(now).map((p) => (
|
||||
<button
|
||||
key={p.label}
|
||||
role="menuitem"
|
||||
className="flex w-full items-center justify-between gap-3 px-3 py-1.5 text-left text-app-fg hover:bg-app-hover"
|
||||
onClick={() => onPick(p.when)}
|
||||
>
|
||||
<span>{t(p.label)}</span>
|
||||
<span className="text-[13px] text-app-muted">{whenLabel(p.when, now)}</span>
|
||||
</button>
|
||||
))}
|
||||
<div className="my-1 border-t border-app-border" />
|
||||
{custom ? (
|
||||
<div className="space-y-2 px-3 py-1.5">
|
||||
<input
|
||||
type="datetime-local"
|
||||
className="w-full rounded border border-app-border bg-app-surface px-2 py-1 text-app-fg"
|
||||
value={value}
|
||||
min={localInput(now)}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button className="rounded px-2.5 py-1 text-app-muted hover:bg-app-hover" onClick={() => setCustom(false)}>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
<button
|
||||
className="rounded bg-app-accent px-2.5 py-1 font-medium text-app-accent-fg disabled:opacity-50"
|
||||
disabled={!valid}
|
||||
onClick={() => onPick(picked)}
|
||||
>
|
||||
{t("snooze.action")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
role="menuitem"
|
||||
className="block w-full px-3 py-1.5 text-left text-app-fg hover:bg-app-hover"
|
||||
onClick={() => setCustom(true)}
|
||||
>
|
||||
{t("snooze.pick")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { useT } from "../../i18n";
|
||||
import { api } from "../../lib/api";
|
||||
import { avatarColor } from "../../lib/avatarColor";
|
||||
import type { Subscription } from "../../lib/types";
|
||||
import {
|
||||
dateLabel,
|
||||
initialsOf,
|
||||
SubscriptionStatus,
|
||||
targetOf,
|
||||
useSubscriptionActions,
|
||||
} from "../list/SubscriptionsList";
|
||||
import { Icon } from "../Icon";
|
||||
import { AvatarImage } from "../SenderAvatar";
|
||||
|
||||
/** The reading pane for a sender picked in Subscriptions: figures, actions, its mail. */
|
||||
export function SubscriptionPane({
|
||||
sub,
|
||||
onOpenThread,
|
||||
barClass,
|
||||
onBack,
|
||||
}: {
|
||||
sub: Subscription;
|
||||
onOpenThread: (id: string) => void;
|
||||
barClass: string;
|
||||
onBack?: () => void;
|
||||
}) {
|
||||
const t = useT();
|
||||
const { unsubscribe, trash, dialog } = useSubscriptionActions();
|
||||
const threads = useQuery({
|
||||
queryKey: ["subscription-threads", sub.key, sub.total],
|
||||
queryFn: async () => {
|
||||
const ids = await api.subscriptionThreads(sub.key);
|
||||
return ids.length ? (await api.threadsByIds(ids)).threads : [];
|
||||
},
|
||||
});
|
||||
const done = sub.unsubscribed_at !== null && !sub.still_sending;
|
||||
const stat = (label: string, value: string) => (
|
||||
<div className="rounded-lg bg-app-bg px-3 py-2">
|
||||
<div className="text-[12px] text-app-faint">{label}</div>
|
||||
<div className="text-[17px] font-semibold tabular-nums text-app-fg">{value}</div>
|
||||
</div>
|
||||
);
|
||||
const readShare = sub.total ? Math.round(((sub.total - sub.unread) / sub.total) * 100) : 0;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{dialog}
|
||||
<div className={barClass}>
|
||||
{onBack && (
|
||||
<button
|
||||
type="button"
|
||||
title={t("common.back")}
|
||||
aria-label={t("common.back")}
|
||||
onClick={onBack}
|
||||
className="-ml-1 mr-1 rounded p-1.5 text-app-fg hover:bg-app-hover"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M15 5l-7 7 7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="wm-online-only flex items-center gap-1.5 rounded px-2.5 py-1 text-app-muted hover:bg-app-hover hover:text-app-fg disabled:opacity-50"
|
||||
disabled={done || unsubscribe.isPending}
|
||||
onClick={() => unsubscribe.mutate(targetOf(sub))}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M5.6 5.6l12.8 12.8" />
|
||||
</svg>
|
||||
{done ? t("subs.unsubscribed") : t("subs.unsubscribe")}
|
||||
</button>
|
||||
<button
|
||||
className="wm-online-only flex items-center gap-1.5 rounded px-2.5 py-1 text-app-muted hover:bg-app-hover hover:text-app-fg disabled:opacity-50"
|
||||
disabled={trash.isPending}
|
||||
onClick={() => trash.mutate(sub)}
|
||||
>
|
||||
<Icon name="trash" size={16} />
|
||||
{t("subs.trashAll")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-6">
|
||||
<div className="mx-auto max-w-2xl space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<span
|
||||
className="relative flex h-14 w-14 shrink-0 items-center justify-center overflow-hidden rounded-full text-lg font-semibold text-white"
|
||||
style={{ backgroundColor: avatarColor(sub.email, sub.name, undefined) }}
|
||||
>
|
||||
{initialsOf(sub.name)}
|
||||
<AvatarImage email={sub.email} />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="truncate text-xl font-semibold text-app-fg">{sub.name}</h2>
|
||||
<SubscriptionStatus sub={sub} />
|
||||
</div>
|
||||
<div className="truncate text-sm text-app-muted">{sub.email}</div>
|
||||
{sub.list_id && <div className="truncate text-xs text-app-faint">{t("subs.list", { id: sub.list_id })}</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sub.still_sending && (
|
||||
<p className="rounded-lg border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-sm text-app-fg">
|
||||
{t("subs.stillSendingHint")}
|
||||
</p>
|
||||
)}
|
||||
{sub.unsubscribed_at && !sub.still_sending && (
|
||||
<p className="text-sm text-app-muted">
|
||||
{t("subs.unsubscribedOn", { date: new Date(sub.unsubscribed_at).toLocaleDateString() })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
{stat(t("subs.statTotal"), String(sub.total))}
|
||||
{stat(t("subs.statPerMonth"), String(sub.per_month))}
|
||||
{stat(t("subs.statRead"), `${readShare}%`)}
|
||||
{stat(t("subs.statSince"), dateLabel(sub.first_at))}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="mb-2 text-[12px] font-semibold uppercase tracking-wide text-app-faint">
|
||||
{t("subs.recent")}
|
||||
</h3>
|
||||
<ul className="divide-y divide-app-border overflow-hidden rounded-lg border border-app-border">
|
||||
{(threads.data ?? []).map((th) => (
|
||||
<li key={th.id}>
|
||||
<button
|
||||
className="flex w-full items-center gap-3 px-3 py-2 text-left hover:bg-app-hover"
|
||||
onClick={() => onOpenThread(th.id)}
|
||||
>
|
||||
<span
|
||||
className={`min-w-0 flex-1 truncate text-sm text-app-fg ${th.unread_count > 0 ? "font-bold" : ""}`}
|
||||
>
|
||||
{th.subject}
|
||||
</span>
|
||||
<span className="shrink-0 text-[13px] tabular-nums text-app-faint">
|
||||
{dateLabel(th.last_message_at)}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{threads.isLoading && <li className="px-3 py-2 text-sm text-app-faint">{t("mail.loadingThread")}</li>}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { useT } from "../../i18n";
|
||||
import { languageName, sameLanguage, splitLanguages, useLocale, useT } from "../../i18n";
|
||||
import { api } from "../../lib/api";
|
||||
import { adaptForDarkMode, useDarkMessages, withDarkPage } from "../../lib/darkMessage";
|
||||
import { fixInvisibleText } from "../../lib/readability";
|
||||
@@ -19,11 +19,20 @@ import { Attachments } from "./Attachments";
|
||||
export function MessageBody({
|
||||
message,
|
||||
onMailto,
|
||||
translateTo = null,
|
||||
onLanguage,
|
||||
onShowOriginal,
|
||||
}: {
|
||||
message: MessageEnvelope;
|
||||
onMailto?: (email: string) => void;
|
||||
/** Show it translated into this language (app/translation.py), or as sent. */
|
||||
translateTo?: string | null;
|
||||
/** The message's own language, once the server knows it. */
|
||||
onLanguage?: (code: string) => void;
|
||||
onShowOriginal?: () => void;
|
||||
}) {
|
||||
const t = useT();
|
||||
const { locale } = useLocale();
|
||||
const queryClient = useQueryClient();
|
||||
// Seed from the server's trusted-sender flag so an already-approved sender
|
||||
// loads images and shows no "blocked" prompt on reopen.
|
||||
@@ -42,18 +51,52 @@ export function MessageBody({
|
||||
const mobileRef = useRef(false);
|
||||
mobileRef.current = useIsMobile();
|
||||
|
||||
const { data, isLoading, isError, error } = useQuery({
|
||||
queryKey: ["message-html", message.id, allowed],
|
||||
const { data, isLoading, isError, error, isPlaceholderData } = useQuery({
|
||||
queryKey: ["message-html", message.id, allowed, translateTo],
|
||||
queryFn: async () => {
|
||||
// ?images=1 asks the server to render remote images for this one request
|
||||
// (the "Load images" one-off); "Always from this sender" persists separately.
|
||||
const url = `/api/messages/${message.id}/html${allowed ? "?images=1" : ""}`;
|
||||
const params = new URLSearchParams();
|
||||
if (allowed) params.set("images", "1");
|
||||
if (translateTo) params.set("translate", translateTo);
|
||||
const query = params.toString();
|
||||
const url = `/api/messages/${message.id}/html${query ? `?${query}` : ""}`;
|
||||
const resp = await fetch(url, { credentials: "include" });
|
||||
if (resp.headers.has("X-WM-Offline") && !resp.ok) throw new Error("offline");
|
||||
if (!resp.ok) throw new Error("failed to load message");
|
||||
return resp.text();
|
||||
if (!resp.ok) {
|
||||
if (translateTo) throw new Error("translate");
|
||||
throw new Error("failed to load message");
|
||||
}
|
||||
// Tracking pixels the server took out of the HTML (sanitize.py).
|
||||
const trackers = Number(resp.headers.get("X-Trackers-Blocked")) || 0;
|
||||
return {
|
||||
html: await resp.text(),
|
||||
trackers,
|
||||
language: resp.headers.get("X-Content-Language") ?? "",
|
||||
translatedFrom: resp.headers.get("X-Translated-From"),
|
||||
};
|
||||
},
|
||||
staleTime: translateTo ? Infinity : 0, // a translation does not change
|
||||
// While a translation (or the images) loads, the message stays as it was.
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
const translating = translateTo !== null && isPlaceholderData;
|
||||
// "Translated from Chinese, English": the languages it had besides the one shown.
|
||||
const translatedFromLabel = (from: string) => {
|
||||
const names = splitLanguages(from)
|
||||
.filter((code) => !translateTo || !sameLanguage(code, translateTo))
|
||||
.map((code) => languageName(code, locale));
|
||||
if (names.length === 0) return t("mail.translated");
|
||||
if (names.length === 1) return t("mail.translatedFrom", { language: names[0] });
|
||||
return t("mail.translatedFromMany", { languages: names.join(", ") });
|
||||
};
|
||||
// Tell the header which language this is (for its Translate button).
|
||||
const reportedLanguage = data?.language;
|
||||
const onLanguageRef = useRef(onLanguage);
|
||||
onLanguageRef.current = onLanguage;
|
||||
useEffect(() => {
|
||||
if (reportedLanguage) onLanguageRef.current?.(reportedLanguage);
|
||||
}, [reportedLanguage]);
|
||||
|
||||
const resize = useCallback(() => {
|
||||
const frame = frameRef.current;
|
||||
@@ -247,6 +290,14 @@ export function MessageBody({
|
||||
|
||||
return (
|
||||
<div>
|
||||
{translating && (
|
||||
<div
|
||||
className="wm-progress"
|
||||
role="progressbar"
|
||||
aria-label={t("mail.translating")}
|
||||
title={t("mail.translating")}
|
||||
/>
|
||||
)}
|
||||
{message.has_remote_images && !allowed && (
|
||||
<div className="flex flex-wrap items-center gap-3 border-b border-app-border bg-amber-100 px-3 py-1.5 text-xs text-amber-900 dark:bg-amber-900/30 dark:text-amber-200">
|
||||
<span>{t("mail.imagesBlocked")}</span>
|
||||
@@ -258,8 +309,37 @@ export function MessageBody({
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{data?.translatedFrom && (
|
||||
<div className="flex flex-wrap items-center gap-2 border-b border-app-border bg-app-accent/10 px-3 py-1.5 text-xs text-app-fg">
|
||||
<span>{translatedFromLabel(data.translatedFrom)}</span>
|
||||
<button className="font-medium text-app-accent hover:underline" onClick={onShowOriginal}>
|
||||
{t("mail.showOriginal")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{isError && error.message === "translate" && (
|
||||
<div className="flex flex-wrap items-center gap-2 border-b border-app-border bg-red-500/10 px-3 py-1.5 text-xs text-app-fg">
|
||||
<span>{t("mail.translateFailed")}</span>
|
||||
<button className="font-medium text-app-accent hover:underline" onClick={onShowOriginal}>
|
||||
{t("mail.showOriginal")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{data !== undefined && data.trackers > 0 && (
|
||||
// A darker strip of its own, like the amber one about images above it.
|
||||
<div
|
||||
className="flex items-center gap-1.5 border-b border-app-border bg-zinc-200 px-3 py-1.5 text-xs text-zinc-700 dark:bg-zinc-700/70 dark:text-zinc-200"
|
||||
title={t("mail.trackersHint")}
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M12 3l7 3v5c0 4.5-3 8.5-7 10-4-1.5-7-5.5-7-10V6z" />
|
||||
<path d="M9 12l2 2 4-4" />
|
||||
</svg>
|
||||
{data.trackers === 1 ? t("mail.trackerBlocked") : t("mail.trackersBlocked", { count: data.trackers })}
|
||||
</div>
|
||||
)}
|
||||
{isLoading && <div className="p-3 text-sm text-app-faint">{t("mail.loadingThread")}</div>}
|
||||
{isError && (
|
||||
{isError && error.message !== "translate" && (
|
||||
<div className={`p-3 text-sm ${error.message === "offline" ? "text-app-faint" : "text-red-500"}`}>
|
||||
{error.message === "offline" ? t("offline.notAvailable") : t("mail.loadFailed")}
|
||||
</div>
|
||||
@@ -271,7 +351,7 @@ export function MessageBody({
|
||||
ref={frameRef}
|
||||
title={`message-${message.id}`}
|
||||
sandbox="allow-same-origin"
|
||||
srcDoc={dark ? withDarkPage(data) : data}
|
||||
srcDoc={dark ? withDarkPage(data.html) : data.html}
|
||||
onLoad={onLoad}
|
||||
style={{ height }}
|
||||
className={`block w-full rounded-lg border-0 ${dark ? "bg-[#212427]" : "bg-white"}`}
|
||||
|
||||
@@ -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;
|
||||
|
||||
+169
-3
@@ -1,6 +1,9 @@
|
||||
// English message catalog. Adding a locale = drop a sibling file with the same
|
||||
// keys and register it in i18n/index.tsx. Keys are flat, dotted namespaces.
|
||||
// Interpolate with {name} placeholders.
|
||||
// English message catalog — the main language and the reference: other
|
||||
// language files (pl.ts, …) use these keys, and any string a language lacks is
|
||||
// shown in English. Adding a language = a sibling file typed as `Catalog`,
|
||||
// registered in i18n/index.tsx.
|
||||
// Keys are flat, dotted namespaces. Interpolate with {name} placeholders;
|
||||
// counted strings take plural forms as "<key>_one", "<key>_few", … (index.tsx).
|
||||
|
||||
export const en = {
|
||||
"common.appName": "Ultimo Email",
|
||||
@@ -79,12 +82,74 @@ export const en = {
|
||||
"nav.sent": "Sent",
|
||||
"nav.spam": "Spam",
|
||||
"nav.trash": "Trash",
|
||||
"nav.snoozed": "Snoozed",
|
||||
"nav.subscriptions": "Subscriptions",
|
||||
"snooze.action": "Snooze",
|
||||
"snooze.unsnooze": "Unsnooze",
|
||||
"snooze.until": "Snooze until…",
|
||||
"snooze.laterToday": "Later today",
|
||||
"snooze.tomorrow": "Tomorrow",
|
||||
"snooze.weekend": "This weekend",
|
||||
"snooze.nextWeek": "Next week",
|
||||
"snooze.pick": "Pick date & time",
|
||||
"snooze.done": "Snoozed until {when}",
|
||||
"snooze.failed": "The conversation could not be snoozed.",
|
||||
"snooze.banner": "Snoozed until {when}",
|
||||
"common.undo": "Undo",
|
||||
"subs.unsubscribe": "Unsubscribe",
|
||||
"subs.unsubscribed": "Unsubscribed",
|
||||
"subs.stillSending": "Still sending",
|
||||
"subs.stillSendingHint": "Mail keeps arriving although you unsubscribed. Try again, or delete their mail and block the sender with a filter.",
|
||||
"subs.unsubscribedOn": "You unsubscribed on {date}.",
|
||||
"subs.trashAll": "Delete all",
|
||||
"subs.trashAllAction": "Move to Trash",
|
||||
"subs.email": "{count} email",
|
||||
"subs.emails": "{count} emails",
|
||||
"subs.perMonth": "{count}/month",
|
||||
"subs.unread": "{count} unread",
|
||||
"subs.noSubject": "(no subject)",
|
||||
"subs.list": "List: {id}",
|
||||
"subs.scanning": "Looking through your mail for subscriptions… {count} messages to go",
|
||||
"subs.none": "No subscriptions found",
|
||||
"subs.noneHint": "Newsletters and other mail you can unsubscribe from show up here, grouped by sender.",
|
||||
"subs.confirm": "Unsubscribe from {name}?",
|
||||
"subs.confirmOneClick": "Ultimo will ask the sender to take you off their list.",
|
||||
"subs.confirmMailto": "Ultimo will send an unsubscribe email to the sender from your account.",
|
||||
"subs.confirmWeb": "The sender's unsubscribe page will open. Finish there.",
|
||||
"subs.doneOneClick": "Unsubscribed from {name}",
|
||||
"subs.doneMailto": "Unsubscribe email to {name} is on its way",
|
||||
"subs.doneWeb": "Finish unsubscribing on the sender's page",
|
||||
"subs.failed": "Could not unsubscribe. Try again later.",
|
||||
"subs.confirmTrash": "Move all mail from {name} to Trash? Conversations you replied to stay where they are.",
|
||||
"subs.trashed": "{count} conversations moved to Trash",
|
||||
"subs.statTotal": "Emails",
|
||||
"subs.statPerMonth": "Per month",
|
||||
"subs.statRead": "Opened",
|
||||
"subs.statSince": "Since",
|
||||
"subs.recent": "Recent mail",
|
||||
"subs.senders": "{count} senders",
|
||||
"subs.select": "Select sender",
|
||||
"subs.selected": "{count} selected",
|
||||
"subs.sendersSelected": "{count} senders selected",
|
||||
"subs.bulkConfirm": "Unsubscribe from {count} senders?",
|
||||
"subs.bulkHow": "Ultimo asks each sender to take you off their list, or sends them an unsubscribe email from your account. Senders that only take it on their own web page are listed for you to open afterwards.",
|
||||
"subs.bulkNothing": "You're already unsubscribed from these senders.",
|
||||
"subs.bulkDone": "Unsubscribed from {count}",
|
||||
"subs.bulkPages": "{count} to finish on their page",
|
||||
"subs.bulkFailed": "{count} failed",
|
||||
"subs.bulkTrashConfirm": "Move all mail from {count} senders to Trash? Conversations you replied to stay where they are.",
|
||||
"subs.finishTitle": "Finish on the senders' pages",
|
||||
"subs.finishHint": "These senders only take an unsubscribe on their own web page. Open each one and confirm there.",
|
||||
"subs.openPage": "Open page",
|
||||
"subs.pageOpened": "Opened",
|
||||
"subs.finishDone": "Done",
|
||||
"nav.search": "Search mail…",
|
||||
"nav.searchInTrash": "Search in Trash",
|
||||
"nav.searchInSpam": "Search in Spam",
|
||||
"search.save": "Save this search",
|
||||
"search.saved": "Saved searches",
|
||||
"search.saveNamePrompt": "Name this saved search:",
|
||||
"search.saveNameLabel": "Name",
|
||||
"nav.storageQuota": "Storage used",
|
||||
"nav.refresh": "Refresh all folders",
|
||||
|
||||
@@ -187,10 +252,14 @@ export const en = {
|
||||
"panel.noAttachments": "No attachments",
|
||||
"panel.download": "Download",
|
||||
"mail.emptyConfirm": "Permanently delete everything in this folder? This can't be undone.",
|
||||
"mail.emptyAction": "Delete everything",
|
||||
"mail.movedToTrash": "Moved to Trash",
|
||||
"mail.loadFailed": "Couldn't load this message.",
|
||||
"mail.imagesBlocked": "Images from this sender are blocked.",
|
||||
"mail.loadImages": "Load images",
|
||||
"mail.trackerBlocked": "1 tracker blocked",
|
||||
"mail.trackersBlocked": "{count} trackers blocked",
|
||||
"mail.trackersHint": "Invisible images that tell the sender when and where you opened the mail were removed.",
|
||||
"mail.alwaysFromSender": "Always from this sender",
|
||||
"mail.to": "to",
|
||||
"mail.from": "From",
|
||||
@@ -256,6 +325,24 @@ export const en = {
|
||||
"compose.fmtHighlight": "Highlight color",
|
||||
"compose.fmtImage": "Insert image",
|
||||
"compose.insertSignature": "Insert signature",
|
||||
"compose.fmtEmoji": "Emoji",
|
||||
"compose.emojiLoading": "Loading emoji…",
|
||||
"compose.templates": "Templates",
|
||||
"compose.saveAsTemplate": "Save as template…",
|
||||
"compose.noTemplates": "No saved templates yet",
|
||||
"compose.deleteTemplate": "Delete template",
|
||||
"compose.templateName": "Template name",
|
||||
"compose.templateEmpty": "Write something first: the template is what you've written, without the signature or quoted mail.",
|
||||
"compose.templateDelete": "Delete the template \"{name}\"?",
|
||||
"compose.templateSaveFailed": "The template could not be saved.",
|
||||
"compose.saveTemplateTitle": "Save as template",
|
||||
"compose.saveTemplateHint": "The subject and what you've written are saved. The signature and any quoted mail are left out.",
|
||||
"compose.templateNamePlaceholder": "e.g. Thanks for your enquiry",
|
||||
"compose.templateReplaceNote": "A template with this name exists. Saving replaces it.",
|
||||
"compose.templateReplaceAction": "Replace",
|
||||
"compose.templateSaving": "Saving…",
|
||||
"compose.templateEmptyShort": "Write something first",
|
||||
"compose.deleteTemplateAction": "Delete",
|
||||
"compose.linkPrompt": "Link URL:",
|
||||
"compose.linkOpen": "Open the link:",
|
||||
"compose.linkEdit": "Edit",
|
||||
@@ -498,6 +585,40 @@ export const en = {
|
||||
"settings.addToCalendar": "Add to calendar",
|
||||
"settings.alsoFile": "Also mark the message as read and file it",
|
||||
"settings.integrations": "Integrations",
|
||||
"settings.shortcuts": "Keyboard shortcuts",
|
||||
"settings.privacy": "Privacy",
|
||||
"settings.completion": "Suggest sentence endings",
|
||||
"settings.completionHint": "While you write, the likely rest of the sentence appears in grey; press Tab to take it. Suggestions come from phrases you use in your own sent mail (plus common ones in English, Polish and German) and never leave this server.",
|
||||
"settings.blockTrackers": "Block email trackers",
|
||||
"settings.blockTrackersHint": "Many newsletters and sales emails hide a tiny invisible image that tells the sender when you opened the message, how often, and roughly where you were. Ultimo removes these images before showing a message. Turned off, they are treated like any other remote image: loaded only when you allow images from that sender.",
|
||||
"settings.shortcutsEnabled": "Use keyboard shortcuts",
|
||||
"settings.shortcutsHint": "Single keys act on the mail list and the open conversation. They are ignored while you type. Press ? in the mail view to see this list.",
|
||||
"shortcuts.title": "Keyboard shortcuts",
|
||||
"shortcuts.then": "then",
|
||||
"shortcuts.groupNavigation": "Navigation",
|
||||
"shortcuts.groupConversation": "Conversation",
|
||||
"shortcuts.groupCompose": "Compose",
|
||||
"shortcuts.next": "Next conversation",
|
||||
"shortcuts.prev": "Previous conversation",
|
||||
"shortcuts.back": "Back to the list",
|
||||
"shortcuts.search": "Search",
|
||||
"shortcuts.goInbox": "Go to Inbox",
|
||||
"shortcuts.goSnoozed": "Go to Snoozed",
|
||||
"shortcuts.goDrafts": "Go to Drafts",
|
||||
"shortcuts.goSent": "Go to Sent",
|
||||
"shortcuts.goSubscriptions": "Go to Subscriptions",
|
||||
"shortcuts.help": "Show keyboard shortcuts",
|
||||
"shortcuts.reply": "Reply",
|
||||
"shortcuts.replyAll": "Reply all",
|
||||
"shortcuts.forward": "Forward",
|
||||
"shortcuts.archive": "Archive",
|
||||
"shortcuts.trash": "Delete",
|
||||
"shortcuts.pin": "Pin / unpin",
|
||||
"shortcuts.snooze": "Snooze",
|
||||
"shortcuts.markRead": "Mark as read",
|
||||
"shortcuts.markUnread": "Mark as unread",
|
||||
"shortcuts.compose": "New message",
|
||||
"shortcuts.escape": "Minimize the compose windows, close a dialog",
|
||||
|
||||
"admin.title": "Ultimo Email administration",
|
||||
"admin.signInTitle": "Administrator sign-in",
|
||||
@@ -530,6 +651,7 @@ export const en = {
|
||||
"admin.inviteDownload": "Download template",
|
||||
"admin.inviteReset": "Back to the built-in template",
|
||||
"admin.inviteResetConfirm": "Remove your template and use the built-in one again?",
|
||||
"admin.inviteResetAction": "Use built-in template",
|
||||
"admin.invitePlaceholders": "Placeholders:",
|
||||
"admin.invitePlaceholdersHelp":
|
||||
"The template must contain {{invite_link}} or {{invite_button}}. The subject can use the names too. Images written into the HTML are sent inside the email.",
|
||||
@@ -595,6 +717,8 @@ export const en = {
|
||||
"settings.addAccount": "Add account",
|
||||
"settings.syncState": "State: {state}",
|
||||
"settings.confirmRemoveAccount": "Remove this account and all its mail?",
|
||||
"settings.removeAccountTitle": "Remove account",
|
||||
"settings.removeAccountAction": "Remove account",
|
||||
"settings.removingAccount": "Removing account…",
|
||||
"settings.editAccount": "Edit account",
|
||||
"settings.color": "Color",
|
||||
@@ -701,7 +825,49 @@ export const en = {
|
||||
"install.stepsFirefox": "1. Open Firefox's menu (⋮).\n2. Choose Add app to Home screen (or Install).",
|
||||
"install.hide": "Don't show this again",
|
||||
"install.gotIt": "Got it",
|
||||
"settings.language": "Language",
|
||||
"settings.languageHint": "The language of Ultimo's menus, buttons and messages. Saved with your account, so it stays the same on every device.",
|
||||
"time.justNow": "just now",
|
||||
"time.minAgo": "{count} min ago",
|
||||
"time.hoursAgo": "{count} hours ago",
|
||||
"time.hoursAgo_one": "1 hour ago",
|
||||
"time.daysAgo": "{count} days ago",
|
||||
"time.daysAgo_one": "1 day ago",
|
||||
"reminder.atTime": "At time of event",
|
||||
"reminder.minutesBefore": "{count} minutes before",
|
||||
"reminder.hoursBefore": "{count} hours before",
|
||||
"reminder.hoursBefore_one": "1 hour before",
|
||||
"reminder.daysBefore": "{count} days before",
|
||||
"reminder.daysBefore_one": "1 day before",
|
||||
"color.blue": "Blue",
|
||||
"color.green": "Green",
|
||||
"color.red": "Red",
|
||||
"color.orange": "Orange",
|
||||
"color.purple": "Purple",
|
||||
"color.teal": "Teal",
|
||||
"color.pink": "Pink",
|
||||
"color.gray": "Gray",
|
||||
"color.custom": "Custom",
|
||||
"settings.selectColor": "Select colour",
|
||||
"labels.remove": "Remove label",
|
||||
"compose.attribution": "On {date}, at {time}, {who} wrote:",
|
||||
"print.attachments": "Attachments",
|
||||
"print.popupBlocked": "Allow pop-ups for this site to print.",
|
||||
"mail.translate": "Translate",
|
||||
"mail.translateTo": "Translate to…",
|
||||
"mail.showOriginal": "Show original",
|
||||
"mail.translating": "Translating…",
|
||||
"mail.translated": "Translated",
|
||||
"mail.translatedFrom": "Translated from {language}",
|
||||
"mail.translatedFromMany": "Translated from {languages}",
|
||||
"mail.translateFailed": "Couldn't translate this message. The translation service may be busy or unavailable.",
|
||||
} as const;
|
||||
|
||||
export type MessageKey = keyof typeof en;
|
||||
export type Messages = Record<MessageKey, string>;
|
||||
/** Plural forms a language may add beside a key (Intl.PluralRules categories). */
|
||||
export type PluralKey = `${MessageKey}_${"zero" | "one" | "two" | "few" | "many" | "other"}`;
|
||||
/** A language file. English (this file) has every key; another language may
|
||||
* lack some — those show in English (i18n/index.tsx) — and adds the plural
|
||||
* forms it needs. */
|
||||
export type Catalog = Partial<Messages> & Partial<Record<PluralKey, string>>;
|
||||
|
||||
+123
-27
@@ -1,15 +1,82 @@
|
||||
// Minimal, dependency-free i18n. English today; add a locale by registering a
|
||||
// catalog with the same keys. Strings are never hard-coded in components — they
|
||||
// go through t().
|
||||
// Minimal, dependency-free i18n. One file per language (en.ts, pl.ts, …), each
|
||||
// with the same keys; register a new one in LANGUAGES below. Strings are never
|
||||
// hard-coded in components — they go through t().
|
||||
//
|
||||
// Plurals follow each language's own rules (Intl.PluralRules): a count picks
|
||||
// "<key>_one", "<key>_few", "<key>_many"… when the catalog has that form, else
|
||||
// "<key>". English needs only "_one"; Polish adds "_few" (2–4, 22–24…), its
|
||||
// base form being the "many" one (5+, 0, 12–14…).
|
||||
|
||||
import { createContext, useContext, useMemo, useState, type ReactNode } from "react";
|
||||
|
||||
import { en, type MessageKey, type Messages } from "./en";
|
||||
import { setDateLanguage } from "../lib/timeFormat";
|
||||
import { en, type Catalog, type MessageKey } from "./en";
|
||||
import { pl } from "./pl";
|
||||
|
||||
const catalogs: Record<string, Messages> = {
|
||||
en,
|
||||
// pl, <- add here to enable Polish, etc.
|
||||
};
|
||||
/** The interface languages, each named in its own language for the picker. */
|
||||
export const LANGUAGES: { code: string; name: string; catalog: Catalog }[] = [
|
||||
{ code: "en", name: "English", catalog: en },
|
||||
{ code: "pl", name: "Polski", catalog: pl },
|
||||
];
|
||||
const catalogs: Record<string, Catalog> = Object.fromEntries(
|
||||
LANGUAGES.map((l) => [l.code, l.catalog]),
|
||||
);
|
||||
|
||||
const STORAGE_KEY = "locale";
|
||||
|
||||
/** A supported language for a code such as "pl-PL", or null. */
|
||||
export function supportedLanguage(code: string | null | undefined): string | null {
|
||||
if (!code) return null;
|
||||
const base = code.toLowerCase().split(/[-_]/)[0];
|
||||
return catalogs[base] ? base : null;
|
||||
}
|
||||
|
||||
/** The browser's / system's preferred language we have, else English. */
|
||||
export function detectLanguage(): string {
|
||||
const wanted = navigator.languages?.length ? navigator.languages : [navigator.language];
|
||||
for (const code of wanted) {
|
||||
const found = supportedLanguage(code);
|
||||
if (found) return found;
|
||||
}
|
||||
return "en";
|
||||
}
|
||||
|
||||
/** The language to start with before the account's choice is known (the
|
||||
* sign-in page): the one last used in this browser, else the detected one. */
|
||||
function initialLanguage(): string {
|
||||
try {
|
||||
const stored = supportedLanguage(localStorage.getItem(STORAGE_KEY));
|
||||
if (stored) return stored;
|
||||
} catch {
|
||||
/* storage blocked */
|
||||
}
|
||||
return detectLanguage();
|
||||
}
|
||||
|
||||
/** A language's name in the interface language ("en" in Polish → "angielski"). */
|
||||
export function languageName(code: string, inLanguage: string): string {
|
||||
try {
|
||||
return new Intl.DisplayNames([inLanguage], { type: "language" }).of(code) ?? code;
|
||||
} catch {
|
||||
return code;
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether two language codes are the same language ("pl" and "pl-PL"). */
|
||||
export function sameLanguage(a: string, b: string): boolean {
|
||||
return a.toLowerCase().split(/[-_]/)[0] === b.toLowerCase().split(/[-_]/)[0];
|
||||
}
|
||||
|
||||
/** Whether a message in ``languages`` ("zh-Hans,pl,en", as the server found
|
||||
* them) has a part that is not in ``locale``: then it can be translated. */
|
||||
export function needsTranslation(languages: string, locale: string): boolean {
|
||||
return splitLanguages(languages).some((code) => !sameLanguage(code, locale));
|
||||
}
|
||||
|
||||
/** "zh-Hans,pl,en" → ["zh-Hans", "pl", "en"]. */
|
||||
export function splitLanguages(languages: string | null | undefined): string[] {
|
||||
return (languages ?? "").split(",").filter((code) => code && code !== "auto");
|
||||
}
|
||||
|
||||
export type Vars = Record<string, string | number>;
|
||||
export type TFunc = (key: MessageKey, vars?: Vars) => string;
|
||||
@@ -30,29 +97,58 @@ function interpolate(template: string, vars?: Vars): string {
|
||||
);
|
||||
}
|
||||
|
||||
export function I18nProvider({ children }: { children: ReactNode }) {
|
||||
const [locale, setLocale] = useState<string>(() => localStorage.getItem("locale") ?? "en");
|
||||
function makeT(locale: string): TFunc {
|
||||
const catalog = (catalogs[locale] ?? en) as Record<string, string>;
|
||||
const english = en as Record<string, string>;
|
||||
const plurals = new Intl.PluralRules(locale);
|
||||
const lookup = (key: string): string | undefined => catalog[key];
|
||||
return (key, vars) => {
|
||||
let text: string | undefined;
|
||||
if (typeof vars?.count === "number") {
|
||||
text = lookup(`${key}_${plurals.select(vars.count)}`);
|
||||
}
|
||||
// A missing string (a new key not translated yet) falls back to English.
|
||||
text ??= lookup(key) ?? english[key] ?? key;
|
||||
return interpolate(text, vars);
|
||||
};
|
||||
}
|
||||
|
||||
const value = useMemo<I18nContextValue>(() => {
|
||||
const catalog = catalogs[locale] ?? en;
|
||||
// A count of exactly one picks the "<key>_one" form where there is one
|
||||
// ("1 message", not "1 messages").
|
||||
const lookup = (key: string): string | undefined =>
|
||||
(catalog as Record<string, string>)[key] ?? (en as Record<string, string>)[key];
|
||||
const t: TFunc = (key, vars) =>
|
||||
interpolate(
|
||||
(vars?.count === 1 ? lookup(`${key}_one`) : undefined) ?? lookup(key) ?? key,
|
||||
vars,
|
||||
);
|
||||
return {
|
||||
// The current t() for code outside React (lib helpers, toasts).
|
||||
let currentT: TFunc = makeT(initialLanguage());
|
||||
export function translate(key: MessageKey, vars?: Vars): string {
|
||||
return currentT(key, vars);
|
||||
}
|
||||
|
||||
function apply(locale: string): void {
|
||||
currentT = makeT(locale);
|
||||
setDateLanguage(locale);
|
||||
document.documentElement.lang = locale;
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, locale);
|
||||
} catch {
|
||||
/* storage blocked: the account's setting still holds it */
|
||||
}
|
||||
}
|
||||
|
||||
export function I18nProvider({ children }: { children: ReactNode }) {
|
||||
const [locale, setLocaleState] = useState<string>(() => {
|
||||
const first = initialLanguage();
|
||||
apply(first);
|
||||
return first;
|
||||
});
|
||||
|
||||
const value = useMemo<I18nContextValue>(
|
||||
() => ({
|
||||
locale,
|
||||
setLocale: (l) => {
|
||||
localStorage.setItem("locale", l);
|
||||
setLocale(l);
|
||||
const code = supportedLanguage(l) ?? "en";
|
||||
apply(code);
|
||||
setLocaleState(code);
|
||||
},
|
||||
t,
|
||||
};
|
||||
}, [locale]);
|
||||
t: makeT(locale),
|
||||
}),
|
||||
[locale],
|
||||
);
|
||||
|
||||
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,890 @@
|
||||
// Polski. Klucze jak w en.ts (język główny): brakujący tekst pokaże się po
|
||||
// angielsku. Liczby: "<klucz>_one" (1), "<klucz>_few" (2–4, 22–24…), sam
|
||||
// "<klucz>" dla pozostałych (0, 5–21, 25…).
|
||||
|
||||
import type { Catalog } from "./en";
|
||||
|
||||
export const pl: Catalog = {
|
||||
"common.appName": "Ultimo Email",
|
||||
"common.loading": "Wczytywanie…",
|
||||
"common.cancel": "Anuluj",
|
||||
"common.save": "Zapisz",
|
||||
"common.saving": "Zapisywanie…",
|
||||
"common.delete": "Usuń",
|
||||
"common.remove": "Usuń",
|
||||
"common.edit": "Edytuj",
|
||||
"common.close": "Zamknij",
|
||||
"common.clear": "Wyczyść",
|
||||
"common.copy": "Kopiuj",
|
||||
"common.back": "Wstecz",
|
||||
"common.next": "Dalej",
|
||||
"common.signOut": "Wyloguj się",
|
||||
"common.retry": "Spróbuj ponownie",
|
||||
"common.optional": "opcjonalnie",
|
||||
|
||||
"auth.signInTitle": "Zaloguj się",
|
||||
"auth.registerTitle": "Załóż konto",
|
||||
"auth.handle": "Nazwa użytkownika",
|
||||
"auth.handlePlaceholder": "np. alicja",
|
||||
"auth.localpartPlaceholder": "alicja",
|
||||
"auth.handleHint": "3–30 znaków: a–z, 0–9, . _ -",
|
||||
"auth.password": "Hasło",
|
||||
"auth.passwordHint": "co najmniej 8 znaków",
|
||||
"auth.totpCode": "Kod 2FA",
|
||||
"auth.totpPrompt": "Wpisz 6-cyfrowy kod z aplikacji uwierzytelniającej",
|
||||
"auth.rememberDevice": "Nie pytaj o kod na tym urządzeniu",
|
||||
"auth.signIn": "Zaloguj się",
|
||||
"auth.signingIn": "Logowanie…",
|
||||
"auth.create": "Załóż konto",
|
||||
"auth.creating": "Zakładanie…",
|
||||
"auth.noAccount": "Nie masz konta?",
|
||||
"auth.forgotPassword": "Nie pamiętasz hasła?",
|
||||
"auth.forgotTitle": "Zresetuj hasło",
|
||||
"auth.forgotHint":
|
||||
"Wpisz nazwę użytkownika. Na adres e-mail do odzyskiwania konta wyślemy link do ustawienia nowego hasła.",
|
||||
"auth.changePassword": "Zmień hasło",
|
||||
"auth.sending": "Wysyłanie…",
|
||||
"auth.forgotSent": "Jeśli to konto istnieje, na adres do odzyskiwania przyjdzie wiadomość e-mail.",
|
||||
"auth.backToSignIn": "Wróć do logowania",
|
||||
"auth.resetTitle": "Ustaw nowe hasło",
|
||||
"auth.newPassword": "Nowe hasło",
|
||||
"auth.repeatPassword": "Powtórz nowe hasło",
|
||||
"auth.passwordsDiffer": "Hasła się różnią.",
|
||||
"auth.passwordTooShort": "Użyj co najmniej 8 znaków.",
|
||||
"auth.resetTotpPrompt":
|
||||
"To konto ma weryfikację dwuetapową. Aby zakończyć, wpisz 6-cyfrowy kod z aplikacji uwierzytelniającej.",
|
||||
"auth.confirm": "Potwierdź",
|
||||
"auth.resetDone": "Hasło zostało zmienione. Wylogowaliśmy cię ze wszystkich urządzeń — zaloguj się nowym hasłem.",
|
||||
"auth.resetInvalid":
|
||||
"Ten link jest nieprawidłowy, został już użyty albo wygasł. Poproś o nowy, aby zmienić hasło.",
|
||||
"auth.requestNewLink": "Poproś o nowy link",
|
||||
"auth.haveAccount": "Masz już konto?",
|
||||
"auth.register": "Zarejestruj się",
|
||||
"auth.checking": "Sprawdzanie…",
|
||||
"auth.firstUserTitle": "Załóż konto administratora",
|
||||
"auth.firstUserHint":
|
||||
"To pierwsze konto na tym serwerze, więc zostanie kontem administratora. Po jego utworzeniu rejestracja zamknie się automatycznie.",
|
||||
"auth.registrationClosedTitle": "Rejestracja jest zamknięta",
|
||||
"auth.registrationClosed":
|
||||
"Ten serwer nie przyjmuje nowych kont. Poproś administratora o otwarcie rejestracji.",
|
||||
|
||||
"nav.mail": "Poczta",
|
||||
"nav.back": "Wstecz",
|
||||
"nav.settings": "Ustawienia",
|
||||
"nav.compose": "Napisz",
|
||||
"nav.collapseSidebar": "Zwiń pasek boczny",
|
||||
"nav.expandSidebar": "Rozwiń pasek boczny",
|
||||
"nav.allAccounts": "Wszystkie konta",
|
||||
"nav.inbox": "Odebrane",
|
||||
"nav.archive": "Archiwum",
|
||||
"nav.drafts": "Wersje robocze",
|
||||
"nav.sent": "Wysłane",
|
||||
"nav.spam": "Spam",
|
||||
"nav.trash": "Kosz",
|
||||
"nav.snoozed": "Odłożone",
|
||||
"nav.subscriptions": "Subskrypcje",
|
||||
"snooze.action": "Odłóż",
|
||||
"snooze.unsnooze": "Przywróć",
|
||||
"snooze.until": "Odłóż do…",
|
||||
"snooze.laterToday": "Później dzisiaj",
|
||||
"snooze.tomorrow": "Jutro",
|
||||
"snooze.weekend": "W ten weekend",
|
||||
"snooze.nextWeek": "W przyszłym tygodniu",
|
||||
"snooze.pick": "Wybierz datę i godzinę",
|
||||
"snooze.done": "Odłożono do {when}",
|
||||
"snooze.failed": "Nie udało się odłożyć wątku.",
|
||||
"snooze.banner": "Odłożono do {when}",
|
||||
"common.undo": "Cofnij",
|
||||
"subs.unsubscribe": "Wypisz się",
|
||||
"subs.unsubscribed": "Wypisano",
|
||||
"subs.stillSending": "Nadal wysyła",
|
||||
"subs.stillSendingHint":
|
||||
"Wiadomości wciąż przychodzą, choć się wypisałeś. Spróbuj ponownie albo usuń ich pocztę i zablokuj nadawcę filtrem.",
|
||||
"subs.unsubscribedOn": "Wypisano {date}.",
|
||||
"subs.trashAll": "Usuń wszystko",
|
||||
"subs.trashAllAction": "Przenieś do kosza",
|
||||
"subs.email": "{count} wiadomość",
|
||||
"subs.emails": "{count} wiadomości",
|
||||
"subs.perMonth": "{count}/mies.",
|
||||
"subs.unread": "{count} nieprzeczytanych",
|
||||
"subs.unread_one": "1 nieprzeczytana",
|
||||
"subs.unread_few": "{count} nieprzeczytane",
|
||||
"subs.noSubject": "(bez tematu)",
|
||||
"subs.list": "Lista: {id}",
|
||||
"subs.scanning": "Szukam subskrypcji w poczcie… zostało {count} wiadomości",
|
||||
"subs.scanning_one": "Szukam subskrypcji w poczcie… została 1 wiadomość",
|
||||
"subs.scanning_few": "Szukam subskrypcji w poczcie… zostały {count} wiadomości",
|
||||
"subs.none": "Nie znaleziono subskrypcji",
|
||||
"subs.noneHint": "Tu pojawią się newslettery i inne wiadomości, z których można się wypisać, pogrupowane według nadawcy.",
|
||||
"subs.confirm": "Wypisać się z {name}?",
|
||||
"subs.confirmOneClick": "Ultimo poprosi nadawcę o usunięcie cię z listy.",
|
||||
"subs.confirmMailto": "Ultimo wyśle do nadawcy wiadomość z prośbą o wypisanie, z twojego konta.",
|
||||
"subs.confirmWeb": "Otworzy się strona nadawcy do wypisywania. Dokończ tam.",
|
||||
"subs.doneOneClick": "Wypisano z {name}",
|
||||
"subs.doneMailto": "Prośba o wypisanie do {name} jest w drodze",
|
||||
"subs.doneWeb": "Dokończ wypisywanie na stronie nadawcy",
|
||||
"subs.failed": "Nie udało się wypisać. Spróbuj później.",
|
||||
"subs.confirmTrash": "Przenieść całą pocztę od {name} do kosza? Wątki, na które odpowiedziałeś, zostaną na miejscu.",
|
||||
"subs.trashed": "Przeniesiono {count} wątków do kosza",
|
||||
"subs.trashed_one": "Przeniesiono 1 wątek do kosza",
|
||||
"subs.trashed_few": "Przeniesiono {count} wątki do kosza",
|
||||
"subs.statTotal": "Wiadomości",
|
||||
"subs.statPerMonth": "Na miesiąc",
|
||||
"subs.statRead": "Otwarte",
|
||||
"subs.statSince": "Od",
|
||||
"subs.recent": "Ostatnia poczta",
|
||||
"subs.senders": "{count} nadawców",
|
||||
"subs.senders_one": "1 nadawca",
|
||||
"subs.senders_few": "{count} nadawców",
|
||||
"subs.select": "Zaznacz nadawcę",
|
||||
"subs.selected": "Zaznaczono: {count}",
|
||||
"subs.sendersSelected": "Zaznaczono {count} nadawców",
|
||||
"subs.sendersSelected_one": "Zaznaczono 1 nadawcę",
|
||||
"subs.sendersSelected_few": "Zaznaczono {count} nadawców",
|
||||
"subs.bulkConfirm": "Wypisać się od {count} nadawców?",
|
||||
"subs.bulkConfirm_one": "Wypisać się od 1 nadawcy?",
|
||||
"subs.bulkHow":
|
||||
"Ultimo poprosi każdego nadawcę o usunięcie cię z listy albo wyśle mu prośbę o wypisanie z twojego konta. Nadawców, którzy przyjmują to tylko na swojej stronie, pokażemy na liście do otwarcia później.",
|
||||
"subs.bulkNothing": "Jesteś już wypisany od tych nadawców.",
|
||||
"subs.bulkDone": "Wypisano: {count}",
|
||||
"subs.bulkPages": "{count} do dokończenia na stronie",
|
||||
"subs.bulkFailed": "Nieudane: {count}",
|
||||
"subs.bulkTrashConfirm":
|
||||
"Przenieść całą pocztę od {count} nadawców do kosza? Wątki, na które odpowiedziałeś, zostaną na miejscu.",
|
||||
"subs.finishTitle": "Dokończ na stronach nadawców",
|
||||
"subs.finishHint": "Ci nadawcy przyjmują wypisanie tylko na swojej stronie. Otwórz każdą i potwierdź tam.",
|
||||
"subs.openPage": "Otwórz stronę",
|
||||
"subs.pageOpened": "Otwarto",
|
||||
"subs.finishDone": "Gotowe",
|
||||
"nav.search": "Szukaj w poczcie…",
|
||||
"nav.searchInTrash": "Szukaj w koszu",
|
||||
"nav.searchInSpam": "Szukaj w spamie",
|
||||
"search.save": "Zapisz to wyszukiwanie",
|
||||
"search.saved": "Zapisane wyszukiwania",
|
||||
"search.saveNamePrompt": "Nazwij zapisane wyszukiwanie:",
|
||||
"search.saveNameLabel": "Nazwa",
|
||||
"nav.storageQuota": "Zajęte miejsce",
|
||||
"nav.refresh": "Odśwież wszystkie foldery",
|
||||
|
||||
"mail.noConversations": "Brak wątków.",
|
||||
"mail.me": "ja",
|
||||
"nav.menu": "Menu",
|
||||
"mail.selectConversation": "Wybierz wątek.",
|
||||
"mail.loadingThread": "Wczytywanie wątku…",
|
||||
"mail.expandAll": "Rozwiń wszystko",
|
||||
"mail.collapseAll": "Zwiń wszystko",
|
||||
"mail.print": "Drukuj",
|
||||
"mail.previewVideoUnsupported": "Tego filmu nie da się odtworzyć w przeglądarce.",
|
||||
"mail.previewPage": "Strona {current} / {total}",
|
||||
"mail.previewResetZoom": "Przywróć powiększenie",
|
||||
"mail.previewPrev": "Poprzedni",
|
||||
"mail.previewNext": "Następny",
|
||||
"preview.pdfProtected": "Ten PDF jest chroniony hasłem",
|
||||
"preview.password": "Hasło",
|
||||
"preview.pdfWrongPassword": "Nieprawidłowe hasło — spróbuj ponownie",
|
||||
"preview.unlock": "Odblokuj",
|
||||
"preview.pdfError": "Nie udało się otworzyć tego PDF-a.",
|
||||
"mail.orderOldest": "Od najstarszych",
|
||||
"mail.orderNewest": "Od najnowszych",
|
||||
"filter.all": "Wszystkie",
|
||||
"filter.unread": "Nieprzeczytane",
|
||||
"filter.pinned": "Przypięte",
|
||||
"filter.sortOldest": "Sortuj od najstarszych",
|
||||
"filter.sortNewest": "Sortuj od najnowszych",
|
||||
"pager.prev": "Poprzednia strona",
|
||||
"pager.next": "Następna strona",
|
||||
"mail.reply": "Odpowiedz",
|
||||
"mail.replyAll": "Odpowiedz wszystkim",
|
||||
"mail.forward": "Przekaż dalej",
|
||||
"mail.archive": "Archiwizuj",
|
||||
"mail.markRead": "Oznacz jako przeczytane",
|
||||
"mail.markUnread": "Oznacz jako nieprzeczytane",
|
||||
"mail.star": "Oznacz gwiazdką",
|
||||
"mail.unstar": "Usuń gwiazdkę",
|
||||
"mail.pin": "Przypnij",
|
||||
"mail.unpin": "Odepnij",
|
||||
"mail.delete": "Usuń",
|
||||
"mail.restore": "Przywróć",
|
||||
"mail.select": "Zaznacz wątek",
|
||||
"mail.selectAll": "Zaznacz wszystko",
|
||||
"mail.conversationSelected": "Zaznaczono 1 wątek",
|
||||
"mail.conversationsSelected": "Zaznaczono {count} wątków",
|
||||
"mail.conversationsSelected_one": "Zaznaczono 1 wątek",
|
||||
"mail.conversationsSelected_few": "Zaznaczono {count} wątki",
|
||||
"mail.alsoIn": "Dostarczono także do",
|
||||
"mail.emptyTrash": "Opróżnij kosz",
|
||||
"mail.emptySpam": "Opróżnij spam",
|
||||
"mail.more": "Więcej",
|
||||
"mail.markAllRead": "Oznacz wszystkie jako przeczytane",
|
||||
"mail.labels": "Etykiety",
|
||||
"mail.noLabels": "Brak etykiet",
|
||||
"mail.createLabel": "Utwórz nową…",
|
||||
"mail.labelName": "Nazwa etykiety",
|
||||
|
||||
"panel.toggle": "Pokaż/ukryj panel boczny",
|
||||
"panel.calendar": "Kalendarz",
|
||||
"panel.people": "Osoby",
|
||||
"panel.contacts": "Kontakty",
|
||||
"panel.attachments": "Załączniki",
|
||||
"panel.search": "Szukaj",
|
||||
"panel.newEvent": "Nowe wydarzenie",
|
||||
"panel.miniCalendar": "Mini kalendarz",
|
||||
"panel.week": "Tydzień",
|
||||
"panel.month": "Miesiąc",
|
||||
"panel.view": "Widok",
|
||||
"panel.day": "Dzień",
|
||||
"panel.3days": "3 dni",
|
||||
"panel.agenda": "Lista",
|
||||
"panel.allCalendars": "Wszystkie kalendarze",
|
||||
"panel.searchEvents": "Szukaj wydarzeń",
|
||||
"panel.today": "Dzisiaj",
|
||||
"panel.noEvents": "Brak wydarzeń",
|
||||
"panel.untitled": "(bez tytułu)",
|
||||
"panel.allDay": "Cały dzień",
|
||||
"panel.openCalendar": "Otwórz kalendarz w nowej karcie",
|
||||
"panel.inConversation": "W wątku",
|
||||
"panel.allContacts": "Wszystkie kontakty",
|
||||
"panel.recent": "Ostatnie",
|
||||
"panel.vips": "VIP",
|
||||
"panel.autosaved": "Zapisane automatycznie",
|
||||
"panel.senders": "Nadawcy",
|
||||
"panel.noPeople": "Brak osób",
|
||||
"panel.composeAll": "Napisz do wszystkich",
|
||||
"panel.inviteAll": "Zaproś wszystkich na nowe wydarzenie",
|
||||
"panel.call": "Zadzwoń",
|
||||
"panel.compose": "Napisz",
|
||||
"panel.invite": "Zaproś",
|
||||
"panel.contactDetails": "Szczegóły kontaktu",
|
||||
"panel.showAll": "Pokaż wszystko",
|
||||
"panel.mailSection": "Poczta",
|
||||
"panel.noSubject": "(bez tematu)",
|
||||
"panel.findAll": "Znajdź wszystkie wątki z {name}",
|
||||
"panel.eventsSection": "Wydarzenia",
|
||||
"panel.noMatches": "Brak wyników",
|
||||
"panel.openContact": "Otwórz kontakt w nowej karcie",
|
||||
"panel.openConversation": "Otwórz wątek, aby zobaczyć jego załączniki.",
|
||||
"panel.noAttachments": "Brak załączników",
|
||||
"panel.download": "Pobierz",
|
||||
"mail.emptyConfirm": "Trwale usunąć wszystko z tego folderu? Tego nie da się cofnąć.",
|
||||
"mail.emptyAction": "Usuń wszystko",
|
||||
"mail.movedToTrash": "Przeniesiono do kosza",
|
||||
"mail.loadFailed": "Nie udało się wczytać tej wiadomości.",
|
||||
"mail.imagesBlocked": "Obrazy od tego nadawcy są zablokowane.",
|
||||
"mail.loadImages": "Pokaż obrazy",
|
||||
"mail.trackerBlocked": "Zablokowano 1 moduł śledzący",
|
||||
"mail.trackersBlocked": "Zablokowano {count} modułów śledzących",
|
||||
"mail.trackersBlocked_few": "Zablokowano {count} moduły śledzące",
|
||||
"mail.trackersHint":
|
||||
"Usunięto niewidoczne obrazy, które mówią nadawcy, kiedy i gdzie otworzyłeś wiadomość.",
|
||||
"mail.alwaysFromSender": "Zawsze od tego nadawcy",
|
||||
"mail.to": "do",
|
||||
"mail.from": "Od",
|
||||
"mail.subjectLabel": "Temat",
|
||||
"mail.dateLabel": "Data",
|
||||
"mail.sizeLabel": "Rozmiar",
|
||||
"mail.actions": "Działania",
|
||||
"mail.forwardAsAttachment": "Przekaż jako załącznik",
|
||||
"mail.showQuoted": "Pokaż cytowany tekst",
|
||||
"mail.hideQuoted": "Ukryj cytowany tekst",
|
||||
"mail.markReadUnread": "Oznacz jako przeczytane / nieprzeczytane",
|
||||
"mail.messageCount": "{count} wiadomości",
|
||||
"mail.messageCount_one": "1 wiadomość",
|
||||
"mail.downloadAll": "Pobierz wszystkie załączniki ({count})",
|
||||
"mail.downloadZip": "Pobierz ZIP",
|
||||
"mail.download": "Pobierz",
|
||||
|
||||
"compose.newMessage": "Nowa wiadomość",
|
||||
"compose.showQuoted": "Pokaż lub ukryj cytowaną wiadomość",
|
||||
"compose.to": "Do",
|
||||
"compose.cc": "DW",
|
||||
"compose.bcc": "UDW",
|
||||
"compose.subject": "Temat",
|
||||
"compose.send": "Wyślij",
|
||||
"compose.sending": "Wysyłanie…",
|
||||
"compose.discard": "Odrzuć",
|
||||
"compose.saveDraft": "Zapisz wersję roboczą",
|
||||
"compose.closePrompt": "Zapisać tę wiadomość jako wersję roboczą?",
|
||||
"compose.sent": "Wiadomość wysłana.",
|
||||
"compose.undoIn": "jeszcze {count} s:",
|
||||
"compose.undoIn_one": "jeszcze 1 s:",
|
||||
"compose.undo": "Cofnij",
|
||||
"compose.sendFailed": "Nie udało się wysłać.",
|
||||
"compose.savingDraft": "Zapisywanie wersji roboczej…",
|
||||
"compose.draftSaved": "Wersja robocza zapisana",
|
||||
"compose.noDrafts": "Brak wersji roboczych.",
|
||||
"compose.otherClient": "inny program",
|
||||
"compose.noRecipient": "(brak odbiorcy)",
|
||||
"compose.noSubject": "(bez tematu)",
|
||||
"compose.from": "Od",
|
||||
"compose.attach": "Załącz",
|
||||
"compose.more": "Więcej",
|
||||
"compose.minimize": "Minimalizuj",
|
||||
"compose.restore": "Przywróć",
|
||||
"compose.moreWindows": "Więcej wersji roboczych",
|
||||
"compose.expand": "Powiększ",
|
||||
"compose.collapse": "Zmniejsz",
|
||||
"compose.popOut": "Otwórz w osobnym oknie",
|
||||
"compose.addRecipient": "Dodaj odbiorcę…",
|
||||
"compose.fmtBold": "Pogrubienie",
|
||||
"compose.fmtItalic": "Kursywa",
|
||||
"compose.fmtUnderline": "Podkreślenie",
|
||||
"compose.fmtStrike": "Przekreślenie",
|
||||
"compose.fmtBulletList": "Lista punktowana",
|
||||
"compose.fmtNumberList": "Lista numerowana",
|
||||
"compose.fmtQuote": "Cytat",
|
||||
"compose.fmtLink": "Wstaw link",
|
||||
"compose.fmtAlignLeft": "Wyrównaj do lewej",
|
||||
"compose.fmtAlignCenter": "Wyśrodkuj",
|
||||
"compose.fmtAlignRight": "Wyrównaj do prawej",
|
||||
"compose.fmtClear": "Usuń formatowanie",
|
||||
"compose.fmtColor": "Kolor tekstu",
|
||||
"compose.fmtHighlight": "Kolor wyróżnienia",
|
||||
"compose.fmtImage": "Wstaw obraz",
|
||||
"compose.insertSignature": "Wstaw podpis",
|
||||
"compose.fmtEmoji": "Emoji",
|
||||
"compose.emojiLoading": "Wczytywanie emoji…",
|
||||
"compose.templates": "Szablony",
|
||||
"compose.saveAsTemplate": "Zapisz jako szablon…",
|
||||
"compose.noTemplates": "Brak zapisanych szablonów",
|
||||
"compose.deleteTemplate": "Usuń szablon",
|
||||
"compose.templateName": "Nazwa szablonu",
|
||||
"compose.templateEmpty":
|
||||
"Najpierw coś napisz: szablon to to, co napisałeś, bez podpisu i cytowanej wiadomości.",
|
||||
"compose.templateDelete": "Usunąć szablon „{name}”?",
|
||||
"compose.templateSaveFailed": "Nie udało się zapisać szablonu.",
|
||||
"compose.saveTemplateTitle": "Zapisz jako szablon",
|
||||
"compose.saveTemplateHint":
|
||||
"Zapisujemy temat i to, co napisałeś. Podpis i cytowana wiadomość zostaną pominięte.",
|
||||
"compose.templateNamePlaceholder": "np. Dziękuję za zapytanie",
|
||||
"compose.templateReplaceNote": "Szablon o tej nazwie już istnieje. Zapisanie go zastąpi.",
|
||||
"compose.templateReplaceAction": "Zastąp",
|
||||
"compose.templateSaving": "Zapisywanie…",
|
||||
"compose.templateEmptyShort": "Najpierw coś napisz",
|
||||
"compose.deleteTemplateAction": "Usuń",
|
||||
"compose.linkPrompt": "Adres linku:",
|
||||
"compose.linkOpen": "Otwórz link:",
|
||||
"compose.linkEdit": "Edytuj",
|
||||
"compose.linkDelete": "Usuń",
|
||||
"compose.linkSave": "Zapisz",
|
||||
"compose.spellTitle": "Pisownia i gramatyka",
|
||||
"compose.spellIgnore": "Ignoruj",
|
||||
"compose.spellRemove": "(usuń)",
|
||||
"compose.spellMenu": "Pisownia",
|
||||
"compose.spellAuto": "Wykrywaj automatycznie",
|
||||
"compose.spellOff": "Wyłączona",
|
||||
"compose.spellOffMessage": "Wyłącz dla tej wiadomości",
|
||||
"compose.imageTooLarge": "Obraz jest za duży (maks. 5 MB).",
|
||||
"compose.attachTooLarge": "Plik przekracza limit 25 MB.",
|
||||
"compose.attachments": "Załączniki",
|
||||
"compose.dropAttach": "Upuść, aby załączyć",
|
||||
"compose.dropBody": "Upuść obraz, aby wstawić · inne pliki zostaną załączone",
|
||||
|
||||
"wizard.title": "Dodaj konto pocztowe",
|
||||
"wizard.step1": "Twój adres e-mail",
|
||||
"wizard.emailPlaceholder": "ty@example.com",
|
||||
"wizard.discover": "Dalej",
|
||||
"wizard.discovering": "Szukam ustawień…",
|
||||
"wizard.foundVia": "Znaleziono przez {source}",
|
||||
"wizard.imapServer": "Serwer IMAP",
|
||||
"wizard.smtpServer": "Serwer SMTP",
|
||||
"wizard.host": "Host",
|
||||
"wizard.port": "Port",
|
||||
"wizard.security": "Zabezpieczenia",
|
||||
"wizard.accountPassword": "Hasło do konta",
|
||||
"wizard.appPasswordHint": "W Gmailu, Yahoo, iCloud i Outlooku użyj hasła do aplikacji.",
|
||||
"wizard.help": "Pomoc",
|
||||
"wizard.appPasswordHelp.title": "Utwórz hasło do aplikacji",
|
||||
"wizard.appPasswordHelp.intro":
|
||||
"Ci dostawcy wymagają jednorazowego hasła do aplikacji zamiast zwykłego hasła:",
|
||||
"wizard.appPasswordHelp.prefix": "Upewnij się, że",
|
||||
"wizard.appPasswordHelp.twoStep": "masz włączoną weryfikację dwuetapową",
|
||||
"wizard.appPasswordHelp.middle": "(jeśli jeszcze nie), a potem",
|
||||
"wizard.appPasswordHelp.generate": "wygeneruj hasło do aplikacji",
|
||||
"wizard.test": "Sprawdź i przejdź dalej",
|
||||
"wizard.testing": "Sprawdzam połączenie…",
|
||||
"wizard.testOk": "Połączenie działa.",
|
||||
"wizard.create": "Dodaj konto",
|
||||
"wizard.creating": "Dodawanie…",
|
||||
"wizard.created": "Konto dodane — synchronizuję pocztę.",
|
||||
"wizard.startReading": "Zacznij czytać",
|
||||
"wizard.backToAccounts": "Wróć do kont",
|
||||
"wizard.manualConfig": "Wpisz ustawienia ręcznie",
|
||||
"wizard.displayName": "Wyświetlana nazwa",
|
||||
"wizard.syncWindow": "Ile poczty synchronizować",
|
||||
"wizard.syncWindow.all": "Całą pocztę",
|
||||
"wizard.syncWindow.all.hint": "Najpierw ostatnie 30 dni, resztę w tle.",
|
||||
"wizard.syncWindow.3months": "Tylko ostatnie 3 miesiące",
|
||||
"wizard.syncWindow.3months.hint": "Starsza poczta zostaje na serwerze, dopóki jej nie pobierzesz.",
|
||||
"wizard.syncWindow.new": "Tylko nowa poczta",
|
||||
"wizard.syncWindow.new.hint":
|
||||
"Nic sprzed tej chwili — starszą pocztę pobierzesz w dowolnym momencie w ustawieniach konta.",
|
||||
"wizard.syncSpam": "Synchronizować spam?",
|
||||
"wizard.syncSpam.hint": "Importuj także folder Spam. Domyślnie wyłączone.",
|
||||
|
||||
"wizard.appPassword.title": "Utwórz hasło do aplikacji",
|
||||
"wizard.appPassword.intro":
|
||||
"Ten dostawca wymaga jednorazowego hasła do aplikacji zamiast zwykłego hasła. Otwórz poniższą stronę, wygeneruj hasło i wklej je tutaj.",
|
||||
"wizard.appPassword.open": "Otwórz hasła do aplikacji ({provider})",
|
||||
"wizard.appPassword.appNameHint": "Proponowana nazwa: {hint}",
|
||||
"wizard.appPassword.pastePlaceholder": "Wklej hasło do aplikacji",
|
||||
"wizard.appPassword.verifying": "Sprawdzanie…",
|
||||
"wizard.appPassword.connected": "Połączono ✓",
|
||||
"wizard.appPassword.docs": "Pomoc dostawcy",
|
||||
"wizard.appPassword.useManual": "Wpisz ustawienia serwera ręcznie",
|
||||
"wizard.appPassword.provider.gmail": "Google",
|
||||
"wizard.appPassword.provider.yahoo": "Yahoo",
|
||||
"wizard.appPassword.provider.icloud": "iCloud",
|
||||
"wizard.appPassword.provider.outlook": "Microsoft",
|
||||
"wizard.appPassword.failTitle": "To hasło nie zadziałało",
|
||||
"wizard.appPassword.failCauses":
|
||||
"Częste przyczyny: weryfikacja dwuetapowa nie jest jeszcze w pełni włączona; konto jest zupełnie nowe i dostawca wstrzymuje hasła do aplikacji, dopóki nie „dojrzeje”; albo to konto służbowe lub szkolne, w którym administrator wyłączył tę funkcję.",
|
||||
"wizard.appPassword.oauthInstead": "Zaloguj się przez Google",
|
||||
"wizard.help.gmail.step1": "Włącz weryfikację dwuetapową na koncie Google, jeśli jeszcze tego nie zrobiłeś.",
|
||||
"wizard.help.gmail.step2": "Kliknij przycisk, aby wygenerować hasło do aplikacji.",
|
||||
"wizard.help.gmail.step3": "Na stronie haseł do aplikacji nadaj hasłu nazwę (np. „ultimo.email”) i je utwórz.",
|
||||
"wizard.help.gmail.step4": "Skopiuj 16-znakowe hasło i wklej je poniżej.",
|
||||
"wizard.help.outlook.step1": "Włącz weryfikację dwuetapową w ustawieniach zabezpieczeń konta Microsoft.",
|
||||
"wizard.help.outlook.step2": "W zaawansowanych opcjach zabezpieczeń utwórz nowe hasło do aplikacji.",
|
||||
"wizard.help.outlook.step3": "Skopiuj wygenerowane hasło i wklej je poniżej.",
|
||||
"wizard.help.yahoo.step1": "Otwórz Zabezpieczenia konta i włącz weryfikację dwuetapową.",
|
||||
"wizard.help.yahoo.step2": "Wybierz „Wygeneruj hasło do aplikacji” i nadaj mu nazwę.",
|
||||
"wizard.help.yahoo.step3": "Skopiuj hasło i wklej je poniżej.",
|
||||
"wizard.help.icloud.step1": "Włącz uwierzytelnianie dwupoziomowe na koncie Apple.",
|
||||
"wizard.help.icloud.step2": "W sekcji Logowanie i bezpieczeństwo wygeneruj hasło dla aplikacji.",
|
||||
"wizard.help.icloud.step3": "Skopiuj hasło i wklej je poniżej.",
|
||||
|
||||
"settings.title": "Ustawienia",
|
||||
"settings.profile": "Profil",
|
||||
"settings.security": "Bezpieczeństwo",
|
||||
"settings.accounts": "Konta",
|
||||
"settings.downloadAllMail": "Synchronizuj resztę poczty",
|
||||
"settings.syncedPartially": "Zsynchronizowane częściowo",
|
||||
"backfill.importing": "Importuję wiadomości… {done} z {total}",
|
||||
"backfill.preparing": "Przygotowuję import wiadomości…",
|
||||
"settings.appearance": "Wygląd",
|
||||
"settings.handle": "Nazwa użytkownika",
|
||||
"settings.recoveryEmail": "E-mail do odzyskiwania",
|
||||
"settings.spellcheck": "Sprawdzanie pisowni i gramatyki",
|
||||
"settings.on": "Wł.",
|
||||
"settings.off": "Wył.",
|
||||
"settings.spellcheckLanguage": "Język sprawdzania pisowni",
|
||||
"settings.spellcheckAuto": "Wykrywaj automatycznie (każdy akapit)",
|
||||
"settings.spellcheckUnavailable": "Niedostępne: ten serwer nie ma skonfigurowanego sprawdzania pisowni.",
|
||||
"settings.recoveryEmailHint": "Jeśli zapomnisz hasła, wyślemy tu link do ustawienia nowego.",
|
||||
"settings.recoveryEmailNew": "Adres e-mail do odzyskiwania",
|
||||
"settings.recoveryEmailRemoveHint": "Zostaw puste, aby go usunąć.",
|
||||
"settings.currentPassword": "Obecne hasło",
|
||||
"settings.typePassword": "Wpisz hasło — przy tym potwierdzeniu nie można go wkleić ani wypełnić automatycznie.",
|
||||
"settings.change": "Zmień",
|
||||
"settings.add": "Dodaj",
|
||||
"settings.saving": "Zapisywanie…",
|
||||
"settings.twoFactor": "Uwierzytelnianie dwuskładnikowe",
|
||||
"settings.twoFactorOn": "2FA jest włączone.",
|
||||
"settings.twoFactorOff": "2FA nie jest włączone.",
|
||||
"settings.enable2fa": "Włącz 2FA",
|
||||
"settings.disable2fa": "Wyłącz 2FA",
|
||||
"settings.disable2faPrompt": "Wpisz hasło, aby wyłączyć 2FA:",
|
||||
"settings.disable": "Wyłącz",
|
||||
"settings.twoFactorDisabled": "2FA zostało wyłączone.",
|
||||
"settings.scanQr": "Zeskanuj to w aplikacji uwierzytelniającej, a potem wpisz kod:",
|
||||
"settings.secret": "Klucz",
|
||||
"settings.confirm": "Potwierdź",
|
||||
"settings.sessions": "Aktywne sesje",
|
||||
"appPasswords.title": "Hasła do aplikacji",
|
||||
"appPasswords.help":
|
||||
"Aplikacje kalendarza i kontaktów — w telefonie, Thunderbird, macOS — logują się do Ultimo hasłem do aplikacji, nigdy hasłem do konta. Każdej aplikacji daj osobne, aby móc unieważnić jedno bez wpływu na pozostałe.",
|
||||
"appPasswords.helpMail":
|
||||
"Programy pocztowe — Thunderbird, Poczta w telefonie, Outlook — oraz aplikacje kalendarza i kontaktów logują się do Ultimo hasłem do aplikacji. Hasło do konta w nich nie działa. Każdej aplikacji daj osobne, aby móc unieważnić jedno bez wpływu na pozostałe.",
|
||||
"appPasswords.mailTitle": "Konfiguracja zewnętrznych programów pocztowych:",
|
||||
"appPasswords.mailIncoming": "Poczta przychodząca (IMAP)",
|
||||
"appPasswords.mailOutgoing": "Poczta wychodząca (SMTP)",
|
||||
"appPasswords.mailPorts": "{host}, port {port}, SSL/TLS",
|
||||
"appPasswords.mailPortsAlt": "lub port {port}, STARTTLS",
|
||||
"appPasswords.connectTitle": "Konfiguracja zewnętrznych aplikacji kalendarza i kontaktów:",
|
||||
"appPasswords.connectServer": "Serwer",
|
||||
"appPasswords.connectUser": "Nazwa użytkownika",
|
||||
"appPasswords.connectPassword": "Hasło",
|
||||
"appPasswords.connectPasswordValue": "hasło do aplikacji (utwórz je poniżej)",
|
||||
"appPasswords.connectHint":
|
||||
"iPhone/iPad: Ustawienia → Aplikacje → Kalendarz (lub Kontakty) → Konta kalendarzy → Dodaj konto → Inne → Dodaj konto CalDAV (CardDAV). Android: aplikacja DAVx⁵. Thunderbird: Nowy kalendarz → W sieci oraz Nowa książka adresowa → CardDAV.",
|
||||
"appPasswords.new": "Utwórz hasło do aplikacji",
|
||||
"appPasswords.name": "Nazwa",
|
||||
"appPasswords.namePlaceholder": "np. Thunderbird na laptopie",
|
||||
"appPasswords.confirmPassword": "Hasło do twojego konta",
|
||||
"appPasswords.create": "Utwórz",
|
||||
"appPasswords.madeFor": "Hasło do aplikacji „{name}”:",
|
||||
"appPasswords.copy": "Kopiuj",
|
||||
"appPasswords.copied": "Skopiowano",
|
||||
"appPasswords.onceOnly":
|
||||
"Wpisz je w polu hasła aplikacji, bez spacji. Nie zobaczysz go ponownie — jeśli je zgubisz, unieważnij je i utwórz nowe.",
|
||||
"appPasswords.done": "Gotowe",
|
||||
"appPasswords.created": "Utworzono {date}",
|
||||
"appPasswords.lastUsed": "ostatnio użyte {date}",
|
||||
"appPasswords.neverUsed": "nigdy nieużyte",
|
||||
"appPasswords.lastUsedDav": "kalendarze lub kontakty zsynchronizowane {date}",
|
||||
"appPasswords.confirmRevoke": "Unieważnić hasło do aplikacji „{name}”? Aplikacja, która go używa, przestanie się logować.",
|
||||
"settings.thisDevice": "to urządzenie",
|
||||
"settings.revoke": "Unieważnij",
|
||||
"settings.threadOrder": "Kolejność w wątku",
|
||||
"settings.timezone": "Strefa czasowa",
|
||||
"settings.timeFormat": "Format godziny",
|
||||
"settings.timeFormat24": "24-godzinny (14:30)",
|
||||
"settings.timeFormat12": "12-godzinny (2:30 PM)",
|
||||
"settings.calendars": "Kalendarze",
|
||||
"settings.myCalendars": "Moje kalendarze",
|
||||
"settings.calendarOptions": "Opcje kalendarza",
|
||||
"settings.createCalendar": "Utwórz kalendarz",
|
||||
"settings.editCalendar": "Edytuj kalendarz",
|
||||
"settings.export": "Eksportuj",
|
||||
"settings.calendarDisplay": "Wyświetlanie kalendarza",
|
||||
"settings.default": "Domyślny",
|
||||
"settings.defaultCalendar": "Domyślny kalendarz",
|
||||
"settings.firstCalendar": "Pierwszy kalendarz",
|
||||
"settings.defaultRemindersTimed": "Domyślne przypomnienia o wydarzeniach z godziną",
|
||||
"settings.defaultRemindersAllday": "Domyślne przypomnienia o wydarzeniach całodniowych",
|
||||
"settings.addReminder": "Dodaj przypomnienie",
|
||||
"settings.name": "Nazwa",
|
||||
"settings.onlyYou": "Tylko ty widzisz ten kalendarz.",
|
||||
"settings.publish": "Opublikuj",
|
||||
"settings.publishHint": "Udostępnij ten kalendarz (tylko do odczytu) każdemu.",
|
||||
"settings.fullEventDetails": "Pełne szczegóły wydarzeń",
|
||||
"settings.freeBusy": "Informacje wolny/zajęty",
|
||||
"settings.addAccountHint": "Połącz zewnętrzne konto kalendarza.",
|
||||
"settings.subscriptions": "Subskrypcje",
|
||||
"settings.subscriptionsHint": "Dodaj kalendarz tylko do odczytu z adresu iCal (.ics).",
|
||||
"settings.subscribe": "Subskrybuj",
|
||||
"settings.subscribeUrl": "Adres iCal (.ics)",
|
||||
"settings.subscribed": "Subskrybowane",
|
||||
"settings.comingSoon": "Wkrótce",
|
||||
"settings.subscribeError": "Nie udało się zasubskrybować tego adresu.",
|
||||
"settings.connectTitle": "Połącz {provider}",
|
||||
"settings.connect": "Połącz",
|
||||
"settings.connecting": "Łączenie…",
|
||||
"settings.connectError": "Nie udało się połączyć. Sprawdź dane i spróbuj ponownie.",
|
||||
"settings.connectAuthFailed":
|
||||
"Logowanie odrzucone. Jeśli masz włączone uwierzytelnianie dwuskładnikowe, użyj hasła do aplikacji (nie zwykłego hasła).",
|
||||
"settings.connectPrivateHost":
|
||||
"Ten serwer jest w sieci prywatnej, która ze względów bezpieczeństwa jest niedostępna. Użyj publicznego adresu HTTPS.",
|
||||
"settings.connectUnreachable": "Nie udało się połączyć z serwerem. Sprawdź, czy adres jest poprawny i zaczyna się od https://.",
|
||||
"settings.connectNoCalendars": "Połączono, ale na tym koncie nie ma kalendarzy.",
|
||||
"settings.connectNeedsUrl": "Wpisz adres serwera CalDAV.",
|
||||
"settings.davServerUrl": "Adres serwera CalDAV",
|
||||
"settings.appleId": "Apple ID (e-mail)",
|
||||
"settings.username": "Nazwa użytkownika",
|
||||
"settings.appPassword": "Hasło dla aplikacji",
|
||||
"settings.password": "Hasło",
|
||||
"settings.icloudHint": "Użyj hasła dla aplikacji z account.apple.com → Logowanie i bezpieczeństwo.",
|
||||
"settings.calendarCount": "{count} kalendarzy",
|
||||
"settings.calendarCount_one": "1 kalendarz",
|
||||
"settings.calendarCount_few": "{count} kalendarze",
|
||||
"settings.removeSubscription": "Usuń subskrypcję",
|
||||
"settings.removeSubscriptionConfirm": "Przestać synchronizować „{name}” i usunąć jego wydarzenia?",
|
||||
"settings.deleteCalendar": "Usuń kalendarz",
|
||||
"settings.thisCalendar": "ten kalendarz",
|
||||
"settings.deleteCalendarConfirm": "Usunąć „{name}” i wszystkie jego wydarzenia? Tego nie da się cofnąć.",
|
||||
"settings.showAlert": "Pokaż powiadomienie",
|
||||
"settings.emailMe": "Wyślij mi e-mail",
|
||||
"settings.invitations": "Zaproszenia",
|
||||
"settings.inviteWhen": "Gdy dostaję wydarzenia e-mailem",
|
||||
"settings.inviteAddUpdate": "Dodawaj nowe wydarzenia do kalendarza i aktualizuj zmienione",
|
||||
"settings.inviteUpdateOnly": "Aktualizuj tylko wydarzenia, które już dodałem do kalendarza",
|
||||
"settings.inviteNone": "Nie aktualizuj kalendarza automatycznie",
|
||||
"settings.addNewFrom": "Dodawaj nowe wydarzenia od",
|
||||
"settings.myContacts": "Moich kontaktów",
|
||||
"settings.anyone": "Każdego",
|
||||
"settings.addToCalendar": "Dodawaj do kalendarza",
|
||||
"settings.alsoFile": "Oznacz też wiadomość jako przeczytaną i ją zarchiwizuj",
|
||||
"settings.integrations": "Integracje",
|
||||
"settings.shortcuts": "Skróty klawiszowe",
|
||||
"settings.privacy": "Prywatność",
|
||||
"settings.completion": "Podpowiadaj zakończenia zdań",
|
||||
"settings.completionHint":
|
||||
"Podczas pisania prawdopodobny ciąg dalszy zdania pojawia się na szaro; naciśnij Tab, aby go wstawić. Podpowiedzi pochodzą ze zwrotów, których używasz w wysłanej poczcie (oraz typowych zwrotów po angielsku, polsku i niemiecku), i nie opuszczają tego serwera.",
|
||||
"settings.blockTrackers": "Blokuj moduły śledzące w poczcie",
|
||||
"settings.blockTrackersHint":
|
||||
"Wiele newsletterów i maili handlowych ukrywa maleńki, niewidoczny obraz, który mówi nadawcy, kiedy otworzyłeś wiadomość, ile razy i mniej więcej gdzie byłeś. Ultimo usuwa takie obrazy przed wyświetleniem wiadomości. Po wyłączeniu są traktowane jak każdy inny obraz z sieci: wczytywane dopiero, gdy zezwolisz na obrazy od tego nadawcy.",
|
||||
"settings.shortcutsEnabled": "Używaj skrótów klawiszowych",
|
||||
"settings.shortcutsHint":
|
||||
"Pojedyncze klawisze działają na liście poczty i w otwartym wątku. Podczas pisania są ignorowane. Naciśnij ? w widoku poczty, aby zobaczyć tę listę.",
|
||||
"shortcuts.title": "Skróty klawiszowe",
|
||||
"shortcuts.then": "potem",
|
||||
"shortcuts.groupNavigation": "Nawigacja",
|
||||
"shortcuts.groupConversation": "Wątek",
|
||||
"shortcuts.groupCompose": "Pisanie",
|
||||
"shortcuts.next": "Następny wątek",
|
||||
"shortcuts.prev": "Poprzedni wątek",
|
||||
"shortcuts.back": "Wróć do listy",
|
||||
"shortcuts.search": "Szukaj",
|
||||
"shortcuts.goInbox": "Przejdź do Odebranych",
|
||||
"shortcuts.goSnoozed": "Przejdź do Odłożonych",
|
||||
"shortcuts.goDrafts": "Przejdź do Wersji roboczych",
|
||||
"shortcuts.goSent": "Przejdź do Wysłanych",
|
||||
"shortcuts.goSubscriptions": "Przejdź do Subskrypcji",
|
||||
"shortcuts.help": "Pokaż skróty klawiszowe",
|
||||
"shortcuts.reply": "Odpowiedz",
|
||||
"shortcuts.replyAll": "Odpowiedz wszystkim",
|
||||
"shortcuts.forward": "Przekaż dalej",
|
||||
"shortcuts.archive": "Archiwizuj",
|
||||
"shortcuts.trash": "Usuń",
|
||||
"shortcuts.pin": "Przypnij / odepnij",
|
||||
"shortcuts.snooze": "Odłóż",
|
||||
"shortcuts.markRead": "Oznacz jako przeczytane",
|
||||
"shortcuts.markUnread": "Oznacz jako nieprzeczytane",
|
||||
"shortcuts.compose": "Nowa wiadomość",
|
||||
"shortcuts.escape": "Zminimalizuj okna pisania, zamknij okno dialogowe",
|
||||
|
||||
"admin.title": "Administracja Ultimo Email",
|
||||
"admin.signInTitle": "Logowanie administratora",
|
||||
"admin.signInHint": "Zaloguj się kontem administratora, aby zarządzać tym serwerem.",
|
||||
"admin.signOut": "Wyloguj się",
|
||||
"admin.signedInAs": "Zalogowano jako {handle}",
|
||||
"admin.notAdminTitle": "To nie jest konto administratora",
|
||||
"admin.notAdmin": "Zalogowano jako {handle}, ale to konto nie ma uprawnień administratora na tym serwerze.",
|
||||
"admin.registration": "Rejestracja nowych użytkowników",
|
||||
"admin.registrationOn": "Otwarta",
|
||||
"admin.registrationOff": "Zamknięta",
|
||||
"admin.registrationHelp": "Gdy jest zamknięta, strona rejestracji jest ukryta, a serwer odrzuca nowe konta.",
|
||||
"admin.invitations": "Zezwalaj na zaproszenia",
|
||||
"admin.invitationsOn": "Wł.",
|
||||
"admin.invitationsOff": "Wył.",
|
||||
"admin.invitationsHelp":
|
||||
"Każdy użytkownik może zaprosić 2 znajomych z paska bocznego. Link z zaproszenia pozwala założyć konto jednej osobie, nawet przy zamkniętej rejestracji, i wygasa po 14 dniach (niewykorzystane wracają do puli).",
|
||||
"admin.inviteMail": "E-mail z zaproszeniem",
|
||||
"admin.inviteConfigure": "Konfiguruj…",
|
||||
"admin.inviteMailHelp":
|
||||
"Wysyłany przez konto poczty systemowej (po prawej stronie panelu), które musi mieć prawo wysyłania z adresu nadawcy. Odpowiedzi trafiają do użytkownika, który wysłał zaproszenie.",
|
||||
"admin.inviteFrom": "Adres nadawcy",
|
||||
"admin.inviteFromHint": "Puste: pokazany adres zastępczy.",
|
||||
"admin.inviteSubject": "Temat",
|
||||
"admin.inviteTemplateDefault": "Używany jest wbudowany szablon.",
|
||||
"admin.inviteTemplateCustom": "Używany jest twój przesłany szablon.",
|
||||
"admin.inviteUpload": "Prześlij szablon HTML…",
|
||||
"admin.inviteDownload": "Pobierz szablon",
|
||||
"admin.inviteReset": "Wróć do wbudowanego szablonu",
|
||||
"admin.inviteResetConfirm": "Usunąć twój szablon i znów używać wbudowanego?",
|
||||
"admin.inviteResetAction": "Użyj wbudowanego szablonu",
|
||||
"admin.invitePlaceholders": "Zmienne:",
|
||||
"admin.invitePlaceholdersHelp":
|
||||
"Szablon musi zawierać {{invite_link}} lub {{invite_button}}. Temat też może używać imion. Obrazy wpisane w HTML są wysyłane wewnątrz wiadomości.",
|
||||
"admin.invitePreview": "Podgląd z przykładowymi imionami —",
|
||||
"admin.inviteTestSent": "Wysłano testowe zaproszenie (jego link jest fikcyjny).",
|
||||
"admin.defaultQuota": "Domyślny limit miejsca dla nowych użytkowników",
|
||||
"admin.quotaHelp": "Dotyczy kont tworzonych od teraz. Istniejące skrzynki zachowują przyznany limit.",
|
||||
"admin.save": "Zapisz",
|
||||
"admin.saving": "Zapisywanie…",
|
||||
"admin.saved": "Zapisano",
|
||||
"admin.quotaInvalid": "Wpisz rozmiar większy od 0.",
|
||||
"display.title": "Wygląd skrzynki",
|
||||
"display.readingPane": "Okienko odczytu",
|
||||
"display.side": "Z boku",
|
||||
"display.bottom": "Na dole",
|
||||
"display.off": "Wyłączone",
|
||||
"display.dividers": "Pokazuj linię między wierszami",
|
||||
"admin.mail": "Wychodząca poczta systemowa",
|
||||
"admin.mailHelp":
|
||||
"Konto pocztowe, z którego serwer wysyła linki do resetowania hasła. Bez niego „Nie pamiętasz hasła?” niczego nie wyśle.",
|
||||
"admin.mailOn": "Skonfigurowane — linki do resetowania hasła mogą być wysyłane.",
|
||||
"admin.mailOff": "Nieskonfigurowane — linki do resetowania hasła nie mogą jeszcze być wysyłane.",
|
||||
"admin.smtpHost": "Serwer SMTP",
|
||||
"admin.smtpPort": "Port",
|
||||
"admin.smtpSecurity": "Zabezpieczenia",
|
||||
"admin.smtpStarttls": "STARTTLS",
|
||||
"admin.smtpSsl": "SSL/TLS",
|
||||
"admin.smtpNone": "Brak",
|
||||
"admin.smtpUsername": "Login",
|
||||
"admin.smtpPassword": "Hasło",
|
||||
"admin.smtpPasswordKept": "(zapisane — zostaw puste, aby zachować)",
|
||||
"admin.smtpPasswordRemove": "Usuń zapisane hasło",
|
||||
"admin.smtpFrom": "Adres nadawcy",
|
||||
"admin.publicUrl": "Publiczny adres tego serwera",
|
||||
"admin.publicUrlHint":
|
||||
"Gdzie użytkownicy otwierają Ultimo Email, np. https://mail.example.com — tu prowadzą linki do resetowania hasła.",
|
||||
"admin.testTo": "Wyślij testowy e-mail do",
|
||||
"admin.sendTest": "Wyślij test",
|
||||
"admin.testSent": "Wysłano testowy e-mail.",
|
||||
"admin.users": "Użytkownicy",
|
||||
"admin.userSearch": "Szukaj po loginie, adresie e-mail lub nazwie…",
|
||||
"admin.noUsers": "Brak pasujących użytkowników.",
|
||||
"admin.you": "ty",
|
||||
"admin.adminBadge": "admin",
|
||||
"admin.blockedBadge": "zablokowany",
|
||||
"admin.usage": "{used} z {quota}",
|
||||
"admin.noMailAccounts": "Brak połączonych kont pocztowych",
|
||||
"admin.userQuota": "Limit",
|
||||
"admin.blockedSince": "Zablokowany od {date}. Ten użytkownik nie może się zalogować.",
|
||||
"admin.block": "Zablokuj konto",
|
||||
"admin.unblock": "Odblokuj konto",
|
||||
"admin.blockConfirm":
|
||||
"Zablokować {handle}? Zostanie wylogowany ze wszystkich urządzeń i nie zaloguje się, dopóki go nie odblokujesz. Poczta i dane zostają zachowane.",
|
||||
"admin.deleteUser": "Usuń konto",
|
||||
"admin.deleteConfirm":
|
||||
"Trwale usunąć {handle} wraz z całą pocztą, kontaktami, kalendarzami i ustawieniami? Tego nie da się cofnąć.",
|
||||
"admin.selfNote": "To twoje własne konto: nie można go tu zablokować ani usunąć.",
|
||||
"admin.cancel": "Anuluj",
|
||||
"admin.close": "Zamknij",
|
||||
"settings.jitsi": "Jitsi Meet",
|
||||
"settings.jitsiHint":
|
||||
"Serwer, na którym tworzone są linki do rozmów wideo dla wydarzeń w kalendarzu. Zostaw puste, aby używać publicznego serwera https://meet.jit.si/.",
|
||||
"settings.addAccount": "Dodaj konto",
|
||||
"settings.syncState": "Stan: {state}",
|
||||
"settings.confirmRemoveAccount": "Usunąć to konto i całą jego pocztę?",
|
||||
"settings.removeAccountTitle": "Usuń konto",
|
||||
"settings.removeAccountAction": "Usuń konto",
|
||||
"settings.removingAccount": "Usuwanie konta…",
|
||||
"settings.editAccount": "Edytuj konto",
|
||||
"settings.color": "Kolor",
|
||||
"settings.serverSettings": "Zmień ustawienia serwera / hasło",
|
||||
"settings.serverSettingsHint": "Wpisz hasło do skrzynki, aby zastosować zmiany serwera lub hasła.",
|
||||
"settings.signature": "Podpis",
|
||||
"settings.configureAccount": "Konfiguruj konto",
|
||||
"settings.yourMailbox": "Twoja skrzynka",
|
||||
"mail.darkMessages": "Ciemny tryb wiadomości",
|
||||
"mail.darkMessagesOn":
|
||||
"Ciemny tryb wiadomości jest włączony: wiadomości są wyświetlane na ciemnej stronie. Kliknij, aby pokazać je tak, jak zostały wysłane.",
|
||||
"mail.darkMessagesOff":
|
||||
"Ciemny tryb wiadomości jest wyłączony: wiadomości są pokazywane tak, jak zostały wysłane. Kliknij, aby wyświetlić je na ciemnej stronie.",
|
||||
"settings.yourMailboxHint": "Twój adres w Ultimo. Zostaje tak długo, jak twoje konto.",
|
||||
"settings.editSignature": "Edytuj podpis",
|
||||
"settings.theme": "Motyw",
|
||||
"theme.light": "Jasny",
|
||||
"theme.dark": "Ciemny",
|
||||
"theme.system": "Systemowy",
|
||||
"theme.toggle": "Przełącz motyw",
|
||||
"offline.label": "Offline",
|
||||
"offline.backOnline": "Znów online",
|
||||
"offline.explainSaved": "Offline — pokazuję pocztę zapisaną na tym urządzeniu, zaktualizowaną {when}.",
|
||||
"offline.explainSavedNoTime": "Offline — pokazuję pocztę zapisaną na tym urządzeniu.",
|
||||
"offline.explainNotSaved":
|
||||
"Brak połączenia z serwerem. Aby czytać ostatnią pocztę bez połączenia, włącz dostęp offline w Ustawienia → Konta.",
|
||||
"offline.noticeSaved": "Jesteś offline. Ostatnia poczta jest nadal dostępna.",
|
||||
"offline.noticeNotSaved": "Jesteś offline — brak połączenia z serwerem.",
|
||||
"offline.notAvailable": "Niedostępne offline",
|
||||
"offline.threadNotSaved": "Ten wątek nie jest zapisany na tym urządzeniu.",
|
||||
"offline.folderNotSaved": "Ten folder nie jest zapisany na tym urządzeniu.",
|
||||
"offline.noticeScheduled": "Zapisano w Zaplanowanych — wyślemy, gdy znów będziesz online.",
|
||||
"offline.noticeSendingScheduled": "Wysyłanie {count} zaplanowanych wiadomości…",
|
||||
"offline.noticeSendingScheduled_one": "Wysyłanie 1 zaplanowanej wiadomości…",
|
||||
"offline.draftsNotSaved": "Offline dostępne są tylko wersje robocze napisane na tym urządzeniu.",
|
||||
"compose.sendLater": "Zaplanuj",
|
||||
"compose.savedOnDevice": "Zapisano na tym urządzeniu",
|
||||
"compose.forwardAttachFailed": "Nie udało się dołączyć plików z oryginalnej wiadomości.",
|
||||
"compose.attachOnDevice": "Na tym urządzeniu — zostanie przesłane, gdy znów będziesz online",
|
||||
"compose.onDevice": "Na tym urządzeniu",
|
||||
"nav.scheduled": "Zaplanowane",
|
||||
"scheduled.help": "Te wiadomości wyjdą same — gdy znów będziesz online albo gdy serwer pocztowy je przyjmie.",
|
||||
"scheduled.empty": "Nic nie zaplanowano.",
|
||||
"scheduled.waiting": "Czeka",
|
||||
"scheduled.sending": "Wysyłanie…",
|
||||
"scheduled.failed": "Nie wysłano",
|
||||
"scheduled.retrying": "Ponowna próba {when}",
|
||||
"scheduled.retryNow": "Spróbuj teraz",
|
||||
"scheduled.edit": "Edytuj",
|
||||
"scheduled.delete": "Usuń",
|
||||
"scheduled.statusRetrying":
|
||||
"Serwer pocztowy nie przyjął jeszcze tej wiadomości ({count} prób). Następna próba {when}; próbuje do {until}.",
|
||||
"scheduled.statusRetrying_one":
|
||||
"Serwer pocztowy nie przyjął jeszcze tej wiadomości (1 próba). Następna próba {when}; próbuje do {until}.",
|
||||
"scheduled.statusRetrying_few":
|
||||
"Serwer pocztowy nie przyjął jeszcze tej wiadomości ({count} próby). Następna próba {when}; próbuje do {until}.",
|
||||
"scheduled.statusFailed": "Nie wysłano: {error}",
|
||||
"scheduled.lastError": "Ostatni błąd: {error}",
|
||||
"scheduled.to": "Do",
|
||||
"send.noticeRetrying": "Nie udało się wysłać „{subject}” — jest w Zaplanowanych i zostanie wysłane ponownie.",
|
||||
"send.noticeFailed": "Nie udało się wysłać „{subject}” — jest w Zaplanowanych: {error}",
|
||||
"send.noticeSent": "Wysłano „{subject}”.",
|
||||
"account.unreachable": "Brak połączenia z serwerem pocztowym od {when}",
|
||||
"account.authFailed": "Logowanie nieudane — otwórz Ustawienia → Konta → Konfiguruj konto i wpisz hasło ponownie",
|
||||
"settings.stateUnreachable": "Brak połączenia z serwerem od {when}",
|
||||
"settings.stateAuthFailed": "Logowanie nieudane — użyj Konfiguruj konto, aby wpisać hasło ponownie",
|
||||
"offline.searchPlaceholder": "Szukaj (offline)",
|
||||
"offline.cantReach": "Brak połączenia z serwerem.",
|
||||
"offline.settingTitle": "Dostęp offline",
|
||||
"offline.settingHelp":
|
||||
"Przechowuj na tym urządzeniu 100 najnowszych wątków z Odebranych oraz te, które otwierasz, aby czytać je bez połączenia. Są zapisane w tej przeglądarce bez szyfrowania i usuwane przy wylogowaniu — nie włączaj tego na wspólnych komputerach.",
|
||||
"offline.settingStatus": "Zapisano {count} wątków · {size} · zaktualizowano {when}",
|
||||
"offline.settingStatus_one": "Zapisano 1 wątek · {size} · zaktualizowano {when}",
|
||||
"offline.settingStatus_few": "Zapisano {count} wątki · {size} · zaktualizowano {when}",
|
||||
"offline.settingStatusNoTime": "Zapisano {count} wątków · {size}",
|
||||
"offline.settingStatusNoTime_one": "Zapisano 1 wątek · {size}",
|
||||
"offline.settingStatusNoTime_few": "Zapisano {count} wątki · {size}",
|
||||
"offline.settingSaving": "Zapisywanie ostatniej poczty…",
|
||||
"offline.settingUnsupported":
|
||||
"Niedostępne tutaj: przeglądarki pozwalają na dostęp offline tylko wtedy, gdy aplikacja jest otwarta przez HTTPS.",
|
||||
"push.settingTitle": "Powiadomienia",
|
||||
"push.settingHelp":
|
||||
"Dostawaj powiadomienie na tym urządzeniu, gdy do Odebranych przyjdzie nowa poczta, nawet gdy Ultimo nie jest otwarte.",
|
||||
"push.needsInstall":
|
||||
"Na iPhonie i iPadzie najpierw dodaj Ultimo do ekranu początkowego (Udostępnij → Do ekranu początk.), a potem włącz to w aplikacji.",
|
||||
"push.blocked":
|
||||
"Powiadomienia z tej strony są zablokowane w przeglądarce. Zezwól na nie w ustawieniach strony w przeglądarce, a potem włącz tę opcję.",
|
||||
"push.unsupported":
|
||||
"Niedostępne tutaj: ta przeglądarka nie pokazuje powiadomień ze stron albo aplikacja nie jest otwarta przez HTTPS.",
|
||||
"push.notAllowed": "Nie zezwolono na powiadomienia.",
|
||||
"push.waiting":
|
||||
"Czekam na zgodę przeglądarki. Jeśli nie zapytała, kliknij ikonę po lewej stronie paska adresu i ustaw Powiadomienia na Zezwalaj — wtedy włączą się same.",
|
||||
"push.failed": "Nie udało się włączyć powiadomień.",
|
||||
"install.entry": "Zainstaluj aplikację",
|
||||
"invite.entry": "Zaproś znajomego",
|
||||
"invite.title": "Zaproś znajomego",
|
||||
"invite.help":
|
||||
"Wyślemy mu e-mailem link do założenia konta. Zostało ci {count} zaproszeń; każdy link działa raz.",
|
||||
"invite.help_one":
|
||||
"Wyślemy mu e-mailem link do założenia konta. To twoje ostatnie zaproszenie; link działa raz.",
|
||||
"invite.help_few":
|
||||
"Wyślemy mu e-mailem link do założenia konta. Zostały ci {count} zaproszenia; każdy link działa raz.",
|
||||
"invite.name": "Imię znajomego",
|
||||
"invite.email": "Adres e-mail znajomego",
|
||||
"invite.send": "Wyślij zaproszenie",
|
||||
"invite.sending": "Wysyłanie…",
|
||||
"invite.sent": "Wysłano zaproszenie do: {name}",
|
||||
"invite.welcome": "Cześć, {name} — {inviter} cię zaprasza. Wybierz adres i hasło.",
|
||||
"invite.linkInvalid": "To zaproszenie zostało już wykorzystane albo wygasło.",
|
||||
"install.title": "Zainstaluj Ultimo",
|
||||
"install.stepsIos":
|
||||
"1. Stuknij przycisk Udostępnij (kwadrat ze strzałką) na pasku Safari.\n2. Wybierz Do ekranu początk.\n3. Stuknij Dodaj. Ultimo otworzy się z ekranu początkowego jak aplikacja i może pokazywać powiadomienia o nowej poczcie.",
|
||||
"install.stepsOpera":
|
||||
"1. Otwórz menu Opery (⋮).\n2. Wybierz Dodaj do → Ekran główny.\n\nOpera doda skrót do Ultimo na ekranie głównym. Aby zainstalować Ultimo jako pełną aplikację, otwórz tę stronę w Chrome i stuknij tam Zainstaluj aplikację.",
|
||||
"install.stepsFirefox": "1. Otwórz menu Firefoksa (⋮).\n2. Wybierz Dodaj aplikację do ekranu głównego (lub Zainstaluj).",
|
||||
"install.hide": "Nie pokazuj więcej",
|
||||
"install.gotIt": "Rozumiem",
|
||||
"settings.language": "Język",
|
||||
"settings.languageHint":
|
||||
"Język menu, przycisków i komunikatów Ultimo. Zapisany na twoim koncie, więc jest taki sam na każdym urządzeniu.",
|
||||
"time.justNow": "przed chwilą",
|
||||
"time.minAgo": "{count} min temu",
|
||||
"time.hoursAgo": "{count} godzin temu",
|
||||
"time.hoursAgo_one": "godzinę temu",
|
||||
"time.hoursAgo_few": "{count} godziny temu",
|
||||
"time.daysAgo": "{count} dni temu",
|
||||
"time.daysAgo_one": "1 dzień temu",
|
||||
"reminder.atTime": "W chwili rozpoczęcia",
|
||||
"reminder.minutesBefore": "{count} minut wcześniej",
|
||||
"reminder.minutesBefore_few": "{count} minuty wcześniej",
|
||||
"reminder.hoursBefore": "{count} godzin wcześniej",
|
||||
"reminder.hoursBefore_one": "1 godzinę wcześniej",
|
||||
"reminder.hoursBefore_few": "{count} godziny wcześniej",
|
||||
"reminder.daysBefore": "{count} dni wcześniej",
|
||||
"reminder.daysBefore_one": "1 dzień wcześniej",
|
||||
"color.blue": "Niebieski",
|
||||
"color.green": "Zielony",
|
||||
"color.red": "Czerwony",
|
||||
"color.orange": "Pomarańczowy",
|
||||
"color.purple": "Fioletowy",
|
||||
"color.teal": "Morski",
|
||||
"color.pink": "Różowy",
|
||||
"color.gray": "Szary",
|
||||
"color.custom": "Własny",
|
||||
"settings.selectColor": "Wybierz kolor",
|
||||
"labels.remove": "Usuń etykietę",
|
||||
"compose.attribution": "{date}, o {time}, {who} napisał(a):",
|
||||
"print.attachments": "Załączniki",
|
||||
"print.popupBlocked": "Zezwól tej stronie na wyskakujące okna, aby drukować.",
|
||||
"mail.translate": "Przetłumacz",
|
||||
"mail.translateTo": "Przetłumacz na…",
|
||||
"mail.showOriginal": "Pokaż oryginał",
|
||||
"mail.translating": "Tłumaczenie…",
|
||||
"mail.translated": "Przetłumaczono",
|
||||
"mail.translatedFrom": "Przetłumaczono z języka: {language}",
|
||||
"mail.translatedFromMany": "Przetłumaczono z języków: {languages}",
|
||||
"mail.translateFailed": "Nie udało się przetłumaczyć tej wiadomości. Usługa tłumaczenia może być zajęta lub niedostępna.",
|
||||
};
|
||||
@@ -129,6 +129,23 @@ body {
|
||||
.wmc-editor:empty::before {
|
||||
content: "";
|
||||
}
|
||||
/* Sentence completion: the suggested rest of the sentence, in grey, with a
|
||||
small "Tab" key after it (components/compose/completion.ts). */
|
||||
.wmc-editor .wmc-ghost {
|
||||
color: rgb(var(--app-faint));
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
.wmc-editor .wmc-ghost::after {
|
||||
content: attr(data-hint);
|
||||
margin-left: 6px;
|
||||
padding: 0 4px;
|
||||
border: 1px solid rgb(var(--app-border));
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
line-height: 14px;
|
||||
vertical-align: 1px;
|
||||
}
|
||||
.wmc-editor a {
|
||||
color: rgb(var(--app-accent));
|
||||
text-decoration: underline;
|
||||
@@ -246,6 +263,36 @@ html[data-offline] .wm-online-only:hover {
|
||||
}
|
||||
}
|
||||
|
||||
/* The line across a message while it is being translated (MessageBody): a
|
||||
blue stripe running left to right along a white track. */
|
||||
.wm-progress {
|
||||
position: relative;
|
||||
height: 4px;
|
||||
overflow: hidden;
|
||||
background-color: #fff;
|
||||
}
|
||||
.wm-progress::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
width: 40%;
|
||||
background-color: #2563eb;
|
||||
animation: wm-progress-run 1.2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes wm-progress-run {
|
||||
from {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(250%);
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.wm-progress::before {
|
||||
animation-duration: 3s;
|
||||
}
|
||||
}
|
||||
|
||||
/* Attachment tiles (thread/Attachments.tsx) shrink with the reading column so
|
||||
three fit in a row; their name bar adapts to the tile's own width. */
|
||||
.wm-tile {
|
||||
|
||||
@@ -23,6 +23,9 @@ import type {
|
||||
RegistrationStatus,
|
||||
RemoteDraftOut,
|
||||
SavedSearch,
|
||||
MailTemplate,
|
||||
Subscription,
|
||||
SubscriptionList,
|
||||
SearchOut,
|
||||
SendResponse,
|
||||
ServerConfig,
|
||||
@@ -294,11 +297,22 @@ export const api = {
|
||||
| "invitation_file"
|
||||
| "spellcheck_enabled"
|
||||
| "spellcheck_language"
|
||||
| "shortcuts_enabled"
|
||||
| "block_trackers"
|
||||
| "completion_enabled"
|
||||
| "ui_language"
|
||||
>
|
||||
>,
|
||||
) =>
|
||||
apiFetch<UserSettings>("/settings", { method: "PATCH", body: JSON.stringify(patch) }),
|
||||
quota: () => apiFetch<QuotaOut>("/settings/quota"),
|
||||
// Sentence completion: the grey rest of the sentence (app/completion.py). A POST
|
||||
// so the text being typed never appears in a URL (or an access log).
|
||||
complete: (before: string) =>
|
||||
apiFetch<{ suggestion: string | null }>("/completion", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ before }),
|
||||
}),
|
||||
spellcheck: (text: string, language: string) =>
|
||||
apiFetch<SpellCheckOut>("/spellcheck", {
|
||||
method: "POST",
|
||||
@@ -356,6 +370,47 @@ export const api = {
|
||||
deleteSavedSearch: (id: string) =>
|
||||
apiFetch<{ status: string }>(`/saved-searches/${id}`, { method: "DELETE" }),
|
||||
|
||||
// --- snooze ---
|
||||
snoozeThread: (threadId: string, until: Date) =>
|
||||
apiFetch<{ thread_id: string; snoozed_until: string | null }>(`/threads/${threadId}/snooze`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ until: until.toISOString() }),
|
||||
}),
|
||||
unsnoozeThread: (threadId: string) =>
|
||||
apiFetch<{ thread_id: string }>(`/threads/${threadId}/unsnooze`, { method: "POST" }),
|
||||
|
||||
// --- subscriptions (bulk senders) ---
|
||||
subscriptions: (offset = 0, limit = 50, order: "asc" | "desc" = "desc") =>
|
||||
apiFetch<SubscriptionList>(`/subscriptions?offset=${offset}&limit=${limit}&order=${order}`),
|
||||
subscriptionThreads: (key: string) =>
|
||||
apiFetch<string[]>(`/subscriptions/threads?key=${encodeURIComponent(key)}`),
|
||||
unsubscribe: (key: string, messageId?: string) =>
|
||||
apiFetch<{ status: "done" | "open"; method: Subscription["method"]; url: string | null }>(
|
||||
"/subscriptions/unsubscribe",
|
||||
{ method: "POST", body: JSON.stringify({ key, message_id: messageId ?? null }) },
|
||||
),
|
||||
trashSubscription: (key: string) =>
|
||||
apiFetch<{ moved: number }>("/subscriptions/trash", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ key }),
|
||||
}),
|
||||
|
||||
// --- email translation (app/translation.py) ---
|
||||
translationLanguages: () =>
|
||||
apiFetch<{ available: boolean; languages: { code: string; name: string }[] }>(
|
||||
"/translation/languages",
|
||||
),
|
||||
|
||||
// --- templates (saved replies) ---
|
||||
templates: () => apiFetch<MailTemplate[]>("/templates"),
|
||||
saveTemplate: (name: string, subject: string, bodyHtml: string) =>
|
||||
apiFetch<MailTemplate>("/templates", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name, subject, body_html: bodyHtml }),
|
||||
}),
|
||||
deleteTemplate: (id: string) =>
|
||||
apiFetch<{ status: string }>(`/templates/${id}`, { method: "DELETE" }),
|
||||
|
||||
// --- compose ---
|
||||
suggestRecipients: (q: string) =>
|
||||
apiFetch<RecipientSuggestion[]>(`/recipients/suggest?q=${encodeURIComponent(q)}`),
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
import { api } from "./api";
|
||||
import { getDateLocale, getHour12 } from "./timeFormat";
|
||||
import type { Address, AttachmentOut, MessageEnvelope } from "./types";
|
||||
import { notify } from "../components/AppDialog";
|
||||
import { translate } from "../i18n";
|
||||
|
||||
function esc(s: string): string {
|
||||
return s
|
||||
@@ -71,18 +73,18 @@ function metaRow(label: string, value: string): string {
|
||||
|
||||
function section(msg: MessageEnvelope, subject: string, body: string, atts: AttachmentOut[]): string {
|
||||
const attachLine = atts.length
|
||||
? `<tr><th>Attachments</th><td>${atts
|
||||
? `<tr><th>${esc(translate("print.attachments"))}</th><td>${atts
|
||||
.map((a) => `${esc(a.filename)} (${fmtBytes(a.size_bytes)})`)
|
||||
.join(", ")}</td></tr>`
|
||||
: "";
|
||||
return `<section class="wmc-print-msg">
|
||||
<div class="wmc-print-head">
|
||||
<div class="wmc-print-subject">${esc(subject || "(no subject)")}</div>
|
||||
<div class="wmc-print-subject">${esc(subject || translate("compose.noSubject"))}</div>
|
||||
<table class="wmc-print-meta">
|
||||
${metaRow("From", fmtList(msg.from_addr))}
|
||||
${metaRow("To", fmtList(msg.to_addr))}
|
||||
${metaRow("Cc", fmtList(msg.cc_addr))}
|
||||
${metaRow("Date", fmtDate(msg.date))}
|
||||
${metaRow(translate("mail.from"), fmtList(msg.from_addr))}
|
||||
${metaRow(translate("compose.to"), fmtList(msg.to_addr))}
|
||||
${metaRow(translate("compose.cc"), fmtList(msg.cc_addr))}
|
||||
${metaRow(translate("mail.dateLabel"), fmtDate(msg.date))}
|
||||
${attachLine}
|
||||
</table>
|
||||
</div>
|
||||
@@ -165,7 +167,7 @@ export async function printMessages(subject: string, messages: MessageEnvelope[]
|
||||
// Open the window synchronously (inside the click) so it isn't blocked as a popup.
|
||||
const win = window.open("", "_blank", "width=820,height=1000");
|
||||
if (!win) {
|
||||
window.alert("Please allow pop-ups for this site to print.");
|
||||
notify(translate("print.popupBlocked"), 6000);
|
||||
return;
|
||||
}
|
||||
const title = esc(subject || "(no subject)");
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { MessageKey } from "../i18n/en";
|
||||
|
||||
// Every keyboard shortcut of the mail view, as shown in Settings → Keyboard
|
||||
// shortcuts and the "?" overlay. The handling lives in useKeyboardShortcuts.ts;
|
||||
// keep the two in step. "g i" is a sequence: g, then i.
|
||||
export interface ShortcutItem {
|
||||
keys: string[];
|
||||
label: MessageKey;
|
||||
}
|
||||
|
||||
export const SHORTCUT_GROUPS: { title: MessageKey; items: ShortcutItem[] }[] = [
|
||||
{
|
||||
title: "shortcuts.groupNavigation",
|
||||
items: [
|
||||
{ keys: ["j"], label: "shortcuts.next" },
|
||||
{ keys: ["k"], label: "shortcuts.prev" },
|
||||
{ keys: ["u"], label: "shortcuts.back" },
|
||||
{ keys: ["/"], label: "shortcuts.search" },
|
||||
{ keys: ["g", "i"], label: "shortcuts.goInbox" },
|
||||
{ keys: ["g", "z"], label: "shortcuts.goSnoozed" },
|
||||
{ keys: ["g", "d"], label: "shortcuts.goDrafts" },
|
||||
{ keys: ["g", "t"], label: "shortcuts.goSent" },
|
||||
{ keys: ["g", "b"], label: "shortcuts.goSubscriptions" },
|
||||
{ keys: ["?"], label: "shortcuts.help" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "shortcuts.groupConversation",
|
||||
items: [
|
||||
{ keys: ["r"], label: "shortcuts.reply" },
|
||||
{ keys: ["a"], label: "shortcuts.replyAll" },
|
||||
{ keys: ["f"], label: "shortcuts.forward" },
|
||||
{ keys: ["e"], label: "shortcuts.archive" },
|
||||
{ keys: ["#"], label: "shortcuts.trash" },
|
||||
{ keys: ["s"], label: "shortcuts.pin" },
|
||||
{ keys: ["b"], label: "shortcuts.snooze" },
|
||||
{ keys: ["Shift", "I"], label: "shortcuts.markRead" },
|
||||
{ keys: ["Shift", "U"], label: "shortcuts.markUnread" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "shortcuts.groupCompose",
|
||||
items: [
|
||||
{ keys: ["c"], label: "shortcuts.compose" },
|
||||
{ keys: ["Esc"], label: "shortcuts.escape" },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -27,11 +27,19 @@ export function getHour12(): boolean {
|
||||
return hour12;
|
||||
}
|
||||
|
||||
// The interface language (i18n/index.tsx): dates are written in it.
|
||||
let dateLanguage = "en";
|
||||
export function setDateLanguage(code: string): void {
|
||||
dateLanguage = code;
|
||||
}
|
||||
|
||||
/**
|
||||
* Locale for date formatting, tied to the time preference: 24-hour → en-GB
|
||||
* (day/month/year, European), 12-hour → en-US (month/day/year, American).
|
||||
* Locale for date formatting. In English it follows the time preference:
|
||||
* 24-hour → en-GB (day/month/year, European), 12-hour → en-US (month/day/year,
|
||||
* American). Other languages use their own (Polish: pl-PL).
|
||||
*/
|
||||
export function getDateLocale(): string {
|
||||
if (dateLanguage === "pl") return "pl-PL";
|
||||
return hour12 ? "en-US" : "en-GB";
|
||||
}
|
||||
|
||||
|
||||
@@ -91,6 +91,8 @@ export interface ThreadSummary {
|
||||
has_attachments: boolean;
|
||||
pinned: boolean;
|
||||
last_message_at: string;
|
||||
/** While snoozed: when it comes back to the Inbox. */
|
||||
snoozed_until?: string | null;
|
||||
}
|
||||
|
||||
export interface ThreadListOut {
|
||||
@@ -126,6 +128,12 @@ export interface MessageEnvelope {
|
||||
size_bytes: number;
|
||||
snippet: string;
|
||||
also_in_account_ids: string[];
|
||||
/** Bulk mail: its Subscriptions group, how it can be left, and whether it was. */
|
||||
subscription_key?: string | null;
|
||||
unsubscribe_via?: "one_click" | "mailto" | "web" | null;
|
||||
unsubscribed?: boolean;
|
||||
/** Its language once found (the first time it is shown); "" unknown. */
|
||||
content_language?: string;
|
||||
}
|
||||
|
||||
export interface ThreadDetail {
|
||||
@@ -134,6 +142,8 @@ export interface ThreadDetail {
|
||||
order: "asc" | "desc";
|
||||
pinned: boolean;
|
||||
messages: MessageEnvelope[];
|
||||
/** While snoozed: when it comes back to the Inbox. */
|
||||
snoozed_until?: string | null;
|
||||
}
|
||||
|
||||
export type SyncWindow = "all" | "3months" | "new";
|
||||
@@ -284,6 +294,14 @@ export interface UserSettings {
|
||||
spellcheck_enabled: boolean;
|
||||
/** "auto", or a LanguageTool code such as "pl-PL". */
|
||||
spellcheck_language: string;
|
||||
/** Gmail-style keyboard shortcuts in the mail view. */
|
||||
shortcuts_enabled: boolean;
|
||||
/** Remove tracking pixels from mail (Settings → Privacy). */
|
||||
block_trackers: boolean;
|
||||
/** Grey sentence endings in the compose window, taken with Tab. */
|
||||
completion_enabled: boolean;
|
||||
/** Interface language ("en", "pl"…); "" until first chosen. */
|
||||
ui_language: string;
|
||||
}
|
||||
|
||||
/** One problem the spell checker found, by character offset in the checked text. */
|
||||
@@ -310,9 +328,48 @@ export interface QuotaOut {
|
||||
|
||||
export interface FolderCounts {
|
||||
inbox_unread: number;
|
||||
snoozed?: number;
|
||||
drafts: number;
|
||||
}
|
||||
|
||||
/** A bulk sender mailing the user (Subscriptions page). */
|
||||
export interface Subscription {
|
||||
key: string;
|
||||
name: string;
|
||||
email: string;
|
||||
list_id: string | null;
|
||||
total: number;
|
||||
unread: number;
|
||||
last_30_days: number;
|
||||
per_month: number;
|
||||
first_at: string;
|
||||
last_at: string;
|
||||
last_subject: string;
|
||||
one_click: boolean;
|
||||
has_mailto: boolean;
|
||||
has_web: boolean;
|
||||
unsubscribed_at: string | null;
|
||||
method: "one_click" | "mailto" | "web" | null;
|
||||
still_sending: boolean;
|
||||
}
|
||||
|
||||
export interface SubscriptionList {
|
||||
/** One page of senders. */
|
||||
subscriptions: Subscription[];
|
||||
/** Senders in all. */
|
||||
total: number;
|
||||
/** Messages whose list headers are still being read (older mail). */
|
||||
pending: number;
|
||||
}
|
||||
|
||||
export interface MailTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
subject: string;
|
||||
body_html: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface SavedSearch {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
@@ -1,15 +1,28 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
// Keyboard shortcuts (SPEC §9.4). Ignores keystrokes while typing in inputs,
|
||||
// except Escape. Supports the `g i` chord (go to inbox).
|
||||
// Keyboard shortcuts (SPEC §9.4; the list users see is lib/shortcuts.ts).
|
||||
// Ignores keystrokes while typing in inputs, except Escape. "g" starts a
|
||||
// two-key sequence (g i = Inbox). With `enabled` false only Escape works.
|
||||
export interface Shortcuts {
|
||||
enabled?: boolean;
|
||||
onSearch?: () => void;
|
||||
onCompose?: () => void;
|
||||
onReply?: () => void;
|
||||
onReplyAll?: () => void;
|
||||
onForward?: () => void;
|
||||
onArchive?: () => void;
|
||||
onTrash?: () => void;
|
||||
onTogglePin?: () => void;
|
||||
onSnooze?: () => void;
|
||||
onMarkRead?: () => void;
|
||||
onMarkUnread?: () => void;
|
||||
onBack?: () => void;
|
||||
onGoInbox?: () => void;
|
||||
onGoSnoozed?: () => void;
|
||||
onGoDrafts?: () => void;
|
||||
onGoSent?: () => void;
|
||||
onGoSubscriptions?: () => void;
|
||||
onHelp?: () => void;
|
||||
onEscape?: () => void;
|
||||
onNext?: () => void;
|
||||
onPrev?: () => void;
|
||||
@@ -18,7 +31,7 @@ export interface Shortcuts {
|
||||
function isTyping(el: EventTarget | null): boolean {
|
||||
if (!(el instanceof HTMLElement)) return false;
|
||||
const tag = el.tagName;
|
||||
return tag === "INPUT" || tag === "TEXTAREA" || el.isContentEditable;
|
||||
return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || el.isContentEditable;
|
||||
}
|
||||
|
||||
export function useKeyboardShortcuts(shortcuts: Shortcuts): void {
|
||||
@@ -33,49 +46,54 @@ export function useKeyboardShortcuts(shortcuts: Shortcuts): void {
|
||||
s.onEscape?.();
|
||||
return;
|
||||
}
|
||||
if (isTyping(e.target)) return;
|
||||
if (s.enabled === false || isTyping(e.target)) return;
|
||||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
|
||||
const run = (fn: (() => void) | undefined) => {
|
||||
if (!fn) return;
|
||||
e.preventDefault();
|
||||
fn();
|
||||
};
|
||||
|
||||
if (pendingG.current) {
|
||||
pendingG.current = false;
|
||||
if (e.key === "i") {
|
||||
e.preventDefault();
|
||||
s.onGoInbox?.();
|
||||
const go: Record<string, (() => void) | undefined> = {
|
||||
i: s.onGoInbox,
|
||||
z: s.onGoSnoozed,
|
||||
d: s.onGoDrafts,
|
||||
t: s.onGoSent,
|
||||
b: s.onGoSubscriptions,
|
||||
};
|
||||
if (e.key in go) {
|
||||
run(go[e.key]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case "/":
|
||||
e.preventDefault();
|
||||
s.onSearch?.();
|
||||
break;
|
||||
case "c":
|
||||
s.onCompose?.();
|
||||
break;
|
||||
case "r":
|
||||
s.onReply?.();
|
||||
break;
|
||||
case "a":
|
||||
s.onReplyAll?.();
|
||||
break;
|
||||
case "f":
|
||||
s.onForward?.();
|
||||
break;
|
||||
case "e":
|
||||
s.onArchive?.();
|
||||
break;
|
||||
case "j":
|
||||
s.onNext?.();
|
||||
break;
|
||||
case "k":
|
||||
s.onPrev?.();
|
||||
break;
|
||||
case "g":
|
||||
pendingG.current = true;
|
||||
window.setTimeout(() => (pendingG.current = false), 800);
|
||||
break;
|
||||
// Shift+letter arrives as the capital letter.
|
||||
const actions: Record<string, (() => void) | undefined> = {
|
||||
"/": s.onSearch,
|
||||
"?": s.onHelp,
|
||||
c: s.onCompose,
|
||||
r: s.onReply,
|
||||
a: s.onReplyAll,
|
||||
f: s.onForward,
|
||||
e: s.onArchive,
|
||||
"#": s.onTrash,
|
||||
s: s.onTogglePin,
|
||||
b: s.onSnooze,
|
||||
I: s.onMarkRead,
|
||||
U: s.onMarkUnread,
|
||||
u: s.onBack,
|
||||
j: s.onNext,
|
||||
k: s.onPrev,
|
||||
};
|
||||
if (e.key === "g") {
|
||||
pendingG.current = true;
|
||||
window.setTimeout(() => (pendingG.current = false), 1000);
|
||||
return;
|
||||
}
|
||||
if (e.key in actions) run(actions[e.key]);
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
|
||||
@@ -38,6 +38,17 @@ import { ThreadView } from "../components/thread/ThreadView";
|
||||
import { MailboxDisplay } from "../components/mail/MailboxDisplay";
|
||||
import { RightPanel, type CalendarFocus, type PanelTab } from "../components/mail/RightPanel";
|
||||
import { ScheduledSendPane } from "../components/mail/ScheduledSendPane";
|
||||
import { SnoozeMenu } from "../components/mail/SnoozeMenu";
|
||||
import { clearSnapshots, restoredWindows, saveWindowList } from "../components/compose/restore";
|
||||
import { useConfirm, usePrompt } from "../components/AppDialog";
|
||||
import { SubscriptionPane } from "../components/mail/SubscriptionPane";
|
||||
import {
|
||||
FinishOnPageDialog,
|
||||
SubscriptionBulkButtons,
|
||||
SubscriptionsList,
|
||||
useBulkSubscriptionActions,
|
||||
} from "../components/list/SubscriptionsList";
|
||||
import { ShortcutsHelp } from "../components/ShortcutList";
|
||||
import { useT } from "../i18n";
|
||||
import type { MessageKey } from "../i18n/en";
|
||||
import { api, isOfflineError } from "../lib/api";
|
||||
@@ -76,11 +87,13 @@ const COLLAPSED_W = 60;
|
||||
// Unified per-folder views. "Spam" maps to the IMAP special-use "junk".
|
||||
const FOLDERS: { view: string; icon: string; label: MessageKey }[] = [
|
||||
{ view: "folder:inbox", icon: "inbox", label: "nav.inbox" },
|
||||
{ view: "snoozed", icon: "snooze", label: "nav.snoozed" },
|
||||
{ view: "folder:archive", icon: "archive", label: "nav.archive" },
|
||||
{ view: "folder:drafts", icon: "drafts", label: "nav.drafts" },
|
||||
{ view: "folder:sent", icon: "sent", label: "nav.sent" },
|
||||
{ view: "folder:junk", icon: "spam", label: "nav.spam" },
|
||||
{ view: "folder:trash", icon: "trash", label: "nav.trash" },
|
||||
{ view: "subscriptions", icon: "subscriptions", label: "nav.subscriptions" },
|
||||
];
|
||||
|
||||
// Phone swipes still in their Undo window, remembered across a refresh: the
|
||||
@@ -168,8 +181,15 @@ export function MailLayout() {
|
||||
});
|
||||
// Every open compose window, oldest first — the dock lays them out left to
|
||||
// right and keeps each one mounted for its whole life.
|
||||
const [composeWins, setComposeWins] = useState<ComposeWin[]>([]);
|
||||
const composeSeq = useRef(0);
|
||||
// Brought back from before Settings (or a reload) — compose/restore.ts.
|
||||
const [composeWins, setComposeWins] = useState<ComposeWin[]>(restoredWindows);
|
||||
useEffect(() => saveWindowList(composeWins), [composeWins]);
|
||||
useEffect(() => clearSnapshots(), []);
|
||||
// Past every restored window's id and activation, so a new window ("c<n>")
|
||||
// never takes a restored one's id.
|
||||
const composeSeq = useRef(
|
||||
Math.max(0, ...composeWins.map((w) => Math.max(w.activated, Number(w.id.slice(1)) || 0))),
|
||||
);
|
||||
// A draft opened from the Drafts folder is edited in the reading pane, the
|
||||
// way a thread is read there — not in a floating window.
|
||||
const [paneDraft, setPaneDraft] = useState<ComposerInit | null>(null);
|
||||
@@ -273,6 +293,69 @@ export function MailLayout() {
|
||||
// Messages written offline: drafts kept on this device, and the Scheduled
|
||||
// folder of ones sent offline (lib/scheduled.ts).
|
||||
const isScheduled = view === "local:scheduled";
|
||||
// Subscriptions: bulk senders, listed in place of conversations (api/subscriptions.py).
|
||||
const isSubscriptions = view === "subscriptions";
|
||||
const showSubscriptions = isSubscriptions && !term.trim();
|
||||
// Its own sort order (newest / oldest latest mail), remembered apart from the
|
||||
// conversations' so that flipping one leaves the other alone.
|
||||
const [subOrder, setSubOrder] = useState<"newest" | "oldest">(() => {
|
||||
try {
|
||||
return localStorage.getItem("subs.order") === "oldest" ? "oldest" : "newest";
|
||||
} catch {
|
||||
return "newest";
|
||||
}
|
||||
});
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem("subs.order", subOrder);
|
||||
} catch {
|
||||
/* storage blocked: the order lasts for this page only */
|
||||
}
|
||||
}, [subOrder]);
|
||||
const subscriptionsQuery = useQuery({
|
||||
queryKey: ["subscriptions", page, subOrder],
|
||||
queryFn: () =>
|
||||
api.subscriptions(page * PAGE_SIZE, PAGE_SIZE, subOrder === "newest" ? "desc" : "asc"),
|
||||
enabled: isSubscriptions,
|
||||
placeholderData: keepPreviousData, // the current page stays while the next loads
|
||||
// Older mail is still being read for list headers: look again until done.
|
||||
refetchInterval: (q) => ((q.state.data?.pending ?? 0) > 0 ? 4000 : false),
|
||||
});
|
||||
const [subKey, setSubKey] = useState<string | null>(null);
|
||||
// Checked senders, for the bulk Unsubscribe / Delete all.
|
||||
const [subChecked, setSubChecked] = useState<Set<string>>(new Set());
|
||||
// Senders deleted from the last page can leave it empty: step back to one with rows.
|
||||
const subsPageEmpty =
|
||||
page > 0 && subscriptionsQuery.data?.subscriptions.length === 0 && subscriptionsQuery.data.total > 0;
|
||||
useEffect(() => {
|
||||
if (isSubscriptions && subsPageEmpty) setPage((p) => Math.max(0, p - 1));
|
||||
}, [isSubscriptions, subsPageEmpty]);
|
||||
// A selection belongs to the page it was made on, as with conversations.
|
||||
useEffect(() => {
|
||||
setSubKey(null);
|
||||
setSubChecked(new Set());
|
||||
}, [view, page]);
|
||||
const subBulk = useBulkSubscriptionActions(() => setSubChecked(new Set()));
|
||||
const checkedSubs = (subscriptionsQuery.data?.subscriptions ?? []).filter((x) => subChecked.has(x.key));
|
||||
const toggleSubCheck = (key: string) =>
|
||||
setSubChecked((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
const allSubsChecked =
|
||||
(subscriptionsQuery.data?.subscriptions.length ?? 0) > 0 &&
|
||||
(subscriptionsQuery.data?.subscriptions ?? []).every((x) => subChecked.has(x.key));
|
||||
const toggleAllSubs = () => {
|
||||
const all = subscriptionsQuery.data?.subscriptions ?? [];
|
||||
setSubChecked((prev) =>
|
||||
all.length > 0 && all.every((x) => prev.has(x.key)) ? new Set() : new Set(all.map((x) => x.key)),
|
||||
);
|
||||
};
|
||||
const selectedSub = isSubscriptions
|
||||
? (subscriptionsQuery.data?.subscriptions.find((x) => x.key === subKey) ?? null)
|
||||
: null;
|
||||
const localMessages = useLocalMessages();
|
||||
const deviceDrafts = localMessages.filter((m) => m.state === "draft");
|
||||
// Plus sends the mail server could not take, retrying on the server (or failed).
|
||||
@@ -299,7 +382,7 @@ export function MailLayout() {
|
||||
}
|
||||
return api.threads(view, { offset: page * PAGE_SIZE, limit: PAGE_SIZE, filter, order: orderParam });
|
||||
},
|
||||
enabled: !isDrafts && !isScheduled && !searching,
|
||||
enabled: !isDrafts && !isScheduled && !isSubscriptions && !searching,
|
||||
placeholderData: keepPreviousData, // keep the current page visible while the next loads
|
||||
});
|
||||
const draftsQuery = useQuery({
|
||||
@@ -376,8 +459,12 @@ export function MailLayout() {
|
||||
pageThreads = hiddenIds.size || swipedIds.size ? all.filter((th) => !isHidden(th.id)) : all;
|
||||
total = Math.max(0, (threadsQuery.data?.total ?? 0) - (all.length - pageThreads.length));
|
||||
}
|
||||
// The Subscriptions folder pages its senders the same way (server-side).
|
||||
const pagerTotal = showSubscriptions ? (subscriptionsQuery.data?.total ?? 0) : total;
|
||||
const pageStart = page * PAGE_SIZE;
|
||||
const pageEnd = pageStart + pageThreads.length;
|
||||
const pageEnd =
|
||||
pageStart +
|
||||
(showSubscriptions ? (subscriptionsQuery.data?.subscriptions.length ?? 0) : pageThreads.length);
|
||||
// Search: fetch further pages of results as far as the page shown needs (one
|
||||
// conversation more, to know whether a next page exists) — or all of them
|
||||
// for oldest-first, whose first page is the end of the results.
|
||||
@@ -394,7 +481,7 @@ export function MailLayout() {
|
||||
if (searchLoaded <= wanted && searchPages < MAX_SEARCH_PAGES) void fetchMoreSearch();
|
||||
}, [searching, searchHasMore, searchFetchingMore, fetchMoreSearch, searchLoaded, searchPages, page, listOrder]);
|
||||
// Reset to the first page whenever the list identity (not the page) changes.
|
||||
useEffect(() => setPage(0), [view, filter, debouncedTerm, listOrder]);
|
||||
useEffect(() => setPage(0), [view, filter, debouncedTerm, listOrder, subOrder]);
|
||||
|
||||
// Master "select all" state over the currently listed threads.
|
||||
const listedSelectedCount = pageThreads.filter((th) => checkedIds.has(th.id)).length;
|
||||
@@ -439,11 +526,21 @@ export function MailLayout() {
|
||||
mutationFn: (id: string) => api.deleteSavedSearch(id),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["saved-searches"] }),
|
||||
});
|
||||
const onSaveSearch = () => {
|
||||
// Our own dialogs (components/AppDialog.tsx) instead of the browser's.
|
||||
const [askConfirm, confirmDialog] = useConfirm();
|
||||
const [askText, promptDialog] = usePrompt();
|
||||
const onSaveSearch = async () => {
|
||||
const q = term.trim();
|
||||
if (!q) return;
|
||||
const name = window.prompt(t("search.saveNamePrompt"), q);
|
||||
if (name && name.trim()) saveSearch.mutate(name.trim());
|
||||
const name = await askText({
|
||||
title: t("search.save"),
|
||||
message: q,
|
||||
label: t("search.saveNameLabel"),
|
||||
initial: q,
|
||||
confirmLabel: t("common.save"),
|
||||
maxLength: 80,
|
||||
});
|
||||
if (name) saveSearch.mutate(name);
|
||||
};
|
||||
const runSavedSearch = (s: SavedSearch) => {
|
||||
setTerm(s.query);
|
||||
@@ -626,6 +723,51 @@ export function MailLayout() {
|
||||
queryClient.invalidateQueries({ queryKey: ["thread", detail.id] });
|
||||
};
|
||||
|
||||
// Snooze (app/snooze.py): the conversation leaves the Inbox until then.
|
||||
const [snoozeOpen, setSnoozeOpen] = useState(false);
|
||||
const closeSnooze = useCallback(() => setSnoozeOpen(false), []);
|
||||
const snoozeThread = async (when: Date) => {
|
||||
setSnoozeOpen(false);
|
||||
if (!threadId) return;
|
||||
const id = threadId;
|
||||
try {
|
||||
await api.snoozeThread(id, when);
|
||||
} catch {
|
||||
window.dispatchEvent(new CustomEvent("webmail:toast", { detail: { text: t("snooze.failed") } }));
|
||||
return;
|
||||
}
|
||||
const whenText = `${when.toLocaleDateString(getDateLocale(), { weekday: "short", day: "numeric", month: "short" })}, ${formatClock(when)}`;
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("webmail:toast", {
|
||||
detail: {
|
||||
text: t("snooze.done", { when: whenText }),
|
||||
ms: 6000,
|
||||
action: {
|
||||
label: t("common.undo"),
|
||||
run: async () => {
|
||||
await api.unsnoozeThread(id);
|
||||
queryClient.invalidateQueries({ queryKey: ["threads"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["folder-counts"] });
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
afterMove();
|
||||
};
|
||||
const unsnooze = async () => {
|
||||
if (!threadId) return;
|
||||
await api.unsnoozeThread(threadId);
|
||||
queryClient.invalidateQueries({ queryKey: ["thread", threadId] });
|
||||
afterMove();
|
||||
};
|
||||
const markUnread = async () => {
|
||||
if (!threadId) return;
|
||||
await api.threadFlags(threadId, [], ["\\Seen"]);
|
||||
afterMove(); // back to the list: staying open would mark it read again
|
||||
};
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
|
||||
// Per-thread actions for the mail-list hover buttons (act on a given id, not the
|
||||
// currently-open thread). Close the reading pane only if the acted thread is open.
|
||||
//
|
||||
@@ -873,7 +1015,13 @@ export function MailLayout() {
|
||||
// work runs in a worker; refresh the views after it and via the WS message.deleted.
|
||||
const [emptying, setEmptying] = useState(false);
|
||||
const emptyCurrentFolder = async () => {
|
||||
if (!window.confirm(t("mail.emptyConfirm"))) return;
|
||||
const ok = await askConfirm({
|
||||
title: currentFolder === "trash" ? t("mail.emptyTrash") : t("mail.emptySpam"),
|
||||
message: t("mail.emptyConfirm"),
|
||||
confirmLabel: t("mail.emptyAction"),
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
setEmptying(true);
|
||||
try {
|
||||
await api.emptyFolder(currentFolder);
|
||||
@@ -910,14 +1058,29 @@ export function MailLayout() {
|
||||
};
|
||||
|
||||
useKeyboardShortcuts({
|
||||
enabled: settingsQuery.data?.shortcuts_enabled ?? true,
|
||||
onSearch: () => searchRef.current?.focus(),
|
||||
onCompose: () => accountsQuery.data?.[0] && openComposer({ accountId: accountsQuery.data[0].id, kind: "new" }),
|
||||
onReply: () => setReplySignal({ kind: "reply", seq: Date.now() }),
|
||||
onReplyAll: () => setReplySignal({ kind: "reply_all", seq: Date.now() }),
|
||||
onForward: () => setReplySignal({ kind: "forward", seq: Date.now() }),
|
||||
onArchive: archive,
|
||||
onTrash: () => currentFolder !== "trash" && void trash(),
|
||||
onTogglePin: () => void togglePin(),
|
||||
onSnooze: () => threadId && setSnoozeOpen(true),
|
||||
onMarkRead: () => threadId && markRead.mutate(threadId),
|
||||
onMarkUnread: () => void markUnread(),
|
||||
onBack: () => navigate("/mail"),
|
||||
onGoInbox: () => selectView("folder:inbox"),
|
||||
onEscape: () => setComposeWins(minimizeAll),
|
||||
onGoSnoozed: () => selectView("snoozed"),
|
||||
onGoDrafts: () => selectView("folder:drafts"),
|
||||
onGoSent: () => selectView("folder:sent"),
|
||||
onGoSubscriptions: () => selectView("subscriptions"),
|
||||
onHelp: () => setHelpOpen(true),
|
||||
onEscape: () => {
|
||||
if (helpOpen) setHelpOpen(false);
|
||||
else setComposeWins(minimizeAll);
|
||||
},
|
||||
onNext: () => openRelative(1),
|
||||
onPrev: () => openRelative(-1),
|
||||
});
|
||||
@@ -998,7 +1161,7 @@ export function MailLayout() {
|
||||
}, [queryClient]);
|
||||
|
||||
// On a phone the reading pane replaces the list rather than sitting beside it.
|
||||
const paneShown = Boolean(paneDraft || threadId);
|
||||
const paneShown = Boolean(paneDraft || threadId || selectedSub);
|
||||
|
||||
return (
|
||||
<div className={`flex bg-app-bg text-app-fg ${mobile ? "h-[100dvh]" : "h-screen"}`}>
|
||||
@@ -1068,7 +1231,9 @@ export function MailLayout() {
|
||||
const badge =
|
||||
f.view === "folder:inbox"
|
||||
? countsQuery.data?.inbox_unread
|
||||
: f.view === "folder:drafts"
|
||||
: f.view === "snoozed"
|
||||
? countsQuery.data?.snoozed || undefined
|
||||
: f.view === "folder:drafts"
|
||||
? (countsQuery.data?.drafts ?? 0) + deviceDrafts.length || undefined
|
||||
: undefined;
|
||||
return (
|
||||
@@ -1079,7 +1244,15 @@ export function MailLayout() {
|
||||
title={collapsed ? t(f.label) : undefined}
|
||||
onClick={() => selectView(f.view)}
|
||||
>
|
||||
<Icon name={f.icon} size={f.icon === "trash" ? 20 : 18} />
|
||||
{f.icon === "snooze" ? (
|
||||
<span className="flex w-[18px] justify-center">
|
||||
<Clock size={18} />
|
||||
</span>
|
||||
) : f.icon === "subscriptions" ? (
|
||||
<SubscriptionsIcon />
|
||||
) : (
|
||||
<Icon name={f.icon} size={f.icon === "trash" ? 20 : 18} />
|
||||
)}
|
||||
{!collapsed && <span className="flex-1 truncate">{t(f.label)}</span>}
|
||||
{!collapsed && badge ? (
|
||||
<span className="ml-auto shrink-0 tabular-nums text-[13px] text-app-muted">{badge}</span>
|
||||
@@ -1351,7 +1524,84 @@ export function MailLayout() {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{!isDrafts && !isScheduled && (
|
||||
{showSubscriptions && (
|
||||
// The Subscriptions folder's own bar, built like the conversations' one.
|
||||
<div
|
||||
className={`flex h-[52px] shrink-0 items-center gap-5 border-b border-app-border bg-app-bg text-[15px] ${
|
||||
mobile ? "px-3" : "px-6"
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("mail.selectAll")}
|
||||
title={t("mail.selectAll")}
|
||||
onClick={toggleAllSubs}
|
||||
disabled={!subscriptionsQuery.data?.subscriptions.length}
|
||||
className={`${mobile ? "hidden" : "flex"} ml-[28px] h-[17px] w-[17px] shrink-0 items-center justify-center rounded-md border-2 transition-colors disabled:opacity-40 ${
|
||||
allSubsChecked || subChecked.size > 0
|
||||
? "border-app-accent bg-app-accent text-white"
|
||||
: "border-app-faint hover:border-app-muted"
|
||||
}`}
|
||||
>
|
||||
{allSubsChecked ? (
|
||||
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3.5 8.5l3 3 6-7" />
|
||||
</svg>
|
||||
) : subChecked.size > 0 ? (
|
||||
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round">
|
||||
<path d="M4 8h8" />
|
||||
</svg>
|
||||
) : null}
|
||||
</button>
|
||||
<span className="text-app-muted">
|
||||
{subChecked.size > 0
|
||||
? t("subs.selected", { count: subChecked.size })
|
||||
: t("subs.senders", { count: subscriptionsQuery.data?.total ?? 0 })}
|
||||
</span>
|
||||
<div className="-mr-1.5 ml-auto flex items-center gap-1">
|
||||
<button
|
||||
className="rounded p-1.5 text-app-muted hover:bg-app-hover hover:text-app-fg"
|
||||
title={subOrder === "newest" ? t("filter.sortOldest") : t("filter.sortNewest")}
|
||||
aria-label={subOrder === "newest" ? t("filter.sortOldest") : t("filter.sortNewest")}
|
||||
onClick={() => setSubOrder((o) => (o === "newest" ? "oldest" : "newest"))}
|
||||
>
|
||||
<SortIcon />
|
||||
</button>
|
||||
<div className="relative">
|
||||
<button
|
||||
className="rounded p-1.5 text-app-muted hover:bg-app-hover hover:text-app-fg"
|
||||
title={t("mail.more")}
|
||||
aria-label={t("mail.more")}
|
||||
onClick={() => setMoreOpen((o) => !o)}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
|
||||
<circle cx="5" cy="12" r="1.6" />
|
||||
<circle cx="12" cy="12" r="1.6" />
|
||||
<circle cx="19" cy="12" r="1.6" />
|
||||
</svg>
|
||||
</button>
|
||||
{moreOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setMoreOpen(false)} />
|
||||
<div className="absolute right-0 z-20 mt-1 min-w-[180px] rounded-lg border border-app-border bg-app-surface py-1 shadow-lg">
|
||||
<button
|
||||
className="flex w-full items-center px-3 py-2 text-left text-[13px] text-app-fg hover:bg-app-hover"
|
||||
onClick={() => {
|
||||
setMoreOpen(false);
|
||||
setDisplayOpen(true);
|
||||
}}
|
||||
>
|
||||
{t("display.title")}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{displayOpen && <MailboxDisplay onClose={closeDisplay} layouts={!mobile} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isDrafts && !isScheduled && !showSubscriptions && (
|
||||
<div
|
||||
className={`flex h-[52px] shrink-0 items-center gap-5 border-b border-app-border bg-app-bg text-[15px] ${
|
||||
mobile ? "px-3" : "px-6"
|
||||
@@ -1461,6 +1711,13 @@ export function MailLayout() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{single && showSubscriptions && subChecked.size > 0 && (
|
||||
// As for conversations: with no pane beside the list, the bulk buttons sit above it.
|
||||
<div className="flex h-[48px] shrink-0 items-center gap-1 border-b border-app-border bg-app-surface px-2 text-[15px]">
|
||||
<span className="px-2 text-[13px] text-app-muted">{subChecked.size}</span>
|
||||
<SubscriptionBulkButtons selected={checkedSubs} bulk={subBulk} />
|
||||
</div>
|
||||
)}
|
||||
{single && checkedIds.size > 0 && (
|
||||
// Side by side, bulk actions show in the reading pane; with a single
|
||||
// panel there is none beside the list, so they sit above it.
|
||||
@@ -1485,6 +1742,20 @@ export function MailLayout() {
|
||||
if (threadId) navigate("/mail");
|
||||
}}
|
||||
/>
|
||||
) : showSubscriptions ? (
|
||||
<SubscriptionsList
|
||||
data={subscriptionsQuery.data}
|
||||
loading={subscriptionsQuery.isLoading}
|
||||
selectedKey={subKey}
|
||||
onSelect={(key) => {
|
||||
setSubKey(key);
|
||||
if (threadId) navigate("/mail");
|
||||
}}
|
||||
checkedKeys={subChecked}
|
||||
onToggleCheck={toggleSubCheck}
|
||||
busyKeys={subBulk.running ? subChecked : undefined}
|
||||
compact={mobile}
|
||||
/>
|
||||
) : isDrafts && !term.trim() ? (
|
||||
<DraftsList
|
||||
drafts={draftsQuery.data ?? []}
|
||||
@@ -1545,10 +1816,10 @@ export function MailLayout() {
|
||||
>
|
||||
<RefreshIcon spinning={refreshing} size={mobile ? 22.5 : 15} />
|
||||
</button>
|
||||
{!isDrafts && !isScheduled && total > 0 ? (
|
||||
{!isDrafts && !isScheduled && pagerTotal > 0 ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<span>
|
||||
{pageStart + 1}–{pageEnd} of {total}
|
||||
{pageStart + 1}–{pageEnd} of {pagerTotal}
|
||||
</span>
|
||||
<div className="-mr-1 flex items-center gap-0.5">
|
||||
<PagerButton
|
||||
@@ -1559,7 +1830,7 @@ export function MailLayout() {
|
||||
big={mobile}
|
||||
/>
|
||||
<PagerButton
|
||||
disabled={pageEnd >= total}
|
||||
disabled={pageEnd >= pagerTotal}
|
||||
onClick={() => setPage(page + 1)}
|
||||
label={t("pager.next")}
|
||||
dir="right"
|
||||
@@ -1614,6 +1885,15 @@ export function MailLayout() {
|
||||
onClose={closePaneDraft}
|
||||
fullWindow
|
||||
/>
|
||||
) : isSubscriptions && subChecked.size > 0 && !single ? (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className={paneBarClass}>
|
||||
<SubscriptionBulkButtons selected={checkedSubs} bulk={subBulk} />
|
||||
</div>
|
||||
<div className="flex flex-1 items-center justify-center text-3xl text-app-faint">
|
||||
{t("subs.sendersSelected", { count: subChecked.size })}
|
||||
</div>
|
||||
</div>
|
||||
) : checkedIds.size > 0 && !single ? (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className={paneBarClass}>
|
||||
@@ -1681,6 +1961,20 @@ export function MailLayout() {
|
||||
<Icon name="trash" size={16} />
|
||||
{t("mail.delete")}
|
||||
</ToolbarButton>
|
||||
{detailQuery.data.snoozed_until ? (
|
||||
<ToolbarButton onClick={unsnooze} active>
|
||||
<Clock size={16} />
|
||||
{t("snooze.unsnooze")}
|
||||
</ToolbarButton>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<ToolbarButton onClick={() => setSnoozeOpen((v) => !v)} active={snoozeOpen}>
|
||||
<Clock size={16} />
|
||||
{t("snooze.action")}
|
||||
</ToolbarButton>
|
||||
{snoozeOpen && <SnoozeMenu onPick={snoozeThread} onClose={closeSnooze} />}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<span className={`ml-auto text-[13px] text-app-faint ${mobile ? "hidden" : ""}`}>
|
||||
@@ -1746,6 +2040,13 @@ export function MailLayout() {
|
||||
<NotSavedOffline />
|
||||
</div>
|
||||
</div>
|
||||
) : selectedSub ? (
|
||||
<SubscriptionPane
|
||||
sub={selectedSub}
|
||||
barClass={paneBarClass}
|
||||
onBack={single ? () => setSubKey(null) : undefined}
|
||||
onOpenThread={(id) => navigate(`/mail/t/${id}`)}
|
||||
/>
|
||||
) : (
|
||||
// Clipped to the pane: dragged very narrow, the picture and the line
|
||||
// are cut at its edges instead of spilling over the list beside it.
|
||||
@@ -1823,6 +2124,13 @@ export function MailLayout() {
|
||||
/>
|
||||
<UndoSendToast mobile={mobile} onUndone={reopenUnsent} />
|
||||
<Toast mobile={mobile} />
|
||||
{helpOpen && <ShortcutsHelp onClose={() => setHelpOpen(false)} />}
|
||||
{confirmDialog}
|
||||
{promptDialog}
|
||||
{subBulk.dialog}
|
||||
{subBulk.finishOnPage.length > 0 && (
|
||||
<FinishOnPageDialog items={subBulk.finishOnPage} onClose={subBulk.closeFinish} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1985,6 +2293,19 @@ function SortIcon() {
|
||||
);
|
||||
}
|
||||
|
||||
// Subscriptions folder: a stack of envelopes (newsletters).
|
||||
function SubscriptionsIcon() {
|
||||
return (
|
||||
<span className="flex w-[18px] justify-center" aria-hidden>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="8" width="15" height="11" rx="2" />
|
||||
<path d="M3.5 9l7 5 7-5" />
|
||||
<path d="M7 5h11a3 3 0 0 1 3 3v8" />
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarItem({
|
||||
active,
|
||||
onClick,
|
||||
|
||||
@@ -7,7 +7,8 @@ import { RichTextEditor } from "../../components/compose/RichTextEditor";
|
||||
import { TypedPasswordInput } from "../../components/TypedPasswordInput";
|
||||
import { ListDividersOption } from "../../components/mail/ListDividersOption";
|
||||
import { ReadingPanePicker } from "../../components/mail/ReadingPanePicker";
|
||||
import { useT } from "../../i18n";
|
||||
import { LANGUAGES, translate, useLocale, useT } from "../../i18n";
|
||||
import type { MessageKey } from "../../i18n/en";
|
||||
import { api, ApiError } from "../../lib/api";
|
||||
import type { CalendarOut, ReminderPref } from "../../lib/types";
|
||||
import { useMe } from "../../lib/auth";
|
||||
@@ -28,8 +29,19 @@ import { formatClock, getDateLocale, setTimeFormat } from "../../lib/timeFormat"
|
||||
import { PluginSlot } from "../../plugins/PluginSlot";
|
||||
import { usePluginSettingsSections } from "../../plugins/manifest";
|
||||
import { AccountWizard } from "../wizard/AccountWizard";
|
||||
import { ShortcutList } from "../../components/ShortcutList";
|
||||
import { useConfirm } from "../../components/AppDialog";
|
||||
|
||||
const SECTIONS = ["profile", "security", "accounts", "calendars", "appearance", "integrations"] as const;
|
||||
const SECTIONS = [
|
||||
"profile",
|
||||
"security",
|
||||
"privacy",
|
||||
"accounts",
|
||||
"calendars",
|
||||
"appearance",
|
||||
"shortcuts",
|
||||
"integrations",
|
||||
] as const;
|
||||
type Section = (typeof SECTIONS)[number];
|
||||
|
||||
export function Settings() {
|
||||
@@ -60,9 +72,11 @@ export function Settings() {
|
||||
{addingAccount && <AccountWizard embedded onDone={() => navigate("/settings/accounts")} />}
|
||||
{!addingAccount && section === "profile" && <ProfileSection />}
|
||||
{!addingAccount && section === "security" && <SecuritySection />}
|
||||
{section === "privacy" && <PrivacySection />}
|
||||
{!addingAccount && section === "accounts" && <AccountsSection />}
|
||||
{section === "calendars" && <CalendarsSection />}
|
||||
{section === "appearance" && <AppearanceSection />}
|
||||
{section === "shortcuts" && <ShortcutsSection />}
|
||||
{section === "integrations" && <IntegrationsSection />}
|
||||
{/* Plugin-contributed settings UI (SPEC §11.3 settings.page): one section
|
||||
per plugin, selected from the nav like any built-in section. */}
|
||||
@@ -516,6 +530,7 @@ function OfflineAccessSetting() {
|
||||
*/
|
||||
function AppPasswordsSetting() {
|
||||
const t = useT();
|
||||
const [confirm, confirmDialog] = useConfirm();
|
||||
const me = useMe().data;
|
||||
const handle = me?.handle ?? "";
|
||||
const mailHost = me?.mail_host ?? null;
|
||||
@@ -559,6 +574,7 @@ function AppPasswordsSetting() {
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{confirmDialog}
|
||||
<h2 className="text-lg font-semibold">{t("appPasswords.title")}</h2>
|
||||
<p className="text-sm text-app-muted">{t(mailHost ? "appPasswords.helpMail" : "appPasswords.help")}</p>
|
||||
{/* What to type into a mail app (the hosted mailbox, SPEC App. D §D.5). */}
|
||||
@@ -643,8 +659,14 @@ function AppPasswordsSetting() {
|
||||
<button
|
||||
className="shrink-0 text-xs text-red-500 disabled:opacity-50"
|
||||
disabled={revoke.isPending}
|
||||
onClick={() => {
|
||||
if (confirm(t("appPasswords.confirmRevoke", { name: a.name }))) revoke.mutate(a.id);
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: t("settings.revoke"),
|
||||
message: t("appPasswords.confirmRevoke", { name: a.name }),
|
||||
confirmLabel: t("settings.revoke"),
|
||||
danger: true,
|
||||
});
|
||||
if (ok) revoke.mutate(a.id);
|
||||
}}
|
||||
>
|
||||
{t("settings.revoke")}
|
||||
@@ -865,6 +887,7 @@ function SecuritySection() {
|
||||
|
||||
function AccountsSection() {
|
||||
const t = useT();
|
||||
const [confirm, confirmDialog] = useConfirm();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const accounts = useQuery({ queryKey: ["accounts"], queryFn: api.accounts });
|
||||
@@ -893,6 +916,7 @@ function AccountsSection() {
|
||||
|
||||
return (
|
||||
<section className="max-w-5xl space-y-3">
|
||||
{confirmDialog}
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">{t("settings.accounts")}</h2>
|
||||
<button
|
||||
@@ -971,8 +995,15 @@ function AccountsSection() {
|
||||
{a.kind !== "native" && (
|
||||
<button
|
||||
className="text-xs text-red-500"
|
||||
onClick={() => {
|
||||
if (confirm(t("settings.confirmRemoveAccount"))) {
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: t("settings.removeAccountTitle"),
|
||||
message: t("settings.confirmRemoveAccount"),
|
||||
detail: a.email,
|
||||
confirmLabel: t("settings.removeAccountAction"),
|
||||
danger: true,
|
||||
});
|
||||
if (ok) {
|
||||
setDeletingId(a.id);
|
||||
remove.mutate(a.id);
|
||||
}
|
||||
@@ -1320,6 +1351,61 @@ function SpellcheckSetting() {
|
||||
);
|
||||
}
|
||||
|
||||
function CompletionSetting() {
|
||||
const t = useT();
|
||||
const queryClient = useQueryClient();
|
||||
const settings = useQuery({ queryKey: ["settings"], queryFn: api.settings });
|
||||
const patch = useMutation({
|
||||
mutationFn: api.patchSettings,
|
||||
onSuccess: (data) => queryClient.setQueryData(["settings"], data),
|
||||
});
|
||||
return (
|
||||
<div className="space-y-1 border-b border-app-border py-3 text-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-app-muted">{t("settings.completion")}</span>
|
||||
<SettingsToggle
|
||||
checked={settings.data?.completion_enabled ?? true}
|
||||
disabled={!settings.data || patch.isPending}
|
||||
onChange={(v) => patch.mutate({ completion_enabled: v })}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-app-faint">{t("settings.completionHint")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LanguageSetting() {
|
||||
const t = useT();
|
||||
const { locale, setLocale } = useLocale();
|
||||
const queryClient = useQueryClient();
|
||||
const patch = useMutation({
|
||||
mutationFn: api.patchSettings,
|
||||
onSuccess: (data) => queryClient.setQueryData(["settings"], data),
|
||||
});
|
||||
return (
|
||||
<div className="space-y-1 border-b border-app-border py-2 text-sm">
|
||||
<label className="flex items-center justify-between gap-3">
|
||||
<span className="text-app-muted">{t("settings.language")}</span>
|
||||
<select
|
||||
className="rounded border border-app-border bg-app-surface px-2 py-1"
|
||||
value={locale}
|
||||
onChange={(e) => {
|
||||
setLocale(e.target.value); // at once; saved with the account behind it
|
||||
patch.mutate({ ui_language: e.target.value });
|
||||
}}
|
||||
>
|
||||
{LANGUAGES.map((l) => (
|
||||
<option key={l.code} value={l.code}>
|
||||
{l.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<p className="text-xs text-app-faint">{t("settings.languageHint")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AppearanceSection() {
|
||||
const t = useT();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -1333,6 +1419,7 @@ function AppearanceSection() {
|
||||
return (
|
||||
<section className="max-w-4xl space-y-3">
|
||||
<h2 className="text-lg font-semibold">{t("settings.appearance")}</h2>
|
||||
<LanguageSetting />
|
||||
<div className="flex items-center justify-between border-b border-app-border py-2 text-sm">
|
||||
<span className="text-app-muted">{t("settings.theme")}</span>
|
||||
<div className="flex gap-1 rounded-lg border border-app-border p-0.5">
|
||||
@@ -1359,6 +1446,7 @@ function AppearanceSection() {
|
||||
</div>
|
||||
</div>
|
||||
<SpellcheckSetting />
|
||||
<CompletionSetting />
|
||||
<label className="flex items-center justify-between border-b border-app-border py-2 text-sm">
|
||||
<span className="text-app-muted">{t("settings.threadOrder")}</span>
|
||||
<select
|
||||
@@ -1390,6 +1478,67 @@ function AppearanceSection() {
|
||||
);
|
||||
}
|
||||
|
||||
function PrivacySection() {
|
||||
const t = useT();
|
||||
const queryClient = useQueryClient();
|
||||
const settings = useQuery({ queryKey: ["settings"], queryFn: api.settings });
|
||||
const patch = useMutation({
|
||||
mutationFn: api.patchSettings,
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(["settings"], data);
|
||||
// Open messages render afresh, with or without their trackers.
|
||||
void queryClient.invalidateQueries({ queryKey: ["message-html"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["thread"] });
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<section className="max-w-4xl space-y-3">
|
||||
<h2 className="text-lg font-semibold">{t("settings.privacy")}</h2>
|
||||
<div className="space-y-1 border-b border-app-border py-3 text-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-app-fg">{t("settings.blockTrackers")}</span>
|
||||
<SettingsToggle
|
||||
checked={settings.data?.block_trackers ?? true}
|
||||
disabled={!settings.data || patch.isPending}
|
||||
onChange={(v) => patch.mutate({ block_trackers: v })}
|
||||
/>
|
||||
</div>
|
||||
<p className="max-w-2xl text-app-muted">{t("settings.blockTrackersHint")}</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ShortcutsSection() {
|
||||
const t = useT();
|
||||
const queryClient = useQueryClient();
|
||||
const settings = useQuery({ queryKey: ["settings"], queryFn: api.settings });
|
||||
const patch = useMutation({
|
||||
mutationFn: api.patchSettings,
|
||||
onSuccess: (data) => queryClient.setQueryData(["settings"], data),
|
||||
});
|
||||
const on = settings.data?.shortcuts_enabled ?? true;
|
||||
|
||||
return (
|
||||
<section className="max-w-4xl space-y-4">
|
||||
<h2 className="text-lg font-semibold">{t("settings.shortcuts")}</h2>
|
||||
<div className="flex items-center justify-between gap-3 border-b border-app-border py-2 text-sm">
|
||||
<span className="text-app-muted">{t("settings.shortcutsEnabled")}</span>
|
||||
<SettingsToggle
|
||||
checked={on}
|
||||
disabled={!settings.data || patch.isPending}
|
||||
onChange={(v) => patch.mutate({ shortcuts_enabled: v })}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-app-muted">{t("settings.shortcutsHint")}</p>
|
||||
<div className={on ? "" : "opacity-50"}>
|
||||
<ShortcutList />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const DEFAULT_JITSI = "https://meet.jit.si/";
|
||||
|
||||
function IntegrationsSection() {
|
||||
@@ -1423,14 +1572,14 @@ function IntegrationsSection() {
|
||||
}
|
||||
|
||||
const CAL_COLORS: [string, string][] = [
|
||||
["Blue", "#3b82f6"],
|
||||
["Green", "#22c55e"],
|
||||
["Red", "#ef4444"],
|
||||
["Orange", "#f97316"],
|
||||
["Purple", "#a855f7"],
|
||||
["Teal", "#14b8a6"],
|
||||
["Pink", "#ec4899"],
|
||||
["Gray", "#6b7280"],
|
||||
["color.blue", "#3b82f6"],
|
||||
["color.green", "#22c55e"],
|
||||
["color.red", "#ef4444"],
|
||||
["color.orange", "#f97316"],
|
||||
["color.purple", "#a855f7"],
|
||||
["color.teal", "#14b8a6"],
|
||||
["color.pink", "#ec4899"],
|
||||
["color.gray", "#6b7280"],
|
||||
];
|
||||
|
||||
function BellIcon({ className }: { className?: string }) {
|
||||
@@ -1456,14 +1605,14 @@ function ColorSelect({ value, onChange }: { value: string; onChange: (hex: strin
|
||||
const [open, setOpen] = useState(false);
|
||||
const palette: [string, string][] = CAL_COLORS.some(([, hex]) => hex === value)
|
||||
? CAL_COLORS
|
||||
: [["Custom", value], ...CAL_COLORS];
|
||||
: [[translate("color.custom"), value], ...CAL_COLORS];
|
||||
return (
|
||||
<div className="relative inline-block">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="flex items-center gap-2 rounded border border-app-border bg-app-surface px-2.5 py-2"
|
||||
aria-label="Select colour"
|
||||
aria-label={translate("settings.selectColor")}
|
||||
>
|
||||
<span className="inline-block h-5 w-5 rounded-full" style={{ background: value }} />
|
||||
<svg viewBox="0 0 20 20" fill="currentColor" className="h-4 w-4 text-app-muted">
|
||||
@@ -1482,7 +1631,7 @@ function ColorSelect({ value, onChange }: { value: string; onChange: (hex: strin
|
||||
<button
|
||||
key={hex}
|
||||
type="button"
|
||||
title={name}
|
||||
title={name.startsWith("color.") ? translate(name as MessageKey) : name}
|
||||
onClick={() => {
|
||||
onChange(hex);
|
||||
setOpen(false);
|
||||
@@ -1619,10 +1768,10 @@ const TZ_LIST: [string, string][] = TZ_ZONES.map((z): [string, string] => {
|
||||
const REMINDER_OPTS = [0, 5, 10, 15, 30, 60, 120, 1440, 2880];
|
||||
|
||||
function reminderText(m: number): string {
|
||||
if (m === 0) return "At time of event";
|
||||
if (m % 1440 === 0) return `${m / 1440} day${m / 1440 > 1 ? "s" : ""} before`;
|
||||
if (m % 60 === 0) return `${m / 60} hour${m / 60 > 1 ? "s" : ""} before`;
|
||||
return `${m} minutes before`;
|
||||
if (m === 0) return translate("reminder.atTime");
|
||||
if (m % 1440 === 0) return translate("reminder.daysBefore", { count: m / 1440 });
|
||||
if (m % 60 === 0) return translate("reminder.hoursBefore", { count: m / 60 });
|
||||
return translate("reminder.minutesBefore", { count: m });
|
||||
}
|
||||
|
||||
function CalendarsSection() {
|
||||
|
||||
@@ -54,4 +54,28 @@ describe("antispam Actions-menu item", () => {
|
||||
expect(await render(folder, null)).toBe("");
|
||||
}
|
||||
});
|
||||
|
||||
it("loads again after a lesson, so a reopened menu is up to date", async () => {
|
||||
let learned: string | null = null;
|
||||
const ctx = {
|
||||
api: {
|
||||
pluginFetch: async (path: string) => {
|
||||
if (path === "/learn") {
|
||||
learned = "spam";
|
||||
return {};
|
||||
}
|
||||
return { message_id: "m1", special_use: "inbox", verdict: learned ? { learned } : null };
|
||||
},
|
||||
},
|
||||
};
|
||||
const el = document.createElement("div");
|
||||
const unmount = mount(el, ctx, { messageId: "m1" });
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(el.textContent).toBe("Mark as Spam");
|
||||
el.querySelector("button")!.click();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(el.textContent).toBe("Marked as spam");
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { caretContext, ghostEl, showGhost, typedIntoGhost } from "../components/compose/completion";
|
||||
|
||||
function editor(html: string): HTMLElement {
|
||||
const el = document.createElement("div");
|
||||
el.contentEditable = "true";
|
||||
el.innerHTML = html;
|
||||
document.body.appendChild(el);
|
||||
return el;
|
||||
}
|
||||
|
||||
function caretAt(node: Node, offset: number) {
|
||||
const r = document.createRange();
|
||||
r.setStart(node, offset);
|
||||
r.collapse(true);
|
||||
const sel = window.getSelection()!;
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(r);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
describe("caretContext", () => {
|
||||
it("gives the current line up to the caret at a line's end", () => {
|
||||
const el = editor("<div>First line</div><div>I look forward </div>");
|
||||
const text = el.lastChild!.firstChild!;
|
||||
caretAt(text, (text as Text).length);
|
||||
expect(caretContext(el)?.before).toBe("I look forward ");
|
||||
});
|
||||
|
||||
it("offers nothing in the middle of a line, or in the signature or a quote", () => {
|
||||
const el = editor('<div>I look forward to it</div><div class="wmc-signature">Best regards</div>');
|
||||
caretAt(el.firstChild!.firstChild!, 6);
|
||||
expect(caretContext(el)).toBeNull();
|
||||
const sig = el.querySelector(".wmc-signature")!.firstChild!;
|
||||
caretAt(sig, (sig as Text).length);
|
||||
expect(caretContext(el)).toBeNull();
|
||||
const quoted = editor("<blockquote>Quoted text</blockquote>");
|
||||
caretAt(quoted.firstChild!.firstChild!, 11);
|
||||
expect(caretContext(quoted)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the ghost", () => {
|
||||
it("sits after the caret, and typing its letters shortens it", () => {
|
||||
const el = editor("<div>I look forward to hea</div>");
|
||||
const text = el.firstChild!.firstChild!;
|
||||
caretAt(text, (text as Text).length);
|
||||
showGhost(caretContext(el)!.range, "ring from you.", "Tab");
|
||||
expect(ghostEl(el)?.textContent).toBe("ring from you.");
|
||||
expect(ghostEl(el)?.getAttribute("contenteditable")).toBe("false");
|
||||
|
||||
const typeR = new InputEvent("beforeinput", { inputType: "insertText", data: "r" });
|
||||
expect(typedIntoGhost(el, typeR)).toBe(true);
|
||||
expect(ghostEl(el)?.textContent).toBe("ing from you.");
|
||||
|
||||
const typeX = new InputEvent("beforeinput", { inputType: "insertText", data: "x" });
|
||||
expect(typedIntoGhost(el, typeX)).toBe(false);
|
||||
expect(ghostEl(el)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
clearSnapshots,
|
||||
restoredWindows,
|
||||
saveSnapshot,
|
||||
saveWindowList,
|
||||
} from "../components/compose/restore";
|
||||
import type { ComposeWin } from "../components/compose/windows";
|
||||
|
||||
const win = (id: string, minimized = false): ComposeWin => ({
|
||||
id,
|
||||
init: { accountId: "a", kind: "new" },
|
||||
minimized,
|
||||
expanded: false,
|
||||
activated: Number(id.slice(1)),
|
||||
});
|
||||
|
||||
afterEach(() => sessionStorage.clear());
|
||||
|
||||
describe("compose windows outlive leaving the mail view", () => {
|
||||
it("brings back listed windows that left their content, as they were", () => {
|
||||
saveWindowList([win("c1"), win("c2", true), win("c3")]);
|
||||
saveSnapshot("c1", { accountId: "a", kind: "new", subject: "Quote", html: "<div>Hi</div>" });
|
||||
saveSnapshot("c2", { accountId: "a", kind: "reply", draftId: "d2" });
|
||||
// c3: sent or discarded, so no content kept — it does not come back.
|
||||
const back = restoredWindows();
|
||||
expect(back.map((w) => [w.id, w.minimized, w.init.subject ?? w.init.draftId])).toEqual([
|
||||
["c1", false, "Quote"],
|
||||
["c2", true, "d2"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("reads the same twice, ignores windows closed meanwhile, and clears", () => {
|
||||
saveWindowList([win("c1")]);
|
||||
saveSnapshot("c1", { accountId: "a", kind: "new" });
|
||||
saveSnapshot("c9", { accountId: "a", kind: "new" }); // closed: not in the list
|
||||
expect(restoredWindows()).toHaveLength(1);
|
||||
expect(restoredWindows()).toHaveLength(1); // StrictMode's second call
|
||||
clearSnapshots();
|
||||
expect(restoredWindows()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { I18nProvider, LANGUAGES, detectLanguage, useLocale, useT } from "../i18n";
|
||||
import { en } from "../i18n/en";
|
||||
|
||||
const placeholders = (s: string) => [...s.matchAll(/\{(\w+)\}/g)].map((m) => m[1]).sort();
|
||||
const PLURAL = /_(zero|one|two|few|many|other)$/;
|
||||
|
||||
describe("language files", () => {
|
||||
for (const { code, catalog } of LANGUAGES.filter((l) => l.code !== "en")) {
|
||||
it(`${code}: only English's keys (plus plural forms), same placeholders`, () => {
|
||||
const english = en as Record<string, string>;
|
||||
for (const [key, text] of Object.entries(catalog as Record<string, string>)) {
|
||||
const base = english[key] !== undefined ? key : key.replace(PLURAL, "");
|
||||
expect(english[base], `${code}: unknown key ${key}`).toBeDefined();
|
||||
// A plural form may spell out its number ("1 wiadomość"): no {count} needed.
|
||||
const want = placeholders(english[base]).filter((p) => !(PLURAL.test(key) && p === "count"));
|
||||
const got = placeholders(text).filter((p) => !(PLURAL.test(key) && p === "count"));
|
||||
expect(got, `${code}: placeholders of ${key}`).toEqual(want);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function Show({ k, count }: { k: Parameters<ReturnType<typeof useT>>[0]; count?: number }) {
|
||||
const t = useT();
|
||||
return <span>{t(k, count === undefined ? undefined : { count })}</span>;
|
||||
}
|
||||
|
||||
function SwitchTo({ code }: { code: string }) {
|
||||
const { setLocale } = useLocale();
|
||||
return <button onClick={() => setLocale(code)}>go</button>;
|
||||
}
|
||||
|
||||
afterEach(() => localStorage.clear());
|
||||
|
||||
describe("t()", () => {
|
||||
it("uses each language's plural forms", () => {
|
||||
localStorage.setItem("locale", "pl");
|
||||
const forms = [1, 2, 4, 5, 12, 22, 25].map((n) => {
|
||||
const { unmount } = render(
|
||||
<I18nProvider>
|
||||
<Show k="mail.conversationsSelected" count={n} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
const text = screen.getByText(/Zaznaczono/).textContent;
|
||||
unmount();
|
||||
return text;
|
||||
});
|
||||
expect(forms).toEqual([
|
||||
"Zaznaczono 1 wątek",
|
||||
"Zaznaczono 2 wątki",
|
||||
"Zaznaczono 4 wątki",
|
||||
"Zaznaczono 5 wątków",
|
||||
"Zaznaczono 12 wątków",
|
||||
"Zaznaczono 22 wątki",
|
||||
"Zaznaczono 25 wątków",
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back to English for a string a language lacks", async () => {
|
||||
const pl = LANGUAGES.find((l) => l.code === "pl")!.catalog as Record<string, string>;
|
||||
const saved = pl["mail.print"];
|
||||
delete pl["mail.print"];
|
||||
localStorage.setItem("locale", "pl");
|
||||
render(
|
||||
<I18nProvider>
|
||||
<Show k="mail.print" />
|
||||
<SwitchTo code="pl" />
|
||||
</I18nProvider>,
|
||||
);
|
||||
expect(screen.getByText("Print")).toBeTruthy();
|
||||
pl["mail.print"] = saved;
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectLanguage", () => {
|
||||
it("takes the first preferred language we have, else English", () => {
|
||||
const spy = vi.spyOn(navigator, "languages", "get");
|
||||
spy.mockReturnValue(["de-DE", "pl-PL", "en"]);
|
||||
expect(detectLanguage()).toBe("pl");
|
||||
spy.mockReturnValue(["fr-FR", "es"]);
|
||||
expect(detectLanguage()).toBe("en");
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { snoozePresets } from "../components/mail/SnoozeMenu";
|
||||
|
||||
// 2026-10-18 is a Sunday.
|
||||
const day = (date: number, hour = 10) => new Date(2026, 9, date, hour, 0, 0);
|
||||
const labels = (now: Date) => snoozePresets(now).map((p) => p.label);
|
||||
const when = (now: Date, label: string) =>
|
||||
snoozePresets(now).find((p) => p.label === label)?.when.toDateString();
|
||||
|
||||
describe("snoozePresets", () => {
|
||||
it("never offers the same time twice", () => {
|
||||
for (let d = 18; d <= 24; d++) {
|
||||
const times = snoozePresets(day(d)).map((p) => p.when.getTime());
|
||||
expect(new Set(times).size).toBe(times.length);
|
||||
}
|
||||
});
|
||||
|
||||
it("next week is the coming Monday, or the one after on a Sunday", () => {
|
||||
expect(when(day(18), "snooze.nextWeek")).toBe(day(26).toDateString()); // Sun
|
||||
expect(when(day(19), "snooze.nextWeek")).toBe(day(26).toDateString()); // Mon
|
||||
expect(when(day(23), "snooze.nextWeek")).toBe(day(26).toDateString()); // Fri
|
||||
});
|
||||
|
||||
it("offers the weekend from Monday to Thursday only", () => {
|
||||
expect(labels(day(20))).toContain("snooze.weekend"); // Tue
|
||||
expect(when(day(20), "snooze.weekend")).toBe(day(24).toDateString());
|
||||
expect(labels(day(23))).not.toContain("snooze.weekend"); // Fri
|
||||
expect(labels(day(18))).not.toContain("snooze.weekend"); // Sun
|
||||
});
|
||||
|
||||
it("drops 'later today' late in the evening", () => {
|
||||
expect(labels(day(20, 10))).toContain("snooze.laterToday");
|
||||
expect(labels(day(20, 20))).not.toContain("snooze.laterToday");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useBulkSubscriptionActions } from "../components/list/SubscriptionsList";
|
||||
import { I18nProvider } from "../i18n";
|
||||
import { api } from "../lib/api";
|
||||
import type { Subscription } from "../lib/types";
|
||||
|
||||
function sub(key: string, extra: Partial<Subscription> = {}): Subscription {
|
||||
return {
|
||||
key,
|
||||
name: key,
|
||||
email: `${key}@x.example`,
|
||||
list_id: null,
|
||||
total: 1,
|
||||
unread: 0,
|
||||
last_30_days: 1,
|
||||
per_month: 1,
|
||||
first_at: "2026-01-01T00:00:00Z",
|
||||
last_at: "2026-10-01T00:00:00Z",
|
||||
last_subject: "s",
|
||||
one_click: true,
|
||||
has_mailto: false,
|
||||
has_web: true,
|
||||
unsubscribed_at: null,
|
||||
method: null,
|
||||
still_sending: false,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
type Bulk = ReturnType<typeof useBulkSubscriptionActions>;
|
||||
|
||||
// Renders the hook's confirmation dialog, as MailLayout does, and hands out the hook.
|
||||
function setup() {
|
||||
const onDone = vi.fn();
|
||||
const qc = new QueryClient();
|
||||
const current: { bulk?: Bulk } = {};
|
||||
function Harness() {
|
||||
const bulk = useBulkSubscriptionActions(onDone);
|
||||
current.bulk = bulk;
|
||||
return <>{bulk.dialog}</>;
|
||||
}
|
||||
render(
|
||||
<I18nProvider>
|
||||
<QueryClientProvider client={qc}>
|
||||
<Harness />
|
||||
</QueryClientProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
return { bulk: () => current.bulk as Bulk, onDone };
|
||||
}
|
||||
|
||||
/** Start an action, then answer its dialog. */
|
||||
async function answer(start: () => Promise<void>, button: string | null) {
|
||||
let done!: Promise<void>;
|
||||
act(() => {
|
||||
done = start();
|
||||
});
|
||||
if (button === null) return done; // nothing to ask
|
||||
const btn = await screen.findByRole("button", { name: button });
|
||||
await act(async () => {
|
||||
fireEvent.click(btn);
|
||||
await done;
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("bulk unsubscribe", () => {
|
||||
it("skips senders already left and collects the web-only ones", async () => {
|
||||
const calls: string[] = [];
|
||||
vi.spyOn(api, "unsubscribe").mockImplementation(async (key: string) => {
|
||||
calls.push(key);
|
||||
if (key === "web") return { status: "open", method: "web", url: "https://x.example/u" };
|
||||
if (key === "bad") throw new Error("boom");
|
||||
return { status: "done", method: "one_click", url: null };
|
||||
});
|
||||
const { bulk, onDone } = setup();
|
||||
await answer(
|
||||
() =>
|
||||
bulk().unsubscribeMany([
|
||||
sub("a"),
|
||||
sub("web", { one_click: false }),
|
||||
sub("gone", { unsubscribed_at: "2026-10-01T00:00:00Z" }),
|
||||
sub("bad"),
|
||||
]),
|
||||
"Unsubscribe",
|
||||
);
|
||||
expect(calls.sort()).toEqual(["a", "bad", "web"]);
|
||||
await waitFor(() => expect(bulk().finishOnPage).toEqual([{ name: "web", url: "https://x.example/u" }]));
|
||||
expect(onDone).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does nothing when the dialog is cancelled", async () => {
|
||||
const spy = vi.spyOn(api, "unsubscribe");
|
||||
const { bulk, onDone } = setup();
|
||||
await answer(() => bulk().unsubscribeMany([sub("a")]), "Cancel");
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
expect(onDone).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("bulk delete", () => {
|
||||
it("moves every selected sender's mail", async () => {
|
||||
const spy = vi.spyOn(api, "trashSubscription").mockResolvedValue({ moved: 2 });
|
||||
const { bulk, onDone } = setup();
|
||||
await answer(() => bulk().trashMany([sub("a"), sub("b"), sub("c")]), "Move to Trash");
|
||||
expect(spy.mock.calls.map((c) => c[0]).sort()).toEqual(["a", "b", "c"]);
|
||||
expect(onDone).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user