Merge pull request #147 from Coffey-Labs/i18n-extract-batch1

Extract 515 strings by codemod
This commit is contained in:
Coffey Labs
2026-08-31 10:02:15 -07:00
committed by GitHub
58 changed files with 1224 additions and 730 deletions
+3 -1
View File
@@ -10,5 +10,7 @@ See [KNOWN-ISSUES.md](KNOWN-ISSUES.md) for what is built but worth knowing about
- **Sharing a mail folder.** Stalwart stores the share and never delivers it; see [KNOWN-ISSUES.md](KNOWN-ISSUES.md). Withdrawn until the server does something with it. Sharing files, calendars and address books is unaffected and works.
- Snooze (nothing in JMAP or Stalwart supports it, and ihasmail never stores a password, so nothing could act on a mailbox while you are away)
- Translations (strings are English-only for now)
- **Translations.** In progress, and the only entry here that is "not yet" rather than "no". The groundwork shipped in [#145](https://github.com/Coffey-Labs/ihasmail/pull/145): an interface-language setting separate from the date-and-time locale, `<html lang>` served from it, and the structural work that keeps a browser's own translator from rewriting the page underneath React. What is left is the part that was always the hard part — extracting every user-facing string, and having each catalogue read by somebody who speaks the language. A language is offered in Settings only once its catalogue is complete, so a half-translated build shows English and nothing else; the picker is the gate, not the calendar.
Planned order, and it is an order rather than a wish list: **German, French, Dutch, Spanish, Portuguese (Brazil)** first, then **Russian, Ukrainian, Chinese (Simplified), Japanese**. Arabic, Hebrew and Persian are deliberately not on either list. They are right-to-left, and that is a layout and bidi problem rather than a longer catalogue — shipping them as though they were the same kind of work is how an RTL build ends up unusable and nobody says so.
- **Two-factor sign-in.** Today an account with 2FA must use an app password (see [Quick start](README.md#quick-start-docker)), and Settings Security offers no way to switch 2FA *on* — only off, for an account that already has it. Supporting a TOTP code directly means implementing OAuth: Stalwart offers the authorization-code and device flows and no password grant, so ihasmail would hand sign-in to Stalwart's own login and come back with a token. That is a better security posture than the sealed password it holds now — a refresh token rather than a credential — but it replaces ihasmail's own sign-in page for those users and may need an OAuth client registered. Came out of [#75](https://github.com/Coffey-Labs/ihasmail/issues/75), which is closed: what was reported there was a sign-in refused with nothing but "Invalid credentials", and that was fixed by saying what is actually happening and pointing at app passwords. The OAuth work it uncovered is tracked here rather than as an open issue, so there is no ticket to watch for it.
+2 -1
View File
@@ -21,7 +21,8 @@
"lint": "npm run typecheck",
"mock": "npm run mock -w server",
"dev:mock": "concurrently -n mock,server,web -c yellow,blue,magenta \"npm run mock -w server\" \"STALWART_URL=http://127.0.0.1:8788 npm run dev -w server\" \"npm run dev -w web\"",
"dev:mock:no-future-release": "concurrently -n mock,server,web -c yellow,blue,magenta \"npm run mock:no-future-release -w server\" \"STALWART_URL=http://127.0.0.1:8788 npm run dev -w server\" \"npm run dev -w web\""
"dev:mock:no-future-release": "concurrently -n mock,server,web -c yellow,blue,magenta \"npm run mock:no-future-release -w server\" \"STALWART_URL=http://127.0.0.1:8788 npm run dev -w server\" \"npm run dev -w web\"",
"i18n:coverage": "node scripts/i18n-coverage.mjs"
},
"devDependencies": {
"concurrently": "^9.1.2",
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env node
/*
* How much of the interface is extracted, and what is left.
*
* Extraction is ~1,000 strings across ~56 files, which is far too many to
* carry in anyone's head or to eyeball in review. This counts what is still
* hardcoded so the work can be done a file at a time and the remainder is
* always a number rather than a feeling.
*
* It is a progress report, not a gate: run it, do a file, run it again. It
* exits non-zero only with --check, so CI can be told to fail on regressions
* later, once the number is low enough for that to mean something.
*/
import ts from "typescript";
import { readFileSync, globSync } from "node:fs";
/** Attributes a person reads. `className` and `key` are not among them. */
const ATTRS = new Set(["title", "aria-label", "placeholder", "alt", "label", "hint", "confirmLabel", "message", "description"]);
/* Text that is not prose: punctuation, separators, and the single glyphs used
as dividers. Counting these as untranslated would put a floor under the
number that no amount of work could reach. */
const NOT_PROSE = /^[\s·—–\-—:;,.()[\]{}/|+×✓~<>#*@0-9]*$/u;
const files = globSync("web/src/**/*.tsx").filter((f) => !f.includes("__tests__"));
const rows = [];
let done = 0, todo = 0;
for (const file of files) {
const text = readFileSync(file, "utf8");
const src = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
let left = 0;
const wrapped = (text.match(/\bt\(\s*["'`]/g) || []).length + (text.match(/\bplural\(/g) || []).length;
const visit = (node) => {
if (ts.isJsxText(node) && node.text.trim().length > 1 && !NOT_PROSE.test(node.text.trim())) left++;
if (ts.isJsxAttribute(node) && ATTRS.has(node.name.getText(src))) {
const i = node.initializer;
const lit = i && (ts.isStringLiteral(i) ? i : ts.isJsxExpression(i) && i.expression && ts.isStringLiteral(i.expression) ? i.expression : null);
if (lit && lit.text.trim().length > 1) left++;
}
ts.forEachChild(node, visit);
};
visit(src);
done += wrapped;
todo += left;
if (left) rows.push([file.replace("web/src/", ""), left, wrapped]);
}
rows.sort((a, b) => b[1] - a[1]);
const pct = done + todo === 0 ? 100 : Math.round((done / (done + todo)) * 100);
console.log(`i18n extraction: ${done} wrapped, ${todo} remaining across ${rows.length} files (${pct}%)\n`);
for (const [f, left, w] of rows.slice(0, Number(process.argv.find((a) => a.startsWith("--top="))?.slice(6) ?? 15))) {
console.log(` ${String(left).padStart(4)} left${w ? `, ${w} done` : " "} ${f}`);
}
if (rows.length > 15 && !process.argv.includes("--all")) console.log(`\n …and ${rows.length - 15} more (--all, or --top=N)`);
if (process.argv.includes("--check") && todo > 0) process.exit(1);
+130
View File
@@ -0,0 +1,130 @@
#!/usr/bin/env node
/*
* Wrap the strings a codemod can safely wrap, and report the ones it cannot.
*
* Roughly 1,000 strings is too many to hand-edit without introducing typos
* into the copy itself, and a parser does not get bored. But it must not be
* trusted with everything: text that is split around an interpolation arrives
* as separate fragments, and wrapping each fragment on its own produces
* "Move " and " messages", which no translator can do anything with. Those are
* left alone and listed, because they need a sentence built by hand.
*
* node scripts/i18n-extract.mjs <file...> rewrite in place
* node scripts/i18n-extract.mjs --dry <file...>
*/
import ts from "typescript";
import { readFileSync, writeFileSync } from "node:fs";
const ATTRS = new Set(["title", "aria-label", "placeholder", "alt", "label", "hint", "confirmLabel", "description"]);
const NOT_PROSE = /^[\s·—–\-:;,.()[\]{}/|+×✓~<>#*@0-9]*$/u;
/*
* Elements whose text is not prose however much it looks like it. `label:name`
* inside <code> is a search operator: translating it breaks the thing it
* documents. The first run of this wrapped exactly that, which is why the list
* exists.
*/
const CODE_TAGS = new Set(["code", "kbd", "pre", "samp", "var"]);
/*
* JSX decodes HTML entities in text; a JS string literal does not. Moving
* `Language &amp; region` into t("...") without decoding renders the entity
* literally on screen -- which the first run of this did, and which no
* typecheck or test noticed. It took looking at the page.
*/
const ENTITIES = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: "\u00a0", mdash: "—", ndash: "", hellip: "…", times: "×", middot: "·" };
const decode = (s) => s.replace(/&(\w+);/g, (whole, name) => ENTITIES[name] ?? whole)
.replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n)));
const tagOf = (node, src) => (ts.isJsxElement(node) ? node.openingElement.tagName.getText(src) : "");
const optedOut = (node, src) => {
const opening = ts.isJsxElement(node) ? node.openingElement : ts.isJsxSelfClosingElement(node) ? node : null;
return Boolean(opening?.attributes.properties.some((a) =>
ts.isJsxAttribute(a) && a.name.getText(src) === "translate" &&
a.initializer && ts.isStringLiteral(a.initializer) && a.initializer.text === "no"));
};
const dry = process.argv.includes("--dry");
const files = process.argv.slice(2).filter((a) => !a.startsWith("--"));
let wrapped = 0;
const skipped = [];
for (const file of files) {
const text = readFileSync(file, "utf8");
const src = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
/*
* `t` is a natural name for a callback parameter, and several files already
* use it -- `(t: SieveTest) => ...`, `.map((t) => ...)`. An import called
* `t` is shadowed inside those callbacks, silently where the local happens
* to be callable. So the name is checked first and aliased where it is
* taken, per file, rather than assumed to be free.
*/
let bound = false;
const scan = (n) => {
if ((ts.isParameter(n) || ts.isVariableDeclaration(n) || ts.isBindingElement(n)) && n.name && ts.isIdentifier(n.name) && n.name.text === "t") bound = true;
ts.forEachChild(n, scan);
};
scan(src);
const T = bound ? "translate" : "t";
/** [start, end, replacement] — applied back-to-front so offsets hold. */
const edits = [];
const visit = (node) => {
if (ts.isJsxElement(node) || ts.isJsxFragment(node)) {
if (CODE_TAGS.has(tagOf(node, src).toLowerCase()) || optedOut(node, src)) return; // and not its children
const kids = node.children;
const meaningful = kids.filter((c) => !(ts.isJsxText(c) && !c.text.trim()));
for (const c of kids) {
if (!ts.isJsxText(c)) continue;
const raw = c.text;
const body = raw.trim();
if (body.length < 2 || NOT_PROSE.test(body)) continue;
// Split around an interpolation: the fragments are not sentences.
if (meaningful.length > 1) {
const { line } = src.getLineAndCharacterOfPosition(c.getStart(src));
skipped.push({ file, line: line + 1, why: "text split around an expression", text: body.slice(0, 52) });
continue;
}
if (decode(body).includes('"')) {
const { line } = src.getLineAndCharacterOfPosition(c.getStart(src));
skipped.push({ file, line: line + 1, why: "contains a quote", text: body.slice(0, 52) });
continue;
}
// Keep the original leading/trailing whitespace: JSX collapses it, and
// reflowing here would change the rendered spacing.
const lead = raw.slice(0, raw.indexOf(body[0]));
const tail = raw.slice(raw.lastIndexOf(body[body.length - 1]) + 1);
edits.push([c.getStart(src), c.getEnd(), `${lead}{${T}("${decode(body.replace(/\s+/g, " "))}")}${tail}`]);
wrapped++;
}
}
if (ts.isJsxAttribute(node) && ATTRS.has(node.name.getText(src))) {
const i = node.initializer;
const lit = i && (ts.isStringLiteral(i) ? i : ts.isJsxExpression(i) && i.expression && ts.isStringLiteral(i.expression) ? i.expression : null);
if (lit && lit.text.trim().length > 1 && !NOT_PROSE.test(lit.text)) {
if (decode(lit.text).includes('"')) {
const { line } = src.getLineAndCharacterOfPosition(lit.getStart(src));
skipped.push({ file, line: line + 1, why: "contains a quote", text: lit.text.slice(0, 52) });
} else {
edits.push([i.getStart(src), i.getEnd(), `{${T}("${decode(lit.text)}")}`]);
wrapped++;
}
}
}
ts.forEachChild(node, visit);
};
visit(src);
if (!edits.length) continue;
let out = text;
for (const [start, end, rep] of edits.sort((a, b) => b[0] - a[0])) out = out.slice(0, start) + rep + out.slice(end);
if (!/from "@\/lib\/i18n"/.test(out)) {
const lastImport = [...out.matchAll(/^import .*?;$/gm)].pop();
const decl = bound ? 'import { t as translate } from "@/lib/i18n";' : 'import { t } from "@/lib/i18n";';
if (lastImport) out = out.slice(0, lastImport.index + lastImport[0].length) + "\n" + decl + out.slice(lastImport.index + lastImport[0].length);
}
if (!dry) writeFileSync(file, out);
}
console.log(`${dry ? "would wrap" : "wrapped"} ${wrapped} strings across ${files.length} files`);
if (skipped.length) {
console.log(`\n${skipped.length} left for a person:`);
for (const s of skipped) console.log(` ${s.file.replace("web/src/", "")}:${s.line} (${s.why}) ${s.text}`);
}
+15 -2
View File
@@ -1,4 +1,4 @@
import { lazy, Suspense, useEffect } from "react";
import { Fragment, lazy, Suspense, useEffect } from "react";
import { Route, Switch, Redirect, useLocation } from "wouter";
import { useSession } from "@/store/session";
import { useMail } from "@/store/mail";
@@ -20,6 +20,7 @@ import { setUnreadBadge } from "@/lib/notify";
import { useSettings, syncedPart } from "@/store/settings";
import { armSettingsSync, loadRemoteSettings, queueSettingsPush, settingsSyncAvailable } from "@/lib/settingsSync";
import { listenForVerification, renewWebPush } from "@/lib/webpushEnable";
import { useLanguageVersion } from "@/lib/i18n";
const ContactsView = lazy(() => import("@/views/contacts/ContactsView").then((m) => ({ default: m.ContactsView })));
const CalendarView = lazy(() => import("@/views/calendar/CalendarView").then((m) => ({ default: m.CalendarView })));
@@ -29,6 +30,18 @@ const SettingsView = lazy(() => import("@/views/settings/SettingsView").then((m)
export function App() {
const status = useSession((s) => s.status);
const bootstrap = useSession((s) => s.bootstrap);
/*
* Subscribed once, here, and used as a key below.
*
* `t()` is a plain function rather than a hook, so a component has no way of
* knowing its strings just changed. Rather than make every one of the
* thousand call sites a subscriber -- which would turn extracting a string
* from "wrap it" into "wrap it and add a hook" -- the whole tree is thrown
* away and rebuilt when the catalogue changes. Picking a language is a
* once-in-an-account event; paying for it there is far cheaper than paying
* for it on every render everywhere.
*/
const languageVersion = useLanguageVersion();
useEffect(() => {
void bootstrap();
}, [bootstrap]);
@@ -42,7 +55,7 @@ export function App() {
}
return (
<>
{status === "anonymous" ? <LoginPage /> : <AuthedApp />}
<Fragment key={languageVersion}>{status === "anonymous" ? <LoginPage /> : <AuthedApp />}</Fragment>
<ToastHost />
<ConfirmHost />
</>
+79
View File
@@ -0,0 +1,79 @@
import { afterEach, describe, expect, it } from "vitest";
import { currentLanguage, interpolate, plural, setCatalog, t, type Catalog } from "@/lib/i18n";
const de: Catalog = {
strings: { "Archive": "Archivieren", "Move {n} to {folder}": "{n} nach {folder} verschieben" },
plurals: { "{n} messages": { one: "{n} Nachricht", other: "{n} Nachrichten" } },
};
/* Russian is the reason plural() does not take (one, other): it needs three
forms, and which one applies is not a question about the number 1. */
const ru: Catalog = {
strings: {},
plurals: { "{n} messages": { one: "{n} сообщение", few: "{n} сообщения", many: "{n} сообщений", other: "{n} сообщения" } },
};
afterEach(() => setCatalog("en", { strings: {}, plurals: {} }));
describe("t", () => {
it("returns the English it was given when nothing is loaded", () => {
// The whole point of English-as-key: a missing translation degrades to
// readable English rather than to a symbolic name leaking into the UI.
expect(t("Archive")).toBe("Archive");
expect(currentLanguage()).toBe("en");
});
it("translates once a catalogue is in force", () => {
setCatalog("de", de);
expect(t("Archive")).toBe("Archivieren");
});
it("falls back per string, not per catalogue", () => {
setCatalog("de", de);
expect(t("Report spam")).toBe("Report spam");
});
});
describe("interpolation", () => {
it("fills named placeholders", () => {
expect(interpolate("Move {n} to {folder}", { n: 3, folder: "Archive" })).toBe("Move 3 to Archive");
});
it("survives a translator reordering the sentence", () => {
// Positional arguments would not: German moves the parts around and means
// the same thing.
setCatalog("de", de);
expect(t("Move {n} to {folder}", { n: 3, folder: "Archiv" })).toBe("3 nach Archiv verschieben");
});
it("leaves an unknown placeholder alone rather than printing undefined", () => {
expect(interpolate("Hello {who}", {})).toBe("Hello {who}");
});
});
describe("plural", () => {
const FORMS = { one: "{n} message", other: "{n} messages" };
it("picks the English form without a catalogue", () => {
expect(plural(1, FORMS)).toBe("1 message");
expect(plural(0, FORMS)).toBe("0 messages");
expect(plural(5, FORMS)).toBe("5 messages");
});
it("uses the target language's own rule, not English's", () => {
setCatalog("ru", ru);
expect(plural(1, FORMS)).toBe("1 сообщение"); // one
expect(plural(3, FORMS)).toBe("3 сообщения"); // few
expect(plural(7, FORMS)).toBe("7 сообщений"); // many
});
it("falls back to `other` when the catalogue lacks the category", () => {
setCatalog("de", de);
// German has no "few"; asking for 3 must not render undefined.
expect(plural(3, FORMS)).toBe("3 Nachrichten");
});
it("takes extra variables alongside the count", () => {
expect(plural(2, { one: "{n} message in {folder}", other: "{n} messages in {folder}" }, { folder: "Inbox" }))
.toBe("2 messages in Inbox");
});
});
+147
View File
@@ -0,0 +1,147 @@
import { useSyncExternalStore } from "react";
import { DEFAULT_UI_LANGUAGE, resolveUiLanguage } from "@/lib/languages";
/**
* Translation, in about as little machinery as the job takes.
*
* The English text is the key. `t("Archive")` looks "Archive" up in whatever
* catalogue is loaded and returns the English if it is not there, which buys
* three things worth more than tidy symbolic keys: there is no English
* catalogue to keep in step with the code, a missing translation degrades to
* readable English rather than to `mail.list.archive`, and extracting a string
* is wrapping it rather than inventing a name for it. Names are where
* extraction stalls -- 55 components is a lot of small naming arguments.
*
* The cost is that changing English copy orphans its translations. That is the
* right trade here: the copy is the product, and a stale translation should
* fall back to the new English rather than keep showing the old sentence in
* German.
*/
export type Vars = Record<string, string | number>;
/** One entry per plural category the language actually uses. */
export type PluralForms = Partial<Record<Intl.LDMLPluralRule, string>> & { other: string };
export interface Catalog {
/** English source → translation. */
strings: Record<string, string>;
/** English `other` form → the forms this language needs. */
plurals: Record<string, PluralForms>;
}
const EMPTY: Catalog = { strings: {}, plurals: {} };
let current: Catalog = EMPTY;
let currentTag: string = DEFAULT_UI_LANGUAGE;
let version = 0;
const listeners = new Set<() => void>();
function publish(): void {
version += 1;
for (const fn of listeners) fn();
}
/**
* Fill in `{name}` placeholders.
*
* Named rather than positional, because a translator reorders a sentence and
* positional arguments do not survive that -- German puts the verb last, and
* "{0} of {1}" becomes a different order with the same meaning.
*/
export function interpolate(template: string, vars?: Vars): string {
if (!vars) return template;
return template.replace(/\{(\w+)\}/g, (whole, key: string) =>
Object.prototype.hasOwnProperty.call(vars, key) ? String(vars[key]) : whole,
);
}
/** Translate, falling back to the English that was passed in. */
export function t(source: string, vars?: Vars): string {
return interpolate(current.strings[source] ?? source, vars);
}
/**
* Translate a counted thing.
*
* Two forms is an English assumption and does not survive the second phase of
* this: Russian and Ukrainian use three, and picking between them is not
* `n === 1`. `Intl.PluralRules` knows the rule for every language the browser
* knows, so the catalogue supplies the forms and the runtime picks.
*
* The English `other` form is the key, so a call site reads as the sentence it
* produces and needs no invented name.
*/
export function plural(n: number, forms: PluralForms, vars?: Vars): string {
const entry = current.plurals[forms.other] ?? forms;
let category: Intl.LDMLPluralRule = "other";
try {
category = new Intl.PluralRules(currentTag).select(n);
} catch {
/* an unknown tag: "other" is the safe form and English's only plural */
}
return interpolate(entry[category] ?? entry.other, { n, ...vars });
}
/** The language in force, for anything that needs the tag itself. */
export function currentLanguage(): string {
return currentTag;
}
/**
* Put a catalogue in force.
*
* Exported for tests and for the loader; nothing else should call it, because
* the tag and the catalogue have to move together or `plural` selects with one
* language's rules against another's forms.
*/
export function setCatalog(tag: string, catalog: Catalog): void {
currentTag = tag;
current = catalog;
publish();
}
/**
* Load and apply a language.
*
* English is the built-in: it is the source text, so there is nothing to fetch
* and no chance of a missing catalogue leaving the app blank. Everything else
* is a dynamic import, so a reader who never leaves English never downloads a
* catalogue -- which matters, because the main bundle is already large enough
* to warn about.
*/
export async function loadLanguage(tag: string): Promise<void> {
const resolved = resolveUiLanguage(tag);
if (resolved === DEFAULT_UI_LANGUAGE) {
setCatalog(DEFAULT_UI_LANGUAGE, EMPTY);
return;
}
try {
const mod = (await import(`../locales/${resolved}.ts`)) as { catalog: Catalog };
setCatalog(resolved, mod.catalog);
} catch {
// A catalogue that will not load leaves English in force rather than a
// half-rendered page. `resolveUiLanguage` should already have prevented
// this; it being reachable at all is why it is caught.
setCatalog(DEFAULT_UI_LANGUAGE, EMPTY);
}
}
/**
* Re-render when the language changes.
*
* Used once, at the root, to key the tree — rather than at each of the
* thousand call sites, which would make `t()` a hook and extraction far more
* invasive than wrapping a string. Language changes are rare enough that
* re-rendering everything is the cheaper design.
*/
export function useLanguageVersion(): number {
return useSyncExternalStore(
(fn) => {
listeners.add(fn);
return () => listeners.delete(fn);
},
() => version,
() => version,
);
}
+10 -1
View File
@@ -5,6 +5,7 @@ import { queueSettingsPush } from "@/lib/settingsSync";
import { setDateTimePrefs, type DateFormat, type TimeFormat } from "@/lib/datetime";
import type { SwipeAction } from "@/lib/swipe";
import { resolveUiLanguage } from "@/lib/languages";
import { loadLanguage } from "@/lib/i18n";
/**
* "ihasmail" is a dark theme carrying the palette from ihasmail.org. It is a
@@ -368,7 +369,15 @@ function applyDateTimePrefs(s: Settings): void {
* load, which is the thing the whole design avoids.
*/
export function applyLang(s: Settings = useSettings.getState().settings): void {
document.documentElement.lang = resolveUiLanguage(s.uiLanguage);
const tag = resolveUiLanguage(s.uiLanguage);
document.documentElement.lang = tag;
/*
* The catalogue is fetched, so it lands a beat after the attribute. That
* order is deliberate: `lang` is what stops Chrome offering to translate,
* and it should not wait on a network request to say something it already
* knows. English needs no fetch at all and resolves immediately.
*/
void loadLanguage(tag);
}
/** Background of each theme, for the browser chrome (`theme-color`). */
+8 -7
View File
@@ -14,6 +14,7 @@ import {
} from "@/lib/datetime";
import { dateTimeKey, useSettings } from "@/store/settings";
import { anchorFromEl, Popover, type Anchor } from "./popover";
import { t as translate } from "@/lib/i18n";
/*
* Date and time fields that follow the user's configured format.
@@ -74,9 +75,9 @@ function CalendarGrid({ selected, onPick, onClose }: { selected: Date | null; on
return (
<div className="dp-cal">
<div className="dp-head">
<button type="button" className="icon-btn xs" onClick={() => setAnchor(addMonths(anchor, -1))} aria-label="Previous month"><ChevronLeft size={16} /></button>
<button type="button" className="icon-btn xs" onClick={() => setAnchor(addMonths(anchor, -1))} aria-label={translate("Previous month")}><ChevronLeft size={16} /></button>
<span aria-live="polite">{formatMonthYear(anchor)}</span>
<button type="button" className="icon-btn xs" onClick={() => setAnchor(addMonths(anchor, 1))} aria-label="Next month"><ChevronRight size={16} /></button>
<button type="button" className="icon-btn xs" onClick={() => setAnchor(addMonths(anchor, 1))} aria-label={translate("Next month")}><ChevronRight size={16} /></button>
</div>
<div className="dp-dow" aria-hidden="true">{dow.map((d, i) => <span key={i}>{d}</span>)}</div>
<div className="dp-grid" role="grid" ref={gridRef} onKeyDown={onKey}>
@@ -99,8 +100,8 @@ function CalendarGrid({ selected, onPick, onClose }: { selected: Date | null; on
})}
</div>
<div className="dp-foot">
<button type="button" className="btn btn-ghost xs" onClick={() => onPick(startOfDay(new Date()))}>Today</button>
<button type="button" className="btn btn-ghost xs" onClick={onClose}>Close</button>
<button type="button" className="btn btn-ghost xs" onClick={() => onPick(startOfDay(new Date()))}>{translate("Today")}</button>
<button type="button" className="btn btn-ghost xs" onClick={onClose}>{translate("Close")}</button>
</div>
</div>
);
@@ -127,7 +128,7 @@ function TimeList({ selected, onPick }: { selected: Date | null; onPick: (hours:
}, []);
return (
<div className="dp-times" ref={listRef} role="listbox" aria-label="Time">
<div className="dp-times" ref={listRef} role="listbox" aria-label={translate("Time")}>
{slots.map((t, i) => (
<button
key={i}
@@ -234,7 +235,7 @@ export function DateField({ value, onChange, className, disabled, required, id,
if (e.key === "ArrowDown" && !anchor) { e.preventDefault(); open(); }
}}
/>
<button type="button" className="dp-open" disabled={disabled} onClick={open} aria-label="Choose a date" tabIndex={-1}>
<button type="button" className="dp-open" disabled={disabled} onClick={open} aria-label={translate("Choose a date")} tabIndex={-1}>
<CalIcon size={15} />
</button>
{anchor && (
@@ -311,7 +312,7 @@ export function DateTimeField({ value, onChange, className, disabled, required,
if (e.key === "ArrowDown" && !anchor) { e.preventDefault(); open(); }
}}
/>
<button type="button" className="dp-open" disabled={disabled} onClick={open} aria-label="Choose a date and time" tabIndex={-1}>
<button type="button" className="dp-open" disabled={disabled} onClick={open} aria-label={translate("Choose a date and time")} tabIndex={-1}>
<CalIcon size={15} />
</button>
</span>
+2 -1
View File
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { X } from "lucide-react";
import { create } from "zustand";
import { t } from "@/lib/i18n";
interface DialogProps {
open: boolean;
@@ -71,7 +72,7 @@ export function Dialog({ open, onClose, title, children, footer, size = "md", cl
{title !== undefined && (
<div className="dialog-head">
<h2>{title}</h2>
<button className="icon-btn" onClick={onClose} aria-label="Close">
<button className="icon-btn" onClick={onClose} aria-label={t("Close")}>
<X size={20} />
</button>
</div>
+2 -1
View File
@@ -3,6 +3,7 @@ import type { EmailAddress } from "@/jmap/types";
import { avatarColor, initials } from "@/lib/address";
import { useContacts } from "@/store/contacts";
import { contactPhoto } from "@/lib/contacts";
import { t } from "@/lib/i18n";
export function Avatar({ who, size, className }: { who: EmailAddress | { name?: string | null; email?: string } | string | null | undefined; size?: "sm" | "lg" | "xl"; className?: string }) {
const email = typeof who === "string" ? who : (who?.email ?? "");
@@ -82,7 +83,7 @@ export function Kbd({ keys }: { keys: string }) {
<span className="keys">
{keys.split(" ").map((k, i) => (
<span key={i}>
{i > 0 && <span className="muted" style={{ margin: "0 3px" }}>then</span>}
{i > 0 && <span className="muted" style={{ margin: "0 3px" }}>{t("then")}</span>}
{k.split("+").map((p, j) => (
<kbd key={j} className="kbd" style={{ marginRight: 2 }}>
{p === "mod" ? (navigator.platform.includes("Mac") ? "⌘" : "Ctrl") : p === "shift" ? "⇧" : p === "enter" ? "↵" : p === "esc" ? "Esc" : p}
+2 -1
View File
@@ -1,5 +1,6 @@
import { create } from "zustand";
import { X } from "lucide-react";
import { t as translate } from "@/lib/i18n";
export interface Toast {
id: number;
@@ -73,7 +74,7 @@ export function ToastHost() {
{t.action.label}
</button>
)}
<button className="toast-close" aria-label="Dismiss" onClick={() => dismiss(t.id)}>
<button className="toast-close" aria-label={translate("Dismiss")} onClick={() => dismiss(t.id)}>
<X size={16} />
</button>
{t.progress && t.duration > 0 && <span className="toast-progress" style={{ animationDuration: `${t.duration}ms` }} />}
+18 -17
View File
@@ -15,6 +15,7 @@ import { CalendarSidebar } from "./calendar/CalendarSidebar";
import { ShortcutsDialog, useGlobalShortcuts } from "./Shortcuts";
import { formatSize } from "@/lib/format";
import { TranslateBoundary } from "@/ui/TranslateBoundary";
import { t } from "@/lib/i18n";
const PUSH_LABEL = {
connected: "Live updates connected",
@@ -67,7 +68,7 @@ export function AppShell({ children }: { children: ReactNode }) {
return (
<div className="app">
<header className="topbar">
<button className="icon-btn" aria-label="Menu" onClick={() => (isMobile ? setDrawer(true) : update({ sidebarCollapsed: !collapsed }))}>
<button className="icon-btn" aria-label={t("Menu")} onClick={() => (isMobile ? setDrawer(true) : update({ sidebarCollapsed: !collapsed }))}>
<MenuIcon size={22} />
</button>
<Link href="/mail" className="brand">
@@ -83,14 +84,14 @@ export function AppShell({ children }: { children: ReactNode }) {
<span className="push-status hide-mobile" role="img" aria-label={PUSH_LABEL[pushState]} title={PUSH_LABEL[pushState]}>
<span className={`push-dot ${pushState}`} />
</span>
<button className="icon-btn hide-mobile" aria-label="Keyboard shortcuts" title="Keyboard shortcuts (?)" onClick={() => setHelpOpen(true)}>
<button className="icon-btn hide-mobile" aria-label={t("Keyboard shortcuts")} title={t("Keyboard shortcuts (?)")} onClick={() => setHelpOpen(true)}>
<HelpCircle size={21} />
</button>
<ThemeToggle />
<Link href="/settings" className={`icon-btn ${section === "settings" ? "active" : ""}`} aria-label="Settings" title="Settings">
<Link href="/settings" className={`icon-btn ${section === "settings" ? "active" : ""}`} aria-label={t("Settings")} title={t("Settings")}>
<Settings size={21} />
</Link>
<button className="icon-btn" style={{ width: "auto", padding: "0 2px", borderRadius: 999 }} onClick={acctMenu.open} aria-label="Account">
<button className="icon-btn" style={{ width: "auto", padding: "0 2px", borderRadius: 999 }} onClick={acctMenu.open} aria-label={t("Account")}>
<Avatar who={{ name: session?.username, email: session?.username }} size="sm" />
</button>
<Popover anchor={acctMenu.anchor} onClose={acctMenu.close} align="end" width={280}>
@@ -104,14 +105,14 @@ export function AppShell({ children }: { children: ReactNode }) {
</div>
</div>
<MenuSep />
<MenuItem icon={<BookOpen size={16} />} label="Documentation" href="https://docs.ihasmail.org" external />
<MenuItem icon={<BookOpen size={16} />} label={t("Documentation")} href="https://docs.ihasmail.org" external />
{/* The project site. It is linked from the login screen footer, which
is a page a signed-in user never sees again -- so from inside the
app there was no way back to it. */}
<MenuItem icon={<Globe size={16} />} label="About ihasmail" href="https://ihasmail.org" external />
<MenuItem icon={<Settings size={16} />} label="Settings" onClick={() => navigate("/settings")} />
<MenuItem icon={<RefreshCw size={16} />} label="Refresh" onClick={() => window.location.reload()} />
<MenuItem icon={<LogOut size={16} />} label="Sign out" onClick={() => void logout()} />
<MenuItem icon={<Globe size={16} />} label={t("About ihasmail")} href="https://ihasmail.org" external />
<MenuItem icon={<Settings size={16} />} label={t("Settings")} onClick={() => navigate("/settings")} />
<MenuItem icon={<RefreshCw size={16} />} label={t("Refresh")} onClick={() => window.location.reload()} />
<MenuItem icon={<LogOut size={16} />} label={t("Sign out")} onClick={() => void logout()} />
</Popover>
</div>
</header>
@@ -138,14 +139,14 @@ export function AppShell({ children }: { children: ReactNode }) {
{section === "calendar" && <CalendarSidebar />}
{section === "contacts" && <ContactsSidebar />}
{section === "files" && <FilesTree />}
{section === "settings" && <div className="nav-section"><span>Settings</span></div>}
{section === "settings" && <div className="nav-section"><span>{t("Settings")}</span></div>}
</div>
{(section === "mail" || section === "search") && <QuotaBar />}
<nav className="module-bar" aria-label="Go to">
<ModuleLink href="/mail" icon={<Mail size={20} />} label="Mail" active={section === "mail" || section === "search"} />
<ModuleLink href="/calendar" icon={<Calendar size={20} />} label="Calendar" active={section === "calendar"} />
<ModuleLink href="/contacts" icon={<Users size={20} />} label="Contacts" active={section === "contacts"} />
<ModuleLink href="/files" icon={<FolderOpen size={20} />} label="Files" active={section === "files"} />
<nav className="module-bar" aria-label={t("Go to")}>
<ModuleLink href="/mail" icon={<Mail size={20} />} label={t("Mail")} active={section === "mail" || section === "search"} />
<ModuleLink href="/calendar" icon={<Calendar size={20} />} label={t("Calendar")} active={section === "calendar"} />
<ModuleLink href="/contacts" icon={<Users size={20} />} label={t("Contacts")} active={section === "contacts"} />
<ModuleLink href="/files" icon={<FolderOpen size={20} />} label={t("Files")} active={section === "files"} />
</nav>
</aside>
{/*
@@ -160,11 +161,11 @@ export function AppShell({ children }: { children: ReactNode }) {
{isMobile && (
<>
{(section === "mail" || section === "search") && !location.split("/")[3] && (
<button className="fab" aria-label="Compose" onClick={() => openCompose()}>
<button className="fab" aria-label={t("Compose")} onClick={() => openCompose()}>
<PenSquare size={24} />
</button>
)}
<nav className="mobile-tabbar" aria-label="Sections">
<nav className="mobile-tabbar" aria-label={t("Sections")}>
<Link href="/mail" className={section === "mail" || section === "search" ? "active" : ""}>
<Mail size={22} />
Mail
+7 -6
View File
@@ -4,6 +4,7 @@ import { useSession } from "@/store/session";
import { ApiError } from "@/jmap/client";
import { DEFAULT_SOURCE_URL } from "@/lib/source";
import { APP_VERSION } from "@/lib/version";
import { t } from "@/lib/i18n";
export function LoginPage() {
const login = useSession((s) => s.login);
@@ -54,7 +55,7 @@ export function LoginPage() {
<div className="logo">
<img src="/img/logo.png" alt="" width={120} height={143} />
<h1 className="notranslate" translate="no">ihasmail</h1>
<p className="tagline">Fast, friendly webmail. Your mailbox, your way.</p>
<p className="tagline">{t("Fast, friendly webmail. Your mailbox, your way.")}</p>
</div>
{error && (
<div className="error-box mb-16" role="alert">
@@ -62,11 +63,11 @@ export function LoginPage() {
</div>
)}
<div className="field">
<label htmlFor="u">Email or username</label>
<label htmlFor="u">{t("Email or username")}</label>
<input id="u" className="input" type="text" autoComplete="username" autoCapitalize="none" autoCorrect="off" spellCheck={false} value={username} onChange={(e) => setUsername(e.target.value)} autoFocus={!username} required />
</div>
<div className="field">
<label htmlFor="p">Password</label>
<label htmlFor="p">{t("Password")}</label>
<div className="pw-wrap">
<input id="p" className="input" type={showPw ? "text" : "password"} autoComplete="current-password" value={password} onChange={(e) => setPassword(e.target.value)} autoFocus={Boolean(username)} required style={{ paddingRight: 40 }} />
<button type="button" className="icon-btn" onClick={() => setShowPw((v) => !v)} aria-label={showPw ? "Hide password" : "Show password"} tabIndex={-1}>
@@ -76,7 +77,7 @@ export function LoginPage() {
</div>
<label className="check" style={{ marginBottom: 4 }}>
<input type="checkbox" checked={trustDevice} onChange={(e) => setTrustDevice(e.target.checked)} />
<span>This is my own device</span>
<span>{t("This is my own device")}</span>
</label>
<p className="hint" style={{ marginBottom: 12 }}>
{trustDevice
@@ -100,9 +101,9 @@ export function LoginPage() {
*/}
<span className="notranslate" translate="no">ihasmail v{APP_VERSION}</span>
<br />
<a href="https://ihasmail.org" target="_blank" rel="noopener noreferrer">ihasmail.org</a>
<a href="https://ihasmail.org" target="_blank" rel="noopener noreferrer">{t("ihasmail.org")}</a>
{" · "}
<a href={sourceUrl} target="_blank" rel="noopener noreferrer">AGPL-3.0 source</a>
<a href={sourceUrl} target="_blank" rel="noopener noreferrer">{t("AGPL-3.0 source")}</a>
</p>
</form>
</div>
+14 -13
View File
@@ -4,6 +4,7 @@ import { Search, SlidersHorizontal, X } from "lucide-react";
import { useMail } from "@/store/mail";
import { keyboard } from "@/lib/keyboard";
import { DateField } from "@/ui/datefield";
import { t } from "@/lib/i18n";
export function SearchBar() {
const [location, navigate] = useLocation();
@@ -55,31 +56,31 @@ export function SearchBar() {
<form className="searchbar" role="search" onSubmit={submit}>
<div className="search-input">
<Search size={18} className="muted" />
<input ref={inputRef} type="search" placeholder="Search mail (from:, to:, subject:, has:attachment, is:unread, in:, before:, after:)" value={q} onChange={(e) => setQ(e.target.value)} aria-label="Search mail" enterKeyHint="search" />
<input ref={inputRef} type="search" placeholder={t("Search mail (from:, to:, subject:, has:attachment, is:unread, in:, before:, after:)")} value={q} onChange={(e) => setQ(e.target.value)} aria-label={t("Search mail")} enterKeyHint="search" />
{q && (
<button type="button" className="icon-btn sm" aria-label="Clear" onClick={() => { setQ(""); if (location.startsWith("/search")) navigate("/mail"); }}>
<button type="button" className="icon-btn sm" aria-label={t("Clear")} onClick={() => { setQ(""); if (location.startsWith("/search")) navigate("/mail"); }}>
<X size={16} />
</button>
)}
<button type="button" className={`icon-btn sm ${adv ? "active" : ""}`} aria-label="Advanced search" title="Advanced search" onClick={() => setAdv((v) => !v)}>
<button type="button" className={`icon-btn sm ${adv ? "active" : ""}`} aria-label={t("Advanced search")} title={t("Advanced search")} onClick={() => setAdv((v) => !v)}>
<SlidersHorizontal size={16} />
</button>
</div>
{adv && (
<div className="search-panel">
<div className="grid">
<label className="field"><span className="label">From</span><input className="input sm" value={advFields.from} onChange={(e) => setAdvFields({ ...advFields, from: e.target.value })} /></label>
<label className="field"><span className="label">To</span><input className="input sm" value={advFields.to} onChange={(e) => setAdvFields({ ...advFields, to: e.target.value })} /></label>
<label className="field"><span className="label">Subject</span><input className="input sm" value={advFields.subject} onChange={(e) => setAdvFields({ ...advFields, subject: e.target.value })} /></label>
<label className="field"><span className="label">Has the words</span><input className="input sm" value={advFields.words} onChange={(e) => setAdvFields({ ...advFields, words: e.target.value })} /></label>
<label className="field"><span className="label">Folder</span>
<label className="field"><span className="label">{t("From")}</span><input className="input sm" value={advFields.from} onChange={(e) => setAdvFields({ ...advFields, from: e.target.value })} /></label>
<label className="field"><span className="label">{t("To")}</span><input className="input sm" value={advFields.to} onChange={(e) => setAdvFields({ ...advFields, to: e.target.value })} /></label>
<label className="field"><span className="label">{t("Subject")}</span><input className="input sm" value={advFields.subject} onChange={(e) => setAdvFields({ ...advFields, subject: e.target.value })} /></label>
<label className="field"><span className="label">{t("Has the words")}</span><input className="input sm" value={advFields.words} onChange={(e) => setAdvFields({ ...advFields, words: e.target.value })} /></label>
<label className="field"><span className="label">{t("Folder")}</span>
<select className="select" style={{ height: 32 }} value={advFields.folder} onChange={(e) => setAdvFields({ ...advFields, folder: e.target.value })}>
<option value="">All mail</option>
<option value="">{t("All mail")}</option>
{Object.values(mailboxes).sort((a, b) => a.name.localeCompare(b.name)).map((m) => <option key={m.id} value={m.name}>{m.name}</option>)}
</select>
</label>
<div className="field"><span className="label">Date</span>
<div className="row"><DateField aria-label="After" value={advFields.after} onChange={(v) => setAdvFields({ ...advFields, after: v })} /><span className="muted">to</span><DateField aria-label="Before" value={advFields.before} onChange={(v) => setAdvFields({ ...advFields, before: v })} /></div>
<div className="field"><span className="label">{t("Date")}</span>
<div className="row"><DateField aria-label={t("After")} value={advFields.after} onChange={(v) => setAdvFields({ ...advFields, after: v })} /><span className="muted">{t("to")}</span><DateField aria-label={t("Before")} value={advFields.before} onChange={(v) => setAdvFields({ ...advFields, before: v })} /></div>
</div>
</div>
<div className="row" style={{ justifyContent: "space-between", marginTop: 4 }}>
@@ -88,8 +89,8 @@ export function SearchBar() {
<label className="check"><input type="checkbox" checked={advFields.unread} onChange={(e) => setAdvFields({ ...advFields, unread: e.target.checked })} /> Unread only</label>
</div>
<div className="row">
<button type="button" className="btn btn-ghost" onClick={() => setAdv(false)}>Cancel</button>
<button type="button" className="btn btn-primary" onClick={applyAdvanced}>Search</button>
<button type="button" className="btn btn-ghost" onClick={() => setAdv(false)}>{t("Cancel")}</button>
<button type="button" className="btn btn-primary" onClick={applyAdvanced}>{t("Search")}</button>
</div>
</div>
</div>
+2 -1
View File
@@ -5,6 +5,7 @@ import { useMail } from "@/store/mail";
import { useCompose } from "@/store/compose";
import { Dialog } from "@/ui/dialog";
import { Kbd } from "@/ui/misc";
import { t } from "@/lib/i18n";
export function useGlobalShortcuts({ onHelp }: { onHelp: () => void }) {
const [, navigate] = useLocation();
@@ -41,7 +42,7 @@ export function ShortcutsDialog({ open, onClose }: { open: boolean; onClose: ()
return [...g.entries()];
}, [list]);
return (
<Dialog open={open} onClose={onClose} title="Keyboard shortcuts" size="lg">
<Dialog open={open} onClose={onClose} title={t("Keyboard shortcuts")} size="lg">
<div className="shortcut-grid">
{groups.map(([group, items]) => (
<div key={group}>
+11 -10
View File
@@ -10,6 +10,7 @@ import { toast } from "@/ui/toast";
import { askDeleteScope, askEditScope, droppedMessage, runScoped } from "./scope";
import { toLocalDateOnly } from "@/lib/dates";
import { formatTime } from "@/lib/format";
import { t } from "@/lib/i18n";
export type CalendarContext =
| { kind: "event"; inst: EventInstance; anchor: Anchor }
@@ -50,10 +51,10 @@ export function CalendarContextMenu({ ctx, onClose, onOpen, onEdit, onCreate }:
return (
<Popover anchor={ctx.anchor} onClose={onClose} width={240}>
<MenuItem icon={<Plus size={16} />} label={allDay ? `New all-day event on ${formatDayMonth(start)}` : `New event at ${formatTime(start)}`} onClick={() => onCreate(start, end, allDay)} />
{!allDay && <MenuItem icon={<CalendarDays size={16} />} label="New all-day event" onClick={() => { const d = new Date(start); d.setHours(0, 0, 0, 0); onCreate(d, new Date(d.getTime() + 86400000), true); }} />}
{!allDay && <MenuItem icon={<CalendarDays size={16} />} label={t("New all-day event")} onClick={() => { const d = new Date(start); d.setHours(0, 0, 0, 0); onCreate(d, new Date(d.getTime() + 86400000), true); }} />}
<MenuSep />
<MenuItem icon={<CalIcon size={16} />} label="Go to day" onClick={() => navigate(`/calendar/day/${toLocalDateOnly(start)}`)} />
<MenuItem icon={<CalIcon size={16} />} label="Go to week" onClick={() => navigate(`/calendar/week/${toLocalDateOnly(start)}`)} />
<MenuItem icon={<CalIcon size={16} />} label={t("Go to day")} onClick={() => navigate(`/calendar/day/${toLocalDateOnly(start)}`)} />
<MenuItem icon={<CalIcon size={16} />} label={t("Go to week")} onClick={() => navigate(`/calendar/week/${toLocalDateOnly(start)}`)} />
</Popover>
);
}
@@ -109,9 +110,9 @@ export function CalendarContextMenu({ ctx, onClose, onOpen, onEdit, onCreate }:
return (
<Popover anchor={ctx.anchor} onClose={onClose} width={260} closeOnClick={false}>
<MenuItem icon={<ExternalLink size={16} />} label="Open" onClick={() => { onClose(); onOpen(inst, ctx.anchor); }} />
{canEdit && <MenuItem icon={<Pencil size={16} />} label="Edit…" onClick={() => { onClose(); onEdit(inst); }} />}
{canEdit && <MenuItem icon={<Copy size={16} />} label="Duplicate" onClick={() => { onClose(); void duplicate(); }} />}
<MenuItem icon={<ExternalLink size={16} />} label={t("Open")} onClick={() => { onClose(); onOpen(inst, ctx.anchor); }} />
{canEdit && <MenuItem icon={<Pencil size={16} />} label={t("Edit…")} onClick={() => { onClose(); onEdit(inst); }} />}
{canEdit && <MenuItem icon={<Copy size={16} />} label={t("Duplicate")} onClick={() => { onClose(); void duplicate(); }} />}
{canEdit && (
<>
<MenuSep />
@@ -119,8 +120,8 @@ export function CalendarContextMenu({ ctx, onClose, onOpen, onEdit, onCreate }:
{categories.map((c) => (
<MenuItem key={c.name} label={<span className="row gap-8"><span className="label-dot" style={{ background: c.color, width: 12, height: 12 }} />{c.name}</span>} checked={currentCat?.name === c.name} onClick={() => { onClose(); setCategory(currentCat?.name === c.name ? null : c); }} />
))}
<MenuItem icon={<X size={16} />} label="No category" disabled={!currentCat} onClick={() => { onClose(); setCategory(null); }} />
<MenuItem icon={<Tag size={16} />} label="Manage categories…" onClick={() => { onClose(); navigate("/settings/calendar"); }} />
<MenuItem icon={<X size={16} />} label={t("No category")} disabled={!currentCat} onClick={() => { onClose(); setCategory(null); }} />
<MenuItem icon={<Tag size={16} />} label={t("Manage categories…")} onClick={() => { onClose(); navigate("/settings/calendar"); }} />
{/*
A colour is what a category already carries, so a second way to set
one just made two things that could disagree. Picking a category is
@@ -131,9 +132,9 @@ export function CalendarContextMenu({ ctx, onClose, onOpen, onEdit, onCreate }:
another client — would otherwise ignore its category for ever with
nothing on the menu to say why.
*/}
{ev.color && <MenuItem icon={<Palette size={16} />} label="Clear custom colour" onClick={() => { onClose(); setColor(null); }} />}
{ev.color && <MenuItem icon={<Palette size={16} />} label={t("Clear custom colour")} onClick={() => { onClose(); setColor(null); }} />}
<MenuSep />
<MenuItem danger icon={<Trash2 size={16} />} label="Delete" onClick={() => void del()} />
<MenuItem danger icon={<Trash2 size={16} />} label={t("Delete")} onClick={() => void del()} />
</>
)}
</Popover>
+10 -9
View File
@@ -5,6 +5,7 @@ import { Dialog } from "@/ui/dialog";
import { ColorSwatches } from "@/ui/misc";
import { toast } from "@/ui/toast";
import { browserTimeZone, listTimeZones } from "@/lib/dates";
import { t as translate } from "@/lib/i18n";
export function CalendarDialog({ calendar, onClose }: { calendar: Partial<Calendar>; onClose: () => void }) {
const cal = useCalendar();
@@ -30,21 +31,21 @@ export function CalendarDialog({ calendar, onClose }: { calendar: Partial<Calend
}
};
return (
<Dialog open onClose={onClose} title={calendar.id ? "Edit calendar" : "New calendar"} size="sm" footer={<><button className="btn" onClick={onClose}>Cancel</button><button className="btn btn-primary" disabled={busy || !name.trim()} onClick={() => void save()}>Save</button></>}>
<div className="field"><label>Name</label><input className="input" autoFocus value={name} onChange={(e) => setName(e.target.value)} /></div>
<div className="field"><label>Color</label><ColorSwatches value={color} onChange={setColor} /></div>
<div className="field"><label>Description</label><input className="input" value={description} onChange={(e) => setDescription(e.target.value)} /></div>
<div className="field"><label>Time zone</label>
<Dialog open onClose={onClose} title={calendar.id ? "Edit calendar" : "New calendar"} size="sm" footer={<><button className="btn" onClick={onClose}>{translate("Cancel")}</button><button className="btn btn-primary" disabled={busy || !name.trim()} onClick={() => void save()}>{translate("Save")}</button></>}>
<div className="field"><label>{translate("Name")}</label><input className="input" autoFocus value={name} onChange={(e) => setName(e.target.value)} /></div>
<div className="field"><label>{translate("Color")}</label><ColorSwatches value={color} onChange={setColor} /></div>
<div className="field"><label>{translate("Description")}</label><input className="input" value={description} onChange={(e) => setDescription(e.target.value)} /></div>
<div className="field"><label>{translate("Time zone")}</label>
<select className="select" value={tz} onChange={(e) => setTz(e.target.value)}>
<option value="">Default ({browserTimeZone})</option>
{listTimeZones().map((t) => <option key={t} value={t}>{t}</option>)}
</select>
</div>
<div className="field"><label>Free/busy</label>
<div className="field"><label>{translate("Free/busy")}</label>
<select className="select" value={avail} onChange={(e) => setAvail(e.target.value as Calendar["includeInAvailability"])}>
<option value="all">Count all events as busy</option>
<option value="attending">Only events I'm attending</option>
<option value="none">Don't include in availability</option>
<option value="all">{translate("Count all events as busy")}</option>
<option value="attending">{translate("Only events I'm attending")}</option>
<option value="none">{translate("Don't include in availability")}</option>
</select>
</div>
</Dialog>
+18 -17
View File
@@ -12,6 +12,7 @@ import { toast } from "@/ui/toast";
import type { Calendar } from "@/jmap/types";
import { CalendarDialog } from "./CalendarDialog";
import { ShareDialog } from "../settings/ShareDialog";
import { t } from "@/lib/i18n";
export function CalendarSidebar() {
const [location, navigate] = useLocation();
@@ -45,9 +46,9 @@ export function CalendarSidebar() {
<div style={{ padding: "4px 8px" }}>
<div className="mini-cal">
<div className="mc-head">
<button className="icon-btn xs" onClick={() => setAnchor(addMonths(anchor, -1))} aria-label="Previous month"><ChevronLeft size={16} /></button>
<button className="icon-btn xs" onClick={() => setAnchor(addMonths(anchor, -1))} aria-label={t("Previous month")}><ChevronLeft size={16} /></button>
<span>{formatMonthYear(anchor)}</span>
<button className="icon-btn xs" onClick={() => setAnchor(addMonths(anchor, 1))} aria-label="Next month"><ChevronRight size={16} /></button>
<button className="icon-btn xs" onClick={() => setAnchor(addMonths(anchor, 1))} aria-label={t("Next month")}><ChevronRight size={16} /></button>
</div>
<div className="mc-grid">
{dow.map((d, i) => <div key={i} className="mc-dow">{d}</div>)}
@@ -59,16 +60,16 @@ export function CalendarSidebar() {
</div>
</div>
<div className="nav-section" style={{ paddingLeft: 4 }}>
<span>My calendars</span>
<button className="icon-btn" title="New calendar" onClick={() => setEditCal({})}><Plus size={16} /></button>
<span>{t("My calendars")}</span>
<button className="icon-btn" title={t("New calendar")} onClick={() => setEditCal({})}><Plus size={16} /></button>
</div>
{calendars.map((c) => (
<div key={c.id} className={`cal-list-item ${cal.hidden[c.id] ? "hidden-cal" : ""}`} onClick={() => cal.toggleHidden(c.id)} onContextMenu={(e) => { e.preventDefault(); setMenuCal(c); menu.openAt(e.clientX, e.clientY); }}>
<span className="cal-color" style={{ background: c.color ?? "var(--accent)", borderColor: c.color ?? "var(--accent)" }} />
<span className="cal-name">{c.name}</span>
{Object.keys(c.shareWith ?? {}).length > 0 && <Share2 size={12} className="faint" aria-label="Shared" />}
{Object.keys(c.shareWith ?? {}).length > 0 && <Share2 size={12} className="faint" aria-label={t("Shared")} />}
{c.isDefault && <Star size={12} className="faint" />}
<button className="icon-btn xs nav-more" onClick={(e) => { e.stopPropagation(); setMenuCal(c); menu.open(e); }} aria-label="Calendar options"><MoreVertical size={14} /></button>
<button className="icon-btn xs nav-more" onClick={(e) => { e.stopPropagation(); setMenuCal(c); menu.open(e); }} aria-label={t("Calendar options")}><MoreVertical size={14} /></button>
</div>
))}
{/* Calendars other people shared, split by whether the reader has added
@@ -78,7 +79,7 @@ export function CalendarSidebar() {
and adding one is a deliberate act rather than a guess on our part. */}
{sharedSubscribed.length > 0 && (
<>
<div className="nav-section"><span>Shared with me</span></div>
<div className="nav-section"><span>{t("Shared with me")}</span></div>
{sharedSubscribed.map(({ accountId, accountName, calendar: c }) => {
const key = `${accountId}:${c.id}`;
return (
@@ -87,8 +88,8 @@ export function CalendarSidebar() {
<span className="cal-name">{c.name}</span>
<button
className="icon-btn xs nav-more"
title="Remove from my calendar"
aria-label="Remove from my calendar"
title={t("Remove from my calendar")}
aria-label={t("Remove from my calendar")}
onClick={(e) => { e.stopPropagation(); void cal.setSharedSubscribed(accountId, c.id, false); }}
>
<X size={14} />
@@ -100,15 +101,15 @@ export function CalendarSidebar() {
)}
{sharedAvailable.length > 0 && (
<>
<div className="nav-section"><span>Available to add</span></div>
<div className="nav-section"><span>{t("Available to add")}</span></div>
{sharedAvailable.map(({ accountId, accountName, calendar: c }) => (
<div key={`${accountId}:${c.id}`} className="cal-list-item" title={`${c.name} — from ${accountName}`}>
<span className="cal-color" style={{ background: "transparent", borderColor: c.color ?? "var(--border-strong)" }} />
<span className="cal-name faint">{c.name}</span>
<button
className="icon-btn xs nav-more"
title="Add to my calendar"
aria-label="Add to my calendar"
title={t("Add to my calendar")}
aria-label={t("Add to my calendar")}
onClick={(e) => { e.stopPropagation(); void cal.setSharedSubscribed(accountId, c.id, true); }}
>
<Plus size={14} />
@@ -122,15 +123,15 @@ export function CalendarSidebar() {
{menuCal && (
<>
<MenuItem icon={cal.hidden[menuCal.id] ? <Eye size={16} /> : <EyeOff size={16} />} label={cal.hidden[menuCal.id] ? "Show" : "Hide"} onClick={() => cal.toggleHidden(menuCal.id)} />
<MenuItem icon={<Pencil size={16} />} label="Edit" onClick={() => setEditCal(menuCal)} />
<MenuItem icon={<Share2 size={16} />} label="Share…" onClick={() => setShare(menuCal)} disabled={!menuCal.myRights.mayShare} />
<MenuItem icon={<Pencil size={16} />} label={t("Edit")} onClick={() => setEditCal(menuCal)} />
<MenuItem icon={<Share2 size={16} />} label={t("Share…")} onClick={() => setShare(menuCal)} disabled={!menuCal.myRights.mayShare} />
{/* Revoking every share at once, without walking the dialog and
removing people one at a time. Only offered when there is
something to revoke. */}
{Object.keys(menuCal.shareWith ?? {}).length > 0 && (
<MenuItem
icon={<UserMinus size={16} />}
label="Stop sharing"
label={t("Stop sharing")}
disabled={!menuCal.myRights.mayShare}
onClick={async () => {
const who = Object.keys(menuCal.shareWith ?? {}).length;
@@ -149,9 +150,9 @@ export function CalendarSidebar() {
}}
/>
)}
<MenuItem icon={<Star size={16} />} label="Make default" disabled={menuCal.isDefault} onClick={() => void cal.updateCalendar(menuCal.id, { isDefault: true } as Partial<Calendar>).catch((err) => toast.error((err as Error).message))} />
<MenuItem icon={<Star size={16} />} label={t("Make default")} disabled={menuCal.isDefault} onClick={() => void cal.updateCalendar(menuCal.id, { isDefault: true } as Partial<Calendar>).catch((err) => toast.error((err as Error).message))} />
<MenuSep />
<MenuItem danger icon={<Trash2 size={16} />} label="Delete" disabled={!menuCal.myRights.mayDelete} onClick={async () => { if (await confirmDialog({ title: `Delete “${menuCal.name}”?`, message: "All events in this calendar will be deleted.", confirmLabel: "Delete", danger: true })) void cal.destroyCalendar(menuCal.id).catch((err) => toast.error((err as Error).message)); }} />
<MenuItem danger icon={<Trash2 size={16} />} label={t("Delete")} disabled={!menuCal.myRights.mayDelete} onClick={async () => { if (await confirmDialog({ title: `Delete “${menuCal.name}”?`, message: "All events in this calendar will be deleted.", confirmLabel: "Delete", danger: true })) void cal.destroyCalendar(menuCal.id).catch((err) => toast.error((err as Error).message)); }} />
</>
)}
</Popover>
+9 -8
View File
@@ -12,6 +12,7 @@ import { EventPopover } from "./EventPopover";
import { EventEditor, type EditorInit } from "./EventEditor";
import type { Anchor } from "@/ui/popover";
import { CalendarContextMenu, eventColor, type CalendarContext } from "./CalendarContextMenu";
import { t as translate } from "@/lib/i18n";
type View = "month" | "week" | "day" | "agenda";
const HOUR_H = 48;
@@ -91,7 +92,7 @@ export function CalendarView({ view: viewParam, date }: { view?: string; date?:
);
if (!cal.available) {
return <div className="p-16"><Empty icon={<CalIcon size={40} />} title="Calendar is not available">This account does not have the JMAP calendars capability.</Empty></div>;
return <div className="p-16"><Empty icon={<CalIcon size={40} />} title={translate("Calendar is not available")}>{translate("This account does not have the JMAP calendars capability.")}</Empty></div>;
}
const title =
@@ -118,9 +119,9 @@ export function CalendarView({ view: viewParam, date }: { view?: string; date?:
return (
<div className="cal-main">
<div className="cal-toolbar">
<button className="btn btn-sm" onClick={() => go(view, new Date())}>Today</button>
<button className="icon-btn sm" onClick={() => step(-1)} aria-label="Previous"><ChevronLeft size={18} /></button>
<button className="icon-btn sm" onClick={() => step(1)} aria-label="Next"><ChevronRight size={18} /></button>
<button className="btn btn-sm" onClick={() => go(view, new Date())}>{translate("Today")}</button>
<button className="icon-btn sm" onClick={() => step(-1)} aria-label={translate("Previous")}><ChevronLeft size={18} /></button>
<button className="icon-btn sm" onClick={() => step(1)} aria-label={translate("Next")}><ChevronRight size={18} /></button>
<h2 className="truncate">{title}</h2>
<span className="spacer" />
{cal.loading && <span className="spinner" />}
@@ -136,7 +137,7 @@ export function CalendarView({ view: viewParam, date }: { view?: string; date?:
{(effectiveView === "week" || effectiveView === "day") && <TimeGrid days={effectiveView === "week" ? weekDays(anchor, weekStart) : [anchor]} onEvent={onEvent} onEventContext={onEventContext} onSlotContext={onSlotContext} onCreate={(s, e, allDay) => openNew(s, e, allDay)} onDayHeader={(d) => go("day", d)} workStart={settings.workDayStart} workEnd={settings.workDayEnd} />}
{effectiveView === "agenda" && <AgendaView start={anchor} onEvent={onEvent} onEventContext={onEventContext} />}
{ctx && <CalendarContextMenu ctx={ctx} onClose={() => setCtx(null)} onOpen={(inst, a) => setPopover({ inst, anchor: a })} onEdit={(inst) => setEditor({ event: inst.event, start: inst.start, end: inst.end, allDay: inst.allDay })} onCreate={(s, e, allDay) => { setCtx(null); openNew(s, e, allDay); }} />}
{isMobile && <button className="fab" aria-label="New event" onClick={() => openNew()}><Plus size={24} /></button>}
{isMobile && <button className="fab" aria-label={translate("New event")} onClick={() => openNew()}><Plus size={24} /></button>}
{popover && <EventPopover inst={popover.inst} anchor={popover.anchor} onClose={() => setPopover(null)} onEdit={() => { setEditor({ event: popover.inst.event, start: popover.inst.start, end: popover.inst.end, allDay: popover.inst.allDay }); setPopover(null); }} />}
{editor && <EventEditor init={editor} onClose={() => setEditor(null)} />}
</div>
@@ -250,7 +251,7 @@ function TimeGrid({ days, onEvent, onEventContext, onSlotContext, onCreate, onDa
))}
</div>
<div className="week-allday">
<div className="ad-label">all-day</div>
<div className="ad-label">{translate("all-day")}</div>
{days.map((d) => (
<div key={d.toISOString()} className="ad-cell" onClick={() => onCreate(d, addDays(d, 1), true)} onContextMenu={(e) => onSlotContext(d, addDays(d, 1), true, e)}>
{allDay(d).map((i) => <EventChip key={i.key} inst={i} day={d} onClick={(el) => onEvent(i, el)} onContext={(e) => onEventContext(i, e)} />)}
@@ -311,7 +312,7 @@ function TimeGrid({ days, onEvent, onEventContext, onSlotContext, onCreate, onDa
})}
{drag && isSameDay(drag.day, d) && (
<div className="ev-block draft-new" style={{ top: (drag.startMin / 60) * HOUR_H, height: ((drag.endMin - drag.startMin) / 60) * HOUR_H, left: 0, width: "calc(100% - 3px)", background: "var(--accent)" }}>
<div className="ev-title">(new event)</div>
<div className="ev-title">{translate("(new event)")}</div>
<div className="ev-time">{formatTime(new Date(d.getTime() + drag.startMin * 60_000))} {formatTime(new Date(d.getTime() + drag.endMin * 60_000))}</div>
</div>
)}
@@ -394,7 +395,7 @@ function AgendaView({ start, onEvent, onEventContext }: { start: Date; onEvent:
}
return [...map.values()].sort((a, b) => a.day.getTime() - b.day.getTime());
}, [instances, start, end]);
if (!byDay.length) return <Empty icon={<CalIcon size={36} />} title="Nothing scheduled">No events in the next 60 days.</Empty>;
if (!byDay.length) return <Empty icon={<CalIcon size={36} />} title={translate("Nothing scheduled")}>{translate("No events in the next 60 days.")}</Empty>;
return (
<div className="agenda">
{byDay.map(({ day, items }) => (
+34 -33
View File
@@ -15,6 +15,7 @@ import { formatClock, formatNumericDate, formatWeekday } from "@/lib/datetime";
import { WEEKDAYS, describeRule, presetFor, ruleFromPreset, type RecurrencePreset } from "@/lib/recurrence";
import { newKey } from "@/lib/contacts";
import { askEditScope, droppedMessage, runScoped } from "./scope";
import { t as translate } from "@/lib/i18n";
export interface EditorInit {
event?: CalendarEvent;
@@ -258,7 +259,7 @@ function EventForm({ init, base, scope, editing, onClose, settingsTz, defaultAle
}, [start]);
return (
<Dialog open onClose={onClose} title={editing ? "Edit event" : "New event"} size="lg" footer={<><button className="btn" onClick={onClose}>Cancel</button><button className="btn btn-primary" disabled={busy} onClick={() => void save()}>{busy ? "Saving…" : editing ? "Save" : attendees.length && sendInvites ? "Send invites" : "Create"}</button></>}>
<Dialog open onClose={onClose} title={editing ? "Edit event" : "New event"} size="lg" footer={<><button className="btn" onClick={onClose}>{translate("Cancel")}</button><button className="btn btn-primary" disabled={busy} onClick={() => void save()}>{busy ? "Saving…" : editing ? "Save" : attendees.length && sendInvites ? "Send invites" : "Create"}</button></>}>
<div className="event-form">
{ev && isRecurring(ev) && (
<div className="info-box mb-16">
@@ -267,49 +268,49 @@ function EventForm({ init, base, scope, editing, onClose, settingsTz, defaultAle
: "This is a recurring event — changes apply to the whole series."}
</div>
)}
<div className="field"><input className="input" style={{ fontSize: "1.1em", height: 44 }} placeholder="Add title" autoFocus value={title} onChange={(e) => setTitle(e.target.value)} /></div>
<div className="field"><input className="input" style={{ fontSize: "1.1em", height: 44 }} placeholder={translate("Add title")} autoFocus value={title} onChange={(e) => setTitle(e.target.value)} /></div>
<div className="time-row mb-8">
{allDay ? (
<>
<DateField aria-label="Starts" value={toLocalDateOnly(start)} onChange={(v) => v && onStartChange(new Date(`${v}T00:00:00`))} />
<span className="muted center">to</span>
<DateField aria-label="Ends" value={toLocalDateOnly(new Date(end.getTime() - 1))} onChange={(v) => v && setEnd(new Date(new Date(`${v}T00:00:00`).getTime() + DAY_MS))} />
<DateField aria-label={translate("Starts")} value={toLocalDateOnly(start)} onChange={(v) => v && onStartChange(new Date(`${v}T00:00:00`))} />
<span className="muted center">{translate("to")}</span>
<DateField aria-label={translate("Ends")} value={toLocalDateOnly(new Date(end.getTime() - 1))} onChange={(v) => v && setEnd(new Date(new Date(`${v}T00:00:00`).getTime() + DAY_MS))} />
</>
) : (
<>
<DateTimeField aria-label="Starts" value={toInputDateTime(start)} onChange={(v) => v && onStartChange(fromInputDateTime(v))} />
<span className="muted center">to</span>
<DateTimeField aria-label="Ends" value={toInputDateTime(end)} onChange={(v) => v && setEnd(fromInputDateTime(v))} />
<DateTimeField aria-label={translate("Starts")} value={toInputDateTime(start)} onChange={(v) => v && onStartChange(fromInputDateTime(v))} />
<span className="muted center">{translate("to")}</span>
<DateTimeField aria-label={translate("Ends")} value={toInputDateTime(end)} onChange={(v) => v && setEnd(fromInputDateTime(v))} />
</>
)}
</div>
<div className="row wrap" style={{ gap: 16, marginBottom: 8 }}>
<label className="check"><input type="checkbox" checked={allDay} onChange={(e) => { setAllDay(e.target.checked); if (e.target.checked) { const s = new Date(start); s.setHours(0, 0, 0, 0); setStart(s); setEnd(new Date(s.getTime() + Math.max(DAY_MS, Math.ceil((end.getTime() - s.getTime()) / DAY_MS) * DAY_MS))); } }} /> All day</label>
{!allDay && (
<select className="select" style={{ width: "auto", height: 32 }} value={tz} onChange={(e) => setTz(e.target.value)} title="Time zone">
<select className="select" style={{ width: "auto", height: 32 }} value={tz} onChange={(e) => setTz(e.target.value)} title={translate("Time zone")}>
{!listTimeZones().includes(tz) && <option value={tz}>{tz}</option>}
{listTimeZones().map((t) => <option key={t} value={t}>{t}</option>)}
</select>
)}
{!oneDate && (
<select className="select" style={{ width: "auto", height: 32 }} value={preset} onChange={(e) => { const p = e.target.value as RecurrencePreset; setPreset(p); if (p === "custom") setRule(rule ?? { "@type": "RecurrenceRule", frequency: "weekly", byDay: [{ "@type": "NDay", day: WEEKDAYS[(start.getDay() + 6) % 7]!.key }] }); else setRule(ruleFromPreset(p, start)); }}>
<option value="none">Does not repeat</option>
<option value="daily">Daily</option>
<option value="none">{translate("Does not repeat")}</option>
<option value="daily">{translate("Daily")}</option>
<option value="weekly">Weekly on {formatWeekday(start, "long")}</option>
<option value="weekdays">Every weekday</option>
<option value="weekdays">{translate("Every weekday")}</option>
<option value="monthly">Monthly on day {start.getDate()}</option>
<option value="yearly">Yearly</option>
<option value="custom">Custom</option>
<option value="yearly">{translate("Yearly")}</option>
<option value="custom">{translate("Custom…")}</option>
</select>
)}
</div>
{!oneDate && preset === "custom" && (
<div className="card" style={{ marginBottom: 12 }}>
<div className="row wrap" style={{ gap: 8 }}>
<span>Repeat every</span>
<span>{translate("Repeat every")}</span>
<input className="input" type="number" min={1} style={{ width: 70 }} value={customRule.interval ?? 1} onChange={(e) => setRule({ ...customRule, interval: Math.max(1, Number(e.target.value)) })} />
<select className="select" style={{ width: "auto" }} value={customRule.frequency} onChange={(e) => setRule({ ...customRule, frequency: e.target.value as JSCalendarRecurrenceRule["frequency"], byDay: e.target.value === "weekly" ? customRule.byDay : undefined, byMonthDay: e.target.value === "monthly" ? [start.getDate()] : undefined })}>
<option value="daily">day(s)</option><option value="weekly">week(s)</option><option value="monthly">month(s)</option><option value="yearly">year(s)</option>
<option value="daily">{translate("day(s)")}</option><option value="weekly">{translate("week(s)")}</option><option value="monthly">{translate("month(s)")}</option><option value="yearly">{translate("year(s)")}</option>
</select>
</div>
{customRule.frequency === "weekly" && (
@@ -321,33 +322,33 @@ function EventForm({ init, base, scope, editing, onClose, settingsTz, defaultAle
</div>
)}
<div className="row wrap" style={{ gap: 8, marginTop: 8 }}>
<span>Ends</span>
<span>{translate("Ends")}</span>
<select className="select" style={{ width: "auto" }} value={customRule.until ? "until" : customRule.count ? "count" : "never"} onChange={(e) => { const v = e.target.value; setRule({ ...customRule, until: v === "until" ? `${toLocalDateOnly(new Date(start.getTime() + 30 * DAY_MS))}T23:59:59` : undefined, count: v === "count" ? 10 : undefined }); }}>
<option value="never">never</option><option value="until">on date</option><option value="count">after N times</option>
<option value="never">{translate("never")}</option><option value="until">{translate("on date")}</option><option value="count">{translate("after N times")}</option>
</select>
{customRule.until && <DateField aria-label="Repeat until" className="w-auto" value={customRule.until.slice(0, 10)} onChange={(v) => v && setRule({ ...customRule, until: `${v}T23:59:59` })} />}
{customRule.until && <DateField aria-label={translate("Repeat until")} className="w-auto" value={customRule.until.slice(0, 10)} onChange={(v) => v && setRule({ ...customRule, until: `${v}T23:59:59` })} />}
{customRule.count && <input className="input" type="number" min={1} style={{ width: 80 }} value={customRule.count} onChange={(e) => setRule({ ...customRule, count: Math.max(1, Number(e.target.value)) })} />}
</div>
<div className="hint mt-8">{describeRule(customRule)}</div>
</div>
)}
<div className="field-row">
<div className="field"><label>Calendar</label>
<div className="field"><label>{translate("Calendar")}</label>
<select className="select" value={calendarId} disabled={oneDate} title={oneDate ? "An occurrence cannot be moved to another calendar on its own" : undefined} onChange={(e) => setCalendarId(e.target.value)}>
{calendars.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
<div className="field"><label>Location</label><input className="input" value={location} onChange={(e) => setLocation(e.target.value)} placeholder="Add location" /></div>
<div className="field"><label>{translate("Location")}</label><input className="input" value={location} onChange={(e) => setLocation(e.target.value)} placeholder={translate("Add location")} /></div>
</div>
<div className="field"><label>Meeting link</label><input className="input" value={vurl} onChange={(e) => setVurl(e.target.value)} placeholder="https://meet.example.com/…" /></div>
<div className="field"><label>{translate("Meeting link")}</label><input className="input" value={vurl} onChange={(e) => setVurl(e.target.value)} placeholder={translate("https://meet.example.com/…")} /></div>
<div className="field">
<label><Users size={13} /> Guests</label>
<div className="input" style={{ height: "auto", minHeight: 38, padding: "4px 8px" }}>
<RecipientInput value={attendees} onChange={setAttendees} placeholder="Add guests by name or email" />
<RecipientInput value={attendees} onChange={setAttendees} placeholder={translate("Add guests by name or email")} />
</div>
{attendees.length > 0 && (
<>
<Switch checked={sendInvites} onChange={setSendInvites} label="Send invitation emails to guests" />
<Switch checked={sendInvites} onChange={setSendInvites} label={translate("Send invitation emails to guests")} />
{Object.keys(fb).length > 0 && (
<div className="freebusy">
<div className="hint">Availability on {formatNumericDate(start)}</div>
@@ -370,16 +371,16 @@ function EventForm({ init, base, scope, editing, onClose, settingsTz, defaultAle
</>
)}
</div>
<div className="field"><label>Description</label><textarea className="textarea" value={description} onChange={(e) => setDescription(e.target.value)} rows={3} /></div>
<div className="field"><label>{translate("Description")}</label><textarea className="textarea" value={description} onChange={(e) => setDescription(e.target.value)} rows={3} /></div>
<div className="field">
<label>Reminders</label>
<label>{translate("Reminders")}</label>
<div className="alerts-list">
{alerts.map((m, i) => (
<div key={i} className="row">
<select className="select" style={{ width: "auto" }} value={String(m)} onChange={(e) => setAlerts(alerts.map((x, j) => (j === i ? Number(e.target.value) : x)))}>
{[...new Set([...ALERT_OPTIONS, m])].sort((a, b) => a - b).map((o) => <option key={o} value={o}>{o === 0 ? "At time of event" : `${humanDuration(o * 60)} before`}</option>)}
</select>
<button className="icon-btn sm danger" onClick={() => setAlerts(alerts.filter((_, j) => j !== i))} aria-label="Remove reminder"><Trash2 size={16} /></button>
<button className="icon-btn sm danger" onClick={() => setAlerts(alerts.filter((_, j) => j !== i))} aria-label={translate("Remove reminder")}><Trash2 size={16} /></button>
</div>
))}
<button className="btn btn-ghost btn-sm" style={{ alignSelf: "flex-start" }} onClick={() => setAlerts([...alerts, 10])}><Plus size={14} /> Add reminder</button>
@@ -389,17 +390,17 @@ function EventForm({ init, base, scope, editing, onClose, settingsTz, defaultAle
{showMore && (
<div className="mt-8">
<div className="field-row">
<div className="field"><label>Status</label><select className="select" value={status} onChange={(e) => setStatus(e.target.value as typeof status)}><option value="confirmed">Confirmed</option><option value="tentative">Tentative</option><option value="cancelled">Cancelled</option></select></div>
<div className="field"><label>Show as</label><select className="select" value={freeBusy} onChange={(e) => setFreeBusy(e.target.value as typeof freeBusy)}><option value="busy">Busy</option><option value="free">Free</option></select></div>
{!oneDate && <div className="field"><label>Visibility</label><select className="select" value={privacy} onChange={(e) => setPrivacy(e.target.value as typeof privacy)}><option value="public">Default</option><option value="private">Private</option><option value="secret">Secret</option></select></div>}
<div className="field"><label>{translate("Status")}</label><select className="select" value={status} onChange={(e) => setStatus(e.target.value as typeof status)}><option value="confirmed">{translate("Confirmed")}</option><option value="tentative">{translate("Tentative")}</option><option value="cancelled">{translate("Cancelled")}</option></select></div>
<div className="field"><label>{translate("Show as")}</label><select className="select" value={freeBusy} onChange={(e) => setFreeBusy(e.target.value as typeof freeBusy)}><option value="busy">{translate("Busy")}</option><option value="free">{translate("Free")}</option></select></div>
{!oneDate && <div className="field"><label>{translate("Visibility")}</label><select className="select" value={privacy} onChange={(e) => setPrivacy(e.target.value as typeof privacy)}><option value="public">{translate("Default")}</option><option value="private">{translate("Private")}</option><option value="secret">{translate("Secret")}</option></select></div>}
</div>
<div className="field"><label>Category</label>
<div className="field"><label>{translate("Category")}</label>
<select className="select" value={category} onChange={(e) => setCategory(e.target.value)}>
<option value="">None</option>
<option value="">{translate("None")}</option>
{categories.map((c) => <option key={c.name} value={c.name}>{c.name}</option>)}
</select>
</div>
<div className="field"><label>Color</label><div className="row wrap"><ColorSwatches value={color} onChange={setColor} />{color && <button className="btn btn-ghost btn-sm" onClick={() => setColor(null)}>Use {category ? "category" : "calendar"} color</button>}</div></div>
<div className="field"><label>{translate("Color")}</label><div className="row wrap"><ColorSwatches value={color} onChange={setColor} />{color && <button className="btn btn-ghost btn-sm" onClick={() => setColor(null)}>Use {category ? "category" : "calendar"} color</button>}</div></div>
</div>
)}
</div>
+8 -7
View File
@@ -10,6 +10,7 @@ import { describeRule } from "@/lib/recurrence";
import { useCompose } from "@/store/compose";
import { useSettings } from "@/store/settings";
import { categoryOf, eventColor } from "./CalendarContextMenu";
import { t } from "@/lib/i18n";
export function EventPopover({ inst, anchor, onClose, onEdit }: { inst: EventInstance; anchor: Anchor; onClose: () => void; onEdit: () => void }) {
const cal = useCalendar();
@@ -67,9 +68,9 @@ export function EventPopover({ inst, anchor, onClose, onEdit }: { inst: EventIns
return (
<Popover anchor={anchor} onClose={onClose} className="event-popover" closeOnClick={false} side="right" role="dialog" style={{ "--ev-color": color } as React.CSSProperties}>
<div className="row" style={{ justifyContent: "flex-end", gap: 0, marginBottom: -4 }}>
{canEdit && <button className="icon-btn sm" title="Edit" onClick={onEdit}><Pencil size={16} /></button>}
{canEdit && <button className="icon-btn sm danger" title="Delete" onClick={() => void del()} disabled={busy}><Trash2 size={16} /></button>}
<button className="icon-btn sm" title="Close" onClick={onClose}><X size={16} /></button>
{canEdit && <button className="icon-btn sm" title={t("Edit")} onClick={onEdit}><Pencil size={16} /></button>}
{canEdit && <button className="icon-btn sm danger" title={t("Delete")} onClick={() => void del()} disabled={busy}><Trash2 size={16} /></button>}
<button className="icon-btn sm" title={t("Close")} onClick={onClose}><X size={16} /></button>
</div>
<h3>{ev.title || "(untitled)"}</h3>
<div className="ev-line"><Clock size={15} /><span>{formatTimeRange(inst.start, inst.end, inst.allDay)}{ev.timeZone && !inst.allDay ? <span className="hint"> · {ev.timeZone}</span> : null}</span></div>
@@ -82,14 +83,14 @@ export function EventPopover({ inst, anchor, onClose, onEdit }: { inst: EventIns
<div className="ev-line"><CalIcon size={15} /><span>{`${inst.calendar?.name ?? "Calendar"}${ev.status === "cancelled" ? " · cancelled" : ev.status === "tentative" ? " · tentative" : ""}${ev.privacy && ev.privacy !== "public" ? ` · ${ev.privacy}` : ""}${ev.freeBusyStatus === "free" ? " · shown as free" : ""}`}</span></div>
{participants.length > 0 && (
<div className="ev-line" style={{ flexDirection: "column", gap: 2 }}>
<div className="row gap-8"><Users size={15} /><span>{`${participants.length} participant${participants.length === 1 ? "" : "s"}`}</span><button className="icon-btn xs" title="Email everyone" onClick={() => openCompose({ to: participants.map(([, p]) => ({ name: p.name ?? null, email: participantEmail(p) })).filter((a) => a.email), subject: ev.title ?? "" })}><Mail size={13} /></button></div>
<div className="row gap-8"><Users size={15} /><span>{`${participants.length} participant${participants.length === 1 ? "" : "s"}`}</span><button className="icon-btn xs" title={t("Email everyone")} onClick={() => openCompose({ to: participants.map(([, p]) => ({ name: p.name ?? null, email: participantEmail(p) })).filter((a) => a.email), subject: ev.title ?? "" })}><Mail size={13} /></button></div>
<div style={{ paddingLeft: 24, maxHeight: 140, overflow: "auto", width: "100%" }}>
{participants.map(([k, p]) => (
<div key={k} className="participant-row">
<span className={`p-status ${p.participationStatus ?? "needs-action"}`} title={p.participationStatus ?? "needs-action"} />
<span className="truncate">{p.name || participantEmail(p)}</span>
{p.roles?.owner && <span className="hint">organizer</span>}
{p.roles?.optional && <span className="hint">optional</span>}
{p.roles?.owner && <span className="hint">{t("organizer")}</span>}
{p.roles?.optional && <span className="hint">{t("optional")}</span>}
</div>
))}
</div>
@@ -97,7 +98,7 @@ export function EventPopover({ inst, anchor, onClose, onEdit }: { inst: EventIns
)}
{myKeys.length > 0 && !isOrganizer && (
<div className="row" style={{ marginTop: 10, gap: 6 }}>
<span className="hint">Going?</span>
<span className="hint">{t("Going?")}</span>
<button className={`btn btn-sm ${myStatus === "accepted" ? "btn-primary" : ""}`} disabled={busy} onClick={() => void rsvp("accepted")}><Check size={14} /> Yes</button>
<button className={`btn btn-sm ${myStatus === "tentative" ? "btn-primary" : ""}`} disabled={busy} onClick={() => void rsvp("tentative")}><HelpCircle size={14} /> Maybe</button>
<button className={`btn btn-sm ${myStatus === "declined" ? "btn-danger" : ""}`} disabled={busy} onClick={() => void rsvp("declined")}><X size={14} /> No</button>
+40 -39
View File
@@ -21,6 +21,7 @@ import { toast } from "@/ui/toast";
import { ScheduleDialog, ScheduleMenuItems } from "./SchedulePicker";
import { scheduleSupported, scheduleWindowMs } from "@/store/scheduled";
import { formatScheduleTime } from "@/lib/schedule";
import { t as translate } from "@/lib/i18n";
export function Composer({ draft }: { draft: Draft }) {
const update = useCompose((s) => s.update);
@@ -149,27 +150,27 @@ export function Composer({ draft }: { draft: Draft }) {
<div className="composer minimized" onClick={() => focus(key)}>
<div className="composer-head">
<span className="title">{title}</span>
<button className="icon-btn sm" aria-label="Restore" onClick={(e) => { e.stopPropagation(); focus(key); }}><Maximize2 size={16} /></button>
<button className="icon-btn sm" aria-label="Close" onClick={(e) => { e.stopPropagation(); void close(key); }}><X size={16} /></button>
<button className="icon-btn sm" aria-label={translate("Restore")} onClick={(e) => { e.stopPropagation(); focus(key); }}><Maximize2 size={16} /></button>
<button className="icon-btn sm" aria-label={translate("Close")} onClick={(e) => { e.stopPropagation(); void close(key); }}><X size={16} /></button>
</div>
</div>
);
}
return (
<div className={`composer ${d.maximized ? "maximized" : ""} ${dropping ? "dropping" : ""}`} onDragOver={(e) => { if (e.dataTransfer.types.includes("Files")) { e.preventDefault(); setDropping(true); } }} onDragLeave={() => setDropping(false)} onDrop={onDrop} role="dialog" aria-label="Compose message">
<div className={`composer ${d.maximized ? "maximized" : ""} ${dropping ? "dropping" : ""}`} onDragOver={(e) => { if (e.dataTransfer.types.includes("Files")) { e.preventDefault(); setDropping(true); } }} onDragLeave={() => setDropping(false)} onDrop={onDrop} role="dialog" aria-label={translate("Compose message")}>
<div className="composer-head" onDoubleClick={() => patch({ maximized: !d.maximized })}>
<span className="title">{title}</span>
<span className="status">{status}</span>
{!isMobile && <button className="icon-btn sm" aria-label="Minimize" title="Minimize" onClick={() => patch({ minimized: true })}><Minus size={16} /></button>}
{!isMobile && <button className="icon-btn sm" aria-label={translate("Minimize")} title={translate("Minimize")} onClick={() => patch({ minimized: true })}><Minus size={16} /></button>}
{!isMobile && <button className="icon-btn sm" aria-label={d.maximized ? "Restore" : "Maximize"} title={d.maximized ? "Restore" : "Full screen"} onClick={() => patch({ maximized: !d.maximized })}>{d.maximized ? <Minimize2 size={16} /> : <Maximize2 size={16} />}</button>}
<button className="icon-btn sm" aria-label="Close" title="Save & close (Esc)" onClick={() => void close(key)}><X size={18} /></button>
<button className="icon-btn sm" aria-label={translate("Close")} title={translate("Save & close (Esc)")} onClick={() => void close(key)}><X size={18} /></button>
</div>
<div className="composer-body">
<div className="composer-fields">
{identities.length > 1 && (
<div className="composer-field">
<label>From</label>
<label>{translate("From")}</label>
<select className="from-select" value={ident?.id ?? ""} onChange={(e) => setIdentity(key, e.target.value)}>
{identities.map((i) => <option key={i.id} value={i.id}>{i.name ? `${i.name} <${i.email}>` : i.email}</option>)}
</select>
@@ -179,53 +180,53 @@ export function Composer({ draft }: { draft: Draft }) {
<label htmlFor={`${key}-to`}>
{/* Opens the address books. Autocomplete only helps someone who
already knows the name they are half-way through typing. */}
<button type="button" className="link-btn" onClick={() => setAddressBookOpen(true)} title="Choose from address books">To</button>
<button type="button" className="link-btn" onClick={() => setAddressBookOpen(true)} title={translate("Choose from address books")}>{translate("To")}</button>
</label>
<RecipientInput id={`${key}-to`} value={d.to} onChange={(to) => patch({ to })} placeholder="Recipients" autoFocus={initialFocus === "to"} />
<RecipientInput id={`${key}-to`} value={d.to} onChange={(to) => patch({ to })} placeholder={translate("Recipients")} autoFocus={initialFocus === "to"} />
<span className="field-extra">
{/* Beside Cc and Bcc, because that is where someone looks when
they are thinking about who the message goes to. The label
opens it too, for anyone who tries that first. */}
<button type="button" onClick={() => setAddressBookOpen(true)} title="Choose from address books" aria-label="Choose from address books"><BookUser size={15} /></button>
{!d.showCc && <button type="button" onClick={() => patch({ showCc: true })}>Cc</button>}
{!d.showBcc && <button type="button" onClick={() => patch({ showBcc: true })}>Bcc</button>}
{!d.showReplyTo && <button type="button" onClick={() => patch({ showReplyTo: true })} title="Set a Reply-To address">Reply-To</button>}
<button type="button" onClick={() => setAddressBookOpen(true)} title={translate("Choose from address books")} aria-label={translate("Choose from address books")}><BookUser size={15} /></button>
{!d.showCc && <button type="button" onClick={() => patch({ showCc: true })}>{translate("Cc")}</button>}
{!d.showBcc && <button type="button" onClick={() => patch({ showBcc: true })}>{translate("Bcc")}</button>}
{!d.showReplyTo && <button type="button" onClick={() => patch({ showReplyTo: true })} title={translate("Set a Reply-To address")}>{translate("Reply-To")}</button>}
</span>
</div>
{d.showReplyTo && (
<div className="composer-field">
<label htmlFor={`${key}-rt`} title="Replies will go to this address instead of the From address">Reply-To</label>
<RecipientInput id={`${key}-rt`} value={d.replyTo} onChange={(replyTo) => patch({ replyTo })} placeholder="Replies go to…" />
<label htmlFor={`${key}-rt`} title={translate("Replies will go to this address instead of the From address")}>{translate("Reply-To")}</label>
<RecipientInput id={`${key}-rt`} value={d.replyTo} onChange={(replyTo) => patch({ replyTo })} placeholder={translate("Replies go to…")} />
</div>
)}
{d.showCc && (
<div className="composer-field">
<label htmlFor={`${key}-cc`}>Cc</label>
<label htmlFor={`${key}-cc`}>{translate("Cc")}</label>
<RecipientInput id={`${key}-cc`} value={d.cc} onChange={(cc) => patch({ cc })} />
</div>
)}
{d.showBcc && (
<div className="composer-field">
<label htmlFor={`${key}-bcc`}>Bcc</label>
<label htmlFor={`${key}-bcc`}>{translate("Bcc")}</label>
<RecipientInput id={`${key}-bcc`} value={d.bcc} onChange={(bcc) => patch({ bcc })} />
</div>
)}
<div className="composer-field">
<label htmlFor={`${key}-subj`} className="sr-only">Subject</label>
<input id={`${key}-subj`} className="plain" placeholder="Subject" value={d.subject} onChange={(e) => patch({ subject: e.target.value })} autoFocus={initialFocus === "subject"} />
<label htmlFor={`${key}-subj`} className="sr-only">{translate("Subject")}</label>
<input id={`${key}-subj`} className="plain" placeholder={translate("Subject")} value={d.subject} onChange={(e) => patch({ subject: e.target.value })} autoFocus={initialFocus === "subject"} />
{d.priority !== "normal" && <span className="tag" style={{ background: d.priority === "high" ? "var(--danger)" : "var(--fg-faint)" }}>{d.priority === "high" ? "High priority" : "Low priority"}</span>}
{d.requestReceipt && <span className="tag" style={{ background: "var(--accent)" }} title="Read receipt requested"><CheckCheck size={12} /></span>}
{d.requestReceipt && <span className="tag" style={{ background: "var(--accent)" }} title={translate("Read receipt requested")}><CheckCheck size={12} /></span>}
{d.sendAt !== null && (
<button type="button" className="tag" style={{ background: "var(--accent)" }} title="Scheduled — click to clear the schedule" onClick={() => patch({ sendAt: null })}>
<button type="button" className="tag" style={{ background: "var(--accent)" }} title={translate("Scheduled — click to clear the schedule")} onClick={() => patch({ sendAt: null })}>
<Clock size={12} /> {formatScheduleTime(new Date(d.sendAt))} <X size={12} />
</button>
)}
</div>
</div>
{d.format === "html" ? (
<RichEditor ref={editorRef} html={d.html} onChange={onHtml} placeholder="Write your message…" spellcheck={settings.spellcheck} onFiles={(files) => addFiles(key, files)} showToolbar={showToolbar} autoFocus={initialFocus === "body"} />
<RichEditor ref={editorRef} html={d.html} onChange={onHtml} placeholder={translate("Write your message…")} spellcheck={settings.spellcheck} onFiles={(files) => addFiles(key, files)} showToolbar={showToolbar} autoFocus={initialFocus === "body"} />
) : (
<textarea className="editor-textarea" value={d.text} onChange={(e) => patch({ text: e.target.value })} placeholder="Write your message…" spellCheck={settings.spellcheck} />
<textarea className="editor-textarea" value={d.text} onChange={(e) => patch({ text: e.target.value })} placeholder={translate("Write your message…")} spellCheck={settings.spellcheck} />
)}
{d.attachments.some((a) => !a.inline) && (
<div className="composer-attachments">
@@ -236,7 +237,7 @@ export function Composer({ draft }: { draft: Draft }) {
<span className="att-name">{a.name}</span>
<span className="att-size">{a.error ? <span style={{ color: "var(--danger)" }}>{a.error}</span> : a.blobId ? formatSize(a.size) : `${a.progress}%`}{a.inline ? " · inline" : ""}</span>
</span>
<button className="icon-btn xs" aria-label="Remove attachment" onClick={() => removeAttachment(key, a.id)}><X size={14} /></button>
<button className="icon-btn xs" aria-label={translate("Remove attachment")} onClick={() => removeAttachment(key, a.id)}><X size={14} /></button>
{!a.blobId && !a.error && <span className="att-progress" style={{ width: `${a.progress}%` }} />}
</div>
))}
@@ -247,7 +248,7 @@ export function Composer({ draft }: { draft: Draft }) {
<button className="btn btn-primary" onClick={() => void doSend()} disabled={d.sending} title={d.sendAt !== null ? `Hand to the server, held until ${formatScheduleTime(new Date(d.sendAt))} (Ctrl+Enter)` : "Send (Ctrl+Enter)"}>
{d.sendAt !== null ? <><Clock size={16} /> Schedule send</> : <><Send size={16} /> Send</>}
</button>
<button className="btn btn-primary" onClick={sendMenu.open} aria-label="Send options"><ChevronDown size={16} /></button>
<button className="btn btn-primary" onClick={sendMenu.open} aria-label={translate("Send options")}><ChevronDown size={16} /></button>
</span>
<Popover anchor={sendMenu.anchor} onClose={sendMenu.close} side="top" width={280}>
<MenuItem icon={<Send size={16} />} label={d.sendAt !== null ? "Send now instead" : "Send"} kbd={d.sendAt !== null ? undefined : "Ctrl+↵"} onClick={() => { if (d.sendAt !== null) patch({ sendAt: null }); sendMenu.close(); void doSend(); }} />
@@ -273,32 +274,32 @@ export function Composer({ draft }: { draft: Draft }) {
<ScheduleDialog open maxMs={scheduleMax} initial={d.sendAt} onClose={() => setScheduleOpen(false)} onPick={scheduleFor} />
)}
<span className="more-actions">
<button className="icon-btn" title="Attach files" onClick={() => fileRef.current?.click()}><Paperclip size={18} /></button>
{filesAvailable && <button className="icon-btn" title="Attach from Files" onClick={() => setPickerOpen(true)}><FolderOpen size={18} /></button>}
<button className="icon-btn" title={translate("Attach files")} onClick={() => fileRef.current?.click()}><Paperclip size={18} /></button>
{filesAvailable && <button className="icon-btn" title={translate("Attach from Files")} onClick={() => setPickerOpen(true)}><FolderOpen size={18} /></button>}
<input ref={fileRef} type="file" multiple hidden onChange={(e) => { const files = Array.from(e.target.files ?? []); if (files.length) addFiles(key, files); e.target.value = ""; }} />
{d.format === "html" && <button className={`icon-btn ${showToolbar ? "active" : ""}`} title="Formatting options" onClick={() => setShowToolbar((v) => !v)}><Type size={18} /></button>}
{settings.templates.length > 0 && <button className="icon-btn" title="Insert template" onClick={templateMenu.open}><FileText size={18} /></button>}
{d.format === "html" && <button className={`icon-btn ${showToolbar ? "active" : ""}`} title={translate("Formatting options")} onClick={() => setShowToolbar((v) => !v)}><Type size={18} /></button>}
{settings.templates.length > 0 && <button className="icon-btn" title={translate("Insert template")} onClick={templateMenu.open}><FileText size={18} /></button>}
<Popover anchor={templateMenu.anchor} onClose={templateMenu.close} side="top" width={260}>
<MenuTitle>Templates</MenuTitle>
<MenuTitle>{translate("Templates")}</MenuTitle>
{settings.templates.map((t) => <MenuItem key={t.id} label={t.name} onClick={() => insertTemplate(key, t.html, t.subject)} />)}
</Popover>
<button className="icon-btn" onClick={moreMenu.open} aria-label="More options"><MoreVertical size={18} /></button>
<button className="icon-btn" onClick={moreMenu.open} aria-label={translate("More options")}><MoreVertical size={18} /></button>
<Popover anchor={moreMenu.anchor} onClose={moreMenu.close} side="top" width={260}>
<MenuItem icon={<Type size={16} />} label={d.format === "html" ? "Switch to plain text" : "Switch to rich text"} onClick={toggleFormat} />
<MenuItem icon={<CheckCheck size={16} />} label="Request read receipt" checked={d.requestReceipt} onClick={() => patch({ requestReceipt: !d.requestReceipt })} />
<MenuItem icon={<CheckCheck size={16} />} label={translate("Request read receipt")} checked={d.requestReceipt} onClick={() => patch({ requestReceipt: !d.requestReceipt })} />
<MenuSep />
<MenuTitle>Priority</MenuTitle>
<MenuItem label="High" checked={d.priority === "high"} onClick={() => patch({ priority: "high" })} />
<MenuItem label="Normal" checked={d.priority === "normal"} onClick={() => patch({ priority: "normal" })} />
<MenuItem label="Low" checked={d.priority === "low"} onClick={() => patch({ priority: "low" })} />
<MenuTitle>{translate("Priority")}</MenuTitle>
<MenuItem label={translate("High")} checked={d.priority === "high"} onClick={() => patch({ priority: "high" })} />
<MenuItem label={translate("Normal")} checked={d.priority === "normal"} onClick={() => patch({ priority: "normal" })} />
<MenuItem label={translate("Low")} checked={d.priority === "low"} onClick={() => patch({ priority: "low" })} />
<MenuSep />
<MenuItem icon={<ChevronsDown size={16} />} label="Save as template" onClick={async () => { const name = await promptDialog({ title: "Save as template", defaultValue: d.subject || "Template", placeholder: "Template name" }); if (name) updateSettings({ templates: [...useSettings.getState().settings.templates, { id: `t${Date.now()}`, name, subject: d.subject, html: d.format === "html" ? d.html : textToHtml(d.text) }] }); }} />
<MenuItem icon={<FileText size={16} />} label="Save draft now" onClick={() => void saveDraft(key)} />
<MenuItem icon={<ChevronsDown size={16} />} label={translate("Save as template")} onClick={async () => { const name = await promptDialog({ title: "Save as template", defaultValue: d.subject || "Template", placeholder: "Template name" }); if (name) updateSettings({ templates: [...useSettings.getState().settings.templates, { id: `t${Date.now()}`, name, subject: d.subject, html: d.format === "html" ? d.html : textToHtml(d.text) }] }); }} />
<MenuItem icon={<FileText size={16} />} label={translate("Save draft now")} onClick={() => void saveDraft(key)} />
</Popover>
</span>
<span className="spacer" />
{totalSize > 20 * 1024 * 1024 && <span className="hint row gap-4" title="Large attachments may be rejected by some servers"><AlertTriangle size={14} /> {formatSize(totalSize)}</span>}
<button className="icon-btn danger" title="Discard draft" aria-label="Discard draft" onClick={async () => { if (!d.dirty && !d.draftId) { void close(key, { discard: true }); return; } if (await confirmDialog({ title: "Discard this draft?", confirmLabel: "Discard", danger: true })) void close(key, { discard: true }); }}><Trash2 size={18} /></button>
{totalSize > 20 * 1024 * 1024 && <span className="hint row gap-4" title={translate("Large attachments may be rejected by some servers")}><AlertTriangle size={14} /> {formatSize(totalSize)}</span>}
<button className="icon-btn danger" title={translate("Discard draft")} aria-label={translate("Discard draft")} onClick={async () => { if (!d.dirty && !d.draftId) { void close(key, { discard: true }); return; } if (await confirmDialog({ title: "Discard this draft?", confirmLabel: "Discard", danger: true })) void close(key, { discard: true }); }}><Trash2 size={18} /></button>
</div>
</div>
</div>
+5 -4
View File
@@ -6,6 +6,7 @@ import { useFiles } from "@/store/files";
import type { AttachableFile } from "@/store/compose";
import type { FileNode } from "@/jmap/types";
import { formatSize } from "@/lib/format";
import { t } from "@/lib/i18n";
/**
* Pick something already in Files to attach.
@@ -51,11 +52,11 @@ export function FilePicker({ onPick, onClose }: { onPick: (files: AttachableFile
<Dialog
open
onClose={close}
title="Attach from Files"
title={t("Attach from Files")}
size="md"
footer={
<>
<button className="btn" onClick={close}>Cancel</button>
<button className="btn" onClick={close}>{t("Cancel")}</button>
<button
className="btn btn-primary"
disabled={!chosen.length}
@@ -95,7 +96,7 @@ export function FilePicker({ onPick, onClose }: { onPick: (files: AttachableFile
{files.loading && !nodes.length ? (
<Spinner />
) : !nodes.length ? (
<p className="hint">This folder is empty.</p>
<p className="hint">{t("This folder is empty.")}</p>
) : (
nodes.map((n) =>
n.nodeType === "directory" ? (
@@ -131,7 +132,7 @@ export function FilePicker({ onPick, onClose }: { onPick: (files: AttachableFile
// Blobs belong to the account holding them, so one from a share has to
// be copied into yours before a draft can reference it. Worth saying,
// because it is the difference between instant and a wait.
<p className="hint" style={{ marginTop: 10 }}>Shared files are copied to your account when attached.</p>
<p className="hint" style={{ marginTop: 10 }}>{t("Shared files are copied to your account when attached.")}</p>
)}
</Dialog>
);
+10 -9
View File
@@ -6,6 +6,7 @@ import { useContacts } from "@/store/contacts";
import { useSettings } from "@/store/settings";
import { contactDisplayName, contactEmails } from "@/lib/contacts";
import type { ContactCard, EmailAddress } from "@/jmap/types";
import { t } from "@/lib/i18n";
export type Field = "to" | "cc" | "bcc";
@@ -109,13 +110,13 @@ export function RecipientPicker({ onPick, onClose }: { onPick: (field: Field, ad
<Dialog
open
onClose={onClose}
title="Choose recipients"
title={t("Choose recipients")}
size="lg"
footer={
<>
<button className="btn" onClick={onClose}>Cancel</button>
<button className="btn" disabled={!chosen.length} onClick={() => send("bcc")}>Bcc</button>
<button className="btn" disabled={!chosen.length} onClick={() => send("cc")}>Cc</button>
<button className="btn" onClick={onClose}>{t("Cancel")}</button>
<button className="btn" disabled={!chosen.length} onClick={() => send("bcc")}>{t("Bcc")}</button>
<button className="btn" disabled={!chosen.length} onClick={() => send("cc")}>{t("Cc")}</button>
<button className="btn btn-primary" disabled={!chosen.length} onClick={() => send("to")}>
{chosen.length > 1 ? `To — ${chosen.length} people` : "To"}
</button>
@@ -129,14 +130,14 @@ export function RecipientPicker({ onPick, onClose }: { onPick: (field: Field, ad
<input
className="grow"
style={{ background: "none", border: 0, outline: "none", color: "inherit", font: "inherit" }}
placeholder="Search names and addresses"
placeholder={t("Search names and addresses")}
value={q}
onChange={(e) => setQ(e.target.value)}
autoFocus
/>
</label>
<select className="select" value={bookKey} onChange={(e) => setBookKey(e.target.value)} aria-label="Address book">
<option value="all">All address books</option>
<select className="select" value={bookKey} onChange={(e) => setBookKey(e.target.value)} aria-label={t("Address book")}>
<option value="all">{t("All address books")}</option>
{ownBooks.map((b) => <option key={b.id} value={b.id}>{b.name}</option>)}
{subscribed.map((b) => (
<option key={`${b.accountId}:${b.book.id}`} value={`${b.accountId}:${b.book.id}`}>
@@ -149,7 +150,7 @@ export function RecipientPicker({ onPick, onClose }: { onPick: (field: Field, ad
{chosen.length > 0 && (
<div className="row wrap gap-4" style={{ marginBottom: 10 }}>
{chosen.map((r) => (
<button key={r.key} className="chip" onClick={() => toggle(r)} title="Remove">
<button key={r.key} className="chip" onClick={() => toggle(r)} title={t("Remove")}>
{r.name ?? r.email} <X size={12} />
</button>
))}
@@ -158,7 +159,7 @@ export function RecipientPicker({ onPick, onClose }: { onPick: (field: Field, ad
<div style={{ maxHeight: "48vh", overflowY: "auto" }}>
{contacts.loading && !rows.length ? (
<Spinner label="Loading contacts…" />
<Spinner label={t("Loading contacts…")} />
) : !rows.length ? (
<p className="hint">{q ? "Nobody matches that." : "No contacts in this address book."}</p>
) : (
+33 -32
View File
@@ -2,6 +2,7 @@ import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useSta
import { AlignCenter, AlignLeft, AlignRight, Bold, Code, Eraser, Image as ImageIcon, Indent, Italic, Link as LinkIcon, List, ListOrdered, Outdent, Quote, Redo, Smile, Strikethrough, Underline, Undo, Palette, Highlighter, Type } from "lucide-react";
import { sanitizeEditorHtml } from "@/lib/html";
import { Popover, useMenu } from "@/ui/popover";
import { t as translate } from "@/lib/i18n";
export interface RichEditorHandle {
focus(): void;
@@ -218,46 +219,46 @@ export const RichEditor = forwardRef<RichEditorHandle, Props>(function RichEdito
}}
role="textbox"
aria-multiline="true"
aria-label="Message body"
aria-label={translate("Message body")}
/>
{showToolbar && (
<div className="editor-toolbar" role="toolbar" aria-label="Formatting">
<button type="button" className="icon-btn" title="Undo (Ctrl+Z)" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("undo")}><Undo size={16} /></button>
<button type="button" className="icon-btn" title="Redo" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("redo")}><Redo size={16} /></button>
<div className="editor-toolbar" role="toolbar" aria-label={translate("Formatting")}>
<button type="button" className="icon-btn" title={translate("Undo (Ctrl+Z)")} onMouseDown={(e) => e.preventDefault()} onClick={() => exec("undo")}><Undo size={16} /></button>
<button type="button" className="icon-btn" title={translate("Redo")} onMouseDown={(e) => e.preventDefault()} onClick={() => exec("redo")}><Redo size={16} /></button>
<span className="tb-sep" />
<select title="Font size" onMouseDown={saveRange} onChange={(e) => { exec("fontSize", e.target.value); e.target.value = ""; }} defaultValue="">
<option value="" disabled>Size</option>
<option value="1">Small</option>
<option value="3">Normal</option>
<option value="5">Large</option>
<option value="7">Huge</option>
<select title={translate("Font size")} onMouseDown={saveRange} onChange={(e) => { exec("fontSize", e.target.value); e.target.value = ""; }} defaultValue="">
<option value="" disabled>{translate("Size")}</option>
<option value="1">{translate("Small")}</option>
<option value="3">{translate("Normal")}</option>
<option value="5">{translate("Large")}</option>
<option value="7">{translate("Huge")}</option>
</select>
<button type="button" className="icon-btn" title="Bold (Ctrl+B)" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("bold")}><Bold size={16} /></button>
<button type="button" className="icon-btn" title="Italic (Ctrl+I)" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("italic")}><Italic size={16} /></button>
<button type="button" className="icon-btn" title="Underline (Ctrl+U)" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("underline")}><Underline size={16} /></button>
<button type="button" className="icon-btn" title="Strikethrough" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("strikeThrough")}><Strikethrough size={16} /></button>
<button type="button" className="icon-btn" title="Text color" onMouseDown={(e) => { e.preventDefault(); saveRange(); }} onClick={colorMenu.open}><Palette size={16} /></button>
<button type="button" className="icon-btn" title="Highlight" onMouseDown={(e) => { e.preventDefault(); saveRange(); }} onClick={hiliteMenu.open}><Highlighter size={16} /></button>
<button type="button" className="icon-btn" title={translate("Bold (Ctrl+B)")} onMouseDown={(e) => e.preventDefault()} onClick={() => exec("bold")}><Bold size={16} /></button>
<button type="button" className="icon-btn" title={translate("Italic (Ctrl+I)")} onMouseDown={(e) => e.preventDefault()} onClick={() => exec("italic")}><Italic size={16} /></button>
<button type="button" className="icon-btn" title={translate("Underline (Ctrl+U)")} onMouseDown={(e) => e.preventDefault()} onClick={() => exec("underline")}><Underline size={16} /></button>
<button type="button" className="icon-btn" title={translate("Strikethrough")} onMouseDown={(e) => e.preventDefault()} onClick={() => exec("strikeThrough")}><Strikethrough size={16} /></button>
<button type="button" className="icon-btn" title={translate("Text color")} onMouseDown={(e) => { e.preventDefault(); saveRange(); }} onClick={colorMenu.open}><Palette size={16} /></button>
<button type="button" className="icon-btn" title={translate("Highlight")} onMouseDown={(e) => { e.preventDefault(); saveRange(); }} onClick={hiliteMenu.open}><Highlighter size={16} /></button>
<span className="tb-sep" />
<button type="button" className="icon-btn" title="Align left" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("justifyLeft")}><AlignLeft size={16} /></button>
<button type="button" className="icon-btn" title="Center" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("justifyCenter")}><AlignCenter size={16} /></button>
<button type="button" className="icon-btn" title="Align right" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("justifyRight")}><AlignRight size={16} /></button>
<button type="button" className="icon-btn" title={translate("Align left")} onMouseDown={(e) => e.preventDefault()} onClick={() => exec("justifyLeft")}><AlignLeft size={16} /></button>
<button type="button" className="icon-btn" title={translate("Center")} onMouseDown={(e) => e.preventDefault()} onClick={() => exec("justifyCenter")}><AlignCenter size={16} /></button>
<button type="button" className="icon-btn" title={translate("Align right")} onMouseDown={(e) => e.preventDefault()} onClick={() => exec("justifyRight")}><AlignRight size={16} /></button>
<span className="tb-sep" />
<button type="button" className="icon-btn" title="Bulleted list" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("insertUnorderedList")}><List size={16} /></button>
<button type="button" className="icon-btn" title="Numbered list" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("insertOrderedList")}><ListOrdered size={16} /></button>
<button type="button" className="icon-btn" title="Decrease indent" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("outdent")}><Outdent size={16} /></button>
<button type="button" className="icon-btn" title="Increase indent" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("indent")}><Indent size={16} /></button>
<button type="button" className="icon-btn" title="Quote" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("formatBlock", "blockquote")}><Quote size={16} /></button>
<button type="button" className="icon-btn" title="Code block" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("formatBlock", "pre")}><Code size={16} /></button>
<button type="button" className="icon-btn" title="Normal text" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("formatBlock", "div")}><Type size={16} /></button>
<button type="button" className="icon-btn" title={translate("Bulleted list")} onMouseDown={(e) => e.preventDefault()} onClick={() => exec("insertUnorderedList")}><List size={16} /></button>
<button type="button" className="icon-btn" title={translate("Numbered list")} onMouseDown={(e) => e.preventDefault()} onClick={() => exec("insertOrderedList")}><ListOrdered size={16} /></button>
<button type="button" className="icon-btn" title={translate("Decrease indent")} onMouseDown={(e) => e.preventDefault()} onClick={() => exec("outdent")}><Outdent size={16} /></button>
<button type="button" className="icon-btn" title={translate("Increase indent")} onMouseDown={(e) => e.preventDefault()} onClick={() => exec("indent")}><Indent size={16} /></button>
<button type="button" className="icon-btn" title={translate("Quote")} onMouseDown={(e) => e.preventDefault()} onClick={() => exec("formatBlock", "blockquote")}><Quote size={16} /></button>
<button type="button" className="icon-btn" title={translate("Code block")} onMouseDown={(e) => e.preventDefault()} onClick={() => exec("formatBlock", "pre")}><Code size={16} /></button>
<button type="button" className="icon-btn" title={translate("Normal text")} onMouseDown={(e) => e.preventDefault()} onClick={() => exec("formatBlock", "div")}><Type size={16} /></button>
<span className="tb-sep" />
<button type="button" className="icon-btn" title="Insert link (Ctrl+K)" onMouseDown={(e) => { e.preventDefault(); saveRange(); }} onClick={linkMenu.open}><LinkIcon size={16} /></button>
<label className="icon-btn" title="Insert image" onMouseDown={saveRange}>
<button type="button" className="icon-btn" title={translate("Insert link (Ctrl+K)")} onMouseDown={(e) => { e.preventDefault(); saveRange(); }} onClick={linkMenu.open}><LinkIcon size={16} /></button>
<label className="icon-btn" title={translate("Insert image")} onMouseDown={saveRange}>
<ImageIcon size={16} />
<input type="file" accept="image/*" hidden onChange={(e) => { const f = e.target.files?.[0]; if (f) insertImageFile(f); e.target.value = ""; }} />
</label>
<button type="button" className="icon-btn" title="Emoji" onMouseDown={(e) => { e.preventDefault(); saveRange(); }} onClick={emojiMenu.open}><Smile size={16} /></button>
<button type="button" className="icon-btn" title="Remove formatting" onMouseDown={(e) => e.preventDefault()} onClick={() => { exec("removeFormat"); exec("unlink"); }}><Eraser size={16} /></button>
<button type="button" className="icon-btn" title={translate("Emoji")} onMouseDown={(e) => { e.preventDefault(); saveRange(); }} onClick={emojiMenu.open}><Smile size={16} /></button>
<button type="button" className="icon-btn" title={translate("Remove formatting")} onMouseDown={(e) => e.preventDefault()} onClick={() => { exec("removeFormat"); exec("unlink"); }}><Eraser size={16} /></button>
{toolbarExtra}
</div>
)}
@@ -280,8 +281,8 @@ export const RichEditor = forwardRef<RichEditorHandle, Props>(function RichEdito
</Popover>
<Popover anchor={linkMenu.anchor} onClose={linkMenu.close} side="top" closeOnClick={false} width={320}>
<form className="link-popup" onSubmit={(e) => { e.preventDefault(); applyLink(); }}>
<input className="input sm" autoFocus placeholder="https://…" value={linkUrl} onChange={(e) => setLinkUrl(e.target.value)} />
<button type="submit" className="btn btn-sm btn-primary">Link</button>
<input className="input sm" autoFocus placeholder={translate("https://…")} value={linkUrl} onChange={(e) => setLinkUrl(e.target.value)} />
<button type="submit" className="btn btn-sm btn-primary">{translate("Link")}</button>
</form>
</Popover>
</div>
+8 -7
View File
@@ -5,6 +5,7 @@ import { DateTimeField } from "@/ui/datefield";
import { MenuItem, MenuSep, MenuTitle } from "@/ui/popover";
import { describeSpan, formatScheduleTime, schedulePresets, scheduleError } from "@/lib/schedule";
import { toInputDateTime, fromInputDateTime, roundToNext } from "@/lib/dates";
import { t } from "@/lib/i18n";
/**
* The quick picks that hang off the composer's send menu. Anything the server
@@ -15,11 +16,11 @@ export function ScheduleMenuItems({ maxMs, onPick, onCustom }: { maxMs: number;
return (
<>
<MenuSep />
<MenuTitle>Schedule send</MenuTitle>
<MenuTitle>{t("Schedule send")}</MenuTitle>
{presets.map((p) => (
<MenuItem key={p.id} icon={<Clock size={16} />} label={p.label} kbd={formatScheduleTime(p.at)} onClick={() => onPick(p.at)} />
))}
<MenuItem icon={<Clock size={16} />} label="Pick date and time…" onClick={onCustom} />
<MenuItem icon={<Clock size={16} />} label={t("Pick date and time…")} onClick={onCustom} />
</>
);
}
@@ -40,18 +41,18 @@ export function ScheduleDialog({ open, maxMs, initial, onClose, onPick }: {
<Dialog
open={open}
onClose={onClose}
title="Schedule send"
title={t("Schedule send")}
size="sm"
footer={
<>
<button className="btn" onClick={onClose}>Cancel</button>
<button className="btn btn-primary" disabled={Boolean(error)} onClick={() => onPick(at)}>Schedule send</button>
<button className="btn" onClick={onClose}>{t("Cancel")}</button>
<button className="btn btn-primary" disabled={Boolean(error)} onClick={() => onPick(at)}>{t("Schedule send")}</button>
</>
}
>
<div className="field">
<label htmlFor="schedule-at">Send at</label>
<DateTimeField id="schedule-at" value={value} onChange={setValue} aria-label="Date and time to send" />
<label htmlFor="schedule-at">{t("Send at")}</label>
<DateTimeField id="schedule-at" value={value} onChange={setValue} aria-label={t("Date and time to send")} />
</div>
{error ? (
<p className="hint" style={{ color: "var(--danger)" }}>{error}</p>
+34 -33
View File
@@ -7,6 +7,7 @@ import { Dialog } from "@/ui/dialog";
import { DateField } from "@/ui/datefield";
import { toast } from "@/ui/toast";
import { client } from "@/jmap/client";
import { t } from "@/lib/i18n";
interface Props {
card: Partial<ContactCard>;
@@ -155,25 +156,25 @@ export function ContactEditor({ card, defaultBookId, onClose, onSaved }: Props)
const photoSrc = photo?.dataUrl ?? (!removePhoto && existingPhoto ? (existingPhoto.uri?.startsWith("data:") ? existingPhoto.uri : existingPhoto.blobId ? client.downloadUrl(contacts.accountId!, existingPhoto.blobId, "photo", existingPhoto.mediaType ?? "image/jpeg", true) : null) : null);
return (
<Dialog open onClose={onClose} title={isNew ? "New contact" : `Edit ${contactDisplayName(card as ContactCard)}`} size="lg" footer={<><button className="btn" onClick={onClose}>Cancel</button><button className="btn btn-primary" disabled={busy} onClick={() => void save()}>{busy ? "Saving…" : "Save"}</button></>}>
<Dialog open onClose={onClose} title={isNew ? "New contact" : `Edit ${contactDisplayName(card as ContactCard)}`} size="lg" footer={<><button className="btn" onClick={onClose}>{t("Cancel")}</button><button className="btn btn-primary" disabled={busy} onClick={() => void save()}>{busy ? "Saving…" : "Save"}</button></>}>
<div className="contact-form">
<div className="row" style={{ gap: 16, marginBottom: 12 }}>
<label className="avatar xl" style={{ background: "var(--bg-sunken)", color: "var(--fg-muted)", cursor: "pointer", position: "relative" }} title="Change photo">
<label className="avatar xl" style={{ background: "var(--bg-sunken)", color: "var(--fg-muted)", cursor: "pointer", position: "relative" }} title={t("Change photo")}>
{photoSrc ? <img src={photoSrc} alt="" /> : <Camera size={28} />}
<input type="file" accept="image/*" hidden onChange={(e) => { const f = e.target.files?.[0]; if (f) onPhoto(f); e.target.value = ""; }} />
</label>
{photoSrc && <button className="btn btn-ghost btn-sm" onClick={() => { setPhoto(null); setRemovePhoto(true); }}><X size={14} /> Remove photo</button>}
<span className="spacer" />
<div className="field" style={{ marginBottom: 0, width: 160 }}>
<label>Type</label>
<label>{t("Type")}</label>
<select className="select" value={kind} onChange={(e) => setKind(e.target.value as typeof kind)}>
<option value="individual">Person</option>
<option value="org">Organization</option>
<option value="group">Group</option>
<option value="individual">{t("Person")}</option>
<option value="org">{t("Organization")}</option>
<option value="group">{t("Group")}</option>
</select>
</div>
<div className="field" style={{ marginBottom: 0, width: 200 }}>
<label>Address book</label>
<label>{t("Address book")}</label>
<select className="select" value={bookId} onChange={(e) => setBookId(e.target.value)}>
{books.map((b) => <option key={b.id} value={b.id}>{b.name}</option>)}
</select>
@@ -182,21 +183,21 @@ export function ContactEditor({ card, defaultBookId, onClose, onSaved }: Props)
{kind === "individual" ? (
<>
<div className="field-row">
<div className="field"><label>First name</label><input className="input" value={given} onChange={(e) => setGiven(e.target.value)} autoFocus /></div>
<div className="field"><label>Last name</label><input className="input" value={surname} onChange={(e) => setSurname(e.target.value)} /></div>
<div className="field"><label>{t("First name")}</label><input className="input" value={given} onChange={(e) => setGiven(e.target.value)} autoFocus /></div>
<div className="field"><label>{t("Last name")}</label><input className="input" value={surname} onChange={(e) => setSurname(e.target.value)} /></div>
</div>
<details>
<summary className="hint" style={{ cursor: "pointer", marginBottom: 8 }}>More name fields</summary>
<summary className="hint" style={{ cursor: "pointer", marginBottom: 8 }}>{t("More name fields")}</summary>
<div className="field-row">
<div className="field"><label>Prefix</label><input className="input" value={prefix} onChange={(e) => setPrefix(e.target.value)} placeholder="Dr." /></div>
<div className="field"><label>Middle name</label><input className="input" value={middle} onChange={(e) => setMiddle(e.target.value)} /></div>
<div className="field"><label>Suffix</label><input className="input" value={suffix} onChange={(e) => setSuffix(e.target.value)} placeholder="Jr." /></div>
<div className="field"><label>Nickname</label><input className="input" value={nickname} onChange={(e) => setNickname(e.target.value)} /></div>
<div className="field"><label>{t("Prefix")}</label><input className="input" value={prefix} onChange={(e) => setPrefix(e.target.value)} placeholder={t("Dr.")} /></div>
<div className="field"><label>{t("Middle name")}</label><input className="input" value={middle} onChange={(e) => setMiddle(e.target.value)} /></div>
<div className="field"><label>{t("Suffix")}</label><input className="input" value={suffix} onChange={(e) => setSuffix(e.target.value)} placeholder={t("Jr.")} /></div>
<div className="field"><label>{t("Nickname")}</label><input className="input" value={nickname} onChange={(e) => setNickname(e.target.value)} /></div>
</div>
</details>
<div className="field-row">
<div className="field"><label>Company</label><input className="input" value={company} onChange={(e) => setCompany(e.target.value)} /></div>
<div className="field"><label>Job title</label><input className="input" value={jobTitle} onChange={(e) => setJobTitle(e.target.value)} /></div>
<div className="field"><label>{t("Company")}</label><input className="input" value={company} onChange={(e) => setCompany(e.target.value)} /></div>
<div className="field"><label>{t("Job title")}</label><input className="input" value={jobTitle} onChange={(e) => setJobTitle(e.target.value)} /></div>
</div>
</>
) : (
@@ -205,7 +206,7 @@ export function ContactEditor({ card, defaultBookId, onClose, onSaved }: Props)
{kind === "group" && (
<div className="field">
<label>Members</label>
<label>{t("Members")}</label>
<div className="row wrap gap-4 mb-8">
{memberUids.map((uid) => {
const m = Object.values(contacts.cards).find((x) => x.uid === uid);
@@ -213,7 +214,7 @@ export function ContactEditor({ card, defaultBookId, onClose, onSaved }: Props)
})}
</div>
<div style={{ position: "relative" }}>
<input className="input" placeholder="Search contacts to add…" value={memberQuery} onChange={(e) => setMemberQuery(e.target.value)} />
<input className="input" placeholder={t("Search contacts to add…")} value={memberQuery} onChange={(e) => setMemberQuery(e.target.value)} />
{memberCandidates.length > 0 && (
<div className="suggest-list" style={{ width: "100%" }}>
{memberCandidates.map((c) => <div key={c.id} className="suggest-item" onMouseDown={(e) => { e.preventDefault(); setMemberUids([...memberUids, c.uid]); setMemberQuery(""); }}><span className="s-name">{contactDisplayName(c)}</span><span className="s-email">{Object.values(c.emails ?? {})[0]?.address}</span></div>)}
@@ -224,47 +225,47 @@ export function ContactEditor({ card, defaultBookId, onClose, onSaved }: Props)
)}
<div className="field">
<label>Email</label>
<label>{t("Email")}</label>
<div className="multi">
{emails.map((e, i) => (
<div key={e.key} className="multi-row">
<input className="input" type="email" value={e.address} placeholder="[email protected]" onChange={(ev) => setEmails(emails.map((x, j) => (j === i ? { ...x, address: ev.target.value } : x)))} />
<input className="input" type="email" value={e.address} placeholder={t("[email protected]")} onChange={(ev) => setEmails(emails.map((x, j) => (j === i ? { ...x, address: ev.target.value } : x)))} />
<select className="select" value={e.ctx} onChange={(ev) => setEmails(emails.map((x, j) => (j === i ? { ...x, ctx: ev.target.value } : x)))}>{EMAIL_CTX.map((c) => <option key={c} value={c}>{c}</option>)}</select>
<button className="icon-btn sm danger" onClick={() => setEmails(emails.filter((_, j) => j !== i))} aria-label="Remove"><Trash2 size={16} /></button>
<button className="icon-btn sm danger" onClick={() => setEmails(emails.filter((_, j) => j !== i))} aria-label={t("Remove")}><Trash2 size={16} /></button>
</div>
))}
<button className="btn btn-ghost btn-sm" style={{ alignSelf: "flex-start" }} onClick={() => setEmails([...emails, { key: newKey("e"), address: "", ctx: emails.length ? "work" : "private" }])}><Plus size={14} /> Add email</button>
</div>
</div>
<div className="field">
<label>Phone</label>
<label>{t("Phone")}</label>
<div className="multi">
{phones.map((p, i) => (
<div key={p.key} className="multi-row">
<input className="input" type="tel" value={p.number} placeholder="+1 555 0100" onChange={(ev) => setPhones(phones.map((x, j) => (j === i ? { ...x, number: ev.target.value } : x)))} />
<select className="select" value={p.ctx} onChange={(ev) => setPhones(phones.map((x, j) => (j === i ? { ...x, ctx: ev.target.value } : x)))}>{PHONE_CTX.map((c) => <option key={c} value={c}>{c}</option>)}</select>
<button className="icon-btn sm danger" onClick={() => setPhones(phones.filter((_, j) => j !== i))} aria-label="Remove"><Trash2 size={16} /></button>
<button className="icon-btn sm danger" onClick={() => setPhones(phones.filter((_, j) => j !== i))} aria-label={t("Remove")}><Trash2 size={16} /></button>
</div>
))}
<button className="btn btn-ghost btn-sm" style={{ alignSelf: "flex-start" }} onClick={() => setPhones([...phones, { key: newKey("p"), number: "", ctx: "mobile" }])}><Plus size={14} /> Add phone</button>
</div>
</div>
<div className="field">
<label>Address</label>
<label>{t("Address")}</label>
<div className="multi">
{addrs.map((a, i) => (
<div key={a.key} className="card" style={{ marginBottom: 0 }}>
<div className="row mb-8">
<select className="select" style={{ width: 140 }} value={a.ctx} onChange={(ev) => setAddrs(addrs.map((x, j) => (j === i ? { ...x, ctx: ev.target.value } : x)))}>{ADDR_CTX.map((c) => <option key={c} value={c}>{c}</option>)}</select>
<span className="spacer" />
<button className="icon-btn sm danger" onClick={() => setAddrs(addrs.filter((_, j) => j !== i))} aria-label="Remove"><Trash2 size={16} /></button>
<button className="icon-btn sm danger" onClick={() => setAddrs(addrs.filter((_, j) => j !== i))} aria-label={t("Remove")}><Trash2 size={16} /></button>
</div>
<div className="addr-grid">
<input className="input" style={{ gridColumn: "1 / -1" }} placeholder="Street" value={a.street} onChange={(ev) => setAddrs(addrs.map((x, j) => (j === i ? { ...x, street: ev.target.value } : x)))} />
<input className="input" placeholder="City" value={a.city} onChange={(ev) => setAddrs(addrs.map((x, j) => (j === i ? { ...x, city: ev.target.value } : x)))} />
<input className="input" placeholder="State / Region" value={a.region} onChange={(ev) => setAddrs(addrs.map((x, j) => (j === i ? { ...x, region: ev.target.value } : x)))} />
<input className="input" placeholder="Postal code" value={a.postcode} onChange={(ev) => setAddrs(addrs.map((x, j) => (j === i ? { ...x, postcode: ev.target.value } : x)))} />
<input className="input" placeholder="Country" value={a.country} onChange={(ev) => setAddrs(addrs.map((x, j) => (j === i ? { ...x, country: ev.target.value } : x)))} />
<input className="input" style={{ gridColumn: "1 / -1" }} placeholder={t("Street")} value={a.street} onChange={(ev) => setAddrs(addrs.map((x, j) => (j === i ? { ...x, street: ev.target.value } : x)))} />
<input className="input" placeholder={t("City")} value={a.city} onChange={(ev) => setAddrs(addrs.map((x, j) => (j === i ? { ...x, city: ev.target.value } : x)))} />
<input className="input" placeholder={t("State / Region")} value={a.region} onChange={(ev) => setAddrs(addrs.map((x, j) => (j === i ? { ...x, region: ev.target.value } : x)))} />
<input className="input" placeholder={t("Postal code")} value={a.postcode} onChange={(ev) => setAddrs(addrs.map((x, j) => (j === i ? { ...x, postcode: ev.target.value } : x)))} />
<input className="input" placeholder={t("Country")} value={a.country} onChange={(ev) => setAddrs(addrs.map((x, j) => (j === i ? { ...x, country: ev.target.value } : x)))} />
</div>
</div>
))}
@@ -272,10 +273,10 @@ export function ContactEditor({ card, defaultBookId, onClose, onSaved }: Props)
</div>
</div>
<div className="field-row">
<div className="field"><label>Birthday</label><DateField aria-label="Birthday" value={birthday} onChange={setBirthday} /></div>
<div className="field"><label>Website</label><input className="input" value={website} onChange={(e) => setWebsite(e.target.value)} placeholder="https://" /></div>
<div className="field"><label>{t("Birthday")}</label><DateField aria-label={t("Birthday")} value={birthday} onChange={setBirthday} /></div>
<div className="field"><label>{t("Website")}</label><input className="input" value={website} onChange={(e) => setWebsite(e.target.value)} placeholder={t("https://")} /></div>
</div>
<div className="field"><label>Notes</label><textarea className="textarea" value={note} onChange={(e) => setNote(e.target.value)} /></div>
<div className="field"><label>{t("Notes")}</label><textarea className="textarea" value={note} onChange={(e) => setNote(e.target.value)} /></div>
</div>
</Dialog>
);
+19 -18
View File
@@ -8,6 +8,7 @@ import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
import { confirmDialog, promptDialog } from "@/ui/dialog";
import { toast } from "@/ui/toast";
import { ShareDialog } from "../settings/ShareDialog";
import { t } from "@/lib/i18n";
/**
* Re-read the session so newly shared books appear without a sign-in.
@@ -69,18 +70,18 @@ export function ContactsSidebar() {
return (
<>
<div className="nav-section"><span>Contacts</span></div>
<div className="nav-section"><span>{t("Contacts")}</span></div>
<div className={`nav-item ${isOn(null, "all") ? "active" : ""}`} onClick={() => contacts.select({ accountId: null, bookId: "all" })}>
<Users size={17} />
<span className="grow truncate">All contacts</span>
<span className="grow truncate">{t("All contacts")}</span>
</div>
<div className="nav-section">
<span>My address books</span>
<span>{t("My address books")}</span>
<button
className="icon-btn sm"
title="New address book"
aria-label="New address book"
title={t("New address book")}
aria-label={t("New address book")}
onClick={async () => {
const name = await promptDialog({ title: "New address book", placeholder: "Name" });
if (!name?.trim()) return;
@@ -103,16 +104,16 @@ export function ContactsSidebar() {
>
<Book size={17} />
<span className="grow truncate">{b.name}</span>
{Object.keys(b.shareWith ?? {}).length > 0 && <Share2 size={12} className="faint" aria-label="Shared" />}
{Object.keys(b.shareWith ?? {}).length > 0 && <Share2 size={12} className="faint" aria-label={t("Shared")} />}
</div>
))}
<div className="nav-section">
<span>Shared with me</span>
<span>{t("Shared with me")}</span>
<button
className="icon-btn sm"
title="Check for new shares"
aria-label="Check for new shares"
title={t("Check for new shares")}
aria-label={t("Check for new shares")}
onClick={async () => { setRefreshing(true); await refreshShares(true); setRefreshing(false); }}
>
<RefreshCw size={14} className={refreshing ? "spin" : ""} />
@@ -129,8 +130,8 @@ export function ContactsSidebar() {
<span className="grow truncate">{book.name}</span>
<button
className="icon-btn sm"
title="Remove from my contacts"
aria-label="Remove from my contacts"
title={t("Remove from my contacts")}
aria-label={t("Remove from my contacts")}
onClick={(e) => { e.stopPropagation(); void contacts.setBookSubscribed(accountId, book.id, false); }}
>
<X size={13} />
@@ -148,15 +149,15 @@ export function ContactsSidebar() {
guess made on their behalf. */}
{available.length > 0 && (
<>
<div className="nav-section"><span>Available to add</span></div>
<div className="nav-section"><span>{t("Available to add")}</span></div>
{available.map(({ accountId, accountName, book }) => (
<div key={`${accountId}:${book.id}`} className="nav-item" title={`${book.name} — from ${accountName}`}>
<BookOpen size={17} className="faint" />
<span className="grow truncate faint">{book.name}</span>
<button
className="icon-btn sm"
title="Add to my contacts"
aria-label="Add to my contacts"
title={t("Add to my contacts")}
aria-label={t("Add to my contacts")}
onClick={(e) => { e.stopPropagation(); void contacts.setBookSubscribed(accountId, book.id, true); }}
>
<Plus size={13} />
@@ -180,7 +181,7 @@ export function ContactsSidebar() {
<>
<MenuItem
icon={<Pencil size={16} />}
label="Rename"
label={t("Rename")}
onClick={async () => {
const name = await promptDialog({ title: "Rename address book", defaultValue: menuBook.name });
if (!name?.trim() || name === menuBook.name) return;
@@ -191,13 +192,13 @@ export function ContactsSidebar() {
}
}}
/>
<MenuItem icon={<Share2 size={16} />} label="Share…" disabled={!menuBook.myRights?.mayShare} onClick={() => setShare(menuBook)} />
<MenuItem icon={<Share2 size={16} />} label={t("Share…")} disabled={!menuBook.myRights?.mayShare} onClick={() => setShare(menuBook)} />
{/* Revoking the lot, rather than removing people one at a time in
the dialog. Only shown when there is something to revoke. */}
{Object.keys(menuBook.shareWith ?? {}).length > 0 && (
<MenuItem
icon={<UserMinus size={16} />}
label="Stop sharing"
label={t("Stop sharing")}
disabled={!menuBook.myRights?.mayShare}
onClick={async () => {
const who = Object.keys(menuBook.shareWith ?? {}).length;
@@ -220,7 +221,7 @@ export function ContactsSidebar() {
<MenuItem
danger
icon={<Trash2 size={16} />}
label="Delete"
label={t("Delete")}
disabled={menuBook.isDefault}
onClick={async () => {
if (!(await confirmDialog({ title: `Delete “${menuBook.name}”?`, message: "The contacts in it go too.", confirmLabel: "Delete", danger: true }))) return;
+17 -16
View File
@@ -11,6 +11,7 @@ import { confirmDialog } from "@/ui/dialog";
import { toast } from "@/ui/toast";
import { ContactEditor } from "./ContactEditor";
import { avatarColor } from "@/lib/address";
import { t as translate } from "@/lib/i18n";
export function ContactsView({ id }: { id?: string }) {
const [, navigate] = useLocation();
@@ -77,7 +78,7 @@ export function ContactsView({ id }: { id?: string }) {
}, [list]);
if (!contacts.available) {
return <div className="p-16"><Empty icon={<Users size={40} />} title="Contacts are not available">This account does not have the JMAP contacts capability.</Empty></div>;
return <div className="p-16"><Empty icon={<Users size={40} />} title={translate("Contacts are not available")}>{translate("This account does not have the JMAP contacts capability.")}</Empty></div>;
}
const exportAll = () => {
@@ -109,12 +110,12 @@ export function ContactsView({ id }: { id?: string }) {
<div className="list-search row">
<div className="search-input" style={{ flex: 1, height: 38, background: "var(--bg-sunken)", borderRadius: 999, display: "flex", alignItems: "center", gap: 8, padding: "0 12px" }}>
<Search size={16} className="muted" />
<input style={{ flex: 1, border: 0, background: "transparent", outline: "none" }} placeholder="Search contacts" value={q} onChange={(e) => setQ(e.target.value)} />
<input style={{ flex: 1, border: 0, background: "transparent", outline: "none" }} placeholder={translate("Search contacts")} value={q} onChange={(e) => setQ(e.target.value)} />
</div>
<button className="icon-btn" title="New contact" onClick={() => setEditing({})}><Plus size={20} /></button>
<button className="icon-btn" title={translate("New contact")} onClick={() => setEditing({})}><Plus size={20} /></button>
</div>
<div className="contacts-scroll">
{contacts.loading && !contacts.loaded ? <Spinner label="Loading contacts…" /> : !list.length ? (
{contacts.loading && !contacts.loaded ? <Spinner label={translate("Loading contacts…")} /> : !list.length ? (
<Empty icon={<Users size={36} />} title={q ? "No matches" : "No contacts yet"}>{q ? "Try another search." : "Add a contact or import a vCard file."}</Empty>
) : groups.map((g) => (
<div key={g.letter}>
@@ -126,7 +127,7 @@ export function ContactsView({ id }: { id?: string }) {
<div key={c.id} className={`contact-row ${id === c.id ? "active" : ""}`} onClick={() => navigate(`/contacts/${c.id}`)}>
<span className="avatar" style={{ background: photo ? "transparent" : avatarColor(email ?? contactDisplayName(c)) }}>{photo ? <img src={photo} alt="" /> : c.kind === "group" ? <Users size={16} /> : contactDisplayName(c).slice(0, 1).toUpperCase()}</span>
<div className="grow" style={{ minWidth: 0 }}>
<div className="c-name"><span>{contactDisplayName(c)}</span>{c.kind === "group" ? <span className="hint"> · group</span> : null}</div>
<div className="c-name"><span>{contactDisplayName(c)}</span>{c.kind === "group" ? <span className="hint"> {translate("· group")}</span> : null}</div>
<div className="c-email">{email ?? Object.values(c.phones ?? {})[0]?.number ?? Object.values(c.organizations ?? {})[0]?.name ?? ""}</div>
</div>
</div>
@@ -141,7 +142,7 @@ export function ContactsView({ id }: { id?: string }) {
{selected ? (
<ContactDetail card={selected} onBack={() => navigate("/contacts")} onEdit={() => setEditing(selected)} narrow={narrow} onEmail={(addr) => openCompose({ to: [{ name: contactDisplayName(selected), email: addr }] })} />
) : (
<div className="no-thread"><Users size={48} style={{ color: "var(--fg-faint)" }} /><div>Select a contact</div></div>
<div className="no-thread"><Users size={48} style={{ color: "var(--fg-faint)" }} /><div>{translate("Select a contact")}</div></div>
)}
</section>
{editing && <ContactEditor card={editing} defaultBookId={bookId !== "all" ? bookId : (books.find((b) => b.isDefault)?.id ?? books[0]?.id ?? null)} onClose={() => setEditing(null)} onSaved={(cid) => { setEditing(null); navigate(`/contacts/${cid}`); }} />}
@@ -163,7 +164,7 @@ function ContactDetail({ card: c, onBack, onEdit, narrow, onEmail }: { card: Con
return (
<div>
<div className="row" style={{ marginBottom: 12 }}>
{narrow && <button className="icon-btn" onClick={onBack} aria-label="Back"><ArrowLeft size={20} /></button>}
{narrow && <button className="icon-btn" onClick={onBack} aria-label={translate("Back")}><ArrowLeft size={20} /></button>}
<span className="spacer" />
<button className="btn btn-sm" onClick={onEdit}><Pencil size={14} /> Edit</button>
<button className="btn btn-sm" onClick={() => { const a = document.createElement("a"); a.href = URL.createObjectURL(new Blob([toVCard(c)], { type: "text/vcard" })); a.download = `${name.replace(/[^\w.-]+/g, "_")}.vcf`; a.click(); }}><Download size={14} /> vCard</button>
@@ -179,45 +180,45 @@ function ContactDetail({ card: c, onBack, onEdit, narrow, onEmail }: { card: Con
</div>
</div>
{Object.values(c.emails ?? {}).length > 0 && (
<div className="contact-section"><h3>Email</h3>
<div className="contact-section"><h3>{translate("Email")}</h3>
{Object.values(c.emails ?? {}).map((e, i) => (
<div key={i} className="contact-kv"><span className="k">{ctxLabel(e.contexts, e.label) || "email"}</span><span className="v row gap-8"><a href={`mailto:${e.address}`} onClick={(ev) => { ev.preventDefault(); onEmail(e.address); }}>{e.address}</a><button className="icon-btn xs" title="Compose" onClick={() => onEmail(e.address)}><Mail size={14} /></button></span></div>
<div key={i} className="contact-kv"><span className="k">{ctxLabel(e.contexts, e.label) || "email"}</span><span className="v row gap-8"><a href={`mailto:${e.address}`} onClick={(ev) => { ev.preventDefault(); onEmail(e.address); }}>{e.address}</a><button className="icon-btn xs" title={translate("Compose")} onClick={() => onEmail(e.address)}><Mail size={14} /></button></span></div>
))}
</div>
)}
{Object.values(c.phones ?? {}).length > 0 && (
<div className="contact-section"><h3>Phone</h3>
<div className="contact-section"><h3>{translate("Phone")}</h3>
{Object.values(c.phones ?? {}).map((p, i) => (
<div key={i} className="contact-kv"><span className="k">{ctxLabel({ ...p.contexts, ...p.features }, p.label) || "phone"}</span><span className="v row gap-8"><Phone size={14} className="muted" /><a href={`tel:${p.number}`}>{p.number}</a></span></div>
))}
</div>
)}
{Object.values(c.addresses ?? {}).length > 0 && (
<div className="contact-section"><h3>Address</h3>
<div className="contact-section"><h3>{translate("Address")}</h3>
{Object.values(c.addresses ?? {}).map((a, i) => (
<div key={i} className="contact-kv"><span className="k">{ctxLabel(a.contexts) || "address"}</span><span className="v row gap-8" style={{ alignItems: "flex-start" }}><MapPin size={14} className="muted" style={{ marginTop: 3 }} /><span>{formatAddressLines(a).map((l, j) => <div key={j}>{l}</div>)}</span></span></div>
))}
</div>
)}
{(org || Object.values(c.titles ?? {}).length > 1) && (
<div className="contact-section"><h3>Work</h3>
{org?.name && <div className="contact-kv"><span className="k">Company</span><span className="v row gap-8"><Building2 size={14} className="muted" />{`${org.name}${org.units?.length ? ` · ${org.units.map((u) => u.name).join(", ")}` : ""}`}</span></div>}
<div className="contact-section"><h3>{translate("Work")}</h3>
{org?.name && <div className="contact-kv"><span className="k">{translate("Company")}</span><span className="v row gap-8"><Building2 size={14} className="muted" />{`${org.name}${org.units?.length ? ` · ${org.units.map((u) => u.name).join(", ")}` : ""}`}</span></div>}
{Object.values(c.titles ?? {}).map((t, i) => <div key={i} className="contact-kv"><span className="k">{t.kind === "role" ? "Role" : "Title"}</span><span className="v">{t.name}</span></div>)}
</div>
)}
{Object.values(c.anniversaries ?? {}).length > 0 && (
<div className="contact-section"><h3>Dates</h3>
<div className="contact-section"><h3>{translate("Dates")}</h3>
{Object.values(c.anniversaries ?? {}).map((a, i) => <div key={i} className="contact-kv"><span className="k">{a.kind === "birth" ? "Birthday" : a.kind === "wedding" ? "Anniversary" : a.kind}</span><span className="v row gap-8"><Cake size={14} className="muted" />{fmtPartial(a.date)}</span></div>)}
</div>
)}
{(Object.values(c.links ?? {}).length > 0 || Object.values(c.onlineServices ?? {}).length > 0) && (
<div className="contact-section"><h3>Online</h3>
<div className="contact-section"><h3>{translate("Online")}</h3>
{Object.values(c.links ?? {}).map((l, i) => <div key={`l${i}`} className="contact-kv"><span className="k">{l.label ?? "Website"}</span><span className="v row gap-8"><Globe size={14} className="muted" /><a href={l.uri} target="_blank" rel="noreferrer">{l.uri}</a></span></div>)}
{Object.values(c.onlineServices ?? {}).map((s, i) => <div key={`s${i}`} className="contact-kv"><span className="k">{s.service ?? s.label ?? "IM"}</span><span className="v">{s.user ?? s.uri}</span></div>)}
</div>
)}
{Object.values(c.notes ?? {}).length > 0 && (
<div className="contact-section"><h3>Notes</h3>
<div className="contact-section"><h3>{translate("Notes")}</h3>
{Object.values(c.notes ?? {}).map((n, i) => <div key={i} className="contact-kv"><span className="k"><StickyNote size={14} /></span><span className="v" style={{ whiteSpace: "pre-wrap" }}>{n.note}</span></div>)}
</div>
)}
+11 -10
View File
@@ -11,6 +11,7 @@ import { confirmDialog, promptDialog } from "@/ui/dialog";
import { toast } from "@/ui/toast";
import { loadRaw, saveJson } from "@/lib/storage";
import { ShareDialog } from "../settings/ShareDialog";
import { t } from "@/lib/i18n";
/**
* Re-read the session, so the shared accounts on offer are current.
@@ -172,7 +173,7 @@ export function FilesTree() {
</button>
{open && kids.length ? <FolderOpen size={17} /> : <Folder size={17} />}
<span className="grow truncate">{d.name}</span>
{isShared(d) && <Share2 size={12} className="faint" aria-label="Shared" />}
{isShared(d) && <Share2 size={12} className="faint" aria-label={t("Shared")} />}
</div>
{open && kids.map((k) => row(k, depth + 1))}
</div>
@@ -204,11 +205,11 @@ export function FilesTree() {
{(viewingShare || sharedAccounts.length > 0) && (
<>
<div className="nav-section">
<span>Shared with me</span>
<span>{t("Shared with me")}</span>
<button
className="icon-btn sm"
title="Check for new shares"
aria-label="Check for new shares"
title={t("Check for new shares")}
aria-label={t("Check for new shares")}
onClick={async () => { setRefreshing(true); await refreshShares(true); setRefreshing(false); }}
>
<RefreshCw size={14} className={refreshing ? "spin" : ""} />
@@ -218,7 +219,7 @@ export function FilesTree() {
<div className="nav-item" onClick={() => { useFiles.getState().openAccount(ownAccountId); navigate("/files"); }}>
<span className="nav-twisty" aria-hidden="true" />
<HardDrive size={17} />
<span className="grow truncate">Back to my files</span>
<span className="grow truncate">{t("Back to my files")}</span>
</div>
)}
{sharedAccounts.map((a) => (
@@ -232,14 +233,14 @@ export function FilesTree() {
<span className="grow truncate">{a.name}</span>
</div>
))}
{!sharedAccounts.length && <p className="hint" style={{ padding: "4px 12px" }}>Nothing is shared with you.</p>}
{!sharedAccounts.length && <p className="hint" style={{ padding: "4px 12px" }}>{t("Nothing is shared with you.")}</p>}
</>
)}
<Popover anchor={menu.anchor} onClose={menu.close} width={210}>
<MenuItem
icon={<FolderPlus size={16} />}
label="New folder"
label={t("New folder")}
onClick={async () => {
const name = await promptDialog({ title: "New folder", placeholder: "Folder name" });
if (!name?.trim()) return;
@@ -255,7 +256,7 @@ export function FilesTree() {
<>
<MenuItem
icon={<Pencil size={16} />}
label="Rename"
label={t("Rename")}
disabled={!menuNode.myRights?.mayRename}
onClick={async () => {
const name = await promptDialog({ title: "Rename", defaultValue: menuNode.name });
@@ -267,12 +268,12 @@ export function FilesTree() {
}
}}
/>
<MenuItem icon={<Share2 size={16} />} label="Share…" disabled={!menuNode.myRights?.mayShare} onClick={() => setShareNode(menuNode)} />
<MenuItem icon={<Share2 size={16} />} label={t("Share…")} disabled={!menuNode.myRights?.mayShare} onClick={() => setShareNode(menuNode)} />
<MenuSep />
<MenuItem
danger
icon={<Trash2 size={16} />}
label="Delete"
label={t("Delete")}
disabled={!menuNode.myRights?.mayDelete}
onClick={async () => {
if (!(await confirmDialog({ title: `Delete “${menuNode.name}”?`, message: "Everything inside it goes too.", confirmLabel: "Delete", danger: true }))) return;
+15 -14
View File
@@ -13,6 +13,7 @@ import { Empty, Spinner } from "@/ui/misc";
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
import { confirmDialog, promptDialog, Dialog } from "@/ui/dialog";
import { toast } from "@/ui/toast";
import { t } from "@/lib/i18n";
export function FilesView({ nodeId }: { nodeId?: string }) {
const [, navigate] = useLocation();
@@ -62,7 +63,7 @@ export function FilesView({ nodeId }: { nodeId?: string }) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [parentId, files.available]);
if (!files.available) return <div className="p-16"><Empty icon={<FolderOpen size={40} />} title="File storage is not available">This account does not have the JMAP file storage capability.</Empty></div>;
if (!files.available) return <div className="p-16"><Empty icon={<FolderOpen size={40} />} title={t("File storage is not available")}>{t("This account does not have the JMAP file storage capability.")}</Empty></div>;
const ids = files.children[parentId ?? "root"] ?? [];
const nodes = ids.map((id) => files.nodes[id]).filter((n): n is FileNode => Boolean(n));
@@ -140,10 +141,10 @@ export function FilesView({ nodeId }: { nodeId?: string }) {
}}
>
{files.loading && !nodes.length ? <Spinner /> : !nodes.length ? (
<Empty icon={<FolderOpen size={40} />} title="This folder is empty">Drag files here or use Upload.</Empty>
<Empty icon={<FolderOpen size={40} />} title={t("This folder is empty")}>{t("Drag files here or use Upload.")}</Empty>
) : (
<table className="files-table">
<thead><tr><th>Name</th><th className="hide-mobile">Size</th><th className="hide-mobile">Modified</th><th /></tr></thead>
<thead><tr><th>{t("Name")}</th><th className="hide-mobile">{t("Size")}</th><th className="hide-mobile">{t("Modified")}</th><th /></tr></thead>
<tbody>
{nodes.map((n) => (
<tr
@@ -162,10 +163,10 @@ export function FilesView({ nodeId }: { nodeId?: string }) {
}}
onDrop={(e) => { if (n.nodeType === "directory") dropOnto(n.id, e); }}
onClick={() => setSelected(n.id)} onDoubleClick={() => (n.nodeType === "directory" ? navigate(`/files/${n.id}`) : download(n))} onContextMenu={(e) => { e.preventDefault(); setMenuNode(n); menu.openAt(e.clientX, e.clientY); }}>
<td><div className="f-name">{n.nodeType === "directory" ? <Folder size={18} /> : <File size={18} />}<span onClick={(e) => { if (n.nodeType === "directory") { e.stopPropagation(); navigate(`/files/${n.id}`); } }} style={n.nodeType === "directory" ? { cursor: "pointer" } : undefined}>{n.name}</span>{isShared(n) && <Share2 size={13} className="faint" aria-label="Shared" />}</div></td>
<td><div className="f-name">{n.nodeType === "directory" ? <Folder size={18} /> : <File size={18} />}<span onClick={(e) => { if (n.nodeType === "directory") { e.stopPropagation(); navigate(`/files/${n.id}`); } }} style={n.nodeType === "directory" ? { cursor: "pointer" } : undefined}>{n.name}</span>{isShared(n) && <Share2 size={13} className="faint" aria-label={t("Shared")} />}</div></td>
<td className="hide-mobile muted">{n.nodeType === "directory" ? "—" : formatSize(n.size)}</td>
<td className="hide-mobile muted">{formatListDate(n.modified ?? n.created)}</td>
<td style={{ textAlign: "right" }}><button className="icon-btn sm" onClick={(e) => { e.stopPropagation(); setMenuNode(n); menu.open(e); }} aria-label="Options"><MoreVertical size={16} /></button></td>
<td style={{ textAlign: "right" }}><button className="icon-btn sm" onClick={(e) => { e.stopPropagation(); setMenuNode(n); menu.open(e); }} aria-label={t("Options")}><MoreVertical size={16} /></button></td>
</tr>
))}
</tbody>
@@ -175,18 +176,18 @@ export function FilesView({ nodeId }: { nodeId?: string }) {
<Popover anchor={menu.anchor} onClose={menu.close} width={200}>
{!menuNode && (
<>
<MenuItem icon={<Upload size={16} />} label="Upload files…" onClick={() => inputRef.current?.click()} />
<MenuItem icon={<FolderPlus size={16} />} label="New folder" onClick={async () => { const n = await promptDialog({ title: "New folder", placeholder: "Folder name" }); if (n?.trim()) { try { await files.mkdir(parentId, n.trim()); } catch (err) { toast.error((err as Error).message); } } }} />
<MenuItem icon={<Upload size={16} />} label={t("Upload files…")} onClick={() => inputRef.current?.click()} />
<MenuItem icon={<FolderPlus size={16} />} label={t("New folder")} onClick={async () => { const n = await promptDialog({ title: "New folder", placeholder: "Folder name" }); if (n?.trim()) { try { await files.mkdir(parentId, n.trim()); } catch (err) { toast.error((err as Error).message); } } }} />
</>
)}
{menuNode && (
<>
{menuNode.nodeType === "directory" ? <MenuItem icon={<FolderOpen size={16} />} label="Open" onClick={() => navigate(`/files/${menuNode.id}`)} /> : <MenuItem icon={<Download size={16} />} label="Download" onClick={() => download(menuNode)} />}
<MenuItem icon={<Pencil size={16} />} label="Rename" disabled={!menuNode.myRights?.mayRename} onClick={async () => { const n = await promptDialog({ title: "Rename", defaultValue: menuNode.name }); if (n?.trim() && n !== menuNode.name) { try { await files.rename(menuNode.id, n.trim()); } catch (err) { toast.error((err as Error).message); } } }} />
<MenuItem icon={<FolderInput size={16} />} label="Move to…" onClick={() => setMoveNode(menuNode)} />
<MenuItem icon={<Share2 size={16} />} label="Share…" disabled={!menuNode.myRights?.mayShare} onClick={() => setShareNode(menuNode)} />
{menuNode.nodeType === "directory" ? <MenuItem icon={<FolderOpen size={16} />} label={t("Open")} onClick={() => navigate(`/files/${menuNode.id}`)} /> : <MenuItem icon={<Download size={16} />} label={t("Download")} onClick={() => download(menuNode)} />}
<MenuItem icon={<Pencil size={16} />} label={t("Rename")} disabled={!menuNode.myRights?.mayRename} onClick={async () => { const n = await promptDialog({ title: "Rename", defaultValue: menuNode.name }); if (n?.trim() && n !== menuNode.name) { try { await files.rename(menuNode.id, n.trim()); } catch (err) { toast.error((err as Error).message); } } }} />
<MenuItem icon={<FolderInput size={16} />} label={t("Move to…")} onClick={() => setMoveNode(menuNode)} />
<MenuItem icon={<Share2 size={16} />} label={t("Share…")} disabled={!menuNode.myRights?.mayShare} onClick={() => setShareNode(menuNode)} />
<MenuSep />
<MenuItem danger icon={<Trash2 size={16} />} label="Delete" disabled={!menuNode.myRights?.mayDelete} onClick={async () => { if (await confirmDialog({ title: `Delete “${menuNode.name}”?`, confirmLabel: "Delete", danger: true })) { try { await files.destroy([menuNode.id]); toast.success("Deleted"); } catch (err) { toast.error((err as Error).message); } } }} />
<MenuItem danger icon={<Trash2 size={16} />} label={t("Delete")} disabled={!menuNode.myRights?.mayDelete} onClick={async () => { if (await confirmDialog({ title: `Delete “${menuNode.name}”?`, confirmLabel: "Delete", danger: true })) { try { await files.destroy([menuNode.id]); toast.success("Deleted"); } catch (err) { toast.error((err as Error).message); } } }} />
</>
)}
</Popover>
@@ -206,13 +207,13 @@ function MoveDialog({ node, onClose }: { node: FileNode; onClose: () => void })
const dirs = (files.children[cur ?? "root"] ?? []).map((id) => files.nodes[id]).filter((n): n is FileNode => Boolean(n && n.nodeType === "directory" && n.id !== node.id));
const path = files.pathTo(cur);
return (
<Dialog open onClose={onClose} title={`Move “${node.name}`} size="sm" footer={<><button className="btn" onClick={onClose}>Cancel</button><button className="btn btn-primary" disabled={cur === (node.parentId ?? null)} onClick={async () => { try { await files.move(node.id, cur); toast.success("Moved"); onClose(); } catch (err) { toast.error((err as Error).message); } }}>Move here</button></>}>
<Dialog open onClose={onClose} title={`Move “${node.name}`} size="sm" footer={<><button className="btn" onClick={onClose}>{t("Cancel")}</button><button className="btn btn-primary" disabled={cur === (node.parentId ?? null)} onClick={async () => { try { await files.move(node.id, cur); toast.success("Moved"); onClose(); } catch (err) { toast.error((err as Error).message); } }}>{t("Move here")}</button></>}>
<div className="breadcrumb mb-8">
<button onClick={() => setCur(null)}><Home size={14} /></button>
{path.map((n) => <span key={n.id} className="row gap-4"><ChevronRight size={12} /><button onClick={() => setCur(n.id)}>{n.name}</button></span>)}
</div>
{dirs.map((d) => <button key={d.id} className="menu-item" onClick={() => setCur(d.id)}><Folder size={16} /><span className="grow">{d.name}</span><ChevronRight size={14} /></button>)}
{!dirs.length && <p className="hint">No subfolders here.</p>}
{!dirs.length && <p className="hint">{t("No subfolders here.")}</p>}
</Dialog>
);
}
+6 -5
View File
@@ -8,6 +8,7 @@ import { formatAddress } from "@/lib/address";
import { MenuItem, MenuSep, Popover, type Anchor } from "@/ui/popover";
import { toast } from "@/ui/toast";
import { ContactEditor } from "../contacts/ContactEditor";
import { t } from "@/lib/i18n";
/**
* Right-click on anyone named in a message — sender, recipients, Reply-To — to
@@ -43,16 +44,16 @@ export function useAddressMenu() {
<div className="menu-title truncate">{formatAddress(menu.address)}</div>
{contacts.available && (
known ? (
<MenuItem icon={<Pencil size={16} />} label="Edit contact" onClick={() => { setEditing(known); close(); }} />
<MenuItem icon={<Pencil size={16} />} label={t("Edit contact")} onClick={() => { setEditing(known); close(); }} />
) : (
<MenuItem icon={<UserPlus size={16} />} label="Add to contacts" onClick={() => { setEditing(contactFromAddress(menu.address)); close(); }} />
<MenuItem icon={<UserPlus size={16} />} label={t("Add to contacts")} onClick={() => { setEditing(contactFromAddress(menu.address)); close(); }} />
)
)}
<MenuItem icon={<Mail size={16} />} label="New message to this address" onClick={() => { openCompose({ to: [menu.address] }); close(); }} />
<MenuItem icon={<Mail size={16} />} label={t("New message to this address")} onClick={() => { openCompose({ to: [menu.address] }); close(); }} />
<MenuSep />
<MenuItem
icon={<Copy size={16} />}
label="Copy email address"
label={t("Copy email address")}
onClick={() => {
void navigator.clipboard?.writeText(menu.address.email).then(
() => toast.show("Address copied"),
@@ -85,7 +86,7 @@ export function AddressList({ list, onContext, empty = "—" }: { list: EmailAdd
{list.map((a, i) => (
<span key={`${a.email}-${i}`}>
{i > 0 && ", "}
<span className="addr" onContextMenu={(ev) => onContext(ev, a)} title="Right-click for options">{formatAddress(a)}</span>
<span className="addr" onContextMenu={(ev) => onContext(ev, a)} title={t("Right-click for options")}>{formatAddress(a)}</span>
</span>
))}
</>
+8 -7
View File
@@ -8,6 +8,7 @@ import { RuleDialog } from "../settings/RuleDialog";
import { toast } from "@/ui/toast";
import { Spinner } from "@/ui/misc";
import { Dialog } from "@/ui/dialog";
import { t } from "@/lib/i18n";
/** "Filter messages like this…" — creates a Sieve rule seeded from a message, optionally applying it to the current folder. */
export function FilterFromMessageDialog({ email, mailboxId, onClose }: { email: Email; mailboxId: Id | null; onClose: () => void }) {
@@ -26,17 +27,17 @@ export function FilterFromMessageDialog({ email, mailboxId, onClose }: { email:
if (!sieve.available) {
return (
<Dialog open onClose={onClose} title="Filters unavailable" size="sm" footer={<button className="btn" onClick={onClose}>Close</button>}>
<p>Sieve filtering is not enabled for this account.</p>
<Dialog open onClose={onClose} title={t("Filters unavailable")} size="sm" footer={<button className="btn" onClick={onClose}>{t("Close")}</button>}>
<p>{t("Sieve filtering is not enabled for this account.")}</p>
</Dialog>
);
}
if (!ready) return <Dialog open onClose={onClose} title="Create filter" size="sm"><Spinner /></Dialog>;
if (!ready) return <Dialog open onClose={onClose} title={t("Create filter")} size="sm"><Spinner /></Dialog>;
const { rules, loaded, damage } = sieve.rules();
if (rules === null) {
return (
<Dialog open onClose={onClose} title="Create filter" size="sm" footer={<button className="btn" onClick={onClose}>Close</button>}>
<Dialog open onClose={onClose} title={t("Create filter")} size="sm" footer={<button className="btn" onClick={onClose}>{t("Close")}</button>}>
{/*
Three different situations, and telling them apart matters: one is
permanent and two are a reload away. Saying "written by hand" when the
@@ -46,9 +47,9 @@ export function FilterFromMessageDialog({ email, mailboxId, onClose }: { email:
{damage ? (
<p>Your filter script {damage}, so only part of it arrived. Adding a rule would write that part back over the whole thing. Reload the page and try again.</p>
) : loaded ? (
<p>Your active Sieve script was written by hand, so rules can't be added automatically. Open <b>Settings → Filters & rules</b> to edit the script or switch to managed rules.</p>
<p>Your active Sieve script was written by hand, so rules can't be added automatically. Open <b>{t("Settings → Filters & rules")}</b> to edit the script or switch to managed rules.</p>
) : (
<p>Your filter script couldn't be read just now, so adding a rule would risk overwriting it. Reload the page and try again.</p>
<p>{t("Your filter script couldn't be read just now, so adding a rule would risk overwriting it. Reload the page and try again.")}</p>
)}
</Dialog>
);
@@ -57,7 +58,7 @@ export function FilterFromMessageDialog({ email, mailboxId, onClose }: { email:
return (
<RuleDialog
rule={rule}
title="Filter messages like this"
title={t("Filter messages like this")}
saveLabel="Create filter"
applyMailbox={mailbox ? { id: mailbox.id, name: mailbox.name } : null}
applyByDefault
+3 -2
View File
@@ -5,6 +5,7 @@ import type { CalendarEvent, Email, EmailBodyPart } from "@/jmap/types";
import { useCalendar, toInstance, myParticipantKeys, isAttendee, participantEmail } from "@/store/calendar";
import { formatTimeRange } from "@/lib/dates";
import { toast } from "@/ui/toast";
import { t } from "@/lib/i18n";
export function InviteCard({ email, part }: { email: Email; part: EmailBodyPart }) {
const cal = useCalendar();
@@ -110,12 +111,12 @@ export function InviteCard({ email, part }: { email: Email; part: EmailBodyPart
) : (
!existing && <button className="btn btn-sm" disabled={Boolean(busy)} onClick={() => void addToCalendar()}><Calendar size={14} /> Add to calendar</button>
)}
{existing && inst && <button className="btn btn-ghost btn-sm" onClick={() => navigate(`/calendar/day/${inst.start.toISOString().slice(0, 10)}`)}>Open in calendar</button>}
{existing && inst && <button className="btn btn-ghost btn-sm" onClick={() => navigate(`/calendar/day/${inst.start.toISOString().slice(0, 10)}`)}>{t("Open in calendar")}</button>}
</div>
)}
{method === "CANCEL" && existing && (
<div className="rsvp">
<button className="btn btn-sm btn-danger" disabled={Boolean(busy)} onClick={async () => { try { await cal.destroyEvent(existing, false, "series"); setExisting(null); toast.success("Removed from calendar"); } catch (err) { toast.error((err as Error).message); } }}>Remove from calendar</button>
<button className="btn btn-sm btn-danger" disabled={Boolean(busy)} onClick={async () => { try { await cal.destroyEvent(existing, false, "series"); setExisting(null); toast.success("Removed from calendar"); } catch (err) { toast.error((err as Error).message); } }}>{t("Remove from calendar")}</button>
</div>
)}
<span className="sr-only">{email.id}</span>
+3 -2
View File
@@ -5,6 +5,7 @@ import { useMail } from "@/store/mail";
import { Popover } from "@/ui/popover";
import type { Id } from "@/jmap/types";
import { CALENDAR_COLORS } from "@/ui/misc";
import { t } from "@/lib/i18n";
/** Labels are IMAP keywords on the messages; their names/colors live in settings. */
export function LabelPicker({ ids, anchor, onClose, onApplied }: { ids: Id[]; anchor: { x: number; y: number }; onClose: () => void; onApplied?: () => void }) {
@@ -33,7 +34,7 @@ export function LabelPicker({ ids, anchor, onClose, onApplied }: { ids: Id[]; an
return (
<Popover anchor={{ x: anchor.x, y: anchor.y, w: 0, h: 0 }} onClose={onClose} width={260} closeOnClick={false}>
<div className="menu-title">Label as</div>
<div className="menu-title">{t("Label as")}</div>
<div className="menu-search">
<input
className="input sm"
@@ -77,7 +78,7 @@ export function LabelPicker({ ids, anchor, onClose, onApplied }: { ids: Id[]; an
<span>Create {q.trim()}</span>
</button>
)}
{!labels.length && !q && <div className="hint" style={{ padding: "4px 10px 8px" }}>Type a name to create your first label.</div>}
{!labels.length && !q && <div className="hint" style={{ padding: "4px 10px 8px" }}>{t("Type a name to create your first label.")}</div>}
</Popover>
);
}
+3 -2
View File
@@ -3,6 +3,7 @@ import { Folder, Inbox } from "lucide-react";
import { useMail } from "@/store/mail";
import { Dialog } from "@/ui/dialog";
import type { Id, Mailbox } from "@/jmap/types";
import { t } from "@/lib/i18n";
export function MailboxPicker({ title, onClose, onPick, exclude }: { title: string; onClose: () => void; onPick: (id: Id) => void; exclude?: Id[] }) {
const mailboxes = useMail((s) => s.mailboxes);
@@ -23,7 +24,7 @@ export function MailboxPicker({ title, onClose, onPick, exclude }: { title: stri
<input
className="input"
autoFocus
placeholder="Type a folder name…"
placeholder={t("Type a folder name…")}
value={q}
onChange={(e) => {
setQ(e.target.value);
@@ -47,7 +48,7 @@ export function MailboxPicker({ title, onClose, onPick, exclude }: { title: stri
{list.map(({ m, path }, i) => (
<PickerRow key={m.id} m={m} path={path} active={i === active} onClick={() => onPick(m.id)} onHover={() => setActive(i)} />
))}
{!list.length && <div className="empty" style={{ padding: 24 }}>No matching folders</div>}
{!list.length && <div className="empty" style={{ padding: 24 }}>{t("No matching folders")}</div>}
</div>
</Dialog>
);
+13 -12
View File
@@ -14,6 +14,7 @@ import { ShareDialog } from "../settings/ShareDialog";
import { loadRaw, saveJson } from "@/lib/storage";
import { canDropFolder, folderColor, movable } from "@/lib/folderMove";
import { haptic, useTouchRow } from "@/lib/touch";
import { t } from "@/lib/i18n";
const ROLE_ICONS: Record<string, ReactNode> = {
inbox: <Inbox size={20} />,
@@ -125,7 +126,7 @@ export function MailboxTree() {
return (
<>
<nav aria-label="Folders" style={{ marginTop: 6 }}>
<nav aria-label={t("Folders")} style={{ marginTop: 6 }}>
<div
className={`nav-section${rootDrop ? " drop-target" : ""}`}
onDragOver={(e) => {
@@ -143,7 +144,7 @@ export function MailboxTree() {
}}
>
<span>{draggingId && canDropOn(null) ? "Drop here for the top level" : "Folders"}</span>
<button className="icon-btn" title="New folder" aria-label="New folder" onClick={() => void createFolder(null)}>
<button className="icon-btn" title={t("New folder")} aria-label={t("New folder")} onClick={() => void createFolder(null)}>
<Plus size={16} />
</button>
</div>
@@ -170,8 +171,8 @@ export function MailboxTree() {
{labelsSidebar && labels.length > 0 && (
<>
<div className="nav-section">
<span>Labels</span>
<Link href="/settings/labels" className="icon-btn" title="Manage labels" aria-label="Manage labels">
<span>{t("Labels")}</span>
<Link href="/settings/labels" className="icon-btn" title={t("Manage labels")} aria-label={t("Manage labels")}>
<Pencil size={14} />
</Link>
</div>
@@ -298,7 +299,7 @@ function FolderRow({ mailbox: m, label, depth, hasChildren, open, hiddenUnread,
{count > 0 && <span className="nav-dot" />}
<button
className="icon-btn nav-more"
aria-label="Folder options"
aria-label={t("Folder options")}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
@@ -362,18 +363,18 @@ function MailboxMenu({ mailbox: m, onClose, onCreateChild, onShare }: { mailbox:
};
return (
<>
<MenuItem icon={<CheckCheck size={16} />} label="Mark all as read" onClick={() => void useMail.getState().markMailboxRead(m.id)} disabled={!m.unreadEmails} />
<MenuItem icon={<CheckCheck size={16} />} label={t("Mark all as read")} onClick={() => void useMail.getState().markMailboxRead(m.id)} disabled={!m.unreadEmails} />
{hasChildren && (
<MenuItem
icon={<CheckCheck size={16} />}
label="Mark all as read, incl. subfolders"
label={t("Mark all as read, incl. subfolders")}
kbd={m.unreadEmails + subUnread ? String(m.unreadEmails + subUnread) : undefined}
onClick={() => void useMail.getState().markMailboxRead(m.id, true)}
disabled={!m.unreadEmails && !subUnread}
/>
)}
<MenuItem icon={<FolderPlus size={16} />} label="New subfolder" onClick={onCreateChild} disabled={!m.myRights.mayCreateChild} />
<MenuItem icon={<Pencil size={16} />} label="Rename" onClick={() => void rename()} disabled={isSpecial || !m.myRights.mayRename} />
<MenuItem icon={<FolderPlus size={16} />} label={t("New subfolder")} onClick={onCreateChild} disabled={!m.myRights.mayCreateChild} />
<MenuItem icon={<Pencil size={16} />} label={t("Rename")} onClick={() => void rename()} disabled={isSpecial || !m.myRights.mayRename} />
<MenuItem icon={m.isSubscribed ? <EyeOff size={16} /> : <Eye size={16} />} label={m.isSubscribed ? "Hide from list" : "Show in list"} onClick={() => void useMail.getState().updateMailbox(m.id, { isSubscribed: !m.isSubscribed })} disabled={m.role === "inbox"} />
{/* Sharing a mail folder is withdrawn, not removed: Stalwart accepts and
stores the share, and it never reaches the other account -- its own
@@ -381,7 +382,7 @@ function MailboxMenu({ mailbox: m, onClose, onCreateChild, onShare }: { mailbox:
folders. Offering it produced shares that looked real and did nothing.
One that already exists can still be cleared here, which is the only
reason this entry survives at all. */}
{shared && <MenuItem icon={<Share2 size={16} />} label="Stop sharing" onClick={onShare} />}
{shared && <MenuItem icon={<Share2 size={16} />} label={t("Stop sharing")} onClick={onShare} />}
<MenuSep />
<MenuTitle><span className="row gap-4"><Palette size={12} /> Colour</span></MenuTitle>
<div className="color-grid" style={{ gridTemplateColumns: "repeat(6, 26px)", padding: "4px 10px 8px" }}>
@@ -395,10 +396,10 @@ function MailboxMenu({ mailbox: m, onClose, onCreateChild, onShare }: { mailbox:
/>
))}
</div>
{color && <MenuItem icon={<X size={16} />} label="Use the default colour" onClick={() => setColor(null)} />}
{color && <MenuItem icon={<X size={16} />} label={t("Use the default colour")} onClick={() => setColor(null)} />}
<MenuSep />
{canEmpty(m.role) && <MenuItem icon={<Eraser size={16} />} label={emptyLabel(m)} onClick={() => void empty()} danger disabled={!m.totalEmails} />}
<MenuItem icon={<Trash2 size={16} />} label="Delete folder" onClick={() => void remove()} danger disabled={isSpecial || !m.myRights.mayDelete} />
<MenuItem icon={<Trash2 size={16} />} label={t("Delete folder")} onClick={() => void remove()} danger disabled={isSpecial || !m.myRights.mayDelete} />
</>
);
}
+38 -36
View File
@@ -14,6 +14,7 @@ import { useCompose } from "@/store/compose";
import { haptic, usePullToRefresh, useTouchRow, PULL_TRIGGER } from "@/lib/touch";
import { describeSwipe, type SwipeAction, type SwipeDescriptor, type SwipeIcon } from "@/lib/swipe";
import { FilterFromMessageDialog } from "./FilterFromMessage";
import { t } from "@/lib/i18n";
/**
* The glyph on the strip a swipe reveals. Sized larger than the toolbar's
@@ -231,14 +232,14 @@ export function MessageList({ title, list, openThreadId, focusId, setFocusId, on
<div className="mail-list-pane">
<div className="list-toolbar">
{isMobile && isSearch && (
<button className="icon-btn" onClick={() => navigate("/mail")} aria-label="Back">
<button className="icon-btn" onClick={() => navigate("/mail")} aria-label={t("Back")}>
<ArrowLeft size={20} />
</button>
)}
<input
type="checkbox"
className="select-all"
aria-label="Select all"
aria-label={t("Select all")}
checked={allSelected}
ref={(el) => {
if (el) el.indeterminate = selCount > 0 && !allSelected;
@@ -249,14 +250,14 @@ export function MessageList({ title, list, openThreadId, focusId, setFocusId, on
<>
<span className="tb-count">{selCount} selected</span>
<span className="tb-sep" />
<button className="icon-btn" title="Archive (e)" onClick={() => void actions.archive()}><Archive size={19} /></button>
<button className="icon-btn" title={t("Archive (e)")} onClick={() => void actions.archive()}><Archive size={19} /></button>
<button className="icon-btn" title={isTrashOrJunk ? "Delete forever" : "Delete (#)"} onClick={() => void actions.trash()}><Trash2 size={19} /></button>
<button className="icon-btn hide-mobile" title={mailbox?.role === "junk" ? "Not spam" : "Report spam (!)"} onClick={() => void actions.spam()}>{mailbox?.role === "junk" ? <ShieldCheck size={19} /> : <AlertOctagon size={19} />}</button>
<span className="tb-sep" />
<button className="icon-btn" title="Mark as read (Shift+I)" onClick={() => void actions.read(true)}><MailOpen size={19} /></button>
<button className="icon-btn hide-mobile" title="Mark as unread (Shift+U)" onClick={() => void actions.read(false)}><Mail size={19} /></button>
<button className="icon-btn" title="Move to (v)" onClick={() => actions.move()}><FolderInput size={19} /></button>
<button className="icon-btn hide-mobile" title="Labels (l)" onClick={(e) => actions.label(undefined, { x: e.clientX, y: e.clientY })}><Tag size={19} /></button>
<button className="icon-btn" title={t("Mark as read (Shift+I)")} onClick={() => void actions.read(true)}><MailOpen size={19} /></button>
<button className="icon-btn hide-mobile" title={t("Mark as unread (Shift+U)")} onClick={() => void actions.read(false)}><Mail size={19} /></button>
<button className="icon-btn" title={t("Move to (v)")} onClick={() => actions.move()}><FolderInput size={19} /></button>
<button className="icon-btn hide-mobile" title={t("Labels (l)")} onClick={(e) => actions.label(undefined, { x: e.clientX, y: e.clientY })}><Tag size={19} /></button>
{/*
The three buttons above marked hide-mobile have nowhere to go on
a phone, and used to simply not exist there: selecting mail on a
@@ -267,18 +268,18 @@ export function MessageList({ title, list, openThreadId, focusId, setFocusId, on
{isMobile && (
<>
<span className="spacer" />
<button className="icon-btn" onClick={selMenu.open} aria-label="More actions"><MoreVertical size={19} /></button>
<button className="icon-btn" onClick={selMenu.open} aria-label={t("More actions")}><MoreVertical size={19} /></button>
<Popover anchor={selMenu.anchor} onClose={selMenu.close} align="end" width={240}>
<MenuItem
icon={mailbox?.role === "junk" ? <ShieldCheck size={16} /> : <AlertOctagon size={16} />}
label={mailbox?.role === "junk" ? "Not spam" : "Report spam"}
onClick={() => void actions.spam()}
/>
<MenuItem icon={<Mail size={16} />} label="Mark as unread" onClick={() => void actions.read(false)} />
<MenuItem icon={<Tag size={16} />} label="Label…" onClick={() => actions.label(undefined, { x: window.innerWidth / 2, y: 100 })} />
<MenuItem icon={<Mail size={16} />} label={t("Mark as unread")} onClick={() => void actions.read(false)} />
<MenuItem icon={<Tag size={16} />} label={t("Label…")} onClick={() => actions.label(undefined, { x: window.innerWidth / 2, y: 100 })} />
<MenuSep />
<MenuItem icon={<CheckSquare size={16} />} label="Select all" onClick={selectAll} />
<MenuItem icon={<X size={16} />} label="Clear selection" onClick={clearSelection} />
<MenuItem icon={<CheckSquare size={16} />} label={t("Select all")} onClick={selectAll} />
<MenuItem icon={<X size={16} />} label={t("Clear selection")} onClick={clearSelection} />
</Popover>
</>
)}
@@ -288,20 +289,20 @@ export function MessageList({ title, list, openThreadId, focusId, setFocusId, on
<span className="tb-title">{title}</span>
{list && !list.loading && <span className="tb-count">{list.total.toLocaleString()}</span>}
<span className="spacer" />
<button className={`icon-btn ${refreshing ? "active" : ""}`} title="Refresh" onClick={() => void doRefresh()} aria-label="Refresh">
<button className={`icon-btn ${refreshing ? "active" : ""}`} title={t("Refresh")} onClick={() => void doRefresh()} aria-label={t("Refresh")}>
<RefreshCw size={18} className={refreshing ? "spin" : ""} style={refreshing ? { animation: "spin .8s linear infinite" } : undefined} />
</button>
<button className="icon-btn" onClick={moreMenu.open} aria-label="More">
<button className="icon-btn" onClick={moreMenu.open} aria-label={t("More")}>
<MoreVertical size={18} />
</button>
<Popover anchor={moreMenu.anchor} onClose={moreMenu.close} align="end" width={240}>
<MenuTitle>Reading pane</MenuTitle>
<MenuItem icon={<PanelRight size={16} />} label="Right of the list" checked={settings.readingPane === "right"} onClick={() => updateSettings({ readingPane: "right" })} />
<MenuItem icon={<PanelBottom size={16} />} label="Below the list" checked={settings.readingPane === "bottom"} onClick={() => updateSettings({ readingPane: "bottom" })} />
<MenuItem icon={<PanelTop size={16} />} label="Hidden (open full width)" checked={settings.readingPane === "off"} onClick={() => updateSettings({ readingPane: "off" })} />
<MenuTitle>{t("Reading pane")}</MenuTitle>
<MenuItem icon={<PanelRight size={16} />} label={t("Right of the list")} checked={settings.readingPane === "right"} onClick={() => updateSettings({ readingPane: "right" })} />
<MenuItem icon={<PanelBottom size={16} />} label={t("Below the list")} checked={settings.readingPane === "bottom"} onClick={() => updateSettings({ readingPane: "bottom" })} />
<MenuItem icon={<PanelTop size={16} />} label={t("Hidden (open full width)")} checked={settings.readingPane === "off"} onClick={() => updateSettings({ readingPane: "off" })} />
<MenuSep />
<MenuItem icon={<CheckSquare size={16} />} label="Select all" onClick={selectAll} />
<MenuItem icon={<MailOpen size={16} />} label="Mark all as read" onClick={() => mailboxId && void useMail.getState().markMailboxRead(mailboxId)} disabled={!mailboxId} />
<MenuItem icon={<CheckSquare size={16} />} label={t("Select all")} onClick={selectAll} />
<MenuItem icon={<MailOpen size={16} />} label={t("Mark all as read")} onClick={() => mailboxId && void useMail.getState().markMailboxRead(mailboxId)} disabled={!mailboxId} />
{mailbox && canEmpty(mailbox.role) && (
<>
<MenuSep />
@@ -321,7 +322,7 @@ export function MessageList({ title, list, openThreadId, focusId, setFocusId, on
{list?.error && (
<div className="list-hint">
<span className="grow" style={{ color: "var(--danger)" }}>{list.error}</span>
<button onClick={() => void doRefresh()}>Retry</button>
<button onClick={() => void doRefresh()}>{t("Retry")}</button>
</div>
)}
{/*
@@ -336,9 +337,10 @@ export function MessageList({ title, list, openThreadId, focusId, setFocusId, on
{mailbox?.role === "junk" && !!mailbox.totalEmails && !selCount && (
<div className="list-hint">
<span className="grow">
Deleting spam is permanent it does not go to Deleted Items first.
{t("Deleting spam is permanent — it does not go to Deleted Items first.")}
</span>
<button onClick={() => void confirmAndEmpty(mailbox)}>Delete all spam now</button>
<button onClick={() => void confirmAndEmpty(mailbox)}>{t("Delete all spam now")}</button>
</div>
)}
<div
@@ -457,19 +459,19 @@ export function MessageList({ title, list, openThreadId, focusId, setFocusId, on
</div>
</div>
<Popover anchor={ctxMenu.anchor} onClose={ctxMenu.close} width={250}>
<MenuItem icon={<Reply size={16} />} label="Reply" onClick={() => { const e = ctxRow ? emails[ctxRow] : undefined; if (e) void useCompose.getState().reply(e, "reply"); }} />
<MenuItem icon={<Forward size={16} />} label="Forward" onClick={() => { const e = ctxRow ? emails[ctxRow] : undefined; if (e) void useCompose.getState().reply(e, "forward"); }} />
<MenuItem icon={<Reply size={16} />} label={t("Reply")} onClick={() => { const e = ctxRow ? emails[ctxRow] : undefined; if (e) void useCompose.getState().reply(e, "reply"); }} />
<MenuItem icon={<Forward size={16} />} label={t("Forward")} onClick={() => { const e = ctxRow ? emails[ctxRow] : undefined; if (e) void useCompose.getState().reply(e, "forward"); }} />
<MenuSep />
<MenuItem icon={<Archive size={16} />} label="Archive" kbd="e" onClick={() => void actions.archive(ctxTargets)} />
<MenuItem icon={<Trash2 size={16} />} label="Delete" kbd="#" onClick={() => void actions.trash(ctxTargets)} />
<MenuItem icon={<Archive size={16} />} label={t("Archive")} kbd="e" onClick={() => void actions.archive(ctxTargets)} />
<MenuItem icon={<Trash2 size={16} />} label={t("Delete")} kbd="#" onClick={() => void actions.trash(ctxTargets)} />
<MenuItem icon={<AlertOctagon size={16} />} label={mailbox?.role === "junk" ? "Not spam" : "Report spam"} kbd="!" onClick={() => void actions.spam(ctxTargets)} />
<MenuSep />
<MenuItem icon={someUnread ? <MailOpen size={16} /> : <Mail size={16} />} label={someUnread ? "Mark as read" : "Mark as unread"} onClick={() => void actions.read(someUnread, ctxTargets)} />
<MenuItem icon={<Star size={16} />} label={someUnstarred ? "Add star" : "Remove star"} kbd="s" onClick={() => void actions.star(someUnstarred, ctxTargets)} />
<MenuItem icon={<FolderInput size={16} />} label="Move to…" kbd="v" onClick={() => actions.move(ctxTargets)} />
<MenuItem icon={<Tag size={16} />} label="Label…" kbd="l" onClick={() => actions.label(ctxTargets, ctxMenu.anchor ?? { x: 0, y: 0 })} />
<MenuItem icon={<FolderInput size={16} />} label={t("Move to…")} kbd="v" onClick={() => actions.move(ctxTargets)} />
<MenuItem icon={<Tag size={16} />} label={t("Label…")} kbd="l" onClick={() => actions.label(ctxTargets, ctxMenu.anchor ?? { x: 0, y: 0 })} />
<MenuSep />
<MenuItem icon={<Filter size={16} />} label="Filter messages like this…" onClick={() => { const e = ctxRow ? emails[ctxRow] : undefined; if (e) setFilterFrom(e); }} />
<MenuItem icon={<Filter size={16} />} label={t("Filter messages like this…")} onClick={() => { const e = ctxRow ? emails[ctxRow] : undefined; if (e) setFilterFrom(e); }} />
</Popover>
{filterFrom && <FilterFromMessageDialog email={filterFrom} mailboxId={mailboxId} onClose={() => setFilterFrom(null)} />}
</div>
@@ -636,7 +638,7 @@ const Row = memo(function Row({ email: e, threadEmails, top, height, selected, f
role="row"
aria-selected={selected}
>
<input type="checkbox" className="msg-check" checked={selected} onClick={(ev) => ev.stopPropagation()} onChange={(ev) => onSelect(e.id, ev.target.checked)} aria-label="Select" />
<input type="checkbox" className="msg-check" checked={selected} onClick={(ev) => ev.stopPropagation()} onChange={(ev) => onSelect(e.id, ev.target.checked)} aria-label={t("Select")} />
{!twoLine && (
<button className={`msg-star ${starred ? "on" : ""}`} onClick={(ev) => { ev.stopPropagation(); onStar(e.id, !starred); }} aria-label={starred ? "Unstar" : "Star"}>
<Star size={18} fill={starred ? "currentColor" : "none"} />
@@ -656,10 +658,10 @@ const Row = memo(function Row({ email: e, threadEmails, top, height, selected, f
</span>
</div>
<div className="msg-main">
{isDrafts && <span style={{ color: "var(--danger)" }}>Draft</span>}
{isDrafts && <span style={{ color: "var(--danger)" }}>{t("Draft")}</span>}
<span className="msg-subject">{e.subject || "(no subject)"}</span>
{showPreview && <span className="msg-preview">{latest.preview}</span>}
<button className={`msg-star ${starred ? "on" : ""}`} style={{ marginLeft: "auto" }} onClick={(ev) => { ev.stopPropagation(); onStar(e.id, !starred); }} aria-label="Star">
<button className={`msg-star ${starred ? "on" : ""}`} style={{ marginLeft: "auto" }} onClick={(ev) => { ev.stopPropagation(); onStar(e.id, !starred); }} aria-label={t("Star")}>
<Star size={16} fill={starred ? "currentColor" : "none"} />
</button>
</div>
@@ -672,7 +674,7 @@ const Row = memo(function Row({ email: e, threadEmails, top, height, selected, f
{count > 1 && <span className="thread-count">{count}</span>}
</span>
<span className="msg-main">
{isDrafts && <span style={{ color: "var(--danger)", flex: "0 0 auto" }}>Draft</span>}
{isDrafts && <span style={{ color: "var(--danger)", flex: "0 0 auto" }}>{t("Draft")}</span>}
{rowLabels.length > 0 && <span className="msg-labels">{rowLabels.map((l) => <span key={l.keyword} className="tag" style={{ background: l.color }}>{l.name}</span>)}</span>}
<span className="msg-subject">{e.subject || "(no subject)"}</span>
{showPreview && <span className="msg-preview">{latest.preview}</span>}
@@ -682,8 +684,8 @@ const Row = memo(function Row({ email: e, threadEmails, top, height, selected, f
{hasAtt && <Paperclip size={14} className="msg-attach" />}
<span className="msg-date">{formatListDate(latest.receivedAt)}</span>
<span className="msg-actions">
<button className="icon-btn sm" title="Archive" onClick={(ev) => { ev.stopPropagation(); onArchive(e.id); }}><Archive size={16} /></button>
<button className="icon-btn sm" title="Delete" onClick={(ev) => { ev.stopPropagation(); onTrash(e.id); }}><Trash2 size={16} /></button>
<button className="icon-btn sm" title={t("Archive")} onClick={(ev) => { ev.stopPropagation(); onArchive(e.id); }}><Archive size={16} /></button>
<button className="icon-btn sm" title={t("Delete")} onClick={(ev) => { ev.stopPropagation(); onTrash(e.id); }}><Trash2 size={16} /></button>
<button className="icon-btn sm" title={unread ? "Mark as read" : "Mark as unread"} onClick={(ev) => { ev.stopPropagation(); onRead(e.id, unread); }}>{unread ? <MailOpen size={16} /> : <Mail size={16} />}</button>
</span>
</span>
+45 -43
View File
@@ -24,6 +24,7 @@ import { useScheduled } from "@/store/scheduled";
import { formatScheduleTime } from "@/lib/schedule";
import { mdnDecision, refusalText } from "@/lib/mdn";
import { sendReadReceipt } from "@/store/mdn";
import { t as translate } from "@/lib/i18n";
interface Props {
email: Email;
@@ -150,13 +151,13 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
<div className="from" onContextMenu={(ev) => from && addrMenu.open(ev, from)}>
<span className="addr">{displayName(from)}</span>
{expanded && from && <span className="email addr">&lt;{from.email}&gt;</span>}
{isHighPriority && <span className="tag" style={{ background: "var(--danger)" }}>Important</span>}
{isHighPriority && <span className="tag" style={{ background: "var(--danger)" }}>{translate("Important")}</span>}
{authFailed && <span className="tag" style={{ background: "var(--warn)" }} title={e["header:Authentication-Results:asText"] ?? ""}><ShieldAlert size={12} /> Unverified</span>}
</div>
{expanded ? (
<div className="to">
<span className="truncate">to {summarizeRecipients(e)}</span>
<button onClick={(ev) => { ev.stopPropagation(); setDetails((v) => !v); }} aria-label="Show details" title="Show details">
<button onClick={(ev) => { ev.stopPropagation(); setDetails((v) => !v); }} aria-label={translate("Show details")} title={translate("Show details")}>
{details ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</button>
</div>
@@ -167,30 +168,30 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
<div className="meta">
{e.hasAttachment && !expanded && <Paperclip size={14} />}
<span className="date" title={formatFullDate(e.receivedAt)}>{expanded ? formatFullDate(e.receivedAt) : formatListDate(e.receivedAt)}</span>
<button className={`icon-btn sm ${e.keywords.$flagged ? "active" : ""}`} style={e.keywords.$flagged ? { color: "var(--star)", background: "transparent" } : undefined} title="Star" onClick={(ev) => { ev.stopPropagation(); void actions.star(!e.keywords.$flagged, [e.id]); }}>
<button className={`icon-btn sm ${e.keywords.$flagged ? "active" : ""}`} style={e.keywords.$flagged ? { color: "var(--star)", background: "transparent" } : undefined} title={translate("Star")} onClick={(ev) => { ev.stopPropagation(); void actions.star(!e.keywords.$flagged, [e.id]); }}>
<Star size={17} fill={e.keywords.$flagged ? "currentColor" : "none"} />
</button>
{expanded && (
<>
<button className="icon-btn sm hide-mobile" title="Reply (r)" onClick={(ev) => { ev.stopPropagation(); void reply(e, "reply"); }}><Reply size={17} /></button>
<button className="icon-btn sm" onClick={(ev) => { ev.stopPropagation(); moreMenu.open(ev); }} aria-label="More"><MoreVertical size={17} /></button>
<button className="icon-btn sm hide-mobile" title={translate("Reply (r)")} onClick={(ev) => { ev.stopPropagation(); void reply(e, "reply"); }}><Reply size={17} /></button>
<button className="icon-btn sm" onClick={(ev) => { ev.stopPropagation(); moreMenu.open(ev); }} aria-label={translate("More")}><MoreVertical size={17} /></button>
</>
)}
</div>
</header>
<Popover anchor={moreMenu.anchor} onClose={moreMenu.close} align="end" width={240}>
<MenuItem icon={<Reply size={16} />} label="Reply" onClick={() => void reply(e, "reply")} />
<MenuItem icon={<ReplyAll size={16} />} label="Reply all" onClick={() => void reply(e, "replyAll")} />
<MenuItem icon={<Forward size={16} />} label="Forward" onClick={() => void reply(e, "forward")} />
<MenuItem icon={<Reply size={16} />} label={translate("Reply")} onClick={() => void reply(e, "reply")} />
<MenuItem icon={<ReplyAll size={16} />} label={translate("Reply all")} onClick={() => void reply(e, "replyAll")} />
<MenuItem icon={<Forward size={16} />} label={translate("Forward")} onClick={() => void reply(e, "forward")} />
<MenuSep />
<MenuItem icon={<Mail size={16} />} label={e.keywords.$seen ? "Mark as unread" : "Mark as read"} onClick={() => void useMail.getState().markRead([e.id], !e.keywords.$seen)} />
<MenuItem icon={<Trash2 size={16} />} label="Delete this message" onClick={() => void useMail.getState().trash([e.id])} />
<MenuItem icon={<Trash2 size={16} />} label={translate("Delete this message")} onClick={() => void useMail.getState().trash([e.id])} />
<MenuSep />
<MenuItem icon={<Eye size={16} />} label="Show original" onClick={() => void openSource()} />
<MenuItem icon={<Code size={16} />} label="Show headers" onClick={() => setShowHeaders(true)} />
<MenuItem icon={<Download size={16} />} label="Download (.eml)" onClick={downloadEml} />
<MenuItem icon={<Printer size={16} />} label="Print" onClick={() => window.print()} />
<MenuItem icon={<Filter size={16} />} label="Filter messages like this…" onClick={() => setFilterOpen(true)} />
<MenuItem icon={<Eye size={16} />} label={translate("Show original")} onClick={() => void openSource()} />
<MenuItem icon={<Code size={16} />} label={translate("Show headers")} onClick={() => setShowHeaders(true)} />
<MenuItem icon={<Download size={16} />} label={translate("Download (.eml)")} onClick={downloadEml} />
<MenuItem icon={<Printer size={16} />} label={translate("Print")} onClick={() => window.print()} />
<MenuItem icon={<Filter size={16} />} label={translate("Filter messages like this…")} onClick={() => setFilterOpen(true)} />
{from && (
<>
<MenuSep />
@@ -203,18 +204,18 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
<>
{details && (
<dl className="message-details" onClick={(ev) => ev.stopPropagation()}>
<dt>From</dt><dd><AddressList list={e.from} onContext={addrMenu.open} /></dd>
{e.sender?.length && !(e.sender.length === 1 && e.from?.some((f) => f.email === e.sender![0]!.email)) ? <><dt>Sender</dt><dd><AddressList list={e.sender} onContext={addrMenu.open} /></dd></> : null}
{e.replyTo?.length ? <><dt>Reply-To</dt><dd><AddressList list={e.replyTo} onContext={addrMenu.open} /></dd></> : null}
<dt>To</dt><dd><AddressList list={e.to} onContext={addrMenu.open} /></dd>
{e.cc?.length ? <><dt>Cc</dt><dd><AddressList list={e.cc} onContext={addrMenu.open} /></dd></> : null}
{e.bcc?.length ? <><dt>Bcc</dt><dd><AddressList list={e.bcc} onContext={addrMenu.open} /></dd></> : null}
<dt>Date</dt><dd>{formatFullDate(e.sentAt ?? e.receivedAt)}</dd>
<dt>Subject</dt><dd>{e.subject || "(no subject)"}</dd>
{e.messageId?.[0] && <><dt>Message-ID</dt><dd className="mono small">{e.messageId[0]}</dd></>}
{e["header:List-Id:asText"] && <><dt>List</dt><dd>{e["header:List-Id:asText"]}</dd></>}
<dt>Size</dt><dd>{formatSize(e.size)}</dd>
{receiptRequested && <><dt>Receipt</dt><dd>{receipt.offer ? `Requested, to ${receipt.to!.email}. Never sent automatically.` : refusalText(receipt.refusal!)}</dd></>}
<dt>{translate("From")}</dt><dd><AddressList list={e.from} onContext={addrMenu.open} /></dd>
{e.sender?.length && !(e.sender.length === 1 && e.from?.some((f) => f.email === e.sender![0]!.email)) ? <><dt>{translate("Sender")}</dt><dd><AddressList list={e.sender} onContext={addrMenu.open} /></dd></> : null}
{e.replyTo?.length ? <><dt>{translate("Reply-To")}</dt><dd><AddressList list={e.replyTo} onContext={addrMenu.open} /></dd></> : null}
<dt>{translate("To")}</dt><dd><AddressList list={e.to} onContext={addrMenu.open} /></dd>
{e.cc?.length ? <><dt>{translate("Cc")}</dt><dd><AddressList list={e.cc} onContext={addrMenu.open} /></dd></> : null}
{e.bcc?.length ? <><dt>{translate("Bcc")}</dt><dd><AddressList list={e.bcc} onContext={addrMenu.open} /></dd></> : null}
<dt>{translate("Date")}</dt><dd>{formatFullDate(e.sentAt ?? e.receivedAt)}</dd>
<dt>{translate("Subject")}</dt><dd>{e.subject || "(no subject)"}</dd>
{e.messageId?.[0] && <><dt>{translate("Message-ID")}</dt><dd className="mono small">{e.messageId[0]}</dd></>}
{e["header:List-Id:asText"] && <><dt>{translate("List")}</dt><dd>{e["header:List-Id:asText"]}</dd></>}
<dt>{translate("Size")}</dt><dd>{formatSize(e.size)}</dd>
{receiptRequested && <><dt>{translate("Receipt")}</dt><dd>{receipt.offer ? `Requested, to ${receipt.to!.email}. Never sent automatically.` : refusalText(receipt.refusal!)}</dd></>}
</dl>
)}
{receipt.offer && settings.readReceiptPolicy !== "never" && receiptDone !== "dismissed" && (
@@ -241,7 +242,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
>
{receiptDone === "sending" ? "Sending…" : "Send receipt"}
</button>
<button onClick={() => setReceiptDone("dismissed")}>Not this time</button>
<button onClick={() => setReceiptDone("dismissed")}>{translate("Not this time")}</button>
</div>
)}
{scheduled && (
@@ -258,15 +259,16 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
}
}}
>
Cancel send
{translate("Cancel send")}
</button>
</div>
)}
{rendered && rendered.remoteCount > 0 && !remoteAllowed && (
<div className="remote-banner" style={{ margin: "0 16px 8px" }}>
<ImageIcon size={16} />
<span className="grow">Remote images are blocked to protect your privacy.</span>
<button onClick={() => setAllowRemote(true)}>Show images</button>
<span className="grow">{translate("Remote images are blocked to protect your privacy.")}</span>
<button onClick={() => setAllowRemote(true)}>{translate("Show images")}</button>
{from && <button onClick={() => updateSettings({ trustedImageSenders: [...settings.trustedImageSenders, from.email.toLowerCase()] })}>Always from {from.email}</button>}
</div>
)}
@@ -278,18 +280,18 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
{attachments.length > 0 && <AttachmentList attachments={attachments} accountId={accountId} email={e} />}
{unsubscribe && (
<div className="unsubscribe-row">
<span>This looks like a mailing list.</span>
<button className="btn btn-ghost btn-sm" onClick={() => void onUnsubscribe()}>Unsubscribe</button>
<span>{translate("This looks like a mailing list.")}</span>
<button className="btn btn-ghost btn-sm" onClick={() => void onUnsubscribe()}>{translate("Unsubscribe")}</button>
</div>
)}
</>
)}
{addrMenu.node}
{filterOpen && <FilterFromMessageDialog email={e} mailboxId={Object.keys(e.mailboxIds)[0] ?? null} onClose={() => setFilterOpen(false)} />}
<Dialog open={showSource} onClose={() => setShowSource(false)} title="Original message" size="xl">
<Dialog open={showSource} onClose={() => setShowSource(false)} title={translate("Original message")} size="xl">
{source === null ? <div className="center"><span className="spinner" /></div> : <pre className="code notranslate" translate="no" style={{ minHeight: 300, maxHeight: "65vh" }}>{source}</pre>}
</Dialog>
<Dialog open={showHeaders} onClose={() => setShowHeaders(false)} title="Message headers" size="lg">
<Dialog open={showHeaders} onClose={() => setShowHeaders(false)} title={translate("Message headers")} size="lg">
<dl className="message-details" style={{ margin: 0 }}>
{Object.entries(e).filter(([k]) => k.startsWith("header:")).map(([k, v]) => (
<>
@@ -297,11 +299,11 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
<dd key={`${k}-d`} className="mono small">{Array.isArray(v) ? v.map((x: unknown) => (typeof x === "object" && x ? formatAddress(x as EmailAddress) : String(x))).join(", ") : String(v ?? "—")}</dd>
</>
))}
<dt>Received</dt><dd>{formatFullDate(e.receivedAt)}</dd>
{e.inReplyTo?.length ? <><dt>In-Reply-To</dt><dd className="mono small">{e.inReplyTo.join(" ")}</dd></> : null}
{e.references?.length ? <><dt>References</dt><dd className="mono small">{e.references.join(" ")}</dd></> : null}
<dt>{translate("Received")}</dt><dd>{formatFullDate(e.receivedAt)}</dd>
{e.inReplyTo?.length ? <><dt>{translate("In-Reply-To")}</dt><dd className="mono small">{e.inReplyTo.join(" ")}</dd></> : null}
{e.references?.length ? <><dt>{translate("References")}</dt><dd className="mono small">{e.references.join(" ")}</dd></> : null}
</dl>
<p className="hint">Use Show original for the complete raw message.</p>
<p className="hint">{translate("Use “Show original” for the complete raw message.")}</p>
</Dialog>
</article>
);
@@ -449,7 +451,7 @@ function HtmlBody({ html, bodyStyle, themed, onShowImages }: { html: string; bod
<div ref={hostRef} className="body-host notranslate" translate="no" />
{hasQuote && (
<button className="quote-toggle" onClick={() => setQuoteOpen((v) => !v)} title={quoteOpen ? "Hide quoted text" : "Show quoted text"}>
{quoteOpen ? <ChevronUp size={12} /> : <span style={{ letterSpacing: 2 }}></span>}
{quoteOpen ? <ChevronUp size={12} /> : <span style={{ letterSpacing: 2 }}>{translate("•••")}</span>}
{quoteOpen ? "Hide quoted text" : ""}
</button>
)}
@@ -491,7 +493,7 @@ function TextBody({ text }: { text: string }) {
<div ref={hostRef} className="body-host notranslate" translate="no" />
{quoted && (
<button className="quote-toggle" onClick={() => setQuoteOpen((v) => !v)}>
{quoteOpen ? <ChevronUp size={12} /> : <span style={{ letterSpacing: 2 }}></span>}
{quoteOpen ? <ChevronUp size={12} /> : <span style={{ letterSpacing: 2 }}>{translate("•••")}</span>}
{quoteOpen ? "Hide quoted text" : ""}
</button>
)}
@@ -532,8 +534,8 @@ function AttachmentList({ attachments, accountId, email }: { attachments: EmailB
<span className="att-name">{a.name ?? "(unnamed)"}</span>
<span className="att-size">{formatSize(a.size)}</span>
<span className="att-actions">
<button className="icon-btn xs" title="Download" onClick={(ev) => { ev.preventDefault(); ev.stopPropagation(); const l = document.createElement("a"); l.href = url; l.download = a.name ?? ""; l.click(); }}><Download size={14} /></button>
{viewable(a) && <button className="icon-btn xs" title="Open in new tab" onClick={(ev) => { ev.preventDefault(); ev.stopPropagation(); window.open(inlineUrl, "_blank", "noopener"); }}><ExternalLink size={14} /></button>}
<button className="icon-btn xs" title={translate("Download")} onClick={(ev) => { ev.preventDefault(); ev.stopPropagation(); const l = document.createElement("a"); l.href = url; l.download = a.name ?? ""; l.click(); }}><Download size={14} /></button>
{viewable(a) && <button className="icon-btn xs" title={translate("Open in new tab")} onClick={(ev) => { ev.preventDefault(); ev.stopPropagation(); window.open(inlineUrl, "_blank", "noopener"); }}><ExternalLink size={14} /></button>}
</span>
</span>
</a>
@@ -547,7 +549,7 @@ function AttachmentList({ attachments, accountId, email }: { attachments: EmailB
</div>
<Dialog open={Boolean(preview)} onClose={() => setPreview(null)} title={preview?.name ?? "Preview"} size="xl" footer={preview && <a className="btn" href={client.downloadUrl(accountId, preview.blobId!, preview.name ?? "file", preview.type)} download><Download size={16} /> Download</a>}>
{preview?.type.startsWith("image/") && <img src={client.downloadUrl(accountId, preview.blobId!, preview.name ?? "image", preview.type, true)} alt={preview.name ?? ""} style={{ maxHeight: "70vh", display: "block", margin: "0 auto" }} />}
{preview?.type === "application/pdf" && <iframe title="PDF" src={client.downloadUrl(accountId, preview.blobId!, preview.name ?? "file.pdf", preview.type, true)} style={{ width: "100%", height: "70vh", border: 0 }} />}
{preview?.type === "application/pdf" && <iframe title={translate("PDF")} src={client.downloadUrl(accountId, preview.blobId!, preview.name ?? "file.pdf", preview.type, true)} style={{ width: "100%", height: "70vh", border: 0 }} />}
{preview?.type === "text/plain" && <TextAttachment url={client.downloadUrl(accountId, preview.blobId!, preview.name ?? "file.txt", preview.type, true)} />}
<p className="hint" style={{ marginTop: 8 }}>From: {displayName(email.from?.[0])}</p>
</Dialog>
+13 -12
View File
@@ -12,6 +12,7 @@ import { client } from "@/jmap/client";
import { LabelPicker } from "./LabelPicker";
import { threadScrollTarget } from "@/lib/threadScroll";
import { useEdgeBack } from "@/lib/touch";
import { t } from "@/lib/i18n";
/** How long the opening scroll keeps its place while bodies and images land. */
const HOLD_MS = 2000;
@@ -230,30 +231,30 @@ export function ThreadView({ threadId, mailboxId, onBack, actions, onNavigate, h
return (
<div className="thread-view" ref={setViewEl}>
<div className="thread-toolbar">
<button className="icon-btn" onClick={onBack} aria-label="Back to list" title="Back (u)">
<button className="icon-btn" onClick={onBack} aria-label={t("Back to list")} title={t("Back (u)")}>
<ArrowLeft size={20} />
</button>
<button className="icon-btn" title="Archive (e)" onClick={() => void actions.archive(rowIds)}><Archive size={19} /></button>
<button className="icon-btn" title={t("Archive (e)")} onClick={() => void actions.archive(rowIds)}><Archive size={19} /></button>
<button className="icon-btn" title={inJunk ? "Not spam" : "Report spam (!)"} onClick={() => void actions.spam(rowIds)}>{inJunk ? <ShieldCheck size={19} /> : <AlertOctagon size={19} />}</button>
<button className="icon-btn" title="Delete (#)" onClick={() => void actions.trash(rowIds)}><Trash2 size={19} /></button>
<button className="icon-btn" title={t("Delete (#)")} onClick={() => void actions.trash(rowIds)}><Trash2 size={19} /></button>
<span className="tb-sep hide-mobile" />
<button className="icon-btn hide-mobile" title={anyUnread ? "Mark as read" : "Mark as unread"} onClick={() => void actions.read(anyUnread, rowIds)}>{anyUnread ? <MailOpen size={19} /> : <Mail size={19} />}</button>
<button className="icon-btn hide-mobile" title="Move to (v)" onClick={() => actions.move(rowIds)}><FolderInput size={19} /></button>
<button className="icon-btn hide-mobile" title="Labels (l)" onClick={(e) => setLabelAnchor({ x: e.clientX, y: e.clientY })}><Tag size={19} /></button>
<button className="icon-btn" onClick={moreMenu.open} aria-label="More"><MoreVertical size={19} /></button>
<button className="icon-btn hide-mobile" title={t("Move to (v)")} onClick={() => actions.move(rowIds)}><FolderInput size={19} /></button>
<button className="icon-btn hide-mobile" title={t("Labels (l)")} onClick={(e) => setLabelAnchor({ x: e.clientX, y: e.clientY })}><Tag size={19} /></button>
<button className="icon-btn" onClick={moreMenu.open} aria-label={t("More")}><MoreVertical size={19} /></button>
<Popover anchor={moreMenu.anchor} onClose={moreMenu.close} align="start" width={240}>
<MenuItem icon={<Star size={16} />} label={anyStarred ? "Remove star" : "Add star"} onClick={() => void actions.star(!anyStarred, rowIds)} />
<MenuItem icon={<Tag size={16} />} label="Label…" onClick={() => setLabelAnchor({ x: window.innerWidth / 2, y: 100 })} />
<MenuItem icon={<Tag size={16} />} label={t("Label…")} onClick={() => setLabelAnchor({ x: window.innerWidth / 2, y: 100 })} />
<MenuItem icon={allExpanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />} label={allExpanded ? "Collapse all" : "Expand all"} onClick={() => { setAllExpanded((v) => !v); setExpanded({}); }} />
<MenuSep />
<MenuItem icon={<Printer size={16} />} label="Print conversation" onClick={() => window.print()} />
<MenuItem icon={<Printer size={16} />} label={t("Print conversation")} onClick={() => window.print()} />
{last && accountId && (
<MenuItem icon={<Download size={16} />} label="Download latest as .eml" onClick={() => { const a = document.createElement("a"); a.href = client.downloadUrl(accountId, last.blobId, `${(last.subject || "message").replace(/[^\w.-]+/g, "_")}.eml`, "message/rfc822"); a.download = ""; a.click(); }} />
<MenuItem icon={<Download size={16} />} label={t("Download latest as .eml")} onClick={() => { const a = document.createElement("a"); a.href = client.downloadUrl(accountId, last.blobId, `${(last.subject || "message").replace(/[^\w.-]+/g, "_")}.eml`, "message/rfc822"); a.download = ""; a.click(); }} />
)}
</Popover>
<div className="thread-nav hide-mobile">
<button className="icon-btn sm" disabled={!hasPrev} onClick={() => onNavigate(-1)} title="Newer (k)"><ChevronUp size={18} /></button>
<button className="icon-btn sm" disabled={!hasNext} onClick={() => onNavigate(1)} title="Older (j)"><ChevronDown size={18} /></button>
<button className="icon-btn sm" disabled={!hasPrev} onClick={() => onNavigate(-1)} title={t("Newer (k)")}><ChevronUp size={18} /></button>
<button className="icon-btn sm" disabled={!hasNext} onClick={() => onNavigate(1)} title={t("Older (j)")}><ChevronDown size={18} /></button>
</div>
</div>
<div className="thread-scroll" ref={scrollRef}>
@@ -270,7 +271,7 @@ export function ThreadView({ threadId, mailboxId, onBack, actions, onNavigate, h
{messages.length > 1 && <span className="muted small nowrap" style={{ marginTop: 6 }}>{messages.length} messages</span>}
</div>
{error && <div className="error-box" style={{ margin: 16 }}>{error}</div>}
{loading && !messages.length && <Spinner label="Loading conversation…" />}
{loading && !messages.length && <Spinner label={t("Loading conversation…")} />}
{messages.map((e, i) => (
<MessageView
key={e.id}
+2 -1
View File
@@ -4,6 +4,7 @@ import type { EmailBodyPart, Id } from "@/jmap/types";
import { useContacts } from "@/store/contacts";
import { client } from "@/jmap/client";
import { toast } from "@/ui/toast";
import { t } from "@/lib/i18n";
export function VCardCard({ part, accountId }: { part: EmailBodyPart; accountId: Id }) {
const contacts = useContacts();
@@ -30,7 +31,7 @@ export function VCardCard({ part, accountId }: { part: EmailBodyPart; accountId:
<UserPlus size={20} style={{ color: "var(--accent)" }} />
<div className="grow">
<div style={{ fontWeight: 600 }}>{part.name ?? "Contact card"}</div>
<div className="hint">vCard attachment</div>
<div className="hint">{t("vCard attachment")}</div>
</div>
<button className="btn btn-sm" disabled={busy || done} onClick={() => void add()}>{done ? "Added" : "Add to contacts"}</button>
</div>
+13 -12
View File
@@ -2,6 +2,7 @@ import { useSession } from "@/store/session";
import { client } from "@/jmap/client";
import { DEFAULT_SOURCE_URL } from "@/lib/source";
import { APP_VERSION } from "@/lib/version";
import { t } from "@/lib/i18n";
export function AboutSettings() {
const session = useSession((s) => s.session);
@@ -10,28 +11,28 @@ export function AboutSettings() {
const sourceUrl = session?.ihasmail?.sourceUrl ?? DEFAULT_SOURCE_URL;
return (
<div>
<h1>About ihasmail</h1>
<p className="lead">A fast, friendly, open-source webmail for <a href="https://stalw.art" target="_blank" rel="noreferrer">Stalwart Mail Server</a>, built on JMAP.</p>
<h1>{t("About ihasmail")}</h1>
<p className="lead">A fast, friendly, open-source webmail for <a href="https://stalw.art" target="_blank" rel="noreferrer">{t("Stalwart Mail Server")}</a>, built on JMAP.</p>
<div className="row" style={{ gap: 16, alignItems: "center", marginBottom: 16 }}>
<img src="/img/logo.png" alt="ihasmail" width={96} />
<img src="/img/logo.png" alt={t("ihasmail")} width={96} />
<div>
<div style={{ fontWeight: 700, fontSize: "1.2em" }}>ihasmail v{APP_VERSION}</div>
<div className="hint">AGPL-3.0-or-later · <a href={sourceUrl} target="_blank" rel="noreferrer">{sourceUrl.replace(/^https?:\/\//, "")}</a></div>
</div>
</div>
<h2>Server</h2>
<h2>{t("Server")}</h2>
<table className="sessions-table">
<tbody>
<tr><td>Signed in as</td><td>{session?.username}</td></tr>
<tr><td>Stalwart</td><td>{describeServer(session?.ihasmail?.server)}</td></tr>
<tr><td>Accounts</td><td>{Object.values(session?.accounts ?? {}).map((a) => a.name).join(", ")}</td></tr>
<tr><td>Max upload</td><td>{Math.round(client.maxSizeUpload / 1048576)} MB</td></tr>
<tr><td>Image privacy proxy</td><td>{session?.ihasmail?.imageProxy ? "enabled" : "disabled"}</td></tr>
<tr><td>{t("Signed in as")}</td><td>{session?.username}</td></tr>
<tr><td>{t("Stalwart")}</td><td>{describeServer(session?.ihasmail?.server)}</td></tr>
<tr><td>{t("Accounts")}</td><td>{Object.values(session?.accounts ?? {}).map((a) => a.name).join(", ")}</td></tr>
<tr><td>{t("Max upload")}</td><td>{Math.round(client.maxSizeUpload / 1048576)} MB</td></tr>
<tr><td>{t("Image privacy proxy")}</td><td>{session?.ihasmail?.imageProxy ? "enabled" : "disabled"}</td></tr>
</tbody>
</table>
<p className="hint" style={{ marginTop: 6 }}>Stalwart does not publish its version number to mail clients, so ihasmail reports the edition where the server gives one. ihasmail requires 0.16 or newer, and sign-in refuses anything older.</p>
<p className="hint">ihasmail's own version is the date of the commit it was built from, followed by where that commit came from: <strong>v2026.8.30+pr129</strong> was built from a commit dated the 30th of August 2026 that arrived through pull request 129. A commit that did not come through one carries its short SHA instead <code>+g1fa6578</code>. The version deliberately says nothing about Stalwart; what this build needs from the server is the line above.</p>
<h2>Server capabilities</h2>
<p className="hint" style={{ marginTop: 6 }}>{t("Stalwart does not publish its version number to mail clients, so ihasmail reports the edition where the server gives one. ihasmail requires 0.16 or newer, and sign-in refuses anything older.")}</p>
<p className="hint">ihasmail's own version is the date of the commit it was built from, followed by where that commit came from: <strong>{t("v2026.8.30+pr129")}</strong> was built from a commit dated the 30th of August 2026 that arrived through pull request 129. A commit that did not come through one carries its short SHA instead <code>+g1fa6578</code>. The version deliberately says nothing about Stalwart; what this build needs from the server is the line above.</p>
<h2>{t("Server capabilities")}</h2>
<div className="row wrap gap-4">
{caps.map((c) => <span key={c} className="chip mono" style={{ fontSize: ".78em" }}>{c.replace("urn:ietf:params:jmap:", "")}</span>)}
</div>
+35 -30
View File
@@ -2,6 +2,7 @@ import { useSettings } from "@/store/settings";
import { Switch, useIsTouch } from "@/ui/misc";
import { SWIPE_CHOICES, type SwipeAction } from "@/lib/swipe";
import { UI_LANGUAGES } from "@/lib/languages";
import { t as translate } from "@/lib/i18n";
/**
* The theme cards, each previewing the background it actually paints. Kept as
@@ -32,9 +33,9 @@ export function AppearanceSettings() {
const isTouch = useIsTouch();
return (
<div>
<h1>Appearance</h1>
<p className="lead">Make ihasmail yours.</p>
<h2>Theme</h2>
<h1>{translate("Appearance")}</h1>
<p className="lead">{translate("Make ihasmail yours.")}</p>
<h2>{translate("Theme")}</h2>
<div className="theme-grid">
{THEMES.map((t) => (
<button key={t.id} className={`theme-card ${s.theme === t.id ? "active" : ""}`} onClick={() => update({ theme: t.id })}>
@@ -44,43 +45,43 @@ export function AppearanceSettings() {
))}
</div>
<p className="hint" style={{ marginTop: 10 }}>
<strong>ihasmail</strong> is the palette from <a href="https://ihasmail.org" target="_blank" rel="noopener noreferrer">ihasmail.org</a>, and what a new account starts on. It is a dark theme, so it counts as dark wherever that matters, and the accent colour below still applies on top of it.
<strong>{translate("ihasmail")}</strong> is the palette from <a href="https://ihasmail.org" target="_blank" rel="noopener noreferrer">{translate("ihasmail.org")}</a>, and what a new account starts on. It is a dark theme, so it counts as dark wherever that matters, and the accent colour below still applies on top of it.
</p>
<Switch
checked={s.themeMessageBody}
onChange={(v) => update({ themeMessageBody: v })}
label="Apply the theme to messages too"
hint="Plain-text mail already follows the theme. With this on, HTML mail that brings no colours of its own does as well, instead of sitting on a white card. Messages that style themselves are left exactly as the sender designed them."
label={translate("Apply the theme to messages too")}
hint={translate("Plain-text mail already follows the theme. With this on, HTML mail that brings no colours of its own does as well, instead of sitting on a white card. Messages that style themselves are left exactly as the sender designed them.")}
/>
<h2>Accent color</h2>
<h2>{translate("Accent color")}</h2>
<div className="swatches">
{ACCENTS.map((a) => (
<button key={a.id} className={`swatch ${s.accent === a.id ? "active" : ""}`} style={{ background: a.color }} onClick={() => update({ accent: a.id })} aria-label={a.id} title={a.id} />
))}
</div>
<h2>Density & text</h2>
<h2>{translate("Density & text")}</h2>
<div className="field-row">
<div className="field">
<label>Display density</label>
<label>{translate("Display density")}</label>
<select className="select" value={s.density} onChange={(e) => update({ density: e.target.value as typeof s.density })}>
<option value="comfortable">Comfortable</option>
<option value="cozy">Cozy (default)</option>
<option value="compact">Compact</option>
<option value="comfortable">{translate("Comfortable")}</option>
<option value="cozy">{translate("Cozy (default)")}</option>
<option value="compact">{translate("Compact")}</option>
</select>
</div>
<div className="field">
<label>Text size</label>
<label>{translate("Text size")}</label>
<select className="select" value={s.fontSize} onChange={(e) => update({ fontSize: e.target.value as typeof s.fontSize })}>
<option value="small">Small</option>
<option value="medium">Medium</option>
<option value="large">Large</option>
<option value="small">{translate("Small")}</option>
<option value="medium">{translate("Medium")}</option>
<option value="large">{translate("Large")}</option>
</select>
</div>
</div>
<h2>Language</h2>
<h2>{translate("Language")}</h2>
<div className="field" style={{ maxWidth: 320 }}>
<label htmlFor="ui-language">Interface language</label>
<label htmlFor="ui-language">{translate("Interface language")}</label>
<select id="ui-language" className="select" value={s.uiLanguage} onChange={(e) => update({ uiLanguage: e.target.value })}>
{UI_LANGUAGES.map((l) => (
<option key={l.tag} value={l.tag}>{l.name}</option>
@@ -93,19 +94,21 @@ export function AppearanceSettings() {
more are coming is a roadmap.
*/}
<p className="hint">
Only languages ihasmail has been translated into appear here, so this list grows as translations land rather than ahead of them a language offered without strings behind it would leave the page claiming to be in a language it is not.
{translate("Only languages ihasmail has been translated into appear here, so this list grows as translations land rather than ahead of them — a language offered without strings behind it would leave the page claiming to be in a language it is not.")}
</p>
<p className="hint">
This is separate from <strong>Language &amp; region</strong> in General, which decides how dates, times and numbers are written. You can read an English interface with German dates, or the other way round.
This is separate from <strong>{translate("Language & region")}</strong> in General, which decides how dates, times and numbers are written. You can read an English interface with German dates, or the other way round.
</p>
<h2>Swiping</h2>
<h2>{translate("Swiping")}</h2>
<p className="hint">
On a touchscreen, drag a message sideways to act on it. Each direction can do one thing, or nothing. These follow your account, so a phone and a tablet agree; a mouse ignores them and keeps dragging messages into folders instead.
{translate("On a touchscreen, drag a message sideways to act on it. Each direction can do one thing, or nothing. These follow your account, so a phone and a tablet agree; a mouse ignores them and keeps dragging messages into folders instead.")}
</p>
<div className="field-row">
<div className="field">
<label htmlFor="swipe-right">Swipe right</label>
<label htmlFor="swipe-right">{translate("Swipe right")}</label>
<select id="swipe-right" className="select" value={s.swipeRight} onChange={(e) => update({ swipeRight: e.target.value as SwipeAction })}>
{SWIPE_CHOICES.map((c) => (
<option key={c.value} value={c.value}>{c.label}</option>
@@ -113,7 +116,7 @@ export function AppearanceSettings() {
</select>
</div>
<div className="field">
<label htmlFor="swipe-left">Swipe left</label>
<label htmlFor="swipe-left">{translate("Swipe left")}</label>
<select id="swipe-left" className="select" value={s.swipeLeft} onChange={(e) => update({ swipeLeft: e.target.value as SwipeAction })}>
{SWIPE_CHOICES.map((c) => (
<option key={c.value} value={c.value}>{c.label}</option>
@@ -129,17 +132,19 @@ export function AppearanceSettings() {
*/}
{!isTouch && (
<p className="hint">
This screen has no touchscreen, so nothing here changes what it does. Your phone or tablet will pick these up.
{translate("This screen has no touchscreen, so nothing here changes what it does. Your phone or tablet will pick these up.")}
</p>
)}
<p className="hint">
Holding a message selects it, and holding a folder opens its menu. Pull the top of the message list down to check for new mail.
{translate("Holding a message selects it, and holding a folder opens its menu. Pull the top of the message list down to check for new mail.")}
</p>
<h2>Sidebar</h2>
<Switch checked={s.labelsSidebar} onChange={(v) => update({ labelsSidebar: v })} label="Show labels in the sidebar" />
<Switch checked={s.showHiddenFolders} onChange={(v) => update({ showHiddenFolders: v })} label="Show unsubscribed (hidden) folders" />
<Switch checked={s.sidebarCollapsed} onChange={(v) => update({ sidebarCollapsed: v })} label="Collapse sidebar to icons" />
<h2>{translate("Sidebar")}</h2>
<Switch checked={s.labelsSidebar} onChange={(v) => update({ labelsSidebar: v })} label={translate("Show labels in the sidebar")} />
<Switch checked={s.showHiddenFolders} onChange={(v) => update({ showHiddenFolders: v })} label={translate("Show unsubscribed (hidden) folders")} />
<Switch checked={s.sidebarCollapsed} onChange={(v) => update({ sidebarCollapsed: v })} label={translate("Collapse sidebar to icons")} />
</div>
);
}
+35 -34
View File
@@ -2,84 +2,85 @@ import { useSettings } from "@/store/settings";
import { ColorSwatches, CALENDAR_COLORS } from "@/ui/misc";
import { promptDialog } from "@/ui/dialog";
import { Plus, Trash2 } from "lucide-react";
import { t } from "@/lib/i18n";
export function CalendarSettings() {
const s = useSettings((st) => st.settings);
const update = useSettings((st) => st.update);
return (
<div>
<h1>Calendar & contacts</h1>
<p className="lead">Defaults for the calendar views and new events.</p>
<h1>{t("Calendar & contacts")}</h1>
<p className="lead">{t("Defaults for the calendar views and new events.")}</p>
<div className="field-row">
<div className="field">
<label>Default view</label>
<label>{t("Default view")}</label>
<select className="select" value={s.calendarDefaultView} onChange={(e) => update({ calendarDefaultView: e.target.value as typeof s.calendarDefaultView })}>
<option value="day">Day</option>
<option value="week">Week</option>
<option value="month">Month</option>
<option value="agenda">Agenda</option>
<option value="day">{t("Day")}</option>
<option value="week">{t("Week")}</option>
<option value="month">{t("Month")}</option>
<option value="agenda">{t("Agenda")}</option>
</select>
</div>
<div className="field">
<label>Default event length</label>
<label>{t("Default event length")}</label>
<select className="select" value={String(s.defaultEventDuration)} onChange={(e) => update({ defaultEventDuration: Number(e.target.value) })}>
<option value="15">15 minutes</option>
<option value="30">30 minutes</option>
<option value="45">45 minutes</option>
<option value="60">1 hour</option>
<option value="90">1.5 hours</option>
<option value="120">2 hours</option>
<option value="15">{t("15 minutes")}</option>
<option value="30">{t("30 minutes")}</option>
<option value="45">{t("45 minutes")}</option>
<option value="60">{t("1 hour")}</option>
<option value="90">{t("1.5 hours")}</option>
<option value="120">{t("2 hours")}</option>
</select>
</div>
<div className="field">
<label>Default reminder</label>
<label>{t("Default reminder")}</label>
<select className="select" value={String(s.defaultAlertMinutes)} onChange={(e) => update({ defaultAlertMinutes: Number(e.target.value) })}>
<option value="-1">None</option>
<option value="0">At time of event</option>
<option value="5">5 minutes before</option>
<option value="10">10 minutes before</option>
<option value="15">15 minutes before</option>
<option value="30">30 minutes before</option>
<option value="60">1 hour before</option>
<option value="1440">1 day before</option>
<option value="-1">{t("None")}</option>
<option value="0">{t("At time of event")}</option>
<option value="5">{t("5 minutes before")}</option>
<option value="10">{t("10 minutes before")}</option>
<option value="15">{t("15 minutes before")}</option>
<option value="30">{t("30 minutes before")}</option>
<option value="60">{t("1 hour before")}</option>
<option value="1440">{t("1 day before")}</option>
</select>
</div>
</div>
<h2>Colour categories</h2>
<p className="hint">Outlook-style categories you can assign to events from the right-click menu or the event editor. The category name is stored on the event, so it syncs to other clients.</p>
<h2>{t("Colour categories")}</h2>
<p className="hint">{t("Outlook-style categories you can assign to events from the right-click menu or the event editor. The category name is stored on the event, so it syncs to other clients.")}</p>
{s.eventCategories.map((c, i) => (
<div key={c.name} className="card">
<div className="card-head">
<span className="label-dot" style={{ background: c.color, width: 14, height: 14 }} />
<h3>{c.name}</h3>
<button className="icon-btn sm" title="Rename" onClick={async () => { const n = await promptDialog({ title: "Rename category", defaultValue: c.name }); if (n?.trim()) update({ eventCategories: s.eventCategories.map((x, j) => (j === i ? { ...x, name: n.trim() } : x)) }); }}></button>
<button className="icon-btn sm danger" aria-label="Delete category" onClick={() => update({ eventCategories: s.eventCategories.filter((_, j) => j !== i) })}><Trash2 size={16} /></button>
<button className="icon-btn sm" title={t("Rename")} onClick={async () => { const n = await promptDialog({ title: "Rename category", defaultValue: c.name }); if (n?.trim()) update({ eventCategories: s.eventCategories.map((x, j) => (j === i ? { ...x, name: n.trim() } : x)) }); }}></button>
<button className="icon-btn sm danger" aria-label={t("Delete category")} onClick={() => update({ eventCategories: s.eventCategories.filter((_, j) => j !== i) })}><Trash2 size={16} /></button>
</div>
<div style={{ marginTop: 8 }}><ColorSwatches value={c.color} onChange={(col) => update({ eventCategories: s.eventCategories.map((x, j) => (j === i ? { ...x, color: col } : x)) })} /></div>
</div>
))}
<button className="btn mb-16" onClick={async () => { const n = await promptDialog({ title: "New category", placeholder: "Name" }); if (n?.trim() && !s.eventCategories.some((c) => c.name.toLowerCase() === n.trim().toLowerCase())) update({ eventCategories: [...s.eventCategories, { name: n.trim(), color: CALENDAR_COLORS[s.eventCategories.length % CALENDAR_COLORS.length]! }] }); }}><Plus size={16} /> New category</button>
<h2>Working hours</h2>
<h2>{t("Working hours")}</h2>
<div className="field-row">
<div className="field">
<label>Working hours start</label>
<label>{t("Working hours start")}</label>
<select className="select" value={String(s.workDayStart)} onChange={(e) => update({ workDayStart: Number(e.target.value) })}>
{[...Array(24)].map((_, h) => <option key={h} value={h}>{`${h}:00`}</option>)}
</select>
</div>
<div className="field">
<label>Working hours end</label>
<label>{t("Working hours end")}</label>
<select className="select" value={String(s.workDayEnd)} onChange={(e) => update({ workDayEnd: Number(e.target.value) })}>
{[...Array(25)].map((_, h) => <option key={h} value={h}>{`${h}:00`}</option>)}
</select>
</div>
<div className="field">
<label>Week starts on</label>
<label>{t("Week starts on")}</label>
<select className="select" value={String(s.weekStart)} onChange={(e) => update({ weekStart: Number(e.target.value) as 0 | 1 | 6 })}>
<option value="1">Monday</option>
<option value="0">Sunday</option>
<option value="6">Saturday</option>
<option value="1">{t("Monday")}</option>
<option value="0">{t("Sunday")}</option>
<option value="6">{t("Saturday")}</option>
</select>
</div>
</div>
+25 -24
View File
@@ -9,6 +9,7 @@ import { confirmDialog, promptDialog } from "@/ui/dialog";
import { Switch, Spinner } from "@/ui/misc";
import { toast } from "@/ui/toast";
import type { SieveScript } from "@/jmap/types";
import { t } from "@/lib/i18n";
export function FiltersSettings() {
const sieve = useSieve();
@@ -21,16 +22,16 @@ export function FiltersSettings() {
if (!sieve.available) {
return (
<div>
<h1>Filters & rules</h1>
<p className="lead">Sieve filtering is not available for this account.</p>
<h1>{t("Filters & rules")}</h1>
<p className="lead">{t("Sieve filtering is not available for this account.")}</p>
</div>
);
}
return (
<div>
<h1>Filters & rules</h1>
<p className="lead">Sort incoming mail automatically. Rules run on the server (Sieve), so they work for every client you use.</p>
<h1>{t("Filters & rules")}</h1>
<p className="lead">{t("Sort incoming mail automatically. Rules run on the server (Sieve), so they work for every client you use.")}</p>
<div className="view-switch" style={{ marginBottom: 16 }}>
<button className={tab === "rules" ? "active" : ""} onClick={() => setTab("rules")}><Wand2 size={15} /> Rules</button>
<button className={tab === "scripts" ? "active" : ""} onClick={() => setTab("scripts")}><Code size={15} /> Scripts (advanced)</button>
@@ -86,9 +87,9 @@ function RulesEditor() {
if (damage) {
return (
<div className="warn-box">
<div className="row gap-8" style={{ marginBottom: 8 }}><AlertTriangle size={18} /> <b>Only part of your filter script arrived.</b></div>
<div className="row gap-8" style={{ marginBottom: 8 }}><AlertTriangle size={18} /> <b>{t("Only part of your filter script arrived.")}</b></div>
<p style={{ margin: "0 0 8px" }}>It {damage}, so the rules in it can't be shown or edited — saving what did arrive would write it back over the rest. Reload the page to try again. Your rules are still on the server; nothing here has changed them.</p>
<button className="btn" onClick={() => window.location.reload()}>Reload</button>
<button className="btn" onClick={() => window.location.reload()}>{t("Reload")}</button>
</div>
);
}
@@ -97,8 +98,8 @@ function RulesEditor() {
return (
<div className="warn-box">
<div className="row gap-8" style={{ marginBottom: 8 }}><AlertTriangle size={18} /> <b>Your active script “{script?.name}” was written by hand.</b></div>
<p style={{ margin: "0 0 8px" }}>The visual rule editor only manages scripts it created. You can edit the script in the <b>Scripts</b> tab, or start fresh with rules (the existing script will be kept but deactivated).</p>
<button className="btn" onClick={async () => { if (await confirmDialog({ title: "Switch to rules?", message: `“${script?.name}” will be deactivated (not deleted) and a new “ihasmail” script will take over.`, confirmLabel: "Continue" })) void save([]); }}>Start with rules</button>
<p style={{ margin: "0 0 8px" }}>The visual rule editor only manages scripts it created. You can edit the script in the <b>{t("Scripts")}</b> tab, or start fresh with rules (the existing script will be kept but deactivated).</p>
<button className="btn" onClick={async () => { if (await confirmDialog({ title: "Switch to rules?", message: `“${script?.name}” will be deactivated (not deleted) and a new “ihasmail” script will take over.`, confirmLabel: "Continue" })) void save([]); }}>{t("Start with rules")}</button>
</div>
);
}
@@ -106,7 +107,7 @@ function RulesEditor() {
return (
<div>
{activeIsOther && <div className="warn-box mb-16">Another script (“{script?.name}”) is active. Saving rules here will activate the “ihasmail” script instead.</div>}
{list.length === 0 && <div className="empty" style={{ padding: 32 }}><Wand2 size={32} /><h3>No filters yet</h3><p>Create a rule to move newsletters to a folder, flag important senders, or forward mail.</p></div>}
{list.length === 0 && <div className="empty" style={{ padding: 32 }}><Wand2 size={32} /><h3>{t("No filters yet")}</h3><p>{t("Create a rule to move newsletters to a folder, flag important senders, or forward mail.")}</p></div>}
{list.map((r, i) => (
<div
key={r.id}
@@ -132,7 +133,7 @@ function RulesEditor() {
<div className="row">
<span
className="drag-handle"
title="Drag to reorder"
title={t("Drag to reorder")}
aria-hidden="true"
onPointerDown={() => setArmed(r.id)}
onPointerUp={() => setArmed(null)}
@@ -142,21 +143,21 @@ function RulesEditor() {
<div style={{ fontWeight: 600 }}>{r.name}</div>
<div className="hint truncate">{describeRule(r)}</div>
</div>
<button className="icon-btn sm" disabled={i === 0} aria-label="Move up" onClick={() => { const n = [...list]; [n[i - 1], n[i]] = [n[i]!, n[i - 1]!]; setLocal(n); }}><ArrowUp size={16} /></button>
<button className="icon-btn sm" disabled={i === list.length - 1} aria-label="Move down" onClick={() => { const n = [...list]; [n[i + 1], n[i]] = [n[i]!, n[i + 1]!]; setLocal(n); }}><ArrowDown size={16} /></button>
<button className="icon-btn sm danger" aria-label="Delete rule" onClick={() => setLocal(list.filter((x) => x.id !== r.id))}><Trash2 size={16} /></button>
<button className="icon-btn sm" disabled={i === 0} aria-label={t("Move up")} onClick={() => { const n = [...list]; [n[i - 1], n[i]] = [n[i]!, n[i - 1]!]; setLocal(n); }}><ArrowUp size={16} /></button>
<button className="icon-btn sm" disabled={i === list.length - 1} aria-label={t("Move down")} onClick={() => { const n = [...list]; [n[i + 1], n[i]] = [n[i]!, n[i + 1]!]; setLocal(n); }}><ArrowDown size={16} /></button>
<button className="icon-btn sm danger" aria-label={t("Delete rule")} onClick={() => setLocal(list.filter((x) => x.id !== r.id))}><Trash2 size={16} /></button>
</div>
</div>
))}
<div className="row" style={{ marginTop: 12 }}>
<button className="btn" onClick={() => setEditing(newRule())}><Plus size={16} /> New rule</button>
<span className="spacer" />
{dirty && <button className="btn btn-ghost" onClick={() => setLocal(null)}>Discard changes</button>}
{dirty && <button className="btn btn-ghost" onClick={() => setLocal(null)}>{t("Discard changes")}</button>}
<button className="btn btn-primary" disabled={!dirty || saving} onClick={() => void save(list)}>{saving ? "Saving…" : "Save filters"}</button>
</div>
{content && (
<details style={{ marginTop: 20 }}>
<summary className="hint" style={{ cursor: "pointer" }}>Preview generated Sieve script</summary>
<summary className="hint" style={{ cursor: "pointer" }}>{t("Preview generated Sieve script")}</summary>
<pre className="code notranslate" translate="no" style={{ minHeight: 120, marginTop: 8 }}>{rulesToSieve(list)}</pre>
</details>
)}
@@ -227,18 +228,18 @@ function ScriptsEditor() {
if (sel !== null || name !== "" || content !== "") {
return (
<div>
<div className="field"><label>Script name</label><input className="input" value={name} onChange={(e) => setName(e.target.value)} disabled={Boolean(sel)} /></div>
<div className="field"><label>{t("Script name")}</label><input className="input" value={name} onChange={(e) => setName(e.target.value)} disabled={Boolean(sel)} /></div>
<div className="field">
<label>Sieve source</label>
<label>{t("Sieve source")}</label>
<textarea className="code notranslate" translate="no" value={content} onChange={(e) => setContent(e.target.value)} spellCheck={false} style={{ minHeight: 320 }} />
</div>
{validation && <div className="error-box mb-16">{validation}</div>}
<div className="row">
<button className="btn btn-ghost" onClick={() => { setSel(null); setName(""); setContent(""); }}>Cancel</button>
<button className="btn btn-ghost" onClick={() => { setSel(null); setName(""); setContent(""); }}>{t("Cancel")}</button>
<button className="btn" disabled={busy} onClick={async () => { setBusy(true); const err = await sieve.validate(content); setValidation(err); setBusy(false); if (!err) toast.success("Script is valid"); }}><Play size={14} /> Validate</button>
<span className="spacer" />
<button className="btn" disabled={busy} onClick={() => void save(false)}>Save</button>
<button className="btn btn-primary" disabled={busy} onClick={() => void save(true)}>Save & activate</button>
<button className="btn" disabled={busy} onClick={() => void save(false)}>{t("Save")}</button>
<button className="btn btn-primary" disabled={busy} onClick={() => void save(true)}>{t("Save & activate")}</button>
</div>
</div>
);
@@ -247,14 +248,14 @@ function ScriptsEditor() {
return (
<div>
<p className="hint">Advanced: manage raw Sieve scripts. Only one script can be active at a time.</p>
<p className="hint">{t("Advanced: manage raw Sieve scripts. Only one script can be active at a time.")}</p>
{sieve.scripts.map((s) => (
<div key={s.id} className="card">
<div className="card-head">
<h3><span>{s.name} </span>{s.isActive && <span className="tag" style={{ background: "var(--success)" }}>active</span>}</h3>
<button className="btn btn-sm" onClick={() => void open(s)}>Edit</button>
<h3><span>{s.name} </span>{s.isActive && <span className="tag" style={{ background: "var(--success)" }}>{t("active")}</span>}</h3>
<button className="btn btn-sm" onClick={() => void open(s)}>{t("Edit")}</button>
<button className="btn btn-sm" onClick={async () => { try { await sieve.activate(s.isActive ? null : s.id); } catch (err) { toast.error((err as Error).message); } }}><Power size={14} /> {s.isActive ? "Deactivate" : "Activate"}</button>
<button className="icon-btn sm danger" aria-label="Delete script" onClick={async () => { if (await confirmDialog({ title: `Delete script “${s.name}”?`, confirmLabel: "Delete", danger: true })) { try { await sieve.destroy(s.id); } catch (err) { toast.error((err as Error).message); } } }}><Trash2 size={16} /></button>
<button className="icon-btn sm danger" aria-label={t("Delete script")} onClick={async () => { if (await confirmDialog({ title: `Delete script “${s.name}”?`, confirmLabel: "Delete", danger: true })) { try { await sieve.destroy(s.id); } catch (err) { toast.error((err as Error).message); } } }}><Trash2 size={16} /></button>
</div>
</div>
))}
+7 -6
View File
@@ -6,6 +6,7 @@ import { toast } from "@/ui/toast";
import { formatSize } from "@/lib/format";
import { ShareDialog } from "./ShareDialog";
import type { Mailbox } from "@/jmap/types";
import { t } from "@/lib/i18n";
export function FoldersSettings() {
const mailboxes = useMail((s) => s.mailboxes);
@@ -33,23 +34,23 @@ export function FoldersSettings() {
return (
<div>
<h1>Folders</h1>
<h1>{t("Folders")}</h1>
<p className="lead">Create, rename and hide folders. {q && q.hardLimit ? `Storage: ${formatSize(q.used)} of ${formatSize(q.hardLimit)} used.` : ""}</p>
<button className="btn mb-16" onClick={() => void create()}><Plus size={16} /> New folder</button>
<table className="sessions-table">
<thead><tr><th>Folder</th><th>Messages</th><th>Unread</th><th /></tr></thead>
<thead><tr><th>{t("Folder")}</th><th>{t("Messages")}</th><th>{t("Unread")}</th><th /></tr></thead>
<tbody>
{list.map(({ m, path }) => (
<tr key={m.id}>
<td><div className="row gap-8">{m.role === "inbox" ? <Inbox size={16} /> : <Folder size={16} />}<span>{path}</span>{!m.isSubscribed && <span className="badge muted">hidden</span>}{m.role && m.role !== "subscribed" && <span className="hint">({m.role})</span>}</div></td>
<td><div className="row gap-8">{m.role === "inbox" ? <Inbox size={16} /> : <Folder size={16} />}<span>{path}</span>{!m.isSubscribed && <span className="badge muted">{t("hidden")}</span>}{m.role && m.role !== "subscribed" && <span className="hint">({m.role})</span>}</div></td>
<td>{m.totalEmails.toLocaleString()}</td>
<td>{m.unreadEmails.toLocaleString()}</td>
<td>
<div className="row" style={{ justifyContent: "flex-end", gap: 0 }}>
<button className="icon-btn sm" title="Rename" disabled={Boolean(m.role) && m.role !== "subscribed"} onClick={async () => { const n = await promptDialog({ title: "Rename folder", defaultValue: m.name }); if (n?.trim() && n !== m.name) { try { await useMail.getState().updateMailbox(m.id, { name: n.trim() }); } catch (err) { toast.error((err as Error).message); } } }}><Pencil size={16} /></button>
<button className="icon-btn sm" title={t("Rename")} disabled={Boolean(m.role) && m.role !== "subscribed"} onClick={async () => { const n = await promptDialog({ title: "Rename folder", defaultValue: m.name }); if (n?.trim() && n !== m.name) { try { await useMail.getState().updateMailbox(m.id, { name: n.trim() }); } catch (err) { toast.error((err as Error).message); } } }}><Pencil size={16} /></button>
<button className="icon-btn sm" title={m.isSubscribed ? "Hide" : "Show"} disabled={m.role === "inbox"} onClick={() => void useMail.getState().updateMailbox(m.id, { isSubscribed: !m.isSubscribed })}>{m.isSubscribed ? <EyeOff size={16} /> : <Eye size={16} />}</button>
{Object.keys(m.shareWith ?? {}).length > 0 && <button className="icon-btn sm" title="Stop sharing" onClick={() => setShare(m)}><Share2 size={16} /></button>}
<button className="icon-btn sm danger" title="Delete" disabled={Boolean(m.role) && m.role !== "subscribed"} onClick={async () => { if (await confirmDialog({ title: `Delete “${m.name}”?`, message: `${m.totalEmails} message(s) will be permanently deleted.`, confirmLabel: "Delete", danger: true })) { try { await useMail.getState().destroyMailbox(m.id, true); } catch (err) { toast.error((err as Error).message); } } }}><Trash2 size={16} /></button>
{Object.keys(m.shareWith ?? {}).length > 0 && <button className="icon-btn sm" title={t("Stop sharing")} onClick={() => setShare(m)}><Share2 size={16} /></button>}
<button className="icon-btn sm danger" title={t("Delete")} disabled={Boolean(m.role) && m.role !== "subscribed"} onClick={async () => { if (await confirmDialog({ title: `Delete “${m.name}”?`, message: `${m.totalEmails} message(s) will be permanently deleted.`, confirmLabel: "Delete", danger: true })) { try { await useMail.getState().destroyMailbox(m.id, true); } catch (err) { toast.error((err as Error).message); } } }}><Trash2 size={16} /></button>
</div>
</td>
</tr>
+64 -63
View File
@@ -3,6 +3,7 @@ import { Switch } from "@/ui/misc";
import { browserTimeZone, listTimeZones } from "@/lib/dates";
import { toast } from "@/ui/toast";
import { useState } from "react";
import { t } from "@/lib/i18n";
import {
canUnregisterMailtoHandler,
isInstalledApp,
@@ -45,109 +46,108 @@ export function GeneralSettings() {
return (
<div>
<h1>General</h1>
<p className="lead">Reading, sending and list behaviour. Settings are stored in this browser.</p>
<h1>{t("General")}</h1>
<p className="lead">{t("Reading, sending and list behaviour. Settings are stored in this browser.")}</p>
<h2>Reading</h2>
<h2>{t("Reading")}</h2>
<div className="field-row">
<div className="field">
<label>Reading pane</label>
<label>{t("Reading pane")}</label>
<select className="select" value={s.readingPane} onChange={(e) => update({ readingPane: e.target.value as typeof s.readingPane })}>
<option value="right">Right of the list</option>
<option value="bottom">Below the list</option>
<option value="off">Off (open messages full width)</option>
<option value="right">{t("Right of the list")}</option>
<option value="bottom">{t("Below the list")}</option>
<option value="off">{t("Off (open messages full width)")}</option>
</select>
</div>
<div className="field">
<label>Mark as read</label>
<label>{t("Mark as read")}</label>
<select className="select" value={String(s.markReadDelay)} onChange={(e) => update({ markReadDelay: Number(e.target.value) })}>
<option value="0">Immediately when opened</option>
<option value="2">After 2 seconds</option>
<option value="5">After 5 seconds</option>
<option value="-1">Never automatically</option>
<option value="0">{t("Immediately when opened")}</option>
<option value="2">{t("After 2 seconds")}</option>
<option value="5">{t("After 5 seconds")}</option>
<option value="-1">{t("Never automatically")}</option>
</select>
</div>
<div className="field">
<label>After archiving or deleting</label>
<label>{t("After archiving or deleting")}</label>
<select className="select" value={s.autoAdvance} onChange={(e) => update({ autoAdvance: e.target.value as typeof s.autoAdvance })}>
<option value="list">Go back to the list</option>
<option value="older">Open the next (older) conversation</option>
<option value="newer">Open the previous (newer) conversation</option>
<option value="list">{t("Go back to the list")}</option>
<option value="older">{t("Open the next (older) conversation")}</option>
<option value="newer">{t("Open the previous (newer) conversation")}</option>
</select>
</div>
<div className="field">
<label>Remote images</label>
<label>{t("Remote images")}</label>
<select className="select" value={s.imagePolicy} onChange={(e) => update({ imagePolicy: e.target.value as typeof s.imagePolicy })}>
<option value="ask">Ask before showing (recommended)</option>
<option value="contacts">Show automatically from my contacts</option>
<option value="always">Always show</option>
<option value="ask">{t("Ask before showing (recommended)")}</option>
<option value="contacts">{t("Show automatically from my contacts")}</option>
<option value="always">{t("Always show")}</option>
</select>
</div>
</div>
<Switch checked={s.conversationMode} onChange={(v) => update({ conversationMode: v })} label="Conversation view" hint="Group messages from the same thread together." />
<Switch checked={s.showPreview} onChange={(v) => update({ showPreview: v })} label="Show message snippets" hint="Preview the first line of each message in the list." />
<Switch checked={s.showAvatars} onChange={(v) => update({ showAvatars: v })} label="Show sender avatars" />
<Switch checked={s.confirmDelete} onChange={(v) => update({ confirmDelete: v })} label="Confirm before deleting" />
<Switch checked={s.conversationMode} onChange={(v) => update({ conversationMode: v })} label={t("Conversation view")} hint={t("Group messages from the same thread together.")} />
<Switch checked={s.showPreview} onChange={(v) => update({ showPreview: v })} label={t("Show message snippets")} hint={t("Preview the first line of each message in the list.")} />
<Switch checked={s.showAvatars} onChange={(v) => update({ showAvatars: v })} label={t("Show sender avatars")} />
<Switch checked={s.confirmDelete} onChange={(v) => update({ confirmDelete: v })} label={t("Confirm before deleting")} />
<h2>Composing</h2>
<h2>{t("Composing")}</h2>
<div className="field-row">
<div className="field">
<label>Default format</label>
<label>{t("Default format")}</label>
<select className="select" value={s.composeFormat} onChange={(e) => update({ composeFormat: e.target.value as typeof s.composeFormat })}>
<option value="html">Rich text (HTML)</option>
<option value="text">Plain text</option>
<option value="html">{t("Rich text (HTML)")}</option>
<option value="text">{t("Plain text")}</option>
</select>
</div>
<div className="field">
<label>Undo send window</label>
<label>{t("Undo send window")}</label>
<select className="select" value={String(s.undoSendSeconds)} onChange={(e) => update({ undoSendSeconds: Number(e.target.value) })}>
<option value="0">Off</option>
<option value="5">5 seconds</option>
<option value="8">8 seconds</option>
<option value="15">15 seconds</option>
<option value="30">30 seconds</option>
<option value="0">{t("Off")}</option>
<option value="5">{t("5 seconds")}</option>
<option value="8">{t("8 seconds")}</option>
<option value="15">{t("15 seconds")}</option>
<option value="30">{t("30 seconds")}</option>
</select>
</div>
</div>
<Switch checked={s.includeQuote} onChange={(v) => update({ includeQuote: v })} label="Quote original message in replies" />
<Switch checked={s.signatureAboveQuote} onChange={(v) => update({ signatureAboveQuote: v })} label="Place signature above quoted text" />
<Switch checked={s.attachmentReminder} onChange={(v) => update({ attachmentReminder: v })} label="Attachment reminder" hint="Warn when the message mentions an attachment but none is attached." />
<Switch checked={s.requestReadReceipt} onChange={(v) => update({ requestReadReceipt: v })} label="Always request read receipts" />
<Switch checked={s.includeQuote} onChange={(v) => update({ includeQuote: v })} label={t("Quote original message in replies")} />
<Switch checked={s.signatureAboveQuote} onChange={(v) => update({ signatureAboveQuote: v })} label={t("Place signature above quoted text")} />
<Switch checked={s.attachmentReminder} onChange={(v) => update({ attachmentReminder: v })} label={t("Attachment reminder")} hint={t("Warn when the message mentions an attachment but none is attached.")} />
<Switch checked={s.requestReadReceipt} onChange={(v) => update({ requestReadReceipt: v })} label={t("Always request read receipts")} />
<div className="field">
<label>When someone requests a read receipt</label>
<label>{t("When someone requests a read receipt")}</label>
<select className="select" value={s.readReceiptPolicy} onChange={(e) => update({ readReceiptPolicy: e.target.value as ReadReceiptPolicy })}>
<option value="ask">Ask me on each message</option>
<option value="never">Never send one</option>
<option value="ask">{t("Ask me on each message")}</option>
<option value="never">{t("Never send one")}</option>
</select>
<p className="hint">
A receipt tells whoever asked that this address is live and when the message was read, and the sender
chooses where it goes so there is no automatic option. Bulk mail, mailing lists and anything marked
auto-submitted are never offered one at all.
{t("A receipt tells whoever asked that this address is live and when the message was read, and the sender chooses where it goes — so there is no automatic option. Bulk mail, mailing lists and anything marked auto-submitted are never offered one at all.")}
</p>
</div>
<Switch checked={s.spellcheck} onChange={(v) => update({ spellcheck: v })} label="Spell check while typing" />
<Switch checked={s.spellcheck} onChange={(v) => update({ spellcheck: v })} label={t("Spell check while typing")} />
<h2>Locale</h2>
<h2>{t("Locale")}</h2>
<div className="field-row">
<div className="field">
<label>Time zone</label>
<label>{t("Time zone")}</label>
<select className="select" value={s.timeZone ?? ""} onChange={(e) => update({ timeZone: e.target.value || null })}>
<option value="">Browser default ({browserTimeZone})</option>
{listTimeZones().map((tz) => <option key={tz} value={tz}>{tz}</option>)}
</select>
</div>
<div className="field">
<label>Week starts on</label>
<label>{t("Week starts on")}</label>
<select className="select" value={String(s.weekStart)} onChange={(e) => update({ weekStart: Number(e.target.value) as 0 | 1 | 6 })}>
<option value="1">Monday</option>
<option value="0">Sunday</option>
<option value="6">Saturday</option>
<option value="1">{t("Monday")}</option>
<option value="0">{t("Sunday")}</option>
<option value="6">{t("Saturday")}</option>
</select>
</div>
</div>
<div className="field-row">
<div className="field">
<label>Language &amp; region</label>
<label>{t("Language & region")}</label>
<select className="select" value={s.locale} onChange={(e) => update({ locale: e.target.value })}>
<option value="">Automatic ({localeLabel(autoLocale)})</option>
{localeOptions().map((o) => <option key={o.tag} value={o.tag}>{o.label} {o.tag}</option>)}
@@ -155,7 +155,7 @@ export function GeneralSettings() {
<p className="hint">{serverLocale ? `Your mail server reports ${localeLabel(serverLocale)} (${serverLocale}).` : "Your mail server does not report a locale, so the browser's is used."} Dates, times and month names follow this choice.</p>
</div>
<div className="field">
<label>Date format</label>
<label>{t("Date format")}</label>
<select className="select" value={s.dateFormat} onChange={(e) => update({ dateFormat: e.target.value as DateFormat })}>
{DATE_FORMATS.map((f) => (
<option key={f.value} value={f.value}>
@@ -165,27 +165,27 @@ export function GeneralSettings() {
</select>
</div>
<div className="field">
<label>Time format</label>
<label>{t("Time format")}</label>
<select className="select" value={s.timeFormat} onChange={(e) => update({ timeFormat: e.target.value as typeof s.timeFormat })}>
<option value="auto">Automatic ({withPrefs({ locale: s.locale, timeFormat: "auto" }, () => formatClock(SAMPLE))})</option>
<option value="24">24-hour clock (18:23)</option>
<option value="12">12-hour clock (6:23 PM)</option>
<option value="24">{t("24-hour clock (18:23)")}</option>
<option value="12">{t("12-hour clock (6:23 PM)")}</option>
</select>
</div>
</div>
<p className="hint">Preview: {formatFullDateTime(SAMPLE)}</p>
<h2>Default mail app</h2>
<h2>{t("Default mail app")}</h2>
<MailHandlerSettings />
<h2>Backup</h2>
<h2>{t("Backup")}</h2>
<div className="row wrap">
<button className="btn" onClick={() => { const blob = new Blob([exportJson()], { type: "application/json" }); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = "ihasmail-settings.json"; a.click(); }}>Export settings</button>
<button className="btn" onClick={() => { const blob = new Blob([exportJson()], { type: "application/json" }); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = "ihasmail-settings.json"; a.click(); }}>{t("Export settings")}</button>
<label className="btn">
Import settings
<input type="file" accept="application/json" hidden onChange={async (e) => { const f = e.target.files?.[0]; if (!f) return; const ok = importJson(await f.text()); toast[ok ? "success" : "error"](ok ? "Settings imported" : "Invalid settings file"); e.target.value = ""; }} />
</label>
<button className="btn btn-ghost" onClick={() => { reset(); toast.show("Settings reset to defaults"); }}>Reset to defaults</button>
<button className="btn btn-ghost" onClick={() => { reset(); toast.show("Settings reset to defaults"); }}>{t("Reset to defaults")}</button>
</div>
</div>
);
@@ -231,12 +231,13 @@ function MailHandlerSettings() {
</p>
<div className="row wrap">
<button className="btn btn-primary" onClick={ask}>{requested ? "Ask again" : "Make ihasmail the default mail app"}</button>
{requested && canUnregisterMailtoHandler() && <button className="btn btn-ghost" onClick={remove}>Remove</button>}
{requested && canUnregisterMailtoHandler() && <button className="btn btn-ghost" onClick={remove}>{t("Remove")}</button>}
</div>
{requested && <p className="hint mt-8">Requested in this browser. Whether it took effect is up to the browser check its settings if mail links still open elsewhere.</p>}
{requested && <p className="hint mt-8">{t("Requested in this browser. Whether it took effect is up to the browser — check its settings if mail links still open elsewhere.")}</p>}
{!isInstalledApp() && (
<p className="hint mt-8">
For a system-wide default, install ihasmail as an app first (in Chrome: the install icon in the address bar). Your operating system can then offer ihasmail directly wherever it asks which mail app to use.
{t("For a system-wide default, install ihasmail as an app first (in Chrome: the install icon in the address bar). Your operating system can then offer ihasmail directly wherever it asks which mail app to use.")}
</p>
)}
</>
+14 -13
View File
@@ -12,6 +12,7 @@ import { htmlToText } from "@/lib/text";
import { sanitizeEditorHtml } from "@/lib/html";
import { externalizeDataImages, storeSignatureHtml, uploadSignatureImage } from "@/lib/signatureImages";
import { buildMarkerSignature, byteLength, compactHtml, signatureTooLong, SIGNATURE_LIMIT } from "@/lib/signatureHtml";
import { t } from "@/lib/i18n";
export function IdentitiesSettings() {
const identities = useMail((s) => s.identities);
@@ -30,12 +31,12 @@ export function IdentitiesSettings() {
return (
<div>
<h1>Identities & signatures</h1>
<p className="lead">Each identity is a sender address with its own name, Reply-To and signature. The default identity is preselected when you compose; set a Reply-To when replies should go somewhere other than the From address.</p>
<h1>{t("Identities & signatures")}</h1>
<p className="lead">{t("Each identity is a sender address with its own name, Reply-To and signature. The default identity is preselected when you compose; set a Reply-To when replies should go somewhere other than the From address.")}</p>
{identities.map((i) => (
<div key={i.id} className="card clickable" onClick={() => setEditing(i)}>
<div className="card-head">
<h3>{i.name ? `${i.name} <${i.email}>` : i.email} {i.id === defaultId && <span className="tag" style={{ background: "var(--accent)", color: "var(--accent-fg)", marginLeft: 6 }}>Default</span>}</h3>
<h3>{i.name ? `${i.name} <${i.email}>` : i.email} {i.id === defaultId && <span className="tag" style={{ background: "var(--accent)", color: "var(--accent-fg)", marginLeft: 6 }}>{t("Default")}</span>}</h3>
{i.id !== defaultId && (
<button className="btn btn-sm btn-ghost" onClick={(e) => { e.stopPropagation(); setDefault(i.id); toast.success(`${i.email} is now your default identity`); }}><Star size={14} /> Make default</button>
)}
@@ -53,16 +54,16 @@ export function IdentitiesSettings() {
{hidden.includes(i.id) ? <><Eye size={14} /> Show when composing</> : <><EyeOff size={14} /> Hide when composing</>}
</button>
{i.mayDelete && (
<button className="icon-btn sm danger" aria-label="Delete identity" onClick={async (e) => { e.stopPropagation(); if (await confirmDialog({ title: "Delete this identity?", confirmLabel: "Delete", danger: true })) { try { await useMail.getState().destroyIdentity(i.id); } catch (err) { toast.error((err as Error).message); } } }}><Trash2 size={16} /></button>
<button className="icon-btn sm danger" aria-label={t("Delete identity")} onClick={async (e) => { e.stopPropagation(); if (await confirmDialog({ title: "Delete this identity?", confirmLabel: "Delete", danger: true })) { try { await useMail.getState().destroyIdentity(i.id); } catch (err) { toast.error((err as Error).message); } } }}><Trash2 size={16} /></button>
)}
</div>
{hidden.includes(i.id) && <div className="hint" style={{ marginTop: 4 }}>Not offered when composing. It still receives mail, and you can still send from it by showing it again.</div>}
{hidden.includes(i.id) && <div className="hint" style={{ marginTop: 4 }}>{t("Not offered when composing. It still receives mail, and you can still send from it by showing it again.")}</div>}
{(i.htmlSignature || i.textSignature) && <div className="hint" style={{ marginTop: 4 }}>{htmlToText(i.htmlSignature || i.textSignature).slice(0, 120)}</div>}
{i.replyTo?.length ? <div className="hint">Reply-To: {formatAddressList(i.replyTo)}</div> : null}
</div>
))}
<button className="btn" onClick={() => setEditing({ name: "", email: identities[0]?.email ?? "", textSignature: "", htmlSignature: "", replyTo: null, bcc: null })}><Plus size={16} /> Add identity</button>
<p className="hint mt-8">New identities must use an address this account is allowed to send from (aliases configured on the server).</p>
<p className="hint mt-8">{t("New identities must use an address this account is allowed to send from (aliases configured on the server).")}</p>
{hidden.length > 0 && (
<p className="hint">
{`${hidden.length} ${hidden.length === 1 ? "identity is" : "identities are"} hidden from the compose picker. Hiding every one of them would leave nothing to choose from, so in that case they are all offered again.`}
@@ -113,19 +114,19 @@ function IdentityDialog({ identity, onClose }: { identity: Partial<Identity>; on
}
};
return (
<Dialog open onClose={onClose} title={identity.id ? "Edit identity" : "New identity"} size="lg" footer={<><button className="btn" onClick={onClose}>Cancel</button><button className="btn btn-primary" disabled={busy} onClick={() => void save()}>{busy ? "Saving…" : "Save"}</button></>}>
<Dialog open onClose={onClose} title={identity.id ? "Edit identity" : "New identity"} size="lg" footer={<><button className="btn" onClick={onClose}>{t("Cancel")}</button><button className="btn btn-primary" disabled={busy} onClick={() => void save()}>{busy ? "Saving…" : "Save"}</button></>}>
<div className="field-row">
<div className="field"><label>Display name</label><input className="input" value={name} onChange={(e) => setName(e.target.value)} /></div>
<div className="field"><label>Email address</label><input className="input" type="email" value={email} disabled={Boolean(identity.id)} onChange={(e) => setEmail(e.target.value)} /></div>
<div className="field"><label>{t("Display name")}</label><input className="input" value={name} onChange={(e) => setName(e.target.value)} /></div>
<div className="field"><label>{t("Email address")}</label><input className="input" type="email" value={email} disabled={Boolean(identity.id)} onChange={(e) => setEmail(e.target.value)} /></div>
</div>
<div className="field"><label>Reply-To (optional)</label><input className="input" value={replyTo} onChange={(e) => setReplyTo(e.target.value)} placeholder="[email protected]" /><span className="hint">Replies to mail sent from this identity go here instead of the From address.</span></div>
<div className="field"><label>{t("Reply-To (optional)")}</label><input className="input" value={replyTo} onChange={(e) => setReplyTo(e.target.value)} placeholder={t("[email protected]")} /><span className="hint">{t("Replies to mail sent from this identity go here instead of the From address.")}</span></div>
<div className="field">
<label>Signature</label>
<label>{t("Signature")}</label>
<div style={{ border: `1px solid ${tooLong ? "var(--danger)" : "var(--border-strong)"}`, borderRadius: 8, minHeight: 180, display: "flex", flexDirection: "column" }}>
<RichEditor ref={ref} html={html} onChange={setHtml} placeholder="Your signature…" showToolbar imageUpload={uploadSignatureImage} />
<RichEditor ref={ref} html={html} onChange={setHtml} placeholder={t("Your signature…")} showToolbar imageUpload={uploadSignatureImage} />
</div>
<div className="row" style={{ justifyContent: "space-between" }}>
<span className="hint">Images are stored in your Files (folder ihasmail) and embedded when you send.</span>
<span className="hint">{t("Images are stored in your Files (folder “ihasmail”) and embedded when you send.")}</span>
<span className="hint nowrap" style={tooLong ? { color: "var(--warn)", fontWeight: 600 } : undefined}>{sigLen.toLocaleString()} / {SIGNATURE_LIMIT.toLocaleString()}</span>
</div>
{tooLong && <div className="warn-box mt-8">This signature is larger than the server's {SIGNATURE_LIMIT}-byte limit. ihasmail will keep the full version in your Files and store a short text fallback on the server other mail clients will see the plain-text version.</div>}
+4 -3
View File
@@ -3,6 +3,7 @@ import { Plus, Trash2 } from "lucide-react";
import { useSettings } from "@/store/settings";
import { CALENDAR_COLORS, ColorSwatches } from "@/ui/misc";
import { promptDialog } from "@/ui/dialog";
import { t } from "@/lib/i18n";
export function LabelsSettings() {
const labels = useSettings((s) => s.settings.labels);
@@ -19,8 +20,8 @@ export function LabelsSettings() {
return (
<div>
<h1>Labels</h1>
<p className="lead">Labels are IMAP keywords stored on your messages, so they sync to other clients. Names and colours are kept in this browser.</p>
<h1>{t("Labels")}</h1>
<p className="lead">{t("Labels are IMAP keywords stored on your messages, so they sync to other clients. Names and colours are kept in this browser.")}</p>
{labels.map((l) => (
<div key={l.keyword} className="card">
<div className="card-head">
@@ -30,7 +31,7 @@ export function LabelsSettings() {
) : (
<h3 style={{ cursor: "text" }} onClick={() => setEditing(l.keyword)}>{l.name} <span className="hint" style={{ fontWeight: 400 }}>({l.keyword})</span></h3>
)}
<button className="icon-btn sm danger" aria-label="Delete label" onClick={() => update({ labels: labels.filter((x) => x.keyword !== l.keyword) })}><Trash2 size={16} /></button>
<button className="icon-btn sm danger" aria-label={t("Delete label")} onClick={() => update({ labels: labels.filter((x) => x.keyword !== l.keyword) })}><Trash2 size={16} /></button>
</div>
<div style={{ marginTop: 8 }}>
<ColorSwatches value={l.color} onChange={(c) => update({ labels: labels.map((x) => (x.keyword === l.keyword ? { ...x, color: c } : x)) })} />
@@ -6,6 +6,7 @@ import { useSession } from "@/store/session";
import { disableWebPush, enableWebPush, webPushActive } from "@/lib/webpushEnable";
import { supportsEmailPush, webPushAvailable } from "@/lib/webpush";
import { toast } from "@/ui/toast";
import { t } from "@/lib/i18n";
export function NotificationsSettings() {
const s = useSettings((st) => st.settings);
@@ -23,8 +24,8 @@ export function NotificationsSettings() {
}, [s.desktopNotifications]);
return (
<div>
<h1>Notifications</h1>
<p className="lead">{`Live updates are delivered via JMAP push (${pushConnected ? "connected" : "reconnecting…"}).`}</p>
<h1>{t("Notifications")}</h1>
<p className="lead">{t("Live updates are delivered via JMAP push ({state}).", { state: pushConnected ? t("connected") : t("reconnecting…") })}</p>
<Switch
checked={s.desktopNotifications}
onChange={async (v) => {
@@ -35,8 +36,8 @@ export function NotificationsSettings() {
}
update({ desktopNotifications: v });
}}
label="Desktop notifications while ihasmail is open"
hint={perm === "denied" ? "Notifications are blocked in your browser settings." : perm === "unsupported" ? "Not supported in this browser." : "Shows a system notification when new mail arrives in your Inbox while the tab is in the background."}
label={t("Desktop notifications while ihasmail is open")}
hint={perm === "denied" ? t("Notifications are blocked in your browser settings.") : perm === "unsupported" ? t("Not supported in this browser.") : t("Shows a system notification when new mail arrives in your Inbox while the tab is in the background.")}
disabled={perm === "denied" || perm === "unsupported"}
/>
{/*
@@ -57,7 +58,7 @@ export function NotificationsSettings() {
const res = await enableWebPush();
if (!res.ok) { toast.error(res.reason); return; }
setBackground(true);
toast.success("Background notifications are on");
toast.success(t("Background notifications are on"));
} else {
await disableWebPush();
setBackground(false);
@@ -66,20 +67,20 @@ export function NotificationsSettings() {
setBusy(false);
}
}}
label="Notify me even when ihasmail is closed"
label={t("Notify me even when ihasmail is closed")}
hint={
!canBackground
? "Needs a browser with the Push API and a mail server that publishes a push key."
? t("Needs a browser with the Push API and a mail server that publishes a push key.")
: supportsEmailPush()
? "Your mail server delivers these straight to your browser, so they arrive with no ihasmail tab open, naming the sender and subject. Your browser still has to be running — if you quit it completely, notifications wait and arrive when you open it again."
: "Your mail server can wake this browser, but will not include the sender or subject. Your browser still has to be running."
? t("Your mail server delivers these straight to your browser, so they arrive with no ihasmail tab open, naming the sender and subject. Your browser still has to be running — if you quit it completely, notifications wait and arrive when you open it again.")
: t("Your mail server can wake this browser, but will not include the sender or subject. Your browser still has to be running.")
}
/>
<Switch checked={s.notificationSound} onChange={(v) => update({ notificationSound: v })} label="Play a sound for new mail" />
<Switch checked={s.notificationSound} onChange={(v) => update({ notificationSound: v })} label={t("Play a sound for new mail")} />
<div className="row mt-16">
<button className="btn" onClick={() => { showNotification("ihasmail test", { body: "This is what a new-mail notification looks like." }); playNewMailSound(); }}>Test notification</button>
<button className="btn" onClick={() => { showNotification(t("ihasmail test"), { body: t("This is what a new-mail notification looks like.") }); playNewMailSound(); }}>{t("Test notification")}</button>
</div>
<p className="hint mt-8">The tab title and favicon always show your unread Inbox count.</p>
<p className="hint mt-8">{t("The tab title and favicon always show your unread Inbox count.")}</p>
</div>
);
}
+30 -29
View File
@@ -5,6 +5,7 @@ import { HEADER_CHOICES, HEADER_OPS, type SieveAction, type SieveRule, type Siev
import { Dialog, promptDialog } from "@/ui/dialog";
import { toast } from "@/ui/toast";
import type { Id } from "@/jmap/types";
import { t as translate } from "@/lib/i18n";
export interface RuleDialogProps {
rule: SieveRule;
@@ -36,13 +37,13 @@ export function RuleDialog({ rule, onClose, onSave, applyMailbox, applyByDefault
<span>Also apply to existing messages in <b>{applyMailbox.name}</b></span>
</label>
)}
<button className="btn" onClick={onClose}>Cancel</button><button className="btn btn-primary" onClick={() => onSave(r, applyNow && Boolean(applyMailbox))} disabled={!r.name.trim()}>{saveLabel ?? "Done"}</button></>}>
<div className="field"><label>Rule name</label><input className="input" value={r.name} onChange={(e) => setR({ ...r, name: e.target.value })} autoFocus /></div>
<button className="btn" onClick={onClose}>{translate("Cancel")}</button><button className="btn btn-primary" onClick={() => onSave(r, applyNow && Boolean(applyMailbox))} disabled={!r.name.trim()}>{saveLabel ?? "Done"}</button></>}>
<div className="field"><label>{translate("Rule name")}</label><input className="input" value={r.name} onChange={(e) => setR({ ...r, name: e.target.value })} autoFocus /></div>
<div className="row" style={{ marginBottom: 8 }}>
<span className="label">When</span>
<span className="label">{translate("When")}</span>
<select className="select" style={{ width: "auto" }} value={r.join} onChange={(e) => setR({ ...r, join: e.target.value as "allof" | "anyof" })}>
<option value="allof">all of the following match</option>
<option value="anyof">any of the following match</option>
<option value="allof">{translate("all of the following match")}</option>
<option value="anyof">{translate("any of the following match")}</option>
</select>
</div>
{r.tests.map((t, i) => {
@@ -60,35 +61,35 @@ export function RuleDialog({ rule, onClose, onSave, applyMailbox, applyByDefault
else setTest(i, { type: "header", header: v === "__custom__" ? "" : v, op: "contains", value: "" });
}}>
{HEADER_CHOICES.map((h) => <option key={h.value} value={h.value}>{h.label}</option>)}
<option value="address">Sender domain</option>
<option value="size">Message size</option>
<option value="body">Body text</option>
<option value="true">Always (all messages)</option>
<option value="address">{translate("Sender domain")}</option>
<option value="size">{translate("Message size")}</option>
<option value="body">{translate("Body text")}</option>
<option value="true">{translate("Always (all messages)")}</option>
</select>
{customHeader && t.type === "header" && (
<input className="input" placeholder="Header name" aria-label="Header name" value={t.header} onChange={(e) => setTest(i, { ...t, header: e.target.value })} />
<input className="input" placeholder={translate("Header name")} aria-label={translate("Header name")} value={t.header} onChange={(e) => setTest(i, { ...t, header: e.target.value })} />
)}
{t.type === "size" ? (
<select className="select" value={t.op} onChange={(e) => setTest(i, { ...t, op: e.target.value as "over" | "under" })}><option value="over">is larger than</option><option value="under">is smaller than</option></select>
<select className="select" value={t.op} onChange={(e) => setTest(i, { ...t, op: e.target.value as "over" | "under" })}><option value="over">{translate("is larger than")}</option><option value="under">{translate("is smaller than")}</option></select>
) : t.type === "body" ? (
<select className="select" value={t.op} onChange={(e) => setTest(i, { ...t, op: e.target.value as "contains" | "notcontains" })}><option value="contains">contains</option><option value="notcontains">does not contain</option></select>
<select className="select" value={t.op} onChange={(e) => setTest(i, { ...t, op: e.target.value as "contains" | "notcontains" })}><option value="contains">{translate("contains")}</option><option value="notcontains">{translate("does not contain")}</option></select>
) : t.type === "true" ? <span /> : (
<select className="select" value={t.op} onChange={(e) => setTest(i, { ...t, op: e.target.value as SieveTest extends { op: infer O } ? O : never })}>
{HEADER_OPS.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
)}
{t.type === "size" ? (
<div className="row"><input className="input" type="number" min={1} value={Math.round(t.value / 1024)} onChange={(e) => setTest(i, { ...t, value: Number(e.target.value) * 1024 })} /><span className="muted">KB</span></div>
<div className="row"><input className="input" type="number" min={1} value={Math.round(t.value / 1024)} onChange={(e) => setTest(i, { ...t, value: Number(e.target.value) * 1024 })} /><span className="muted">{translate("KB")}</span></div>
) : t.type === "true" ? <span /> : t.type === "header" && (t.op === "exists" || t.op === "notexists") ? <span /> : (
<input className="input" placeholder={t.type === "address" ? "example.com" : "value"} value={(t as { value: string }).value} onChange={(e) => setTest(i, { ...t, value: e.target.value } as SieveTest)} />
)}
<button className="icon-btn sm danger" aria-label="Remove condition" onClick={() => setR({ ...r, tests: r.tests.filter((_, j) => j !== i) })} disabled={r.tests.length <= 1}><Trash2 size={16} /></button>
<button className="icon-btn sm danger" aria-label={translate("Remove condition")} onClick={() => setR({ ...r, tests: r.tests.filter((_, j) => j !== i) })} disabled={r.tests.length <= 1}><Trash2 size={16} /></button>
</div>
);
})}
<button className="btn btn-ghost btn-sm" onClick={() => setR({ ...r, tests: [...r.tests, { type: "header", header: "subject", op: "contains", value: "" }] })}><Plus size={14} /> Add condition</button>
<div className="row" style={{ margin: "16px 0 8px" }}><span className="label">Then</span></div>
<div className="row" style={{ margin: "16px 0 8px" }}><span className="label">{translate("Then")}</span></div>
{r.actions.map((a, i) => (
<div key={i} className="rule-row actions">
<select className="select" value={a.type} onChange={(e) => {
@@ -96,15 +97,15 @@ export function RuleDialog({ rule, onClose, onSave, applyMailbox, applyByDefault
const next: SieveAction = v === "fileinto" ? { type: "fileinto", mailbox: folders[0]?.path ?? "INBOX" } : v === "redirect" ? { type: "redirect", address: "" } : v === "reject" ? { type: "reject", reason: "" } : v === "addflag" ? { type: "addflag", flag: "" } : ({ type: v } as SieveAction);
setAction(i, next);
}}>
<option value="fileinto">Move to folder</option>
<option value="markread">Mark as read</option>
<option value="flag">Star</option>
<option value="addflag">Add label / keyword</option>
<option value="redirect">Forward to</option>
<option value="keep">Keep in Inbox</option>
<option value="discard">Delete</option>
<option value="reject">Reject with message</option>
<option value="stop">Stop processing more rules</option>
<option value="fileinto">{translate("Move to folder")}</option>
<option value="markread">{translate("Mark as read")}</option>
<option value="flag">{translate("Star")}</option>
<option value="addflag">{translate("Add label / keyword")}</option>
<option value="redirect">{translate("Forward to")}</option>
<option value="keep">{translate("Keep in Inbox")}</option>
<option value="discard">{translate("Delete")}</option>
<option value="reject">{translate("Reject with message")}</option>
<option value="stop">{translate("Stop processing more rules")}</option>
</select>
{a.type === "fileinto" ? (
<div className="row">
@@ -138,21 +139,21 @@ export function RuleDialog({ rule, onClose, onSave, applyMailbox, applyByDefault
>
{folders.map((f) => <option key={f.id} value={f.path}>{f.path}</option>)}
{!folders.some((f) => f.path === a.mailbox) && <option value={a.mailbox}>{a.mailbox}</option>}
<option value="__new__"> New folder</option>
<option value="__new__">{translate(" New folder…")}</option>
</select>
<label className="check nowrap"><input type="checkbox" checked={Boolean(a.copy)} onChange={(e) => setAction(i, { ...a, copy: e.target.checked })} /> keep copy</label>
</div>
) : a.type === "redirect" ? (
<div className="row">
<input className="input" type="email" placeholder="[email protected]" value={a.address} onChange={(e) => setAction(i, { ...a, address: e.target.value })} />
<input className="input" type="email" placeholder={translate("[email protected]")} value={a.address} onChange={(e) => setAction(i, { ...a, address: e.target.value })} />
<label className="check nowrap"><input type="checkbox" checked={Boolean(a.copy)} onChange={(e) => setAction(i, { ...a, copy: e.target.checked })} /> keep copy</label>
</div>
) : a.type === "reject" ? (
<input className="input" placeholder="Reason" value={a.reason} onChange={(e) => setAction(i, { ...a, reason: e.target.value })} />
<input className="input" placeholder={translate("Reason")} value={a.reason} onChange={(e) => setAction(i, { ...a, reason: e.target.value })} />
) : a.type === "addflag" || a.type === "setflag" || a.type === "removeflag" ? (
<input className="input" placeholder="keyword (e.g. $important, work)" value={a.flag} onChange={(e) => setAction(i, { ...a, flag: e.target.value })} />
<input className="input" placeholder={translate("keyword (e.g. $important, work)")} value={a.flag} onChange={(e) => setAction(i, { ...a, flag: e.target.value })} />
) : <span />}
<button className="icon-btn sm danger" aria-label="Remove action" onClick={() => setR({ ...r, actions: r.actions.filter((_, j) => j !== i) })} disabled={r.actions.length <= 1}><Trash2 size={16} /></button>
<button className="icon-btn sm danger" aria-label={translate("Remove action")} onClick={() => setR({ ...r, actions: r.actions.filter((_, j) => j !== i) })} disabled={r.actions.length <= 1}><Trash2 size={16} /></button>
</div>
))}
<button className="btn btn-ghost btn-sm" onClick={() => setR({ ...r, actions: [...r.actions, { type: "stop" }] })}><Plus size={14} /> Add action</button>
+36 -34
View File
@@ -5,6 +5,7 @@ import { useSession } from "@/store/session";
import { formatFullDate } from "@/lib/format";
import { toast } from "@/ui/toast";
import { confirmDialog, Dialog } from "@/ui/dialog";
import { t } from "@/lib/i18n";
interface SessionRow {
id: string;
@@ -57,10 +58,10 @@ export function SecuritySettings() {
return (
<div>
<h1>Security & sessions</h1>
<h1>{t("Security & sessions")}</h1>
<p className="lead">You're signed in as <b>{session?.username}</b>. Your password is never stored in the browser; the server keeps it encrypted per-session for talking to Stalwart.</p>
<h2>Password</h2>
<h2>{t("Password")}</h2>
{unsupported ? (
<p className="hint">{unsupported}</p>
) : (
@@ -69,26 +70,26 @@ export function SecuritySettings() {
{!unsupported && state?.otpEnabled && (
<>
<h2>Two-factor authentication</h2>
<h2>{t("Two-factor authentication")}</h2>
<TwoFactorOff reload={async () => { await loadSecurity(); await load(); }} />
</>
)}
<h2>App passwords</h2>
<h2>{t("App passwords")}</h2>
{unsupported ? (
<p className="hint">App passwords are managed by your mail administrator.</p>
<p className="hint">{t("App passwords are managed by your mail administrator.")}</p>
) : (
<AppPasswords state={state} reload={loadSecurity} />
)}
<h2>Active webmail sessions</h2>
{rows === null ? <p className="hint">Loading…</p> : (
<h2>{t("Active webmail sessions")}</h2>
{rows === null ? <p className="hint">{t("Loading…")}</p> : (
<table className="sessions-table">
<thead><tr><th>Device</th><th>IP</th><th>Last active</th><th>Expires</th><th /></tr></thead>
<thead><tr><th>{t("Device")}</th><th>{t("IP")}</th><th>{t("Last active")}</th><th>{t("Expires")}</th><th /></tr></thead>
<tbody>
{rows.map((r) => (
<tr key={r.id}>
<td><div className="truncate" style={{ maxWidth: 320 }} title={r.userAgent}>{shortUa(r.userAgent)}</div>{r.id === current && <span className="badge" style={{ marginTop: 2 }}>this device</span>}</td>
<td><div className="truncate" style={{ maxWidth: 320 }} title={r.userAgent}>{shortUa(r.userAgent)}</div>{r.id === current && <span className="badge" style={{ marginTop: 2 }}>{t("this device")}</span>}</td>
<td className="mono small">{r.ip}</td>
<td>{formatFullDate(new Date(r.lastSeenAt).toISOString())}</td>
<td>{`${formatFullDate(new Date(r.expiresAt).toISOString())}${r.remember ? " (remembered)" : ""}`}</td>
@@ -99,8 +100,8 @@ export function SecuritySettings() {
</table>
)}
<div className="row mt-16">
<button className="btn" onClick={async () => { if (await confirmDialog({ title: "Sign out other sessions?", confirmLabel: "Sign out others" })) { const r = await apiFetch<{ revoked: number }>("/api/auth/sessions/revoke-others", { method: "POST" }); toast.success(`Signed out ${r.revoked} other session(s)`); void load(); } }}>Sign out all other sessions</button>
<button className="btn btn-ghost" onClick={() => void logout()}>Sign out here</button>
<button className="btn" onClick={async () => { if (await confirmDialog({ title: "Sign out other sessions?", confirmLabel: "Sign out others" })) { const r = await apiFetch<{ revoked: number }>("/api/auth/sessions/revoke-others", { method: "POST" }); toast.success(`Signed out ${r.revoked} other session(s)`); void load(); } }}>{t("Sign out all other sessions")}</button>
<button className="btn btn-ghost" onClick={() => void logout()}>{t("Sign out here")}</button>
</div>
</div>
);
@@ -139,24 +140,24 @@ function PasswordForm({ otpEnabled, onChanged }: { otpEnabled: boolean; onChange
return (
<form onSubmit={submit}>
<p className="hint" style={{ marginBottom: 12 }}>Changing your password signs out your other webmail sessions. Any app passwords keep working.</p>
<p className="hint" style={{ marginBottom: 12 }}>{t("Changing your password signs out your other webmail sessions. Any app passwords keep working.")}</p>
<div className="field" style={{ maxWidth: 380 }}>
<label htmlFor="pw-current">Current password</label>
<label htmlFor="pw-current">{t("Current password")}</label>
<input id="pw-current" type="password" autoComplete="current-password" value={current} onChange={(e) => setCurrent(e.target.value)} required />
</div>
{otpEnabled && (
<div className="field" style={{ maxWidth: 380 }}>
<label htmlFor="pw-code">Code from your authenticator</label>
<label htmlFor="pw-code">{t("Code from your authenticator")}</label>
<input id="pw-code" inputMode="numeric" autoComplete="one-time-code" value={code} onChange={(e) => setCode(e.target.value)} placeholder="123456" required />
</div>
)}
<div className="field-row" style={{ maxWidth: 780 }}>
<div className="field">
<label htmlFor="pw-new">New password</label>
<label htmlFor="pw-new">{t("New password")}</label>
<input id="pw-new" type="password" autoComplete="new-password" value={next} onChange={(e) => setNext(e.target.value)} required />
</div>
<div className="field">
<label htmlFor="pw-confirm">Confirm new password</label>
<label htmlFor="pw-confirm">{t("Confirm new password")}</label>
<input id="pw-confirm" type="password" autoComplete="new-password" value={confirm} onChange={(e) => setConfirm(e.target.value)} required />
</div>
</div>
@@ -198,27 +199,27 @@ function TwoFactorOff({ reload }: { reload: () => Promise<void> }) {
return (
<div>
<p className="hint" style={{ marginBottom: 12 }}>
This account has two-factor authentication on. ihasmail can't sign you in with a code yet, so signing in on another
device needs an app password — or you can turn two-factor authentication off here.
{t("This account has two-factor authentication on. ihasmail can't sign you in with a code yet, so signing in on another device needs an app password — or you can turn two-factor authentication off here.")}
</p>
<div className="row" style={{ alignItems: "center", gap: 10 }}>
<ShieldCheck size={18} />
<b>Enabled</b>
<button className="btn btn-sm" onClick={() => { setDisabling(true); setCode(""); setPassword(""); }}>Turn off</button>
<b>{t("Enabled")}</b>
<button className="btn btn-sm" onClick={() => { setDisabling(true); setCode(""); setPassword(""); }}>{t("Turn off")}</button>
</div>
<Dialog open={disabling} onClose={() => setDisabling(false)} title="Turn off two-factor authentication" size="sm"
<Dialog open={disabling} onClose={() => setDisabling(false)} title={t("Turn off two-factor authentication")} size="sm"
footer={<>
<button className="btn btn-ghost" onClick={() => setDisabling(false)}>Cancel</button>
<button className="btn btn-ghost" onClick={() => setDisabling(false)}>{t("Cancel")}</button>
<button className="btn btn-danger" disabled={busy || !password || code.length < 6} onClick={() => void disable()}>{busy ? "Working…" : "Turn off"}</button>
</>}>
<p>Your password alone will be enough to sign in again.</p>
<p>{t("Your password alone will be enough to sign in again.")}</p>
<div className="field">
<label htmlFor="tfa-off-pw">Your password</label>
<label htmlFor="tfa-off-pw">{t("Your password")}</label>
<input id="tfa-off-pw" type="password" autoComplete="current-password" value={password} onChange={(e) => setPassword(e.target.value)} />
</div>
<div className="field">
<label htmlFor="tfa-off-code">Current code</label>
<label htmlFor="tfa-off-code">{t("Current code")}</label>
<input id="tfa-off-code" inputMode="numeric" autoComplete="one-time-code" value={code} onChange={(e) => setCode(e.target.value)} placeholder="123456" />
</div>
</Dialog>
@@ -233,7 +234,7 @@ function AppPasswords({ state, reload }: { state: SecurityState | null; reload:
const [busy, setBusy] = useState(false);
const [issued, setIssued] = useState<{ description: string; secret: string } | null>(null);
if (!state) return <p className="hint">Loading…</p>;
if (!state) return <p className="hint">{t("Loading…")}</p>;
const create = async (e: React.FormEvent) => {
e.preventDefault();
@@ -273,17 +274,18 @@ function AppPasswords({ state, reload }: { state: SecurityState | null; reload:
return (
<div>
<p className="hint" style={{ marginBottom: 12 }}>
A separate password for a mail app or device, which you can revoke on its own. App passwords skip two-factor codes, so they keep working in apps that can't ask for one.
{t("A separate password for a mail app or device, which you can revoke on its own. App passwords skip two-factor codes, so they keep working in apps that can't ask for one.")}
</p>
{state.appPasswords.length > 0 && (
<table className="sessions-table">
<thead><tr><th>Name</th><th>Created</th><th /></tr></thead>
<thead><tr><th>{t("Name")}</th><th>{t("Created")}</th><th /></tr></thead>
<tbody>
{state.appPasswords.map((row) => (
<tr key={row.id}>
<td><KeyRound size={14} style={{ verticalAlign: "-2px", marginRight: 6 }} />{row.description}</td>
<td>{row.createdAt ? formatFullDate(row.createdAt) : ""}</td>
<td style={{ textAlign: "right" }}><button className="btn btn-sm btn-ghost" onClick={() => void revoke(row)}>Revoke</button></td>
<td style={{ textAlign: "right" }}><button className="btn btn-sm btn-ghost" onClick={() => void revoke(row)}>{t("Revoke")}</button></td>
</tr>
))}
</tbody>
@@ -291,14 +293,14 @@ function AppPasswords({ state, reload }: { state: SecurityState | null; reload:
)}
<form onSubmit={create} className="row mt-16" style={{ gap: 8, alignItems: "flex-end", flexWrap: "wrap" }}>
<div className="field" style={{ marginBottom: 0, minWidth: 240 }}>
<label htmlFor="ap-name">New app password for</label>
<input id="ap-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Thunderbird on my laptop" required />
<label htmlFor="ap-name">{t("New app password for")}</label>
<input id="ap-name" value={name} onChange={(e) => setName(e.target.value)} placeholder={t("Thunderbird on my laptop")} required />
</div>
<button className="btn" disabled={busy || !name.trim()}>{busy ? "Creating" : "Create"}</button>
</form>
<Dialog open={Boolean(issued)} onClose={() => setIssued(null)} title="Your new app password" size="sm"
footer={<button className="btn btn-primary" onClick={() => setIssued(null)}>Done</button>}>
<Dialog open={Boolean(issued)} onClose={() => setIssued(null)} title={t("Your new app password")} size="sm"
footer={<button className="btn btn-primary" onClick={() => setIssued(null)}>{t("Done")}</button>}>
{issued && (
<div>
<p>Copy it into <b>{issued.description}</b> now — it isn't shown again.</p>
@@ -318,7 +320,7 @@ function CopyableSecret({ value }: { value: string }) {
<button
type="button"
className="btn btn-sm btn-ghost"
title="Copy"
title={t("Copy")}
onClick={() => void navigator.clipboard?.writeText(value).then(() => toast.success("Copied"), () => toast.error("Could not copy"))}
>
<Copy size={14} />
+5 -4
View File
@@ -13,6 +13,7 @@ import { SecuritySettings } from "./SecuritySettings";
import { AboutSettings } from "./AboutSettings";
import { ShortcutsSettings } from "./ShortcutsSettings";
import { CalendarSettings } from "./CalendarSettings";
import { t } from "@/lib/i18n";
const FiltersSettings = lazy(() => import("./FiltersSettings").then((m) => ({ default: m.FiltersSettings })));
const VacationSettings = lazy(() => import("./VacationSettings").then((m) => ({ default: m.VacationSettings })));
@@ -38,16 +39,16 @@ export function SettingsView({ section }: { section?: string }) {
const current = SECTIONS.find((s) => s.id === section);
return (
<div className={`settings-layout ${section ? "section" : "root"}`}>
<nav className="settings-nav" aria-label="Settings">
<div className="nav-section" style={{ paddingLeft: 8 }}><span>Settings</span></div>
<nav className="settings-nav" aria-label={t("Settings")}>
<div className="nav-section" style={{ paddingLeft: 8 }}><span>{t("Settings")}</span></div>
{SECTIONS.map((s) => (
<Link key={s.id} href={`/settings/${s.id}`} className={`nav-item ${section === s.id ? "active" : ""}`}>
{s.icon}
<span className="nav-label">{s.label}</span>
</Link>
))}
<div className="nav-section" style={{ paddingLeft: 8 }}><span>Shortcuts</span></div>
<Link href="/contacts" className="nav-item"><Users size={18} /><span className="nav-label">Address books</span></Link>
<div className="nav-section" style={{ paddingLeft: 8 }}><span>{t("Shortcuts")}</span></div>
<Link href="/contacts" className="nav-item"><Users size={18} /><span className="nav-label">{t("Address books")}</span></Link>
</nav>
<div className="settings-content">
{section && (
+9 -7
View File
@@ -8,6 +8,7 @@ import { useFiles } from "@/store/files";
import { client, setErrorMessage } from "@/jmap/client";
import { toast } from "@/ui/toast";
import type { Id, Principal } from "@/jmap/types";
import { t } from "@/lib/i18n";
/* The JMAP type name, used verbatim as the `/set` method prefix. */
type Kind = "Mailbox" | "Calendar" | "AddressBook" | "FileNode";
@@ -103,7 +104,7 @@ export function ShareDialog({ kind, id, name, shareWith, onClose }: { kind: Kind
};
return (
<Dialog open onClose={onClose} title={`Share “${name}`} size="lg" footer={<><button className="btn" onClick={onClose}>Cancel</button><button className="btn btn-primary" disabled={busy} onClick={() => void save()}>Save</button></>}>
<Dialog open onClose={onClose} title={`Share “${name}`} size="lg" footer={<><button className="btn" onClick={onClose}>{t("Cancel")}</button><button className="btn btn-primary" disabled={busy} onClick={() => void save()}>{t("Save")}</button></>}>
{/* The list of who it is shared with is rendered whether or not anybody
can be *added*. It used to sit inside the branch below, so a server
with directory queries switched off -- which is the default, and which
@@ -111,20 +112,21 @@ export function ShareDialog({ kind, id, name, shareWith, onClose }: { kind: Kind
share could not be seen, let alone removed. */}
{!principals.length && (
<p className="hint" style={{ marginBottom: 12 }}>
No other users found in the directory, so nobody new can be added. Sharing already in place is listed below and can still be removed.
{t("No other users found in the directory, so nobody new can be added. Sharing already in place is listed below and can still be removed.")}
</p>
)}
{principals.length > 0 && (
<>
<div className="row" style={{ marginBottom: 12 }}>
<select className="select" value={pick} onChange={(e) => setPick(e.target.value)}>
<option value="">Add a person or group</option>
<option value="">{t("Add a person or group…")}</option>
{available.map((p) => (
<option key={p.id} value={p.id}>{`${p.name}${p.email ? ` <${p.email}>` : ""}${p.type !== "individual" ? ` (${p.type})` : ""}`}</option>
))}
</select>
<button className="btn" disabled={!pick} onClick={() => { const p = principals.find((x) => x.id === pick); if (p) add(p, "reader"); }}>Viewer</button>
<button className="btn btn-primary" disabled={!pick} onClick={() => { const p = principals.find((x) => x.id === pick); if (p) add(p, "editor"); }}>Editor</button>
<button className="btn" disabled={!pick} onClick={() => { const p = principals.find((x) => x.id === pick); if (p) add(p, "reader"); }}>{t("Viewer")}</button>
<button className="btn btn-primary" disabled={!pick} onClick={() => { const p = principals.find((x) => x.id === pick); if (p) add(p, "editor"); }}>{t("Editor")}</button>
</div>
</>
)}
@@ -134,7 +136,7 @@ export function ShareDialog({ kind, id, name, shareWith, onClose }: { kind: Kind
<div key={pid} className="card">
<div className="card-head">
<h3><span>{p?.name ?? pid}</span>{p?.email ? <span className="hint" style={{ fontWeight: 400 }}> · {p.email}</span> : null}</h3>
<button className="icon-btn sm danger" onClick={() => { const n = { ...rights }; delete n[pid]; setRights(n); }} aria-label="Remove"><Trash2 size={16} /></button>
<button className="icon-btn sm danger" onClick={() => { const n = { ...rights }; delete n[pid]; setRights(n); }} aria-label={t("Remove")}><Trash2 size={16} /></button>
</div>
<div className="row wrap" style={{ marginTop: 8 }}>
{RIGHTS[kind].map((rt) => (
@@ -147,7 +149,7 @@ export function ShareDialog({ kind, id, name, shareWith, onClose }: { kind: Kind
</div>
);
})}
{!Object.keys(rights).length && <p className="hint">Not shared with anyone yet.</p>}
{!Object.keys(rights).length && <p className="hint">{t("Not shared with anyone yet.")}</p>}
</Dialog>
);
}
+3 -2
View File
@@ -1,6 +1,7 @@
import { useMemo } from "react";
import { keyboard } from "@/lib/keyboard";
import { Kbd } from "@/ui/misc";
import { t } from "@/lib/i18n";
export function ShortcutsSettings() {
const list = useMemo(() => keyboard.list(), []);
@@ -15,7 +16,7 @@ export function ShortcutsSettings() {
}, [list]);
return (
<div>
<h1>Keyboard shortcuts</h1>
<h1>{t("Keyboard shortcuts")}</h1>
<p className="lead">Gmail-style shortcuts are always on. Press <kbd className="kbd">?</kbd> anywhere to see this list.</p>
<div className="shortcut-grid">
{groups.map(([group, items]) => (
@@ -26,7 +27,7 @@ export function ShortcutsSettings() {
))}
</div>
))}
{!groups.length && <p className="hint">Open the Mail view to see all shortcuts.</p>}
{!groups.length && <p className="hint">{t("Open the Mail view to see all shortcuts.")}</p>}
</div>
</div>
);
+9 -8
View File
@@ -4,6 +4,7 @@ import { useSettings, type Template } from "@/store/settings";
import { Dialog } from "@/ui/dialog";
import { RichEditor } from "../compose/RichEditor";
import { htmlToText } from "@/lib/text";
import { t as translate } from "@/lib/i18n";
export function TemplatesSettings() {
const templates = useSettings((s) => s.settings.templates);
@@ -11,13 +12,13 @@ export function TemplatesSettings() {
const [editing, setEditing] = useState<Template | null>(null);
return (
<div>
<h1>Templates</h1>
<p className="lead">Canned responses you can insert into any message from the composer's template button.</p>
<h1>{translate("Templates")}</h1>
<p className="lead">{translate("Canned responses you can insert into any message from the composer's template button.")}</p>
{templates.map((t) => (
<div key={t.id} className="card clickable" onClick={() => setEditing(t)}>
<div className="card-head">
<h3>{t.name}</h3>
<button className="icon-btn sm danger" aria-label="Delete template" onClick={(e) => { e.stopPropagation(); update({ templates: templates.filter((x) => x.id !== t.id) }); }}><Trash2 size={16} /></button>
<button className="icon-btn sm danger" aria-label={translate("Delete template")} onClick={(e) => { e.stopPropagation(); update({ templates: templates.filter((x) => x.id !== t.id) }); }}><Trash2 size={16} /></button>
</div>
{t.subject && <div className="hint">Subject: {t.subject}</div>}
<div className="hint truncate">{htmlToText(t.html).slice(0, 140)}</div>
@@ -25,15 +26,15 @@ export function TemplatesSettings() {
))}
<button className="btn" onClick={() => setEditing({ id: `t${Date.now()}`, name: "", subject: "", html: "" })}><Plus size={16} /> New template</button>
{editing && (
<Dialog open onClose={() => setEditing(null)} title={templates.some((t) => t.id === editing.id) ? "Edit template" : "New template"} size="lg" footer={<><button className="btn" onClick={() => setEditing(null)}>Cancel</button><button className="btn btn-primary" disabled={!editing.name.trim()} onClick={() => { const exists = templates.some((t) => t.id === editing.id); update({ templates: exists ? templates.map((t) => (t.id === editing.id ? editing : t)) : [...templates, editing] }); setEditing(null); }}>Save</button></>}>
<Dialog open onClose={() => setEditing(null)} title={templates.some((t) => t.id === editing.id) ? "Edit template" : "New template"} size="lg" footer={<><button className="btn" onClick={() => setEditing(null)}>{translate("Cancel")}</button><button className="btn btn-primary" disabled={!editing.name.trim()} onClick={() => { const exists = templates.some((t) => t.id === editing.id); update({ templates: exists ? templates.map((t) => (t.id === editing.id ? editing : t)) : [...templates, editing] }); setEditing(null); }}>{translate("Save")}</button></>}>
<div className="field-row">
<div className="field"><label>Name</label><input className="input" value={editing.name} onChange={(e) => setEditing({ ...editing, name: e.target.value })} autoFocus /></div>
<div className="field"><label>Subject (optional)</label><input className="input" value={editing.subject} onChange={(e) => setEditing({ ...editing, subject: e.target.value })} /></div>
<div className="field"><label>{translate("Name")}</label><input className="input" value={editing.name} onChange={(e) => setEditing({ ...editing, name: e.target.value })} autoFocus /></div>
<div className="field"><label>{translate("Subject (optional)")}</label><input className="input" value={editing.subject} onChange={(e) => setEditing({ ...editing, subject: e.target.value })} /></div>
</div>
<div className="field">
<label>Body</label>
<label>{translate("Body")}</label>
<div style={{ border: "1px solid var(--border-strong)", borderRadius: 8, minHeight: 200, display: "flex", flexDirection: "column" }}>
<RichEditor html={editing.html} onChange={(html) => setEditing({ ...editing, html })} showToolbar placeholder="Template text…" />
<RichEditor html={editing.html} onChange={(html) => setEditing({ ...editing, html })} showToolbar placeholder={translate("Template text…")} />
</div>
</div>
</Dialog>
+10 -9
View File
@@ -5,6 +5,7 @@ import { toast } from "@/ui/toast";
import { toInputDateTime, fromInputDateTime, toUTCDate } from "@/lib/dates";
import { DateTimeField } from "@/ui/datefield";
import { client, CAP } from "@/jmap/client";
import { t } from "@/lib/i18n";
export function VacationSettings() {
const vacation = useMail((s) => s.vacation);
@@ -30,7 +31,7 @@ export function VacationSettings() {
setTo(vacation.toDate ? toInputDateTime(new Date(vacation.toDate)) : "");
}, [vacation]);
if (!available) return <div><h1>Out of office</h1><p className="lead">Vacation responses are not available for this account.</p></div>;
if (!available) return <div><h1>{t("Out of office")}</h1><p className="lead">{t("Vacation responses are not available for this account.")}</p></div>;
const submit = async () => {
setBusy(true);
@@ -53,16 +54,16 @@ export function VacationSettings() {
return (
<div>
<h1>Out of office</h1>
<p className="lead">Automatically reply to people who email you while you're away. Each sender gets at most one reply.</p>
<Switch checked={enabled} onChange={setEnabled} label="Auto-reply enabled" />
<h1>{t("Out of office")}</h1>
<p className="lead">{t("Automatically reply to people who email you while you're away. Each sender gets at most one reply.")}</p>
<Switch checked={enabled} onChange={setEnabled} label={t("Auto-reply enabled")} />
<div className="field-row mt-16">
<div className="field"><label>Starts (optional)</label><DateTimeField aria-label="Starts" value={from} onChange={setFrom} /></div>
<div className="field"><label>Ends (optional)</label><DateTimeField aria-label="Ends" value={to} onChange={setTo} /></div>
<div className="field"><label>{t("Starts (optional)")}</label><DateTimeField aria-label={t("Starts")} value={from} onChange={setFrom} /></div>
<div className="field"><label>{t("Ends (optional)")}</label><DateTimeField aria-label={t("Ends")} value={to} onChange={setTo} /></div>
</div>
<div className="field"><label>Subject</label><input className="input" value={subject} onChange={(e) => setSubject(e.target.value)} placeholder="Out of office" /></div>
<div className="field"><label>Message</label><textarea className="textarea" rows={7} value={body} onChange={(e) => setBody(e.target.value)} placeholder="Thanks for your message. I'm away until and will reply when I'm back." /></div>
<button className="btn btn-primary" disabled={busy} onClick={() => void submit()}>Save</button>
<div className="field"><label>{t("Subject")}</label><input className="input" value={subject} onChange={(e) => setSubject(e.target.value)} placeholder={t("Out of office")} /></div>
<div className="field"><label>{t("Message")}</label><textarea className="textarea" rows={7} value={body} onChange={(e) => setBody(e.target.value)} placeholder={t("Thanks for your message. I'm away until … and will reply when I'm back.")} /></div>
<button className="btn btn-primary" disabled={busy} onClick={() => void submit()}>{t("Save")}</button>
</div>
);
}