Merge pull request #147 from Coffey-Labs/i18n-extract-batch1
Extract 515 strings by codemod
This commit is contained in:
+3
-1
@@ -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.
|
- **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)
|
- 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.
|
- **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
@@ -21,7 +21,8 @@
|
|||||||
"lint": "npm run typecheck",
|
"lint": "npm run typecheck",
|
||||||
"mock": "npm run mock -w server",
|
"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": "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": {
|
"devDependencies": {
|
||||||
"concurrently": "^9.1.2",
|
"concurrently": "^9.1.2",
|
||||||
|
|||||||
Executable
+55
@@ -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);
|
||||||
@@ -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 & 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
@@ -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 { Route, Switch, Redirect, useLocation } from "wouter";
|
||||||
import { useSession } from "@/store/session";
|
import { useSession } from "@/store/session";
|
||||||
import { useMail } from "@/store/mail";
|
import { useMail } from "@/store/mail";
|
||||||
@@ -20,6 +20,7 @@ import { setUnreadBadge } from "@/lib/notify";
|
|||||||
import { useSettings, syncedPart } from "@/store/settings";
|
import { useSettings, syncedPart } from "@/store/settings";
|
||||||
import { armSettingsSync, loadRemoteSettings, queueSettingsPush, settingsSyncAvailable } from "@/lib/settingsSync";
|
import { armSettingsSync, loadRemoteSettings, queueSettingsPush, settingsSyncAvailable } from "@/lib/settingsSync";
|
||||||
import { listenForVerification, renewWebPush } from "@/lib/webpushEnable";
|
import { listenForVerification, renewWebPush } from "@/lib/webpushEnable";
|
||||||
|
import { useLanguageVersion } from "@/lib/i18n";
|
||||||
|
|
||||||
const ContactsView = lazy(() => import("@/views/contacts/ContactsView").then((m) => ({ default: m.ContactsView })));
|
const ContactsView = lazy(() => import("@/views/contacts/ContactsView").then((m) => ({ default: m.ContactsView })));
|
||||||
const CalendarView = lazy(() => import("@/views/calendar/CalendarView").then((m) => ({ default: m.CalendarView })));
|
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() {
|
export function App() {
|
||||||
const status = useSession((s) => s.status);
|
const status = useSession((s) => s.status);
|
||||||
const bootstrap = useSession((s) => s.bootstrap);
|
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(() => {
|
useEffect(() => {
|
||||||
void bootstrap();
|
void bootstrap();
|
||||||
}, [bootstrap]);
|
}, [bootstrap]);
|
||||||
@@ -42,7 +55,7 @@ export function App() {
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{status === "anonymous" ? <LoginPage /> : <AuthedApp />}
|
<Fragment key={languageVersion}>{status === "anonymous" ? <LoginPage /> : <AuthedApp />}</Fragment>
|
||||||
<ToastHost />
|
<ToastHost />
|
||||||
<ConfirmHost />
|
<ConfirmHost />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { queueSettingsPush } from "@/lib/settingsSync";
|
|||||||
import { setDateTimePrefs, type DateFormat, type TimeFormat } from "@/lib/datetime";
|
import { setDateTimePrefs, type DateFormat, type TimeFormat } from "@/lib/datetime";
|
||||||
import type { SwipeAction } from "@/lib/swipe";
|
import type { SwipeAction } from "@/lib/swipe";
|
||||||
import { resolveUiLanguage } from "@/lib/languages";
|
import { resolveUiLanguage } from "@/lib/languages";
|
||||||
|
import { loadLanguage } from "@/lib/i18n";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* "ihasmail" is a dark theme carrying the palette from ihasmail.org. It is a
|
* "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.
|
* load, which is the thing the whole design avoids.
|
||||||
*/
|
*/
|
||||||
export function applyLang(s: Settings = useSettings.getState().settings): void {
|
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`). */
|
/** Background of each theme, for the browser chrome (`theme-color`). */
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
} from "@/lib/datetime";
|
} from "@/lib/datetime";
|
||||||
import { dateTimeKey, useSettings } from "@/store/settings";
|
import { dateTimeKey, useSettings } from "@/store/settings";
|
||||||
import { anchorFromEl, Popover, type Anchor } from "./popover";
|
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.
|
* 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 (
|
return (
|
||||||
<div className="dp-cal">
|
<div className="dp-cal">
|
||||||
<div className="dp-head">
|
<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>
|
<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>
|
||||||
<div className="dp-dow" aria-hidden="true">{dow.map((d, i) => <span key={i}>{d}</span>)}</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}>
|
<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>
|
||||||
<div className="dp-foot">
|
<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={() => onPick(startOfDay(new Date()))}>{translate("Today")}</button>
|
||||||
<button type="button" className="btn btn-ghost xs" onClick={onClose}>Close</button>
|
<button type="button" className="btn btn-ghost xs" onClick={onClose}>{translate("Close")}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -127,7 +128,7 @@ function TimeList({ selected, onPick }: { selected: Date | null; onPick: (hours:
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
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) => (
|
{slots.map((t, i) => (
|
||||||
<button
|
<button
|
||||||
key={i}
|
key={i}
|
||||||
@@ -234,7 +235,7 @@ export function DateField({ value, onChange, className, disabled, required, id,
|
|||||||
if (e.key === "ArrowDown" && !anchor) { e.preventDefault(); open(); }
|
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} />
|
<CalIcon size={15} />
|
||||||
</button>
|
</button>
|
||||||
{anchor && (
|
{anchor && (
|
||||||
@@ -311,7 +312,7 @@ export function DateTimeField({ value, onChange, className, disabled, required,
|
|||||||
if (e.key === "ArrowDown" && !anchor) { e.preventDefault(); open(); }
|
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} />
|
<CalIcon size={15} />
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState, type ReactNode } from "react";
|
|||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
|
import { t } from "@/lib/i18n";
|
||||||
|
|
||||||
interface DialogProps {
|
interface DialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -71,7 +72,7 @@ export function Dialog({ open, onClose, title, children, footer, size = "md", cl
|
|||||||
{title !== undefined && (
|
{title !== undefined && (
|
||||||
<div className="dialog-head">
|
<div className="dialog-head">
|
||||||
<h2>{title}</h2>
|
<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} />
|
<X size={20} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+2
-1
@@ -3,6 +3,7 @@ import type { EmailAddress } from "@/jmap/types";
|
|||||||
import { avatarColor, initials } from "@/lib/address";
|
import { avatarColor, initials } from "@/lib/address";
|
||||||
import { useContacts } from "@/store/contacts";
|
import { useContacts } from "@/store/contacts";
|
||||||
import { contactPhoto } from "@/lib/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 }) {
|
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 ?? "");
|
const email = typeof who === "string" ? who : (who?.email ?? "");
|
||||||
@@ -82,7 +83,7 @@ export function Kbd({ keys }: { keys: string }) {
|
|||||||
<span className="keys">
|
<span className="keys">
|
||||||
{keys.split(" ").map((k, i) => (
|
{keys.split(" ").map((k, i) => (
|
||||||
<span key={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) => (
|
{k.split("+").map((p, j) => (
|
||||||
<kbd key={j} className="kbd" style={{ marginRight: 2 }}>
|
<kbd key={j} className="kbd" style={{ marginRight: 2 }}>
|
||||||
{p === "mod" ? (navigator.platform.includes("Mac") ? "⌘" : "Ctrl") : p === "shift" ? "⇧" : p === "enter" ? "↵" : p === "esc" ? "Esc" : p}
|
{p === "mod" ? (navigator.platform.includes("Mac") ? "⌘" : "Ctrl") : p === "shift" ? "⇧" : p === "enter" ? "↵" : p === "esc" ? "Esc" : p}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
|
import { t as translate } from "@/lib/i18n";
|
||||||
|
|
||||||
export interface Toast {
|
export interface Toast {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -73,7 +74,7 @@ export function ToastHost() {
|
|||||||
{t.action.label}
|
{t.action.label}
|
||||||
</button>
|
</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} />
|
<X size={16} />
|
||||||
</button>
|
</button>
|
||||||
{t.progress && t.duration > 0 && <span className="toast-progress" style={{ animationDuration: `${t.duration}ms` }} />}
|
{t.progress && t.duration > 0 && <span className="toast-progress" style={{ animationDuration: `${t.duration}ms` }} />}
|
||||||
|
|||||||
+18
-17
@@ -15,6 +15,7 @@ import { CalendarSidebar } from "./calendar/CalendarSidebar";
|
|||||||
import { ShortcutsDialog, useGlobalShortcuts } from "./Shortcuts";
|
import { ShortcutsDialog, useGlobalShortcuts } from "./Shortcuts";
|
||||||
import { formatSize } from "@/lib/format";
|
import { formatSize } from "@/lib/format";
|
||||||
import { TranslateBoundary } from "@/ui/TranslateBoundary";
|
import { TranslateBoundary } from "@/ui/TranslateBoundary";
|
||||||
|
import { t } from "@/lib/i18n";
|
||||||
|
|
||||||
const PUSH_LABEL = {
|
const PUSH_LABEL = {
|
||||||
connected: "Live updates connected",
|
connected: "Live updates connected",
|
||||||
@@ -67,7 +68,7 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
return (
|
return (
|
||||||
<div className="app">
|
<div className="app">
|
||||||
<header className="topbar">
|
<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} />
|
<MenuIcon size={22} />
|
||||||
</button>
|
</button>
|
||||||
<Link href="/mail" className="brand">
|
<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-status hide-mobile" role="img" aria-label={PUSH_LABEL[pushState]} title={PUSH_LABEL[pushState]}>
|
||||||
<span className={`push-dot ${pushState}`} />
|
<span className={`push-dot ${pushState}`} />
|
||||||
</span>
|
</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} />
|
<HelpCircle size={21} />
|
||||||
</button>
|
</button>
|
||||||
<ThemeToggle />
|
<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} />
|
<Settings size={21} />
|
||||||
</Link>
|
</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" />
|
<Avatar who={{ name: session?.username, email: session?.username }} size="sm" />
|
||||||
</button>
|
</button>
|
||||||
<Popover anchor={acctMenu.anchor} onClose={acctMenu.close} align="end" width={280}>
|
<Popover anchor={acctMenu.anchor} onClose={acctMenu.close} align="end" width={280}>
|
||||||
@@ -104,14 +105,14 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<MenuSep />
|
<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
|
{/* 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
|
is a page a signed-in user never sees again -- so from inside the
|
||||||
app there was no way back to it. */}
|
app there was no way back to it. */}
|
||||||
<MenuItem icon={<Globe size={16} />} label="About ihasmail" href="https://ihasmail.org" external />
|
<MenuItem icon={<Globe size={16} />} label={t("About ihasmail")} href="https://ihasmail.org" external />
|
||||||
<MenuItem icon={<Settings size={16} />} label="Settings" onClick={() => navigate("/settings")} />
|
<MenuItem icon={<Settings size={16} />} label={t("Settings")} onClick={() => navigate("/settings")} />
|
||||||
<MenuItem icon={<RefreshCw size={16} />} label="Refresh" onClick={() => window.location.reload()} />
|
<MenuItem icon={<RefreshCw size={16} />} label={t("Refresh")} onClick={() => window.location.reload()} />
|
||||||
<MenuItem icon={<LogOut size={16} />} label="Sign out" onClick={() => void logout()} />
|
<MenuItem icon={<LogOut size={16} />} label={t("Sign out")} onClick={() => void logout()} />
|
||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -138,14 +139,14 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
{section === "calendar" && <CalendarSidebar />}
|
{section === "calendar" && <CalendarSidebar />}
|
||||||
{section === "contacts" && <ContactsSidebar />}
|
{section === "contacts" && <ContactsSidebar />}
|
||||||
{section === "files" && <FilesTree />}
|
{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>
|
</div>
|
||||||
{(section === "mail" || section === "search") && <QuotaBar />}
|
{(section === "mail" || section === "search") && <QuotaBar />}
|
||||||
<nav className="module-bar" aria-label="Go to">
|
<nav className="module-bar" aria-label={t("Go to")}>
|
||||||
<ModuleLink href="/mail" icon={<Mail size={20} />} label="Mail" active={section === "mail" || section === "search"} />
|
<ModuleLink href="/mail" icon={<Mail size={20} />} label={t("Mail")} active={section === "mail" || section === "search"} />
|
||||||
<ModuleLink href="/calendar" icon={<Calendar size={20} />} label="Calendar" active={section === "calendar"} />
|
<ModuleLink href="/calendar" icon={<Calendar size={20} />} label={t("Calendar")} active={section === "calendar"} />
|
||||||
<ModuleLink href="/contacts" icon={<Users size={20} />} label="Contacts" active={section === "contacts"} />
|
<ModuleLink href="/contacts" icon={<Users size={20} />} label={t("Contacts")} active={section === "contacts"} />
|
||||||
<ModuleLink href="/files" icon={<FolderOpen size={20} />} label="Files" active={section === "files"} />
|
<ModuleLink href="/files" icon={<FolderOpen size={20} />} label={t("Files")} active={section === "files"} />
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
{/*
|
{/*
|
||||||
@@ -160,11 +161,11 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
{isMobile && (
|
{isMobile && (
|
||||||
<>
|
<>
|
||||||
{(section === "mail" || section === "search") && !location.split("/")[3] && (
|
{(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} />
|
<PenSquare size={24} />
|
||||||
</button>
|
</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" : ""}>
|
<Link href="/mail" className={section === "mail" || section === "search" ? "active" : ""}>
|
||||||
<Mail size={22} />
|
<Mail size={22} />
|
||||||
Mail
|
Mail
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useSession } from "@/store/session";
|
|||||||
import { ApiError } from "@/jmap/client";
|
import { ApiError } from "@/jmap/client";
|
||||||
import { DEFAULT_SOURCE_URL } from "@/lib/source";
|
import { DEFAULT_SOURCE_URL } from "@/lib/source";
|
||||||
import { APP_VERSION } from "@/lib/version";
|
import { APP_VERSION } from "@/lib/version";
|
||||||
|
import { t } from "@/lib/i18n";
|
||||||
|
|
||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
const login = useSession((s) => s.login);
|
const login = useSession((s) => s.login);
|
||||||
@@ -54,7 +55,7 @@ export function LoginPage() {
|
|||||||
<div className="logo">
|
<div className="logo">
|
||||||
<img src="/img/logo.png" alt="" width={120} height={143} />
|
<img src="/img/logo.png" alt="" width={120} height={143} />
|
||||||
<h1 className="notranslate" translate="no">ihasmail</h1>
|
<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>
|
</div>
|
||||||
{error && (
|
{error && (
|
||||||
<div className="error-box mb-16" role="alert">
|
<div className="error-box mb-16" role="alert">
|
||||||
@@ -62,11 +63,11 @@ export function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="field">
|
<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 />
|
<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>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label htmlFor="p">Password</label>
|
<label htmlFor="p">{t("Password")}</label>
|
||||||
<div className="pw-wrap">
|
<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 }} />
|
<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}>
|
<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>
|
</div>
|
||||||
<label className="check" style={{ marginBottom: 4 }}>
|
<label className="check" style={{ marginBottom: 4 }}>
|
||||||
<input type="checkbox" checked={trustDevice} onChange={(e) => setTrustDevice(e.target.checked)} />
|
<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>
|
</label>
|
||||||
<p className="hint" style={{ marginBottom: 12 }}>
|
<p className="hint" style={{ marginBottom: 12 }}>
|
||||||
{trustDevice
|
{trustDevice
|
||||||
@@ -100,9 +101,9 @@ export function LoginPage() {
|
|||||||
*/}
|
*/}
|
||||||
<span className="notranslate" translate="no">ihasmail v{APP_VERSION}</span>
|
<span className="notranslate" translate="no">ihasmail v{APP_VERSION}</span>
|
||||||
<br />
|
<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>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+14
-13
@@ -4,6 +4,7 @@ import { Search, SlidersHorizontal, X } from "lucide-react";
|
|||||||
import { useMail } from "@/store/mail";
|
import { useMail } from "@/store/mail";
|
||||||
import { keyboard } from "@/lib/keyboard";
|
import { keyboard } from "@/lib/keyboard";
|
||||||
import { DateField } from "@/ui/datefield";
|
import { DateField } from "@/ui/datefield";
|
||||||
|
import { t } from "@/lib/i18n";
|
||||||
|
|
||||||
export function SearchBar() {
|
export function SearchBar() {
|
||||||
const [location, navigate] = useLocation();
|
const [location, navigate] = useLocation();
|
||||||
@@ -55,31 +56,31 @@ export function SearchBar() {
|
|||||||
<form className="searchbar" role="search" onSubmit={submit}>
|
<form className="searchbar" role="search" onSubmit={submit}>
|
||||||
<div className="search-input">
|
<div className="search-input">
|
||||||
<Search size={18} className="muted" />
|
<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 && (
|
{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} />
|
<X size={16} />
|
||||||
</button>
|
</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} />
|
<SlidersHorizontal size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{adv && (
|
{adv && (
|
||||||
<div className="search-panel">
|
<div className="search-panel">
|
||||||
<div className="grid">
|
<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">{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">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("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">{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">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("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("Folder")}</span>
|
||||||
<select className="select" style={{ height: 32 }} value={advFields.folder} onChange={(e) => setAdvFields({ ...advFields, folder: e.target.value })}>
|
<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>)}
|
{Object.values(mailboxes).sort((a, b) => a.name.localeCompare(b.name)).map((m) => <option key={m.id} value={m.name}>{m.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<div className="field"><span className="label">Date</span>
|
<div className="field"><span className="label">{t("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="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>
|
</div>
|
||||||
<div className="row" style={{ justifyContent: "space-between", marginTop: 4 }}>
|
<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>
|
<label className="check"><input type="checkbox" checked={advFields.unread} onChange={(e) => setAdvFields({ ...advFields, unread: e.target.checked })} /> Unread only</label>
|
||||||
</div>
|
</div>
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<button type="button" className="btn btn-ghost" onClick={() => setAdv(false)}>Cancel</button>
|
<button type="button" className="btn btn-ghost" onClick={() => setAdv(false)}>{t("Cancel")}</button>
|
||||||
<button type="button" className="btn btn-primary" onClick={applyAdvanced}>Search</button>
|
<button type="button" className="btn btn-primary" onClick={applyAdvanced}>{t("Search")}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useMail } from "@/store/mail";
|
|||||||
import { useCompose } from "@/store/compose";
|
import { useCompose } from "@/store/compose";
|
||||||
import { Dialog } from "@/ui/dialog";
|
import { Dialog } from "@/ui/dialog";
|
||||||
import { Kbd } from "@/ui/misc";
|
import { Kbd } from "@/ui/misc";
|
||||||
|
import { t } from "@/lib/i18n";
|
||||||
|
|
||||||
export function useGlobalShortcuts({ onHelp }: { onHelp: () => void }) {
|
export function useGlobalShortcuts({ onHelp }: { onHelp: () => void }) {
|
||||||
const [, navigate] = useLocation();
|
const [, navigate] = useLocation();
|
||||||
@@ -41,7 +42,7 @@ export function ShortcutsDialog({ open, onClose }: { open: boolean; onClose: ()
|
|||||||
return [...g.entries()];
|
return [...g.entries()];
|
||||||
}, [list]);
|
}, [list]);
|
||||||
return (
|
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">
|
<div className="shortcut-grid">
|
||||||
{groups.map(([group, items]) => (
|
{groups.map(([group, items]) => (
|
||||||
<div key={group}>
|
<div key={group}>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { toast } from "@/ui/toast";
|
|||||||
import { askDeleteScope, askEditScope, droppedMessage, runScoped } from "./scope";
|
import { askDeleteScope, askEditScope, droppedMessage, runScoped } from "./scope";
|
||||||
import { toLocalDateOnly } from "@/lib/dates";
|
import { toLocalDateOnly } from "@/lib/dates";
|
||||||
import { formatTime } from "@/lib/format";
|
import { formatTime } from "@/lib/format";
|
||||||
|
import { t } from "@/lib/i18n";
|
||||||
|
|
||||||
export type CalendarContext =
|
export type CalendarContext =
|
||||||
| { kind: "event"; inst: EventInstance; anchor: Anchor }
|
| { kind: "event"; inst: EventInstance; anchor: Anchor }
|
||||||
@@ -50,10 +51,10 @@ export function CalendarContextMenu({ ctx, onClose, onOpen, onEdit, onCreate }:
|
|||||||
return (
|
return (
|
||||||
<Popover anchor={ctx.anchor} onClose={onClose} width={240}>
|
<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)} />
|
<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 />
|
<MenuSep />
|
||||||
<MenuItem icon={<CalIcon size={16} />} label="Go to day" onClick={() => navigate(`/calendar/day/${toLocalDateOnly(start)}`)} />
|
<MenuItem icon={<CalIcon size={16} />} label={t("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 week")} onClick={() => navigate(`/calendar/week/${toLocalDateOnly(start)}`)} />
|
||||||
</Popover>
|
</Popover>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -109,9 +110,9 @@ export function CalendarContextMenu({ ctx, onClose, onOpen, onEdit, onCreate }:
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover anchor={ctx.anchor} onClose={onClose} width={260} closeOnClick={false}>
|
<Popover anchor={ctx.anchor} onClose={onClose} width={260} closeOnClick={false}>
|
||||||
<MenuItem icon={<ExternalLink size={16} />} label="Open" onClick={() => { onClose(); onOpen(inst, ctx.anchor); }} />
|
<MenuItem icon={<ExternalLink size={16} />} label={t("Open")} onClick={() => { onClose(); onOpen(inst, ctx.anchor); }} />
|
||||||
{canEdit && <MenuItem icon={<Pencil size={16} />} label="Edit…" onClick={() => { onClose(); onEdit(inst); }} />}
|
{canEdit && <MenuItem icon={<Pencil size={16} />} label={t("Edit…")} onClick={() => { onClose(); onEdit(inst); }} />}
|
||||||
{canEdit && <MenuItem icon={<Copy size={16} />} label="Duplicate" onClick={() => { onClose(); void duplicate(); }} />}
|
{canEdit && <MenuItem icon={<Copy size={16} />} label={t("Duplicate")} onClick={() => { onClose(); void duplicate(); }} />}
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<>
|
<>
|
||||||
<MenuSep />
|
<MenuSep />
|
||||||
@@ -119,8 +120,8 @@ export function CalendarContextMenu({ ctx, onClose, onOpen, onEdit, onCreate }:
|
|||||||
{categories.map((c) => (
|
{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 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={<X size={16} />} label={t("No category")} disabled={!currentCat} onClick={() => { onClose(); setCategory(null); }} />
|
||||||
<MenuItem icon={<Tag size={16} />} label="Manage categories…" onClick={() => { onClose(); navigate("/settings/calendar"); }} />
|
<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
|
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
|
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
|
another client — would otherwise ignore its category for ever with
|
||||||
nothing on the menu to say why.
|
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 />
|
<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>
|
</Popover>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Dialog } from "@/ui/dialog";
|
|||||||
import { ColorSwatches } from "@/ui/misc";
|
import { ColorSwatches } from "@/ui/misc";
|
||||||
import { toast } from "@/ui/toast";
|
import { toast } from "@/ui/toast";
|
||||||
import { browserTimeZone, listTimeZones } from "@/lib/dates";
|
import { browserTimeZone, listTimeZones } from "@/lib/dates";
|
||||||
|
import { t as translate } from "@/lib/i18n";
|
||||||
|
|
||||||
export function CalendarDialog({ calendar, onClose }: { calendar: Partial<Calendar>; onClose: () => void }) {
|
export function CalendarDialog({ calendar, onClose }: { calendar: Partial<Calendar>; onClose: () => void }) {
|
||||||
const cal = useCalendar();
|
const cal = useCalendar();
|
||||||
@@ -30,21 +31,21 @@ export function CalendarDialog({ calendar, onClose }: { calendar: Partial<Calend
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
return (
|
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></>}>
|
<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>Name</label><input className="input" autoFocus value={name} onChange={(e) => setName(e.target.value)} /></div>
|
<div className="field"><label>{translate("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>{translate("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>{translate("Description")}</label><input className="input" value={description} onChange={(e) => setDescription(e.target.value)} /></div>
|
||||||
<div className="field"><label>Time zone</label>
|
<div className="field"><label>{translate("Time zone")}</label>
|
||||||
<select className="select" value={tz} onChange={(e) => setTz(e.target.value)}>
|
<select className="select" value={tz} onChange={(e) => setTz(e.target.value)}>
|
||||||
<option value="">Default ({browserTimeZone})</option>
|
<option value="">Default ({browserTimeZone})</option>
|
||||||
{listTimeZones().map((t) => <option key={t} value={t}>{t}</option>)}
|
{listTimeZones().map((t) => <option key={t} value={t}>{t}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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"])}>
|
<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="all">{translate("Count all events as busy")}</option>
|
||||||
<option value="attending">Only events I'm attending</option>
|
<option value="attending">{translate("Only events I'm attending")}</option>
|
||||||
<option value="none">Don't include in availability</option>
|
<option value="none">{translate("Don't include in availability")}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { toast } from "@/ui/toast";
|
|||||||
import type { Calendar } from "@/jmap/types";
|
import type { Calendar } from "@/jmap/types";
|
||||||
import { CalendarDialog } from "./CalendarDialog";
|
import { CalendarDialog } from "./CalendarDialog";
|
||||||
import { ShareDialog } from "../settings/ShareDialog";
|
import { ShareDialog } from "../settings/ShareDialog";
|
||||||
|
import { t } from "@/lib/i18n";
|
||||||
|
|
||||||
export function CalendarSidebar() {
|
export function CalendarSidebar() {
|
||||||
const [location, navigate] = useLocation();
|
const [location, navigate] = useLocation();
|
||||||
@@ -45,9 +46,9 @@ export function CalendarSidebar() {
|
|||||||
<div style={{ padding: "4px 8px" }}>
|
<div style={{ padding: "4px 8px" }}>
|
||||||
<div className="mini-cal">
|
<div className="mini-cal">
|
||||||
<div className="mc-head">
|
<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>
|
<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>
|
||||||
<div className="mc-grid">
|
<div className="mc-grid">
|
||||||
{dow.map((d, i) => <div key={i} className="mc-dow">{d}</div>)}
|
{dow.map((d, i) => <div key={i} className="mc-dow">{d}</div>)}
|
||||||
@@ -59,16 +60,16 @@ export function CalendarSidebar() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="nav-section" style={{ paddingLeft: 4 }}>
|
<div className="nav-section" style={{ paddingLeft: 4 }}>
|
||||||
<span>My calendars</span>
|
<span>{t("My calendars")}</span>
|
||||||
<button className="icon-btn" title="New calendar" onClick={() => setEditCal({})}><Plus size={16} /></button>
|
<button className="icon-btn" title={t("New calendar")} onClick={() => setEditCal({})}><Plus size={16} /></button>
|
||||||
</div>
|
</div>
|
||||||
{calendars.map((c) => (
|
{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); }}>
|
<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-color" style={{ background: c.color ?? "var(--accent)", borderColor: c.color ?? "var(--accent)" }} />
|
||||||
<span className="cal-name">{c.name}</span>
|
<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" />}
|
{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>
|
</div>
|
||||||
))}
|
))}
|
||||||
{/* Calendars other people shared, split by whether the reader has added
|
{/* 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. */}
|
and adding one is a deliberate act rather than a guess on our part. */}
|
||||||
{sharedSubscribed.length > 0 && (
|
{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 }) => {
|
{sharedSubscribed.map(({ accountId, accountName, calendar: c }) => {
|
||||||
const key = `${accountId}:${c.id}`;
|
const key = `${accountId}:${c.id}`;
|
||||||
return (
|
return (
|
||||||
@@ -87,8 +88,8 @@ export function CalendarSidebar() {
|
|||||||
<span className="cal-name">{c.name}</span>
|
<span className="cal-name">{c.name}</span>
|
||||||
<button
|
<button
|
||||||
className="icon-btn xs nav-more"
|
className="icon-btn xs nav-more"
|
||||||
title="Remove from my calendar"
|
title={t("Remove from my calendar")}
|
||||||
aria-label="Remove from my calendar"
|
aria-label={t("Remove from my calendar")}
|
||||||
onClick={(e) => { e.stopPropagation(); void cal.setSharedSubscribed(accountId, c.id, false); }}
|
onClick={(e) => { e.stopPropagation(); void cal.setSharedSubscribed(accountId, c.id, false); }}
|
||||||
>
|
>
|
||||||
<X size={14} />
|
<X size={14} />
|
||||||
@@ -100,15 +101,15 @@ export function CalendarSidebar() {
|
|||||||
)}
|
)}
|
||||||
{sharedAvailable.length > 0 && (
|
{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 }) => (
|
{sharedAvailable.map(({ accountId, accountName, calendar: c }) => (
|
||||||
<div key={`${accountId}:${c.id}`} className="cal-list-item" title={`${c.name} — from ${accountName}`}>
|
<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-color" style={{ background: "transparent", borderColor: c.color ?? "var(--border-strong)" }} />
|
||||||
<span className="cal-name faint">{c.name}</span>
|
<span className="cal-name faint">{c.name}</span>
|
||||||
<button
|
<button
|
||||||
className="icon-btn xs nav-more"
|
className="icon-btn xs nav-more"
|
||||||
title="Add to my calendar"
|
title={t("Add to my calendar")}
|
||||||
aria-label="Add to my calendar"
|
aria-label={t("Add to my calendar")}
|
||||||
onClick={(e) => { e.stopPropagation(); void cal.setSharedSubscribed(accountId, c.id, true); }}
|
onClick={(e) => { e.stopPropagation(); void cal.setSharedSubscribed(accountId, c.id, true); }}
|
||||||
>
|
>
|
||||||
<Plus size={14} />
|
<Plus size={14} />
|
||||||
@@ -122,15 +123,15 @@ export function CalendarSidebar() {
|
|||||||
{menuCal && (
|
{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={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={<Pencil size={16} />} label={t("Edit")} onClick={() => setEditCal(menuCal)} />
|
||||||
<MenuItem icon={<Share2 size={16} />} label="Share…" onClick={() => setShare(menuCal)} disabled={!menuCal.myRights.mayShare} />
|
<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
|
{/* Revoking every share at once, without walking the dialog and
|
||||||
removing people one at a time. Only offered when there is
|
removing people one at a time. Only offered when there is
|
||||||
something to revoke. */}
|
something to revoke. */}
|
||||||
{Object.keys(menuCal.shareWith ?? {}).length > 0 && (
|
{Object.keys(menuCal.shareWith ?? {}).length > 0 && (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
icon={<UserMinus size={16} />}
|
icon={<UserMinus size={16} />}
|
||||||
label="Stop sharing"
|
label={t("Stop sharing")}
|
||||||
disabled={!menuCal.myRights.mayShare}
|
disabled={!menuCal.myRights.mayShare}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const who = Object.keys(menuCal.shareWith ?? {}).length;
|
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 />
|
<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>
|
</Popover>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { EventPopover } from "./EventPopover";
|
|||||||
import { EventEditor, type EditorInit } from "./EventEditor";
|
import { EventEditor, type EditorInit } from "./EventEditor";
|
||||||
import type { Anchor } from "@/ui/popover";
|
import type { Anchor } from "@/ui/popover";
|
||||||
import { CalendarContextMenu, eventColor, type CalendarContext } from "./CalendarContextMenu";
|
import { CalendarContextMenu, eventColor, type CalendarContext } from "./CalendarContextMenu";
|
||||||
|
import { t as translate } from "@/lib/i18n";
|
||||||
|
|
||||||
type View = "month" | "week" | "day" | "agenda";
|
type View = "month" | "week" | "day" | "agenda";
|
||||||
const HOUR_H = 48;
|
const HOUR_H = 48;
|
||||||
@@ -91,7 +92,7 @@ export function CalendarView({ view: viewParam, date }: { view?: string; date?:
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!cal.available) {
|
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 =
|
const title =
|
||||||
@@ -118,9 +119,9 @@ export function CalendarView({ view: viewParam, date }: { view?: string; date?:
|
|||||||
return (
|
return (
|
||||||
<div className="cal-main">
|
<div className="cal-main">
|
||||||
<div className="cal-toolbar">
|
<div className="cal-toolbar">
|
||||||
<button className="btn btn-sm" onClick={() => go(view, new Date())}>Today</button>
|
<button className="btn btn-sm" onClick={() => go(view, new Date())}>{translate("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={translate("Previous")}><ChevronLeft size={18} /></button>
|
||||||
<button className="icon-btn sm" onClick={() => step(1)} aria-label="Next"><ChevronRight size={18} /></button>
|
<button className="icon-btn sm" onClick={() => step(1)} aria-label={translate("Next")}><ChevronRight size={18} /></button>
|
||||||
<h2 className="truncate">{title}</h2>
|
<h2 className="truncate">{title}</h2>
|
||||||
<span className="spacer" />
|
<span className="spacer" />
|
||||||
{cal.loading && <span className="spinner" />}
|
{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 === "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} />}
|
{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); }} />}
|
{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); }} />}
|
{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)} />}
|
{editor && <EventEditor init={editor} onClose={() => setEditor(null)} />}
|
||||||
</div>
|
</div>
|
||||||
@@ -250,7 +251,7 @@ function TimeGrid({ days, onEvent, onEventContext, onSlotContext, onCreate, onDa
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="week-allday">
|
<div className="week-allday">
|
||||||
<div className="ad-label">all-day</div>
|
<div className="ad-label">{translate("all-day")}</div>
|
||||||
{days.map((d) => (
|
{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)}>
|
<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)} />)}
|
{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) && (
|
{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-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 className="ev-time">{formatTime(new Date(d.getTime() + drag.startMin * 60_000))} – {formatTime(new Date(d.getTime() + drag.endMin * 60_000))}</div>
|
||||||
</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());
|
return [...map.values()].sort((a, b) => a.day.getTime() - b.day.getTime());
|
||||||
}, [instances, start, end]);
|
}, [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 (
|
return (
|
||||||
<div className="agenda">
|
<div className="agenda">
|
||||||
{byDay.map(({ day, items }) => (
|
{byDay.map(({ day, items }) => (
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { formatClock, formatNumericDate, formatWeekday } from "@/lib/datetime";
|
|||||||
import { WEEKDAYS, describeRule, presetFor, ruleFromPreset, type RecurrencePreset } from "@/lib/recurrence";
|
import { WEEKDAYS, describeRule, presetFor, ruleFromPreset, type RecurrencePreset } from "@/lib/recurrence";
|
||||||
import { newKey } from "@/lib/contacts";
|
import { newKey } from "@/lib/contacts";
|
||||||
import { askEditScope, droppedMessage, runScoped } from "./scope";
|
import { askEditScope, droppedMessage, runScoped } from "./scope";
|
||||||
|
import { t as translate } from "@/lib/i18n";
|
||||||
|
|
||||||
export interface EditorInit {
|
export interface EditorInit {
|
||||||
event?: CalendarEvent;
|
event?: CalendarEvent;
|
||||||
@@ -258,7 +259,7 @@ function EventForm({ init, base, scope, editing, onClose, settingsTz, defaultAle
|
|||||||
}, [start]);
|
}, [start]);
|
||||||
|
|
||||||
return (
|
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">
|
<div className="event-form">
|
||||||
{ev && isRecurring(ev) && (
|
{ev && isRecurring(ev) && (
|
||||||
<div className="info-box mb-16">
|
<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."}
|
: "This is a recurring event — changes apply to the whole series."}
|
||||||
</div>
|
</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">
|
<div className="time-row mb-8">
|
||||||
{allDay ? (
|
{allDay ? (
|
||||||
<>
|
<>
|
||||||
<DateField aria-label="Starts" value={toLocalDateOnly(start)} onChange={(v) => v && onStartChange(new Date(`${v}T00:00:00`))} />
|
<DateField aria-label={translate("Starts")} value={toLocalDateOnly(start)} onChange={(v) => v && onStartChange(new Date(`${v}T00:00:00`))} />
|
||||||
<span className="muted center">to</span>
|
<span className="muted center">{translate("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("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))} />
|
<DateTimeField aria-label={translate("Starts")} value={toInputDateTime(start)} onChange={(v) => v && onStartChange(fromInputDateTime(v))} />
|
||||||
<span className="muted center">to</span>
|
<span className="muted center">{translate("to")}</span>
|
||||||
<DateTimeField aria-label="Ends" value={toInputDateTime(end)} onChange={(v) => v && setEnd(fromInputDateTime(v))} />
|
<DateTimeField aria-label={translate("Ends")} value={toInputDateTime(end)} onChange={(v) => v && setEnd(fromInputDateTime(v))} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="row wrap" style={{ gap: 16, marginBottom: 8 }}>
|
<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>
|
<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 && (
|
{!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().includes(tz) && <option value={tz}>{tz}</option>}
|
||||||
{listTimeZones().map((t) => <option key={t} value={t}>{t}</option>)}
|
{listTimeZones().map((t) => <option key={t} value={t}>{t}</option>)}
|
||||||
</select>
|
</select>
|
||||||
)}
|
)}
|
||||||
{!oneDate && (
|
{!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)); }}>
|
<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="none">{translate("Does not repeat")}</option>
|
||||||
<option value="daily">Daily</option>
|
<option value="daily">{translate("Daily")}</option>
|
||||||
<option value="weekly">Weekly on {formatWeekday(start, "long")}</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="monthly">Monthly on day {start.getDate()}</option>
|
||||||
<option value="yearly">Yearly</option>
|
<option value="yearly">{translate("Yearly")}</option>
|
||||||
<option value="custom">Custom…</option>
|
<option value="custom">{translate("Custom…")}</option>
|
||||||
</select>
|
</select>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!oneDate && preset === "custom" && (
|
{!oneDate && preset === "custom" && (
|
||||||
<div className="card" style={{ marginBottom: 12 }}>
|
<div className="card" style={{ marginBottom: 12 }}>
|
||||||
<div className="row wrap" style={{ gap: 8 }}>
|
<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)) })} />
|
<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 })}>
|
<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>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
{customRule.frequency === "weekly" && (
|
{customRule.frequency === "weekly" && (
|
||||||
@@ -321,33 +322,33 @@ function EventForm({ init, base, scope, editing, onClose, settingsTz, defaultAle
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="row wrap" style={{ gap: 8, marginTop: 8 }}>
|
<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 }); }}>
|
<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>
|
</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)) })} />}
|
{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>
|
||||||
<div className="hint mt-8">{describeRule(customRule)}</div>
|
<div className="hint mt-8">{describeRule(customRule)}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="field-row">
|
<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)}>
|
<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>)}
|
{calendars.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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>
|
||||||
<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">
|
<div className="field">
|
||||||
<label><Users size={13} /> Guests</label>
|
<label><Users size={13} /> Guests</label>
|
||||||
<div className="input" style={{ height: "auto", minHeight: 38, padding: "4px 8px" }}>
|
<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>
|
</div>
|
||||||
{attendees.length > 0 && (
|
{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 && (
|
{Object.keys(fb).length > 0 && (
|
||||||
<div className="freebusy">
|
<div className="freebusy">
|
||||||
<div className="hint">Availability on {formatNumericDate(start)}</div>
|
<div className="hint">Availability on {formatNumericDate(start)}</div>
|
||||||
@@ -370,16 +371,16 @@ function EventForm({ init, base, scope, editing, onClose, settingsTz, defaultAle
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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">
|
<div className="field">
|
||||||
<label>Reminders</label>
|
<label>{translate("Reminders")}</label>
|
||||||
<div className="alerts-list">
|
<div className="alerts-list">
|
||||||
{alerts.map((m, i) => (
|
{alerts.map((m, i) => (
|
||||||
<div key={i} className="row">
|
<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)))}>
|
<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>)}
|
{[...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>
|
</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>
|
</div>
|
||||||
))}
|
))}
|
||||||
<button className="btn btn-ghost btn-sm" style={{ alignSelf: "flex-start" }} onClick={() => setAlerts([...alerts, 10])}><Plus size={14} /> Add reminder</button>
|
<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 && (
|
{showMore && (
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<div className="field-row">
|
<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>{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>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>
|
<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>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>}
|
{!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>
|
||||||
<div className="field"><label>Category</label>
|
<div className="field"><label>{translate("Category")}</label>
|
||||||
<select className="select" value={category} onChange={(e) => setCategory(e.target.value)}>
|
<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>)}
|
{categories.map((c) => <option key={c.name} value={c.name}>{c.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { describeRule } from "@/lib/recurrence";
|
|||||||
import { useCompose } from "@/store/compose";
|
import { useCompose } from "@/store/compose";
|
||||||
import { useSettings } from "@/store/settings";
|
import { useSettings } from "@/store/settings";
|
||||||
import { categoryOf, eventColor } from "./CalendarContextMenu";
|
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 }) {
|
export function EventPopover({ inst, anchor, onClose, onEdit }: { inst: EventInstance; anchor: Anchor; onClose: () => void; onEdit: () => void }) {
|
||||||
const cal = useCalendar();
|
const cal = useCalendar();
|
||||||
@@ -67,9 +68,9 @@ export function EventPopover({ inst, anchor, onClose, onEdit }: { inst: EventIns
|
|||||||
return (
|
return (
|
||||||
<Popover anchor={anchor} onClose={onClose} className="event-popover" closeOnClick={false} side="right" role="dialog" style={{ "--ev-color": color } as React.CSSProperties}>
|
<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 }}>
|
<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" title={t("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>}
|
{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="Close" onClick={onClose}><X size={16} /></button>
|
<button className="icon-btn sm" title={t("Close")} onClick={onClose}><X size={16} /></button>
|
||||||
</div>
|
</div>
|
||||||
<h3>{ev.title || "(untitled)"}</h3>
|
<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>
|
<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>
|
<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 && (
|
{participants.length > 0 && (
|
||||||
<div className="ev-line" style={{ flexDirection: "column", gap: 2 }}>
|
<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%" }}>
|
<div style={{ paddingLeft: 24, maxHeight: 140, overflow: "auto", width: "100%" }}>
|
||||||
{participants.map(([k, p]) => (
|
{participants.map(([k, p]) => (
|
||||||
<div key={k} className="participant-row">
|
<div key={k} className="participant-row">
|
||||||
<span className={`p-status ${p.participationStatus ?? "needs-action"}`} title={p.participationStatus ?? "needs-action"} />
|
<span className={`p-status ${p.participationStatus ?? "needs-action"}`} title={p.participationStatus ?? "needs-action"} />
|
||||||
<span className="truncate">{p.name || participantEmail(p)}</span>
|
<span className="truncate">{p.name || participantEmail(p)}</span>
|
||||||
{p.roles?.owner && <span className="hint">organizer</span>}
|
{p.roles?.owner && <span className="hint">{t("organizer")}</span>}
|
||||||
{p.roles?.optional && <span className="hint">optional</span>}
|
{p.roles?.optional && <span className="hint">{t("optional")}</span>}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -97,7 +98,7 @@ export function EventPopover({ inst, anchor, onClose, onEdit }: { inst: EventIns
|
|||||||
)}
|
)}
|
||||||
{myKeys.length > 0 && !isOrganizer && (
|
{myKeys.length > 0 && !isOrganizer && (
|
||||||
<div className="row" style={{ marginTop: 10, gap: 6 }}>
|
<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 === "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 === "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>
|
<button className={`btn btn-sm ${myStatus === "declined" ? "btn-danger" : ""}`} disabled={busy} onClick={() => void rsvp("declined")}><X size={14} /> No</button>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { toast } from "@/ui/toast";
|
|||||||
import { ScheduleDialog, ScheduleMenuItems } from "./SchedulePicker";
|
import { ScheduleDialog, ScheduleMenuItems } from "./SchedulePicker";
|
||||||
import { scheduleSupported, scheduleWindowMs } from "@/store/scheduled";
|
import { scheduleSupported, scheduleWindowMs } from "@/store/scheduled";
|
||||||
import { formatScheduleTime } from "@/lib/schedule";
|
import { formatScheduleTime } from "@/lib/schedule";
|
||||||
|
import { t as translate } from "@/lib/i18n";
|
||||||
|
|
||||||
export function Composer({ draft }: { draft: Draft }) {
|
export function Composer({ draft }: { draft: Draft }) {
|
||||||
const update = useCompose((s) => s.update);
|
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 minimized" onClick={() => focus(key)}>
|
||||||
<div className="composer-head">
|
<div className="composer-head">
|
||||||
<span className="title">{title}</span>
|
<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={translate("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("Close")} onClick={(e) => { e.stopPropagation(); void close(key); }}><X size={16} /></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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 })}>
|
<div className="composer-head" onDoubleClick={() => patch({ maximized: !d.maximized })}>
|
||||||
<span className="title">{title}</span>
|
<span className="title">{title}</span>
|
||||||
<span className="status">{status}</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>}
|
{!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>
|
||||||
<div className="composer-body">
|
<div className="composer-body">
|
||||||
<div className="composer-fields">
|
<div className="composer-fields">
|
||||||
{identities.length > 1 && (
|
{identities.length > 1 && (
|
||||||
<div className="composer-field">
|
<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)}>
|
<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>)}
|
{identities.map((i) => <option key={i.id} value={i.id}>{i.name ? `${i.name} <${i.email}>` : i.email}</option>)}
|
||||||
</select>
|
</select>
|
||||||
@@ -179,53 +180,53 @@ export function Composer({ draft }: { draft: Draft }) {
|
|||||||
<label htmlFor={`${key}-to`}>
|
<label htmlFor={`${key}-to`}>
|
||||||
{/* Opens the address books. Autocomplete only helps someone who
|
{/* Opens the address books. Autocomplete only helps someone who
|
||||||
already knows the name they are half-way through typing. */}
|
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>
|
</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">
|
<span className="field-extra">
|
||||||
{/* Beside Cc and Bcc, because that is where someone looks when
|
{/* Beside Cc and Bcc, because that is where someone looks when
|
||||||
they are thinking about who the message goes to. The label
|
they are thinking about who the message goes to. The label
|
||||||
opens it too, for anyone who tries that first. */}
|
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>
|
<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 })}>Cc</button>}
|
{!d.showCc && <button type="button" onClick={() => patch({ showCc: true })}>{translate("Cc")}</button>}
|
||||||
{!d.showBcc && <button type="button" onClick={() => patch({ showBcc: true })}>Bcc</button>}
|
{!d.showBcc && <button type="button" onClick={() => patch({ showBcc: true })}>{translate("Bcc")}</button>}
|
||||||
{!d.showReplyTo && <button type="button" onClick={() => patch({ showReplyTo: true })} title="Set a Reply-To address">Reply-To</button>}
|
{!d.showReplyTo && <button type="button" onClick={() => patch({ showReplyTo: true })} title={translate("Set a Reply-To address")}>{translate("Reply-To")}</button>}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{d.showReplyTo && (
|
{d.showReplyTo && (
|
||||||
<div className="composer-field">
|
<div className="composer-field">
|
||||||
<label htmlFor={`${key}-rt`} title="Replies will go to this address instead of the From address">Reply-To</label>
|
<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="Replies go to…" />
|
<RecipientInput id={`${key}-rt`} value={d.replyTo} onChange={(replyTo) => patch({ replyTo })} placeholder={translate("Replies go to…")} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{d.showCc && (
|
{d.showCc && (
|
||||||
<div className="composer-field">
|
<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 })} />
|
<RecipientInput id={`${key}-cc`} value={d.cc} onChange={(cc) => patch({ cc })} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{d.showBcc && (
|
{d.showBcc && (
|
||||||
<div className="composer-field">
|
<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 })} />
|
<RecipientInput id={`${key}-bcc`} value={d.bcc} onChange={(bcc) => patch({ bcc })} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="composer-field">
|
<div className="composer-field">
|
||||||
<label htmlFor={`${key}-subj`} className="sr-only">Subject</label>
|
<label htmlFor={`${key}-subj`} className="sr-only">{translate("Subject")}</label>
|
||||||
<input id={`${key}-subj`} className="plain" placeholder="Subject" value={d.subject} onChange={(e) => patch({ subject: e.target.value })} autoFocus={initialFocus === "subject"} />
|
<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.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 && (
|
{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} />
|
<Clock size={12} /> {formatScheduleTime(new Date(d.sendAt))} <X size={12} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{d.format === "html" ? (
|
{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) && (
|
{d.attachments.some((a) => !a.inline) && (
|
||||||
<div className="composer-attachments">
|
<div className="composer-attachments">
|
||||||
@@ -236,7 +237,7 @@ export function Composer({ draft }: { draft: Draft }) {
|
|||||||
<span className="att-name">{a.name}</span>
|
<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 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>
|
</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}%` }} />}
|
{!a.blobId && !a.error && <span className="att-progress" style={{ width: `${a.progress}%` }} />}
|
||||||
</div>
|
</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)"}>
|
<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</>}
|
{d.sendAt !== null ? <><Clock size={16} /> Schedule send</> : <><Send size={16} /> Send</>}
|
||||||
</button>
|
</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>
|
</span>
|
||||||
<Popover anchor={sendMenu.anchor} onClose={sendMenu.close} side="top" width={280}>
|
<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(); }} />
|
<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} />
|
<ScheduleDialog open maxMs={scheduleMax} initial={d.sendAt} onClose={() => setScheduleOpen(false)} onPick={scheduleFor} />
|
||||||
)}
|
)}
|
||||||
<span className="more-actions">
|
<span className="more-actions">
|
||||||
<button className="icon-btn" title="Attach files" onClick={() => fileRef.current?.click()}><Paperclip 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="Attach from Files" onClick={() => setPickerOpen(true)}><FolderOpen 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 = ""; }} />
|
<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>}
|
{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="Insert template" onClick={templateMenu.open}><FileText 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}>
|
<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)} />)}
|
{settings.templates.map((t) => <MenuItem key={t.id} label={t.name} onClick={() => insertTemplate(key, t.html, t.subject)} />)}
|
||||||
</Popover>
|
</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}>
|
<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={<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 />
|
<MenuSep />
|
||||||
<MenuTitle>Priority</MenuTitle>
|
<MenuTitle>{translate("Priority")}</MenuTitle>
|
||||||
<MenuItem label="High" checked={d.priority === "high"} onClick={() => patch({ priority: "high" })} />
|
<MenuItem label={translate("High")} checked={d.priority === "high"} onClick={() => patch({ priority: "high" })} />
|
||||||
<MenuItem label="Normal" checked={d.priority === "normal"} onClick={() => patch({ priority: "normal" })} />
|
<MenuItem label={translate("Normal")} checked={d.priority === "normal"} onClick={() => patch({ priority: "normal" })} />
|
||||||
<MenuItem label="Low" checked={d.priority === "low"} onClick={() => patch({ priority: "low" })} />
|
<MenuItem label={translate("Low")} checked={d.priority === "low"} onClick={() => patch({ priority: "low" })} />
|
||||||
<MenuSep />
|
<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={<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="Save draft now" onClick={() => void saveDraft(key)} />
|
<MenuItem icon={<FileText size={16} />} label={translate("Save draft now")} onClick={() => void saveDraft(key)} />
|
||||||
</Popover>
|
</Popover>
|
||||||
</span>
|
</span>
|
||||||
<span className="spacer" />
|
<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>}
|
{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="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>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useFiles } from "@/store/files";
|
|||||||
import type { AttachableFile } from "@/store/compose";
|
import type { AttachableFile } from "@/store/compose";
|
||||||
import type { FileNode } from "@/jmap/types";
|
import type { FileNode } from "@/jmap/types";
|
||||||
import { formatSize } from "@/lib/format";
|
import { formatSize } from "@/lib/format";
|
||||||
|
import { t } from "@/lib/i18n";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pick something already in Files to attach.
|
* Pick something already in Files to attach.
|
||||||
@@ -51,11 +52,11 @@ export function FilePicker({ onPick, onClose }: { onPick: (files: AttachableFile
|
|||||||
<Dialog
|
<Dialog
|
||||||
open
|
open
|
||||||
onClose={close}
|
onClose={close}
|
||||||
title="Attach from Files"
|
title={t("Attach from Files")}
|
||||||
size="md"
|
size="md"
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<button className="btn" onClick={close}>Cancel</button>
|
<button className="btn" onClick={close}>{t("Cancel")}</button>
|
||||||
<button
|
<button
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
disabled={!chosen.length}
|
disabled={!chosen.length}
|
||||||
@@ -95,7 +96,7 @@ export function FilePicker({ onPick, onClose }: { onPick: (files: AttachableFile
|
|||||||
{files.loading && !nodes.length ? (
|
{files.loading && !nodes.length ? (
|
||||||
<Spinner />
|
<Spinner />
|
||||||
) : !nodes.length ? (
|
) : !nodes.length ? (
|
||||||
<p className="hint">This folder is empty.</p>
|
<p className="hint">{t("This folder is empty.")}</p>
|
||||||
) : (
|
) : (
|
||||||
nodes.map((n) =>
|
nodes.map((n) =>
|
||||||
n.nodeType === "directory" ? (
|
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
|
// 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,
|
// be copied into yours before a draft can reference it. Worth saying,
|
||||||
// because it is the difference between instant and a wait.
|
// 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>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useContacts } from "@/store/contacts";
|
|||||||
import { useSettings } from "@/store/settings";
|
import { useSettings } from "@/store/settings";
|
||||||
import { contactDisplayName, contactEmails } from "@/lib/contacts";
|
import { contactDisplayName, contactEmails } from "@/lib/contacts";
|
||||||
import type { ContactCard, EmailAddress } from "@/jmap/types";
|
import type { ContactCard, EmailAddress } from "@/jmap/types";
|
||||||
|
import { t } from "@/lib/i18n";
|
||||||
|
|
||||||
export type Field = "to" | "cc" | "bcc";
|
export type Field = "to" | "cc" | "bcc";
|
||||||
|
|
||||||
@@ -109,13 +110,13 @@ export function RecipientPicker({ onPick, onClose }: { onPick: (field: Field, ad
|
|||||||
<Dialog
|
<Dialog
|
||||||
open
|
open
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
title="Choose recipients"
|
title={t("Choose recipients")}
|
||||||
size="lg"
|
size="lg"
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<button className="btn" onClick={onClose}>Cancel</button>
|
<button className="btn" onClick={onClose}>{t("Cancel")}</button>
|
||||||
<button className="btn" disabled={!chosen.length} onClick={() => send("bcc")}>Bcc</button>
|
<button className="btn" disabled={!chosen.length} onClick={() => send("bcc")}>{t("Bcc")}</button>
|
||||||
<button className="btn" disabled={!chosen.length} onClick={() => send("cc")}>Cc</button>
|
<button className="btn" disabled={!chosen.length} onClick={() => send("cc")}>{t("Cc")}</button>
|
||||||
<button className="btn btn-primary" disabled={!chosen.length} onClick={() => send("to")}>
|
<button className="btn btn-primary" disabled={!chosen.length} onClick={() => send("to")}>
|
||||||
{chosen.length > 1 ? `To — ${chosen.length} people` : "To"}
|
{chosen.length > 1 ? `To — ${chosen.length} people` : "To"}
|
||||||
</button>
|
</button>
|
||||||
@@ -129,14 +130,14 @@ export function RecipientPicker({ onPick, onClose }: { onPick: (field: Field, ad
|
|||||||
<input
|
<input
|
||||||
className="grow"
|
className="grow"
|
||||||
style={{ background: "none", border: 0, outline: "none", color: "inherit", font: "inherit" }}
|
style={{ background: "none", border: 0, outline: "none", color: "inherit", font: "inherit" }}
|
||||||
placeholder="Search names and addresses"
|
placeholder={t("Search names and addresses")}
|
||||||
value={q}
|
value={q}
|
||||||
onChange={(e) => setQ(e.target.value)}
|
onChange={(e) => setQ(e.target.value)}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<select className="select" value={bookKey} onChange={(e) => setBookKey(e.target.value)} aria-label="Address book">
|
<select className="select" value={bookKey} onChange={(e) => setBookKey(e.target.value)} aria-label={t("Address book")}>
|
||||||
<option value="all">All address books</option>
|
<option value="all">{t("All address books")}</option>
|
||||||
{ownBooks.map((b) => <option key={b.id} value={b.id}>{b.name}</option>)}
|
{ownBooks.map((b) => <option key={b.id} value={b.id}>{b.name}</option>)}
|
||||||
{subscribed.map((b) => (
|
{subscribed.map((b) => (
|
||||||
<option key={`${b.accountId}:${b.book.id}`} value={`${b.accountId}:${b.book.id}`}>
|
<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 && (
|
{chosen.length > 0 && (
|
||||||
<div className="row wrap gap-4" style={{ marginBottom: 10 }}>
|
<div className="row wrap gap-4" style={{ marginBottom: 10 }}>
|
||||||
{chosen.map((r) => (
|
{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} />
|
{r.name ?? r.email} <X size={12} />
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -158,7 +159,7 @@ export function RecipientPicker({ onPick, onClose }: { onPick: (field: Field, ad
|
|||||||
|
|
||||||
<div style={{ maxHeight: "48vh", overflowY: "auto" }}>
|
<div style={{ maxHeight: "48vh", overflowY: "auto" }}>
|
||||||
{contacts.loading && !rows.length ? (
|
{contacts.loading && !rows.length ? (
|
||||||
<Spinner label="Loading contacts…" />
|
<Spinner label={t("Loading contacts…")} />
|
||||||
) : !rows.length ? (
|
) : !rows.length ? (
|
||||||
<p className="hint">{q ? "Nobody matches that." : "No contacts in this address book."}</p>
|
<p className="hint">{q ? "Nobody matches that." : "No contacts in this address book."}</p>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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 { 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 { sanitizeEditorHtml } from "@/lib/html";
|
||||||
import { Popover, useMenu } from "@/ui/popover";
|
import { Popover, useMenu } from "@/ui/popover";
|
||||||
|
import { t as translate } from "@/lib/i18n";
|
||||||
|
|
||||||
export interface RichEditorHandle {
|
export interface RichEditorHandle {
|
||||||
focus(): void;
|
focus(): void;
|
||||||
@@ -218,46 +219,46 @@ export const RichEditor = forwardRef<RichEditorHandle, Props>(function RichEdito
|
|||||||
}}
|
}}
|
||||||
role="textbox"
|
role="textbox"
|
||||||
aria-multiline="true"
|
aria-multiline="true"
|
||||||
aria-label="Message body"
|
aria-label={translate("Message body")}
|
||||||
/>
|
/>
|
||||||
{showToolbar && (
|
{showToolbar && (
|
||||||
<div className="editor-toolbar" role="toolbar" aria-label="Formatting">
|
<div className="editor-toolbar" role="toolbar" aria-label={translate("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={translate("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>
|
<button type="button" className="icon-btn" title={translate("Redo")} onMouseDown={(e) => e.preventDefault()} onClick={() => exec("redo")}><Redo size={16} /></button>
|
||||||
<span className="tb-sep" />
|
<span className="tb-sep" />
|
||||||
<select title="Font size" onMouseDown={saveRange} onChange={(e) => { exec("fontSize", e.target.value); e.target.value = ""; }} defaultValue="">
|
<select title={translate("Font size")} onMouseDown={saveRange} onChange={(e) => { exec("fontSize", e.target.value); e.target.value = ""; }} defaultValue="">
|
||||||
<option value="" disabled>Size</option>
|
<option value="" disabled>{translate("Size")}</option>
|
||||||
<option value="1">Small</option>
|
<option value="1">{translate("Small")}</option>
|
||||||
<option value="3">Normal</option>
|
<option value="3">{translate("Normal")}</option>
|
||||||
<option value="5">Large</option>
|
<option value="5">{translate("Large")}</option>
|
||||||
<option value="7">Huge</option>
|
<option value="7">{translate("Huge")}</option>
|
||||||
</select>
|
</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={translate("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={translate("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={translate("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={translate("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={translate("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("Highlight")} onMouseDown={(e) => { e.preventDefault(); saveRange(); }} onClick={hiliteMenu.open}><Highlighter size={16} /></button>
|
||||||
<span className="tb-sep" />
|
<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={translate("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={translate("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 right")} onMouseDown={(e) => e.preventDefault()} onClick={() => exec("justifyRight")}><AlignRight size={16} /></button>
|
||||||
<span className="tb-sep" />
|
<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={translate("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={translate("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={translate("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={translate("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={translate("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={translate("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("Normal text")} onMouseDown={(e) => e.preventDefault()} onClick={() => exec("formatBlock", "div")}><Type size={16} /></button>
|
||||||
<span className="tb-sep" />
|
<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>
|
<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="Insert image" onMouseDown={saveRange}>
|
<label className="icon-btn" title={translate("Insert image")} onMouseDown={saveRange}>
|
||||||
<ImageIcon size={16} />
|
<ImageIcon size={16} />
|
||||||
<input type="file" accept="image/*" hidden onChange={(e) => { const f = e.target.files?.[0]; if (f) insertImageFile(f); e.target.value = ""; }} />
|
<input type="file" accept="image/*" hidden onChange={(e) => { const f = e.target.files?.[0]; if (f) insertImageFile(f); e.target.value = ""; }} />
|
||||||
</label>
|
</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={translate("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("Remove formatting")} onMouseDown={(e) => e.preventDefault()} onClick={() => { exec("removeFormat"); exec("unlink"); }}><Eraser size={16} /></button>
|
||||||
{toolbarExtra}
|
{toolbarExtra}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -280,8 +281,8 @@ export const RichEditor = forwardRef<RichEditorHandle, Props>(function RichEdito
|
|||||||
</Popover>
|
</Popover>
|
||||||
<Popover anchor={linkMenu.anchor} onClose={linkMenu.close} side="top" closeOnClick={false} width={320}>
|
<Popover anchor={linkMenu.anchor} onClose={linkMenu.close} side="top" closeOnClick={false} width={320}>
|
||||||
<form className="link-popup" onSubmit={(e) => { e.preventDefault(); applyLink(); }}>
|
<form className="link-popup" onSubmit={(e) => { e.preventDefault(); applyLink(); }}>
|
||||||
<input className="input sm" autoFocus placeholder="https://…" value={linkUrl} onChange={(e) => setLinkUrl(e.target.value)} />
|
<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">Link</button>
|
<button type="submit" className="btn btn-sm btn-primary">{translate("Link")}</button>
|
||||||
</form>
|
</form>
|
||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { DateTimeField } from "@/ui/datefield";
|
|||||||
import { MenuItem, MenuSep, MenuTitle } from "@/ui/popover";
|
import { MenuItem, MenuSep, MenuTitle } from "@/ui/popover";
|
||||||
import { describeSpan, formatScheduleTime, schedulePresets, scheduleError } from "@/lib/schedule";
|
import { describeSpan, formatScheduleTime, schedulePresets, scheduleError } from "@/lib/schedule";
|
||||||
import { toInputDateTime, fromInputDateTime, roundToNext } from "@/lib/dates";
|
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
|
* 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<MenuSep />
|
<MenuSep />
|
||||||
<MenuTitle>Schedule send</MenuTitle>
|
<MenuTitle>{t("Schedule send")}</MenuTitle>
|
||||||
{presets.map((p) => (
|
{presets.map((p) => (
|
||||||
<MenuItem key={p.id} icon={<Clock size={16} />} label={p.label} kbd={formatScheduleTime(p.at)} onClick={() => onPick(p.at)} />
|
<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
|
<Dialog
|
||||||
open={open}
|
open={open}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
title="Schedule send"
|
title={t("Schedule send")}
|
||||||
size="sm"
|
size="sm"
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<button className="btn" onClick={onClose}>Cancel</button>
|
<button className="btn" onClick={onClose}>{t("Cancel")}</button>
|
||||||
<button className="btn btn-primary" disabled={Boolean(error)} onClick={() => onPick(at)}>Schedule send</button>
|
<button className="btn btn-primary" disabled={Boolean(error)} onClick={() => onPick(at)}>{t("Schedule send")}</button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label htmlFor="schedule-at">Send at</label>
|
<label htmlFor="schedule-at">{t("Send at")}</label>
|
||||||
<DateTimeField id="schedule-at" value={value} onChange={setValue} aria-label="Date and time to send" />
|
<DateTimeField id="schedule-at" value={value} onChange={setValue} aria-label={t("Date and time to send")} />
|
||||||
</div>
|
</div>
|
||||||
{error ? (
|
{error ? (
|
||||||
<p className="hint" style={{ color: "var(--danger)" }}>{error}</p>
|
<p className="hint" style={{ color: "var(--danger)" }}>{error}</p>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Dialog } from "@/ui/dialog";
|
|||||||
import { DateField } from "@/ui/datefield";
|
import { DateField } from "@/ui/datefield";
|
||||||
import { toast } from "@/ui/toast";
|
import { toast } from "@/ui/toast";
|
||||||
import { client } from "@/jmap/client";
|
import { client } from "@/jmap/client";
|
||||||
|
import { t } from "@/lib/i18n";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
card: Partial<ContactCard>;
|
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);
|
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 (
|
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="contact-form">
|
||||||
<div className="row" style={{ gap: 16, marginBottom: 12 }}>
|
<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} />}
|
{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 = ""; }} />
|
<input type="file" accept="image/*" hidden onChange={(e) => { const f = e.target.files?.[0]; if (f) onPhoto(f); e.target.value = ""; }} />
|
||||||
</label>
|
</label>
|
||||||
{photoSrc && <button className="btn btn-ghost btn-sm" onClick={() => { setPhoto(null); setRemovePhoto(true); }}><X size={14} /> Remove photo</button>}
|
{photoSrc && <button className="btn btn-ghost btn-sm" onClick={() => { setPhoto(null); setRemovePhoto(true); }}><X size={14} /> Remove photo</button>}
|
||||||
<span className="spacer" />
|
<span className="spacer" />
|
||||||
<div className="field" style={{ marginBottom: 0, width: 160 }}>
|
<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)}>
|
<select className="select" value={kind} onChange={(e) => setKind(e.target.value as typeof kind)}>
|
||||||
<option value="individual">Person</option>
|
<option value="individual">{t("Person")}</option>
|
||||||
<option value="org">Organization</option>
|
<option value="org">{t("Organization")}</option>
|
||||||
<option value="group">Group</option>
|
<option value="group">{t("Group")}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="field" style={{ marginBottom: 0, width: 200 }}>
|
<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)}>
|
<select className="select" value={bookId} onChange={(e) => setBookId(e.target.value)}>
|
||||||
{books.map((b) => <option key={b.id} value={b.id}>{b.name}</option>)}
|
{books.map((b) => <option key={b.id} value={b.id}>{b.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
@@ -182,21 +183,21 @@ export function ContactEditor({ card, defaultBookId, onClose, onSaved }: Props)
|
|||||||
{kind === "individual" ? (
|
{kind === "individual" ? (
|
||||||
<>
|
<>
|
||||||
<div className="field-row">
|
<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>{t("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("Last name")}</label><input className="input" value={surname} onChange={(e) => setSurname(e.target.value)} /></div>
|
||||||
</div>
|
</div>
|
||||||
<details>
|
<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-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>{t("Prefix")}</label><input className="input" value={prefix} onChange={(e) => setPrefix(e.target.value)} placeholder={t("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>{t("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>{t("Suffix")}</label><input className="input" value={suffix} onChange={(e) => setSuffix(e.target.value)} placeholder={t("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("Nickname")}</label><input className="input" value={nickname} onChange={(e) => setNickname(e.target.value)} /></div>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
<div className="field-row">
|
<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>{t("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("Job title")}</label><input className="input" value={jobTitle} onChange={(e) => setJobTitle(e.target.value)} /></div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -205,7 +206,7 @@ export function ContactEditor({ card, defaultBookId, onClose, onSaved }: Props)
|
|||||||
|
|
||||||
{kind === "group" && (
|
{kind === "group" && (
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>Members</label>
|
<label>{t("Members")}</label>
|
||||||
<div className="row wrap gap-4 mb-8">
|
<div className="row wrap gap-4 mb-8">
|
||||||
{memberUids.map((uid) => {
|
{memberUids.map((uid) => {
|
||||||
const m = Object.values(contacts.cards).find((x) => x.uid === 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>
|
||||||
<div style={{ position: "relative" }}>
|
<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 && (
|
{memberCandidates.length > 0 && (
|
||||||
<div className="suggest-list" style={{ width: "100%" }}>
|
<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>)}
|
{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">
|
<div className="field">
|
||||||
<label>Email</label>
|
<label>{t("Email")}</label>
|
||||||
<div className="multi">
|
<div className="multi">
|
||||||
{emails.map((e, i) => (
|
{emails.map((e, i) => (
|
||||||
<div key={e.key} className="multi-row">
|
<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>
|
<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>
|
</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>
|
<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>
|
</div>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>Phone</label>
|
<label>{t("Phone")}</label>
|
||||||
<div className="multi">
|
<div className="multi">
|
||||||
{phones.map((p, i) => (
|
{phones.map((p, i) => (
|
||||||
<div key={p.key} className="multi-row">
|
<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)))} />
|
<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>
|
<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>
|
</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>
|
<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>
|
</div>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>Address</label>
|
<label>{t("Address")}</label>
|
||||||
<div className="multi">
|
<div className="multi">
|
||||||
{addrs.map((a, i) => (
|
{addrs.map((a, i) => (
|
||||||
<div key={a.key} className="card" style={{ marginBottom: 0 }}>
|
<div key={a.key} className="card" style={{ marginBottom: 0 }}>
|
||||||
<div className="row mb-8">
|
<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>
|
<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" />
|
<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>
|
||||||
<div className="addr-grid">
|
<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" 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="City" value={a.city} onChange={(ev) => setAddrs(addrs.map((x, j) => (j === i ? { ...x, city: 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="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("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={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="Country" value={a.country} onChange={(ev) => setAddrs(addrs.map((x, j) => (j === i ? { ...x, country: 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>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -272,10 +273,10 @@ export function ContactEditor({ card, defaultBookId, onClose, onSaved }: Props)
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="field-row">
|
<div className="field-row">
|
||||||
<div className="field"><label>Birthday</label><DateField aria-label="Birthday" value={birthday} onChange={setBirthday} /></div>
|
<div className="field"><label>{t("Birthday")}</label><DateField aria-label={t("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("Website")}</label><input className="input" value={website} onChange={(e) => setWebsite(e.target.value)} placeholder={t("https://")} /></div>
|
||||||
</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>
|
</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
|
|||||||
import { confirmDialog, promptDialog } from "@/ui/dialog";
|
import { confirmDialog, promptDialog } from "@/ui/dialog";
|
||||||
import { toast } from "@/ui/toast";
|
import { toast } from "@/ui/toast";
|
||||||
import { ShareDialog } from "../settings/ShareDialog";
|
import { ShareDialog } from "../settings/ShareDialog";
|
||||||
|
import { t } from "@/lib/i18n";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Re-read the session so newly shared books appear without a sign-in.
|
* Re-read the session so newly shared books appear without a sign-in.
|
||||||
@@ -69,18 +70,18 @@ export function ContactsSidebar() {
|
|||||||
|
|
||||||
return (
|
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" })}>
|
<div className={`nav-item ${isOn(null, "all") ? "active" : ""}`} onClick={() => contacts.select({ accountId: null, bookId: "all" })}>
|
||||||
<Users size={17} />
|
<Users size={17} />
|
||||||
<span className="grow truncate">All contacts</span>
|
<span className="grow truncate">{t("All contacts")}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="nav-section">
|
<div className="nav-section">
|
||||||
<span>My address books</span>
|
<span>{t("My address books")}</span>
|
||||||
<button
|
<button
|
||||||
className="icon-btn sm"
|
className="icon-btn sm"
|
||||||
title="New address book"
|
title={t("New address book")}
|
||||||
aria-label="New address book"
|
aria-label={t("New address book")}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const name = await promptDialog({ title: "New address book", placeholder: "Name" });
|
const name = await promptDialog({ title: "New address book", placeholder: "Name" });
|
||||||
if (!name?.trim()) return;
|
if (!name?.trim()) return;
|
||||||
@@ -103,16 +104,16 @@ export function ContactsSidebar() {
|
|||||||
>
|
>
|
||||||
<Book size={17} />
|
<Book size={17} />
|
||||||
<span className="grow truncate">{b.name}</span>
|
<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>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<div className="nav-section">
|
<div className="nav-section">
|
||||||
<span>Shared with me</span>
|
<span>{t("Shared with me")}</span>
|
||||||
<button
|
<button
|
||||||
className="icon-btn sm"
|
className="icon-btn sm"
|
||||||
title="Check for new shares"
|
title={t("Check for new shares")}
|
||||||
aria-label="Check for new shares"
|
aria-label={t("Check for new shares")}
|
||||||
onClick={async () => { setRefreshing(true); await refreshShares(true); setRefreshing(false); }}
|
onClick={async () => { setRefreshing(true); await refreshShares(true); setRefreshing(false); }}
|
||||||
>
|
>
|
||||||
<RefreshCw size={14} className={refreshing ? "spin" : ""} />
|
<RefreshCw size={14} className={refreshing ? "spin" : ""} />
|
||||||
@@ -129,8 +130,8 @@ export function ContactsSidebar() {
|
|||||||
<span className="grow truncate">{book.name}</span>
|
<span className="grow truncate">{book.name}</span>
|
||||||
<button
|
<button
|
||||||
className="icon-btn sm"
|
className="icon-btn sm"
|
||||||
title="Remove from my contacts"
|
title={t("Remove from my contacts")}
|
||||||
aria-label="Remove from my contacts"
|
aria-label={t("Remove from my contacts")}
|
||||||
onClick={(e) => { e.stopPropagation(); void contacts.setBookSubscribed(accountId, book.id, false); }}
|
onClick={(e) => { e.stopPropagation(); void contacts.setBookSubscribed(accountId, book.id, false); }}
|
||||||
>
|
>
|
||||||
<X size={13} />
|
<X size={13} />
|
||||||
@@ -148,15 +149,15 @@ export function ContactsSidebar() {
|
|||||||
guess made on their behalf. */}
|
guess made on their behalf. */}
|
||||||
{available.length > 0 && (
|
{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 }) => (
|
{available.map(({ accountId, accountName, book }) => (
|
||||||
<div key={`${accountId}:${book.id}`} className="nav-item" title={`${book.name} — from ${accountName}`}>
|
<div key={`${accountId}:${book.id}`} className="nav-item" title={`${book.name} — from ${accountName}`}>
|
||||||
<BookOpen size={17} className="faint" />
|
<BookOpen size={17} className="faint" />
|
||||||
<span className="grow truncate faint">{book.name}</span>
|
<span className="grow truncate faint">{book.name}</span>
|
||||||
<button
|
<button
|
||||||
className="icon-btn sm"
|
className="icon-btn sm"
|
||||||
title="Add to my contacts"
|
title={t("Add to my contacts")}
|
||||||
aria-label="Add to my contacts"
|
aria-label={t("Add to my contacts")}
|
||||||
onClick={(e) => { e.stopPropagation(); void contacts.setBookSubscribed(accountId, book.id, true); }}
|
onClick={(e) => { e.stopPropagation(); void contacts.setBookSubscribed(accountId, book.id, true); }}
|
||||||
>
|
>
|
||||||
<Plus size={13} />
|
<Plus size={13} />
|
||||||
@@ -180,7 +181,7 @@ export function ContactsSidebar() {
|
|||||||
<>
|
<>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
icon={<Pencil size={16} />}
|
icon={<Pencil size={16} />}
|
||||||
label="Rename"
|
label={t("Rename")}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const name = await promptDialog({ title: "Rename address book", defaultValue: menuBook.name });
|
const name = await promptDialog({ title: "Rename address book", defaultValue: menuBook.name });
|
||||||
if (!name?.trim() || name === menuBook.name) return;
|
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
|
{/* Revoking the lot, rather than removing people one at a time in
|
||||||
the dialog. Only shown when there is something to revoke. */}
|
the dialog. Only shown when there is something to revoke. */}
|
||||||
{Object.keys(menuBook.shareWith ?? {}).length > 0 && (
|
{Object.keys(menuBook.shareWith ?? {}).length > 0 && (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
icon={<UserMinus size={16} />}
|
icon={<UserMinus size={16} />}
|
||||||
label="Stop sharing"
|
label={t("Stop sharing")}
|
||||||
disabled={!menuBook.myRights?.mayShare}
|
disabled={!menuBook.myRights?.mayShare}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const who = Object.keys(menuBook.shareWith ?? {}).length;
|
const who = Object.keys(menuBook.shareWith ?? {}).length;
|
||||||
@@ -220,7 +221,7 @@ export function ContactsSidebar() {
|
|||||||
<MenuItem
|
<MenuItem
|
||||||
danger
|
danger
|
||||||
icon={<Trash2 size={16} />}
|
icon={<Trash2 size={16} />}
|
||||||
label="Delete"
|
label={t("Delete")}
|
||||||
disabled={menuBook.isDefault}
|
disabled={menuBook.isDefault}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
if (!(await confirmDialog({ title: `Delete “${menuBook.name}”?`, message: "The contacts in it go too.", confirmLabel: "Delete", danger: true }))) return;
|
if (!(await confirmDialog({ title: `Delete “${menuBook.name}”?`, message: "The contacts in it go too.", confirmLabel: "Delete", danger: true }))) return;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { confirmDialog } from "@/ui/dialog";
|
|||||||
import { toast } from "@/ui/toast";
|
import { toast } from "@/ui/toast";
|
||||||
import { ContactEditor } from "./ContactEditor";
|
import { ContactEditor } from "./ContactEditor";
|
||||||
import { avatarColor } from "@/lib/address";
|
import { avatarColor } from "@/lib/address";
|
||||||
|
import { t as translate } from "@/lib/i18n";
|
||||||
|
|
||||||
export function ContactsView({ id }: { id?: string }) {
|
export function ContactsView({ id }: { id?: string }) {
|
||||||
const [, navigate] = useLocation();
|
const [, navigate] = useLocation();
|
||||||
@@ -77,7 +78,7 @@ export function ContactsView({ id }: { id?: string }) {
|
|||||||
}, [list]);
|
}, [list]);
|
||||||
|
|
||||||
if (!contacts.available) {
|
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 = () => {
|
const exportAll = () => {
|
||||||
@@ -109,12 +110,12 @@ export function ContactsView({ id }: { id?: string }) {
|
|||||||
<div className="list-search row">
|
<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" }}>
|
<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" />
|
<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>
|
</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>
|
||||||
<div className="contacts-scroll">
|
<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>
|
<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) => (
|
) : groups.map((g) => (
|
||||||
<div key={g.letter}>
|
<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}`)}>
|
<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>
|
<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="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 className="c-email">{email ?? Object.values(c.phones ?? {})[0]?.number ?? Object.values(c.organizations ?? {})[0]?.name ?? ""}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -141,7 +142,7 @@ export function ContactsView({ id }: { id?: string }) {
|
|||||||
{selected ? (
|
{selected ? (
|
||||||
<ContactDetail card={selected} onBack={() => navigate("/contacts")} onEdit={() => setEditing(selected)} narrow={narrow} onEmail={(addr) => openCompose({ to: [{ name: contactDisplayName(selected), email: addr }] })} />
|
<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>
|
</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}`); }} />}
|
{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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="row" style={{ marginBottom: 12 }}>
|
<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" />
|
<span className="spacer" />
|
||||||
<button className="btn btn-sm" onClick={onEdit}><Pencil size={14} /> Edit</button>
|
<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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
{Object.values(c.emails ?? {}).length > 0 && (
|
{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) => (
|
{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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{Object.values(c.phones ?? {}).length > 0 && (
|
{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) => (
|
{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 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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{Object.values(c.addresses ?? {}).length > 0 && (
|
{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) => (
|
{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 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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{(org || Object.values(c.titles ?? {}).length > 1) && (
|
{(org || Object.values(c.titles ?? {}).length > 1) && (
|
||||||
<div className="contact-section"><h3>Work</h3>
|
<div className="contact-section"><h3>{translate("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>}
|
{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>)}
|
{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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{Object.values(c.anniversaries ?? {}).length > 0 && (
|
{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>)}
|
{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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{(Object.values(c.links ?? {}).length > 0 || Object.values(c.onlineServices ?? {}).length > 0) && (
|
{(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.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>)}
|
{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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{Object.values(c.notes ?? {}).length > 0 && (
|
{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>)}
|
{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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { confirmDialog, promptDialog } from "@/ui/dialog";
|
|||||||
import { toast } from "@/ui/toast";
|
import { toast } from "@/ui/toast";
|
||||||
import { loadRaw, saveJson } from "@/lib/storage";
|
import { loadRaw, saveJson } from "@/lib/storage";
|
||||||
import { ShareDialog } from "../settings/ShareDialog";
|
import { ShareDialog } from "../settings/ShareDialog";
|
||||||
|
import { t } from "@/lib/i18n";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Re-read the session, so the shared accounts on offer are current.
|
* Re-read the session, so the shared accounts on offer are current.
|
||||||
@@ -172,7 +173,7 @@ export function FilesTree() {
|
|||||||
</button>
|
</button>
|
||||||
{open && kids.length ? <FolderOpen size={17} /> : <Folder size={17} />}
|
{open && kids.length ? <FolderOpen size={17} /> : <Folder size={17} />}
|
||||||
<span className="grow truncate">{d.name}</span>
|
<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>
|
</div>
|
||||||
{open && kids.map((k) => row(k, depth + 1))}
|
{open && kids.map((k) => row(k, depth + 1))}
|
||||||
</div>
|
</div>
|
||||||
@@ -204,11 +205,11 @@ export function FilesTree() {
|
|||||||
{(viewingShare || sharedAccounts.length > 0) && (
|
{(viewingShare || sharedAccounts.length > 0) && (
|
||||||
<>
|
<>
|
||||||
<div className="nav-section">
|
<div className="nav-section">
|
||||||
<span>Shared with me</span>
|
<span>{t("Shared with me")}</span>
|
||||||
<button
|
<button
|
||||||
className="icon-btn sm"
|
className="icon-btn sm"
|
||||||
title="Check for new shares"
|
title={t("Check for new shares")}
|
||||||
aria-label="Check for new shares"
|
aria-label={t("Check for new shares")}
|
||||||
onClick={async () => { setRefreshing(true); await refreshShares(true); setRefreshing(false); }}
|
onClick={async () => { setRefreshing(true); await refreshShares(true); setRefreshing(false); }}
|
||||||
>
|
>
|
||||||
<RefreshCw size={14} className={refreshing ? "spin" : ""} />
|
<RefreshCw size={14} className={refreshing ? "spin" : ""} />
|
||||||
@@ -218,7 +219,7 @@ export function FilesTree() {
|
|||||||
<div className="nav-item" onClick={() => { useFiles.getState().openAccount(ownAccountId); navigate("/files"); }}>
|
<div className="nav-item" onClick={() => { useFiles.getState().openAccount(ownAccountId); navigate("/files"); }}>
|
||||||
<span className="nav-twisty" aria-hidden="true" />
|
<span className="nav-twisty" aria-hidden="true" />
|
||||||
<HardDrive size={17} />
|
<HardDrive size={17} />
|
||||||
<span className="grow truncate">Back to my files</span>
|
<span className="grow truncate">{t("Back to my files")}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{sharedAccounts.map((a) => (
|
{sharedAccounts.map((a) => (
|
||||||
@@ -232,14 +233,14 @@ export function FilesTree() {
|
|||||||
<span className="grow truncate">{a.name}</span>
|
<span className="grow truncate">{a.name}</span>
|
||||||
</div>
|
</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}>
|
<Popover anchor={menu.anchor} onClose={menu.close} width={210}>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
icon={<FolderPlus size={16} />}
|
icon={<FolderPlus size={16} />}
|
||||||
label="New folder"
|
label={t("New folder")}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const name = await promptDialog({ title: "New folder", placeholder: "Folder name" });
|
const name = await promptDialog({ title: "New folder", placeholder: "Folder name" });
|
||||||
if (!name?.trim()) return;
|
if (!name?.trim()) return;
|
||||||
@@ -255,7 +256,7 @@ export function FilesTree() {
|
|||||||
<>
|
<>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
icon={<Pencil size={16} />}
|
icon={<Pencil size={16} />}
|
||||||
label="Rename"
|
label={t("Rename")}
|
||||||
disabled={!menuNode.myRights?.mayRename}
|
disabled={!menuNode.myRights?.mayRename}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const name = await promptDialog({ title: "Rename", defaultValue: menuNode.name });
|
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 />
|
<MenuSep />
|
||||||
<MenuItem
|
<MenuItem
|
||||||
danger
|
danger
|
||||||
icon={<Trash2 size={16} />}
|
icon={<Trash2 size={16} />}
|
||||||
label="Delete"
|
label={t("Delete")}
|
||||||
disabled={!menuNode.myRights?.mayDelete}
|
disabled={!menuNode.myRights?.mayDelete}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
if (!(await confirmDialog({ title: `Delete “${menuNode.name}”?`, message: "Everything inside it goes too.", confirmLabel: "Delete", danger: true }))) return;
|
if (!(await confirmDialog({ title: `Delete “${menuNode.name}”?`, message: "Everything inside it goes too.", confirmLabel: "Delete", danger: true }))) return;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { Empty, Spinner } from "@/ui/misc";
|
|||||||
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
|
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
|
||||||
import { confirmDialog, promptDialog, Dialog } from "@/ui/dialog";
|
import { confirmDialog, promptDialog, Dialog } from "@/ui/dialog";
|
||||||
import { toast } from "@/ui/toast";
|
import { toast } from "@/ui/toast";
|
||||||
|
import { t } from "@/lib/i18n";
|
||||||
|
|
||||||
export function FilesView({ nodeId }: { nodeId?: string }) {
|
export function FilesView({ nodeId }: { nodeId?: string }) {
|
||||||
const [, navigate] = useLocation();
|
const [, navigate] = useLocation();
|
||||||
@@ -62,7 +63,7 @@ export function FilesView({ nodeId }: { nodeId?: string }) {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [parentId, files.available]);
|
}, [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 ids = files.children[parentId ?? "root"] ?? [];
|
||||||
const nodes = ids.map((id) => files.nodes[id]).filter((n): n is FileNode => Boolean(n));
|
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 ? (
|
{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">
|
<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>
|
<tbody>
|
||||||
{nodes.map((n) => (
|
{nodes.map((n) => (
|
||||||
<tr
|
<tr
|
||||||
@@ -162,10 +163,10 @@ export function FilesView({ nodeId }: { nodeId?: string }) {
|
|||||||
}}
|
}}
|
||||||
onDrop={(e) => { if (n.nodeType === "directory") dropOnto(n.id, e); }}
|
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); }}>
|
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">{n.nodeType === "directory" ? "—" : formatSize(n.size)}</td>
|
||||||
<td className="hide-mobile muted">{formatListDate(n.modified ?? n.created)}</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>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -175,18 +176,18 @@ export function FilesView({ nodeId }: { nodeId?: string }) {
|
|||||||
<Popover anchor={menu.anchor} onClose={menu.close} width={200}>
|
<Popover anchor={menu.anchor} onClose={menu.close} width={200}>
|
||||||
{!menuNode && (
|
{!menuNode && (
|
||||||
<>
|
<>
|
||||||
<MenuItem icon={<Upload size={16} />} label="Upload files…" onClick={() => inputRef.current?.click()} />
|
<MenuItem icon={<Upload size={16} />} label={t("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={<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 && (
|
||||||
<>
|
<>
|
||||||
{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)} />}
|
{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="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={<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="Move to…" onClick={() => setMoveNode(menuNode)} />
|
<MenuItem icon={<FolderInput size={16} />} label={t("Move to…")} onClick={() => setMoveNode(menuNode)} />
|
||||||
<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 />
|
<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>
|
</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 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);
|
const path = files.pathTo(cur);
|
||||||
return (
|
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">
|
<div className="breadcrumb mb-8">
|
||||||
<button onClick={() => setCur(null)}><Home size={14} /></button>
|
<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>)}
|
{path.map((n) => <span key={n.id} className="row gap-4"><ChevronRight size={12} /><button onClick={() => setCur(n.id)}>{n.name}</button></span>)}
|
||||||
</div>
|
</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.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>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { formatAddress } from "@/lib/address";
|
|||||||
import { MenuItem, MenuSep, Popover, type Anchor } from "@/ui/popover";
|
import { MenuItem, MenuSep, Popover, type Anchor } from "@/ui/popover";
|
||||||
import { toast } from "@/ui/toast";
|
import { toast } from "@/ui/toast";
|
||||||
import { ContactEditor } from "../contacts/ContactEditor";
|
import { ContactEditor } from "../contacts/ContactEditor";
|
||||||
|
import { t } from "@/lib/i18n";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Right-click on anyone named in a message — sender, recipients, Reply-To — to
|
* 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>
|
<div className="menu-title truncate">{formatAddress(menu.address)}</div>
|
||||||
{contacts.available && (
|
{contacts.available && (
|
||||||
known ? (
|
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 />
|
<MenuSep />
|
||||||
<MenuItem
|
<MenuItem
|
||||||
icon={<Copy size={16} />}
|
icon={<Copy size={16} />}
|
||||||
label="Copy email address"
|
label={t("Copy email address")}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void navigator.clipboard?.writeText(menu.address.email).then(
|
void navigator.clipboard?.writeText(menu.address.email).then(
|
||||||
() => toast.show("Address copied"),
|
() => toast.show("Address copied"),
|
||||||
@@ -85,7 +86,7 @@ export function AddressList({ list, onContext, empty = "—" }: { list: EmailAdd
|
|||||||
{list.map((a, i) => (
|
{list.map((a, i) => (
|
||||||
<span key={`${a.email}-${i}`}>
|
<span key={`${a.email}-${i}`}>
|
||||||
{i > 0 && ", "}
|
{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>
|
</span>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { RuleDialog } from "../settings/RuleDialog";
|
|||||||
import { toast } from "@/ui/toast";
|
import { toast } from "@/ui/toast";
|
||||||
import { Spinner } from "@/ui/misc";
|
import { Spinner } from "@/ui/misc";
|
||||||
import { Dialog } from "@/ui/dialog";
|
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. */
|
/** "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 }) {
|
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) {
|
if (!sieve.available) {
|
||||||
return (
|
return (
|
||||||
<Dialog open onClose={onClose} title="Filters unavailable" size="sm" footer={<button className="btn" onClick={onClose}>Close</button>}>
|
<Dialog open onClose={onClose} title={t("Filters unavailable")} size="sm" footer={<button className="btn" onClick={onClose}>{t("Close")}</button>}>
|
||||||
<p>Sieve filtering is not enabled for this account.</p>
|
<p>{t("Sieve filtering is not enabled for this account.")}</p>
|
||||||
</Dialog>
|
</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();
|
const { rules, loaded, damage } = sieve.rules();
|
||||||
if (rules === null) {
|
if (rules === null) {
|
||||||
return (
|
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
|
Three different situations, and telling them apart matters: one is
|
||||||
permanent and two are a reload away. Saying "written by hand" when the
|
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 ? (
|
{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>
|
<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 ? (
|
) : 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>
|
</Dialog>
|
||||||
);
|
);
|
||||||
@@ -57,7 +58,7 @@ export function FilterFromMessageDialog({ email, mailboxId, onClose }: { email:
|
|||||||
return (
|
return (
|
||||||
<RuleDialog
|
<RuleDialog
|
||||||
rule={rule}
|
rule={rule}
|
||||||
title="Filter messages like this"
|
title={t("Filter messages like this")}
|
||||||
saveLabel="Create filter"
|
saveLabel="Create filter"
|
||||||
applyMailbox={mailbox ? { id: mailbox.id, name: mailbox.name } : null}
|
applyMailbox={mailbox ? { id: mailbox.id, name: mailbox.name } : null}
|
||||||
applyByDefault
|
applyByDefault
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { CalendarEvent, Email, EmailBodyPart } from "@/jmap/types";
|
|||||||
import { useCalendar, toInstance, myParticipantKeys, isAttendee, participantEmail } from "@/store/calendar";
|
import { useCalendar, toInstance, myParticipantKeys, isAttendee, participantEmail } from "@/store/calendar";
|
||||||
import { formatTimeRange } from "@/lib/dates";
|
import { formatTimeRange } from "@/lib/dates";
|
||||||
import { toast } from "@/ui/toast";
|
import { toast } from "@/ui/toast";
|
||||||
|
import { t } from "@/lib/i18n";
|
||||||
|
|
||||||
export function InviteCard({ email, part }: { email: Email; part: EmailBodyPart }) {
|
export function InviteCard({ email, part }: { email: Email; part: EmailBodyPart }) {
|
||||||
const cal = useCalendar();
|
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 && <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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{method === "CANCEL" && existing && (
|
{method === "CANCEL" && existing && (
|
||||||
<div className="rsvp">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
<span className="sr-only">{email.id}</span>
|
<span className="sr-only">{email.id}</span>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useMail } from "@/store/mail";
|
|||||||
import { Popover } from "@/ui/popover";
|
import { Popover } from "@/ui/popover";
|
||||||
import type { Id } from "@/jmap/types";
|
import type { Id } from "@/jmap/types";
|
||||||
import { CALENDAR_COLORS } from "@/ui/misc";
|
import { CALENDAR_COLORS } from "@/ui/misc";
|
||||||
|
import { t } from "@/lib/i18n";
|
||||||
|
|
||||||
/** Labels are IMAP keywords on the messages; their names/colors live in settings. */
|
/** 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 }) {
|
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 (
|
return (
|
||||||
<Popover anchor={{ x: anchor.x, y: anchor.y, w: 0, h: 0 }} onClose={onClose} width={260} closeOnClick={false}>
|
<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">
|
<div className="menu-search">
|
||||||
<input
|
<input
|
||||||
className="input sm"
|
className="input sm"
|
||||||
@@ -77,7 +78,7 @@ export function LabelPicker({ ids, anchor, onClose, onApplied }: { ids: Id[]; an
|
|||||||
<span>Create “{q.trim()}”</span>
|
<span>Create “{q.trim()}”</span>
|
||||||
</button>
|
</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>
|
</Popover>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Folder, Inbox } from "lucide-react";
|
|||||||
import { useMail } from "@/store/mail";
|
import { useMail } from "@/store/mail";
|
||||||
import { Dialog } from "@/ui/dialog";
|
import { Dialog } from "@/ui/dialog";
|
||||||
import type { Id, Mailbox } from "@/jmap/types";
|
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[] }) {
|
export function MailboxPicker({ title, onClose, onPick, exclude }: { title: string; onClose: () => void; onPick: (id: Id) => void; exclude?: Id[] }) {
|
||||||
const mailboxes = useMail((s) => s.mailboxes);
|
const mailboxes = useMail((s) => s.mailboxes);
|
||||||
@@ -23,7 +24,7 @@ export function MailboxPicker({ title, onClose, onPick, exclude }: { title: stri
|
|||||||
<input
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
autoFocus
|
autoFocus
|
||||||
placeholder="Type a folder name…"
|
placeholder={t("Type a folder name…")}
|
||||||
value={q}
|
value={q}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setQ(e.target.value);
|
setQ(e.target.value);
|
||||||
@@ -47,7 +48,7 @@ export function MailboxPicker({ title, onClose, onPick, exclude }: { title: stri
|
|||||||
{list.map(({ m, path }, i) => (
|
{list.map(({ m, path }, i) => (
|
||||||
<PickerRow key={m.id} m={m} path={path} active={i === active} onClick={() => onPick(m.id)} onHover={() => setActive(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>
|
</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { ShareDialog } from "../settings/ShareDialog";
|
|||||||
import { loadRaw, saveJson } from "@/lib/storage";
|
import { loadRaw, saveJson } from "@/lib/storage";
|
||||||
import { canDropFolder, folderColor, movable } from "@/lib/folderMove";
|
import { canDropFolder, folderColor, movable } from "@/lib/folderMove";
|
||||||
import { haptic, useTouchRow } from "@/lib/touch";
|
import { haptic, useTouchRow } from "@/lib/touch";
|
||||||
|
import { t } from "@/lib/i18n";
|
||||||
|
|
||||||
const ROLE_ICONS: Record<string, ReactNode> = {
|
const ROLE_ICONS: Record<string, ReactNode> = {
|
||||||
inbox: <Inbox size={20} />,
|
inbox: <Inbox size={20} />,
|
||||||
@@ -125,7 +126,7 @@ export function MailboxTree() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<nav aria-label="Folders" style={{ marginTop: 6 }}>
|
<nav aria-label={t("Folders")} style={{ marginTop: 6 }}>
|
||||||
<div
|
<div
|
||||||
className={`nav-section${rootDrop ? " drop-target" : ""}`}
|
className={`nav-section${rootDrop ? " drop-target" : ""}`}
|
||||||
onDragOver={(e) => {
|
onDragOver={(e) => {
|
||||||
@@ -143,7 +144,7 @@ export function MailboxTree() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>{draggingId && canDropOn(null) ? "Drop here for the top level" : "Folders"}</span>
|
<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} />
|
<Plus size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -170,8 +171,8 @@ export function MailboxTree() {
|
|||||||
{labelsSidebar && labels.length > 0 && (
|
{labelsSidebar && labels.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="nav-section">
|
<div className="nav-section">
|
||||||
<span>Labels</span>
|
<span>{t("Labels")}</span>
|
||||||
<Link href="/settings/labels" className="icon-btn" title="Manage labels" aria-label="Manage labels">
|
<Link href="/settings/labels" className="icon-btn" title={t("Manage labels")} aria-label={t("Manage labels")}>
|
||||||
<Pencil size={14} />
|
<Pencil size={14} />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -298,7 +299,7 @@ function FolderRow({ mailbox: m, label, depth, hasChildren, open, hiddenUnread,
|
|||||||
{count > 0 && <span className="nav-dot" />}
|
{count > 0 && <span className="nav-dot" />}
|
||||||
<button
|
<button
|
||||||
className="icon-btn nav-more"
|
className="icon-btn nav-more"
|
||||||
aria-label="Folder options"
|
aria-label={t("Folder options")}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -362,18 +363,18 @@ function MailboxMenu({ mailbox: m, onClose, onCreateChild, onShare }: { mailbox:
|
|||||||
};
|
};
|
||||||
return (
|
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 && (
|
{hasChildren && (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
icon={<CheckCheck size={16} />}
|
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}
|
kbd={m.unreadEmails + subUnread ? String(m.unreadEmails + subUnread) : undefined}
|
||||||
onClick={() => void useMail.getState().markMailboxRead(m.id, true)}
|
onClick={() => void useMail.getState().markMailboxRead(m.id, true)}
|
||||||
disabled={!m.unreadEmails && !subUnread}
|
disabled={!m.unreadEmails && !subUnread}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<MenuItem icon={<FolderPlus size={16} />} label="New subfolder" onClick={onCreateChild} disabled={!m.myRights.mayCreateChild} />
|
<MenuItem icon={<FolderPlus size={16} />} label={t("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={<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"} />
|
<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
|
{/* Sharing a mail folder is withdrawn, not removed: Stalwart accepts and
|
||||||
stores the share, and it never reaches the other account -- its own
|
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.
|
folders. Offering it produced shares that looked real and did nothing.
|
||||||
One that already exists can still be cleared here, which is the only
|
One that already exists can still be cleared here, which is the only
|
||||||
reason this entry survives at all. */}
|
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 />
|
<MenuSep />
|
||||||
<MenuTitle><span className="row gap-4"><Palette size={12} /> Colour</span></MenuTitle>
|
<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" }}>
|
<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>
|
</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 />
|
<MenuSep />
|
||||||
{canEmpty(m.role) && <MenuItem icon={<Eraser size={16} />} label={emptyLabel(m)} onClick={() => void empty()} danger disabled={!m.totalEmails} />}
|
{canEmpty(m.role) && <MenuItem icon={<Eraser size={16} />} label={emptyLabel(m)} onClick={() => void empty()} danger disabled={!m.totalEmails} />}
|
||||||
<MenuItem icon={<Trash2 size={16} />} label="Delete folder" onClick={() => void remove()} danger disabled={isSpecial || !m.myRights.mayDelete} />
|
<MenuItem icon={<Trash2 size={16} />} label={t("Delete folder")} onClick={() => void remove()} danger disabled={isSpecial || !m.myRights.mayDelete} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { useCompose } from "@/store/compose";
|
|||||||
import { haptic, usePullToRefresh, useTouchRow, PULL_TRIGGER } from "@/lib/touch";
|
import { haptic, usePullToRefresh, useTouchRow, PULL_TRIGGER } from "@/lib/touch";
|
||||||
import { describeSwipe, type SwipeAction, type SwipeDescriptor, type SwipeIcon } from "@/lib/swipe";
|
import { describeSwipe, type SwipeAction, type SwipeDescriptor, type SwipeIcon } from "@/lib/swipe";
|
||||||
import { FilterFromMessageDialog } from "./FilterFromMessage";
|
import { FilterFromMessageDialog } from "./FilterFromMessage";
|
||||||
|
import { t } from "@/lib/i18n";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The glyph on the strip a swipe reveals. Sized larger than the toolbar's
|
* 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="mail-list-pane">
|
||||||
<div className="list-toolbar">
|
<div className="list-toolbar">
|
||||||
{isMobile && isSearch && (
|
{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} />
|
<ArrowLeft size={20} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="select-all"
|
className="select-all"
|
||||||
aria-label="Select all"
|
aria-label={t("Select all")}
|
||||||
checked={allSelected}
|
checked={allSelected}
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
if (el) el.indeterminate = selCount > 0 && !allSelected;
|
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-count">{selCount} selected</span>
|
||||||
<span className="tb-sep" />
|
<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" 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>
|
<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" />
|
<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" title={t("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 hide-mobile" title={t("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" title={t("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 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
|
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
|
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 && (
|
{isMobile && (
|
||||||
<>
|
<>
|
||||||
<span className="spacer" />
|
<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}>
|
<Popover anchor={selMenu.anchor} onClose={selMenu.close} align="end" width={240}>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
icon={mailbox?.role === "junk" ? <ShieldCheck size={16} /> : <AlertOctagon size={16} />}
|
icon={mailbox?.role === "junk" ? <ShieldCheck size={16} /> : <AlertOctagon size={16} />}
|
||||||
label={mailbox?.role === "junk" ? "Not spam" : "Report spam"}
|
label={mailbox?.role === "junk" ? "Not spam" : "Report spam"}
|
||||||
onClick={() => void actions.spam()}
|
onClick={() => void actions.spam()}
|
||||||
/>
|
/>
|
||||||
<MenuItem icon={<Mail size={16} />} label="Mark as unread" onClick={() => void actions.read(false)} />
|
<MenuItem icon={<Mail size={16} />} label={t("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={<Tag size={16} />} label={t("Label…")} onClick={() => actions.label(undefined, { x: window.innerWidth / 2, y: 100 })} />
|
||||||
<MenuSep />
|
<MenuSep />
|
||||||
<MenuItem icon={<CheckSquare size={16} />} label="Select all" onClick={selectAll} />
|
<MenuItem icon={<CheckSquare size={16} />} label={t("Select all")} onClick={selectAll} />
|
||||||
<MenuItem icon={<X size={16} />} label="Clear selection" onClick={clearSelection} />
|
<MenuItem icon={<X size={16} />} label={t("Clear selection")} onClick={clearSelection} />
|
||||||
</Popover>
|
</Popover>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -288,20 +289,20 @@ export function MessageList({ title, list, openThreadId, focusId, setFocusId, on
|
|||||||
<span className="tb-title">{title}</span>
|
<span className="tb-title">{title}</span>
|
||||||
{list && !list.loading && <span className="tb-count">{list.total.toLocaleString()}</span>}
|
{list && !list.loading && <span className="tb-count">{list.total.toLocaleString()}</span>}
|
||||||
<span className="spacer" />
|
<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} />
|
<RefreshCw size={18} className={refreshing ? "spin" : ""} style={refreshing ? { animation: "spin .8s linear infinite" } : undefined} />
|
||||||
</button>
|
</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} />
|
<MoreVertical size={18} />
|
||||||
</button>
|
</button>
|
||||||
<Popover anchor={moreMenu.anchor} onClose={moreMenu.close} align="end" width={240}>
|
<Popover anchor={moreMenu.anchor} onClose={moreMenu.close} align="end" width={240}>
|
||||||
<MenuTitle>Reading pane</MenuTitle>
|
<MenuTitle>{t("Reading pane")}</MenuTitle>
|
||||||
<MenuItem icon={<PanelRight size={16} />} label="Right of the list" checked={settings.readingPane === "right"} onClick={() => updateSettings({ readingPane: "right" })} />
|
<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="Below the list" checked={settings.readingPane === "bottom"} onClick={() => updateSettings({ readingPane: "bottom" })} />
|
<MenuItem icon={<PanelBottom size={16} />} label={t("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" })} />
|
<MenuItem icon={<PanelTop size={16} />} label={t("Hidden (open full width)")} checked={settings.readingPane === "off"} onClick={() => updateSettings({ readingPane: "off" })} />
|
||||||
<MenuSep />
|
<MenuSep />
|
||||||
<MenuItem icon={<CheckSquare size={16} />} label="Select all" onClick={selectAll} />
|
<MenuItem icon={<CheckSquare size={16} />} label={t("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={<MailOpen size={16} />} label={t("Mark all as read")} onClick={() => mailboxId && void useMail.getState().markMailboxRead(mailboxId)} disabled={!mailboxId} />
|
||||||
{mailbox && canEmpty(mailbox.role) && (
|
{mailbox && canEmpty(mailbox.role) && (
|
||||||
<>
|
<>
|
||||||
<MenuSep />
|
<MenuSep />
|
||||||
@@ -321,7 +322,7 @@ export function MessageList({ title, list, openThreadId, focusId, setFocusId, on
|
|||||||
{list?.error && (
|
{list?.error && (
|
||||||
<div className="list-hint">
|
<div className="list-hint">
|
||||||
<span className="grow" style={{ color: "var(--danger)" }}>{list.error}</span>
|
<span className="grow" style={{ color: "var(--danger)" }}>{list.error}</span>
|
||||||
<button onClick={() => void doRefresh()}>Retry</button>
|
<button onClick={() => void doRefresh()}>{t("Retry")}</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/*
|
{/*
|
||||||
@@ -336,9 +337,10 @@ export function MessageList({ title, list, openThreadId, focusId, setFocusId, on
|
|||||||
{mailbox?.role === "junk" && !!mailbox.totalEmails && !selCount && (
|
{mailbox?.role === "junk" && !!mailbox.totalEmails && !selCount && (
|
||||||
<div className="list-hint">
|
<div className="list-hint">
|
||||||
<span className="grow">
|
<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>
|
</span>
|
||||||
<button onClick={() => void confirmAndEmpty(mailbox)}>Delete all spam now</button>
|
<button onClick={() => void confirmAndEmpty(mailbox)}>{t("Delete all spam now")}</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
@@ -457,19 +459,19 @@ export function MessageList({ title, list, openThreadId, focusId, setFocusId, on
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Popover anchor={ctxMenu.anchor} onClose={ctxMenu.close} width={250}>
|
<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={<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="Forward" onClick={() => { const e = ctxRow ? emails[ctxRow] : undefined; if (e) void useCompose.getState().reply(e, "forward"); }} />
|
<MenuItem icon={<Forward size={16} />} label={t("Forward")} onClick={() => { const e = ctxRow ? emails[ctxRow] : undefined; if (e) void useCompose.getState().reply(e, "forward"); }} />
|
||||||
<MenuSep />
|
<MenuSep />
|
||||||
<MenuItem icon={<Archive size={16} />} label="Archive" kbd="e" onClick={() => void actions.archive(ctxTargets)} />
|
<MenuItem icon={<Archive size={16} />} label={t("Archive")} kbd="e" onClick={() => void actions.archive(ctxTargets)} />
|
||||||
<MenuItem icon={<Trash2 size={16} />} label="Delete" kbd="#" onClick={() => void actions.trash(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)} />
|
<MenuItem icon={<AlertOctagon size={16} />} label={mailbox?.role === "junk" ? "Not spam" : "Report spam"} kbd="!" onClick={() => void actions.spam(ctxTargets)} />
|
||||||
<MenuSep />
|
<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={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={<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={<FolderInput size={16} />} label={t("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={<Tag size={16} />} label={t("Label…")} kbd="l" onClick={() => actions.label(ctxTargets, ctxMenu.anchor ?? { x: 0, y: 0 })} />
|
||||||
<MenuSep />
|
<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>
|
</Popover>
|
||||||
{filterFrom && <FilterFromMessageDialog email={filterFrom} mailboxId={mailboxId} onClose={() => setFilterFrom(null)} />}
|
{filterFrom && <FilterFromMessageDialog email={filterFrom} mailboxId={mailboxId} onClose={() => setFilterFrom(null)} />}
|
||||||
</div>
|
</div>
|
||||||
@@ -636,7 +638,7 @@ const Row = memo(function Row({ email: e, threadEmails, top, height, selected, f
|
|||||||
role="row"
|
role="row"
|
||||||
aria-selected={selected}
|
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 && (
|
{!twoLine && (
|
||||||
<button className={`msg-star ${starred ? "on" : ""}`} onClick={(ev) => { ev.stopPropagation(); onStar(e.id, !starred); }} aria-label={starred ? "Unstar" : "Star"}>
|
<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"} />
|
<Star size={18} fill={starred ? "currentColor" : "none"} />
|
||||||
@@ -656,10 +658,10 @@ const Row = memo(function Row({ email: e, threadEmails, top, height, selected, f
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="msg-main">
|
<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>
|
<span className="msg-subject">{e.subject || "(no subject)"}</span>
|
||||||
{showPreview && <span className="msg-preview">{latest.preview}</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"} />
|
<Star size={16} fill={starred ? "currentColor" : "none"} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>}
|
{count > 1 && <span className="thread-count">{count}</span>}
|
||||||
</span>
|
</span>
|
||||||
<span className="msg-main">
|
<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>}
|
{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>
|
<span className="msg-subject">{e.subject || "(no subject)"}</span>
|
||||||
{showPreview && <span className="msg-preview">{latest.preview}</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" />}
|
{hasAtt && <Paperclip size={14} className="msg-attach" />}
|
||||||
<span className="msg-date">{formatListDate(latest.receivedAt)}</span>
|
<span className="msg-date">{formatListDate(latest.receivedAt)}</span>
|
||||||
<span className="msg-actions">
|
<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={t("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("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>
|
<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>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { useScheduled } from "@/store/scheduled";
|
|||||||
import { formatScheduleTime } from "@/lib/schedule";
|
import { formatScheduleTime } from "@/lib/schedule";
|
||||||
import { mdnDecision, refusalText } from "@/lib/mdn";
|
import { mdnDecision, refusalText } from "@/lib/mdn";
|
||||||
import { sendReadReceipt } from "@/store/mdn";
|
import { sendReadReceipt } from "@/store/mdn";
|
||||||
|
import { t as translate } from "@/lib/i18n";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
email: Email;
|
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)}>
|
<div className="from" onContextMenu={(ev) => from && addrMenu.open(ev, from)}>
|
||||||
<span className="addr">{displayName(from)}</span>
|
<span className="addr">{displayName(from)}</span>
|
||||||
{expanded && from && <span className="email addr"><{from.email}></span>}
|
{expanded && from && <span className="email addr"><{from.email}></span>}
|
||||||
{isHighPriority && <span className="tag" style={{ background: "var(--danger)" }}>Important</span>}
|
{isHighPriority && <span className="tag" style={{ background: "var(--danger)" }}>{translate("Important")}</span>}
|
||||||
{authFailed && <span className="tag" style={{ background: "var(--warn)" }} title={e["header:Authentication-Results:asText"] ?? ""}><ShieldAlert size={12} /> Unverified</span>}
|
{authFailed && <span className="tag" style={{ background: "var(--warn)" }} title={e["header:Authentication-Results:asText"] ?? ""}><ShieldAlert size={12} /> Unverified</span>}
|
||||||
</div>
|
</div>
|
||||||
{expanded ? (
|
{expanded ? (
|
||||||
<div className="to">
|
<div className="to">
|
||||||
<span className="truncate">to {summarizeRecipients(e)}</span>
|
<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} />}
|
{details ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -167,30 +168,30 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
|
|||||||
<div className="meta">
|
<div className="meta">
|
||||||
{e.hasAttachment && !expanded && <Paperclip size={14} />}
|
{e.hasAttachment && !expanded && <Paperclip size={14} />}
|
||||||
<span className="date" title={formatFullDate(e.receivedAt)}>{expanded ? formatFullDate(e.receivedAt) : formatListDate(e.receivedAt)}</span>
|
<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"} />
|
<Star size={17} fill={e.keywords.$flagged ? "currentColor" : "none"} />
|
||||||
</button>
|
</button>
|
||||||
{expanded && (
|
{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 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="More"><MoreVertical size={17} /></button>
|
<button className="icon-btn sm" onClick={(ev) => { ev.stopPropagation(); moreMenu.open(ev); }} aria-label={translate("More")}><MoreVertical size={17} /></button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<Popover anchor={moreMenu.anchor} onClose={moreMenu.close} align="end" width={240}>
|
<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={<Reply size={16} />} label={translate("Reply")} onClick={() => void reply(e, "reply")} />
|
||||||
<MenuItem icon={<ReplyAll size={16} />} label="Reply all" onClick={() => void reply(e, "replyAll")} />
|
<MenuItem icon={<ReplyAll size={16} />} label={translate("Reply all")} onClick={() => void reply(e, "replyAll")} />
|
||||||
<MenuItem icon={<Forward size={16} />} label="Forward" onClick={() => void reply(e, "forward")} />
|
<MenuItem icon={<Forward size={16} />} label={translate("Forward")} onClick={() => void reply(e, "forward")} />
|
||||||
<MenuSep />
|
<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={<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 />
|
<MenuSep />
|
||||||
<MenuItem icon={<Eye size={16} />} label="Show original" onClick={() => void openSource()} />
|
<MenuItem icon={<Eye size={16} />} label={translate("Show original")} onClick={() => void openSource()} />
|
||||||
<MenuItem icon={<Code size={16} />} label="Show headers" onClick={() => setShowHeaders(true)} />
|
<MenuItem icon={<Code size={16} />} label={translate("Show headers")} onClick={() => setShowHeaders(true)} />
|
||||||
<MenuItem icon={<Download size={16} />} label="Download (.eml)" onClick={downloadEml} />
|
<MenuItem icon={<Download size={16} />} label={translate("Download (.eml)")} onClick={downloadEml} />
|
||||||
<MenuItem icon={<Printer size={16} />} label="Print" onClick={() => window.print()} />
|
<MenuItem icon={<Printer size={16} />} label={translate("Print")} onClick={() => window.print()} />
|
||||||
<MenuItem icon={<Filter size={16} />} label="Filter messages like this…" onClick={() => setFilterOpen(true)} />
|
<MenuItem icon={<Filter size={16} />} label={translate("Filter messages like this…")} onClick={() => setFilterOpen(true)} />
|
||||||
{from && (
|
{from && (
|
||||||
<>
|
<>
|
||||||
<MenuSep />
|
<MenuSep />
|
||||||
@@ -203,18 +204,18 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
|
|||||||
<>
|
<>
|
||||||
{details && (
|
{details && (
|
||||||
<dl className="message-details" onClick={(ev) => ev.stopPropagation()}>
|
<dl className="message-details" onClick={(ev) => ev.stopPropagation()}>
|
||||||
<dt>From</dt><dd><AddressList list={e.from} onContext={addrMenu.open} /></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>Sender</dt><dd><AddressList list={e.sender} onContext={addrMenu.open} /></dd></> : null}
|
{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>Reply-To</dt><dd><AddressList list={e.replyTo} onContext={addrMenu.open} /></dd></> : null}
|
{e.replyTo?.length ? <><dt>{translate("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>
|
<dt>{translate("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.cc?.length ? <><dt>{translate("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}
|
{e.bcc?.length ? <><dt>{translate("Bcc")}</dt><dd><AddressList list={e.bcc} onContext={addrMenu.open} /></dd></> : null}
|
||||||
<dt>Date</dt><dd>{formatFullDate(e.sentAt ?? e.receivedAt)}</dd>
|
<dt>{translate("Date")}</dt><dd>{formatFullDate(e.sentAt ?? e.receivedAt)}</dd>
|
||||||
<dt>Subject</dt><dd>{e.subject || "(no subject)"}</dd>
|
<dt>{translate("Subject")}</dt><dd>{e.subject || "(no subject)"}</dd>
|
||||||
{e.messageId?.[0] && <><dt>Message-ID</dt><dd className="mono small">{e.messageId[0]}</dd></>}
|
{e.messageId?.[0] && <><dt>{translate("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></>}
|
{e["header:List-Id:asText"] && <><dt>{translate("List")}</dt><dd>{e["header:List-Id:asText"]}</dd></>}
|
||||||
<dt>Size</dt><dd>{formatSize(e.size)}</dd>
|
<dt>{translate("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></>}
|
{receiptRequested && <><dt>{translate("Receipt")}</dt><dd>{receipt.offer ? `Requested, to ${receipt.to!.email}. Never sent automatically.` : refusalText(receipt.refusal!)}</dd></>}
|
||||||
</dl>
|
</dl>
|
||||||
)}
|
)}
|
||||||
{receipt.offer && settings.readReceiptPolicy !== "never" && receiptDone !== "dismissed" && (
|
{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"}
|
{receiptDone === "sending" ? "Sending…" : "Send receipt"}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => setReceiptDone("dismissed")}>Not this time</button>
|
<button onClick={() => setReceiptDone("dismissed")}>{translate("Not this time")}</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{scheduled && (
|
{scheduled && (
|
||||||
@@ -258,15 +259,16 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Cancel send
|
|
||||||
|
{translate("Cancel send")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{rendered && rendered.remoteCount > 0 && !remoteAllowed && (
|
{rendered && rendered.remoteCount > 0 && !remoteAllowed && (
|
||||||
<div className="remote-banner" style={{ margin: "0 16px 8px" }}>
|
<div className="remote-banner" style={{ margin: "0 16px 8px" }}>
|
||||||
<ImageIcon size={16} />
|
<ImageIcon size={16} />
|
||||||
<span className="grow">Remote images are blocked to protect your privacy.</span>
|
<span className="grow">{translate("Remote images are blocked to protect your privacy.")}</span>
|
||||||
<button onClick={() => setAllowRemote(true)}>Show images</button>
|
<button onClick={() => setAllowRemote(true)}>{translate("Show images")}</button>
|
||||||
{from && <button onClick={() => updateSettings({ trustedImageSenders: [...settings.trustedImageSenders, from.email.toLowerCase()] })}>Always from {from.email}</button>}
|
{from && <button onClick={() => updateSettings({ trustedImageSenders: [...settings.trustedImageSenders, from.email.toLowerCase()] })}>Always from {from.email}</button>}
|
||||||
</div>
|
</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} />}
|
{attachments.length > 0 && <AttachmentList attachments={attachments} accountId={accountId} email={e} />}
|
||||||
{unsubscribe && (
|
{unsubscribe && (
|
||||||
<div className="unsubscribe-row">
|
<div className="unsubscribe-row">
|
||||||
<span>This looks like a mailing list.</span>
|
<span>{translate("This looks like a mailing list.")}</span>
|
||||||
<button className="btn btn-ghost btn-sm" onClick={() => void onUnsubscribe()}>Unsubscribe</button>
|
<button className="btn btn-ghost btn-sm" onClick={() => void onUnsubscribe()}>{translate("Unsubscribe")}</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{addrMenu.node}
|
{addrMenu.node}
|
||||||
{filterOpen && <FilterFromMessageDialog email={e} mailboxId={Object.keys(e.mailboxIds)[0] ?? null} onClose={() => setFilterOpen(false)} />}
|
{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>}
|
{source === null ? <div className="center"><span className="spinner" /></div> : <pre className="code notranslate" translate="no" style={{ minHeight: 300, maxHeight: "65vh" }}>{source}</pre>}
|
||||||
</Dialog>
|
</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 }}>
|
<dl className="message-details" style={{ margin: 0 }}>
|
||||||
{Object.entries(e).filter(([k]) => k.startsWith("header:")).map(([k, v]) => (
|
{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>
|
<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>
|
<dt>{translate("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.inReplyTo?.length ? <><dt>{translate("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}
|
{e.references?.length ? <><dt>{translate("References")}</dt><dd className="mono small">{e.references.join(" ")}</dd></> : null}
|
||||||
</dl>
|
</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>
|
</Dialog>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
@@ -449,7 +451,7 @@ function HtmlBody({ html, bodyStyle, themed, onShowImages }: { html: string; bod
|
|||||||
<div ref={hostRef} className="body-host notranslate" translate="no" />
|
<div ref={hostRef} className="body-host notranslate" translate="no" />
|
||||||
{hasQuote && (
|
{hasQuote && (
|
||||||
<button className="quote-toggle" onClick={() => setQuoteOpen((v) => !v)} title={quoteOpen ? "Hide quoted text" : "Show quoted text"}>
|
<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" : ""}
|
{quoteOpen ? "Hide quoted text" : ""}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -491,7 +493,7 @@ function TextBody({ text }: { text: string }) {
|
|||||||
<div ref={hostRef} className="body-host notranslate" translate="no" />
|
<div ref={hostRef} className="body-host notranslate" translate="no" />
|
||||||
{quoted && (
|
{quoted && (
|
||||||
<button className="quote-toggle" onClick={() => setQuoteOpen((v) => !v)}>
|
<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" : ""}
|
{quoteOpen ? "Hide quoted text" : ""}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -532,8 +534,8 @@ function AttachmentList({ attachments, accountId, email }: { attachments: EmailB
|
|||||||
<span className="att-name">{a.name ?? "(unnamed)"}</span>
|
<span className="att-name">{a.name ?? "(unnamed)"}</span>
|
||||||
<span className="att-size">{formatSize(a.size)}</span>
|
<span className="att-size">{formatSize(a.size)}</span>
|
||||||
<span className="att-actions">
|
<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>
|
<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="Open in new tab" onClick={(ev) => { ev.preventDefault(); ev.stopPropagation(); window.open(inlineUrl, "_blank", "noopener"); }}><ExternalLink 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>
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
@@ -547,7 +549,7 @@ function AttachmentList({ attachments, accountId, email }: { attachments: EmailB
|
|||||||
</div>
|
</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>}>
|
<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.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)} />}
|
{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>
|
<p className="hint" style={{ marginTop: 8 }}>From: {displayName(email.from?.[0])}</p>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { client } from "@/jmap/client";
|
|||||||
import { LabelPicker } from "./LabelPicker";
|
import { LabelPicker } from "./LabelPicker";
|
||||||
import { threadScrollTarget } from "@/lib/threadScroll";
|
import { threadScrollTarget } from "@/lib/threadScroll";
|
||||||
import { useEdgeBack } from "@/lib/touch";
|
import { useEdgeBack } from "@/lib/touch";
|
||||||
|
import { t } from "@/lib/i18n";
|
||||||
|
|
||||||
/** How long the opening scroll keeps its place while bodies and images land. */
|
/** How long the opening scroll keeps its place while bodies and images land. */
|
||||||
const HOLD_MS = 2000;
|
const HOLD_MS = 2000;
|
||||||
@@ -230,30 +231,30 @@ export function ThreadView({ threadId, mailboxId, onBack, actions, onNavigate, h
|
|||||||
return (
|
return (
|
||||||
<div className="thread-view" ref={setViewEl}>
|
<div className="thread-view" ref={setViewEl}>
|
||||||
<div className="thread-toolbar">
|
<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} />
|
<ArrowLeft size={20} />
|
||||||
</button>
|
</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={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" />
|
<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={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={t("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 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="More"><MoreVertical 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}>
|
<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={<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({}); }} />
|
<MenuItem icon={allExpanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />} label={allExpanded ? "Collapse all" : "Expand all"} onClick={() => { setAllExpanded((v) => !v); setExpanded({}); }} />
|
||||||
<MenuSep />
|
<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 && (
|
{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>
|
</Popover>
|
||||||
<div className="thread-nav hide-mobile">
|
<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={!hasPrev} onClick={() => onNavigate(-1)} title={t("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={!hasNext} onClick={() => onNavigate(1)} title={t("Older (j)")}><ChevronDown size={18} /></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="thread-scroll" ref={scrollRef}>
|
<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>}
|
{messages.length > 1 && <span className="muted small nowrap" style={{ marginTop: 6 }}>{messages.length} messages</span>}
|
||||||
</div>
|
</div>
|
||||||
{error && <div className="error-box" style={{ margin: 16 }}>{error}</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) => (
|
{messages.map((e, i) => (
|
||||||
<MessageView
|
<MessageView
|
||||||
key={e.id}
|
key={e.id}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { EmailBodyPart, Id } from "@/jmap/types";
|
|||||||
import { useContacts } from "@/store/contacts";
|
import { useContacts } from "@/store/contacts";
|
||||||
import { client } from "@/jmap/client";
|
import { client } from "@/jmap/client";
|
||||||
import { toast } from "@/ui/toast";
|
import { toast } from "@/ui/toast";
|
||||||
|
import { t } from "@/lib/i18n";
|
||||||
|
|
||||||
export function VCardCard({ part, accountId }: { part: EmailBodyPart; accountId: Id }) {
|
export function VCardCard({ part, accountId }: { part: EmailBodyPart; accountId: Id }) {
|
||||||
const contacts = useContacts();
|
const contacts = useContacts();
|
||||||
@@ -30,7 +31,7 @@ export function VCardCard({ part, accountId }: { part: EmailBodyPart; accountId:
|
|||||||
<UserPlus size={20} style={{ color: "var(--accent)" }} />
|
<UserPlus size={20} style={{ color: "var(--accent)" }} />
|
||||||
<div className="grow">
|
<div className="grow">
|
||||||
<div style={{ fontWeight: 600 }}>{part.name ?? "Contact card"}</div>
|
<div style={{ fontWeight: 600 }}>{part.name ?? "Contact card"}</div>
|
||||||
<div className="hint">vCard attachment</div>
|
<div className="hint">{t("vCard attachment")}</div>
|
||||||
</div>
|
</div>
|
||||||
<button className="btn btn-sm" disabled={busy || done} onClick={() => void add()}>{done ? "Added" : "Add to contacts"}</button>
|
<button className="btn btn-sm" disabled={busy || done} onClick={() => void add()}>{done ? "Added" : "Add to contacts"}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useSession } from "@/store/session";
|
|||||||
import { client } from "@/jmap/client";
|
import { client } from "@/jmap/client";
|
||||||
import { DEFAULT_SOURCE_URL } from "@/lib/source";
|
import { DEFAULT_SOURCE_URL } from "@/lib/source";
|
||||||
import { APP_VERSION } from "@/lib/version";
|
import { APP_VERSION } from "@/lib/version";
|
||||||
|
import { t } from "@/lib/i18n";
|
||||||
|
|
||||||
export function AboutSettings() {
|
export function AboutSettings() {
|
||||||
const session = useSession((s) => s.session);
|
const session = useSession((s) => s.session);
|
||||||
@@ -10,28 +11,28 @@ export function AboutSettings() {
|
|||||||
const sourceUrl = session?.ihasmail?.sourceUrl ?? DEFAULT_SOURCE_URL;
|
const sourceUrl = session?.ihasmail?.sourceUrl ?? DEFAULT_SOURCE_URL;
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1>About ihasmail</h1>
|
<h1>{t("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>
|
<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 }}>
|
<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>
|
||||||
<div style={{ fontWeight: 700, fontSize: "1.2em" }}>ihasmail v{APP_VERSION}</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 className="hint">AGPL-3.0-or-later · <a href={sourceUrl} target="_blank" rel="noreferrer">{sourceUrl.replace(/^https?:\/\//, "")}</a></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h2>Server</h2>
|
<h2>{t("Server")}</h2>
|
||||||
<table className="sessions-table">
|
<table className="sessions-table">
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr><td>Signed in as</td><td>{session?.username}</td></tr>
|
<tr><td>{t("Signed in as")}</td><td>{session?.username}</td></tr>
|
||||||
<tr><td>Stalwart</td><td>{describeServer(session?.ihasmail?.server)}</td></tr>
|
<tr><td>{t("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>{t("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>{t("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("Image privacy proxy")}</td><td>{session?.ihasmail?.imageProxy ? "enabled" : "disabled"}</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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" 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>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>
|
<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>Server capabilities</h2>
|
<h2>{t("Server capabilities")}</h2>
|
||||||
<div className="row wrap gap-4">
|
<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>)}
|
{caps.map((c) => <span key={c} className="chip mono" style={{ fontSize: ".78em" }}>{c.replace("urn:ietf:params:jmap:", "")}</span>)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useSettings } from "@/store/settings";
|
|||||||
import { Switch, useIsTouch } from "@/ui/misc";
|
import { Switch, useIsTouch } from "@/ui/misc";
|
||||||
import { SWIPE_CHOICES, type SwipeAction } from "@/lib/swipe";
|
import { SWIPE_CHOICES, type SwipeAction } from "@/lib/swipe";
|
||||||
import { UI_LANGUAGES } from "@/lib/languages";
|
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
|
* The theme cards, each previewing the background it actually paints. Kept as
|
||||||
@@ -32,9 +33,9 @@ export function AppearanceSettings() {
|
|||||||
const isTouch = useIsTouch();
|
const isTouch = useIsTouch();
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1>Appearance</h1>
|
<h1>{translate("Appearance")}</h1>
|
||||||
<p className="lead">Make ihasmail yours.</p>
|
<p className="lead">{translate("Make ihasmail yours.")}</p>
|
||||||
<h2>Theme</h2>
|
<h2>{translate("Theme")}</h2>
|
||||||
<div className="theme-grid">
|
<div className="theme-grid">
|
||||||
{THEMES.map((t) => (
|
{THEMES.map((t) => (
|
||||||
<button key={t.id} className={`theme-card ${s.theme === t.id ? "active" : ""}`} onClick={() => update({ theme: t.id })}>
|
<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>
|
</div>
|
||||||
<p className="hint" style={{ marginTop: 10 }}>
|
<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>
|
</p>
|
||||||
<Switch
|
<Switch
|
||||||
checked={s.themeMessageBody}
|
checked={s.themeMessageBody}
|
||||||
onChange={(v) => update({ themeMessageBody: v })}
|
onChange={(v) => update({ themeMessageBody: v })}
|
||||||
label="Apply the theme to messages too"
|
label={translate("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."
|
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">
|
<div className="swatches">
|
||||||
{ACCENTS.map((a) => (
|
{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} />
|
<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>
|
</div>
|
||||||
<h2>Density & text</h2>
|
<h2>{translate("Density & text")}</h2>
|
||||||
<div className="field-row">
|
<div className="field-row">
|
||||||
<div className="field">
|
<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 })}>
|
<select className="select" value={s.density} onChange={(e) => update({ density: e.target.value as typeof s.density })}>
|
||||||
<option value="comfortable">Comfortable</option>
|
<option value="comfortable">{translate("Comfortable")}</option>
|
||||||
<option value="cozy">Cozy (default)</option>
|
<option value="cozy">{translate("Cozy (default)")}</option>
|
||||||
<option value="compact">Compact</option>
|
<option value="compact">{translate("Compact")}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<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 })}>
|
<select className="select" value={s.fontSize} onChange={(e) => update({ fontSize: e.target.value as typeof s.fontSize })}>
|
||||||
<option value="small">Small</option>
|
<option value="small">{translate("Small")}</option>
|
||||||
<option value="medium">Medium</option>
|
<option value="medium">{translate("Medium")}</option>
|
||||||
<option value="large">Large</option>
|
<option value="large">{translate("Large")}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h2>Language</h2>
|
<h2>{translate("Language")}</h2>
|
||||||
<div className="field" style={{ maxWidth: 320 }}>
|
<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 })}>
|
<select id="ui-language" className="select" value={s.uiLanguage} onChange={(e) => update({ uiLanguage: e.target.value })}>
|
||||||
{UI_LANGUAGES.map((l) => (
|
{UI_LANGUAGES.map((l) => (
|
||||||
<option key={l.tag} value={l.tag}>{l.name}</option>
|
<option key={l.tag} value={l.tag}>{l.name}</option>
|
||||||
@@ -93,19 +94,21 @@ export function AppearanceSettings() {
|
|||||||
more are coming is a roadmap.
|
more are coming is a roadmap.
|
||||||
*/}
|
*/}
|
||||||
<p className="hint">
|
<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>
|
||||||
<p className="hint">
|
<p className="hint">
|
||||||
This is separate from <strong>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.
|
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>
|
</p>
|
||||||
|
|
||||||
<h2>Swiping</h2>
|
<h2>{translate("Swiping")}</h2>
|
||||||
<p className="hint">
|
<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>
|
</p>
|
||||||
<div className="field-row">
|
<div className="field-row">
|
||||||
<div className="field">
|
<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 })}>
|
<select id="swipe-right" className="select" value={s.swipeRight} onChange={(e) => update({ swipeRight: e.target.value as SwipeAction })}>
|
||||||
{SWIPE_CHOICES.map((c) => (
|
{SWIPE_CHOICES.map((c) => (
|
||||||
<option key={c.value} value={c.value}>{c.label}</option>
|
<option key={c.value} value={c.value}>{c.label}</option>
|
||||||
@@ -113,7 +116,7 @@ export function AppearanceSettings() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<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 })}>
|
<select id="swipe-left" className="select" value={s.swipeLeft} onChange={(e) => update({ swipeLeft: e.target.value as SwipeAction })}>
|
||||||
{SWIPE_CHOICES.map((c) => (
|
{SWIPE_CHOICES.map((c) => (
|
||||||
<option key={c.value} value={c.value}>{c.label}</option>
|
<option key={c.value} value={c.value}>{c.label}</option>
|
||||||
@@ -129,17 +132,19 @@ export function AppearanceSettings() {
|
|||||||
*/}
|
*/}
|
||||||
{!isTouch && (
|
{!isTouch && (
|
||||||
<p className="hint">
|
<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>
|
||||||
)}
|
)}
|
||||||
<p className="hint">
|
<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>
|
</p>
|
||||||
|
|
||||||
<h2>Sidebar</h2>
|
<h2>{translate("Sidebar")}</h2>
|
||||||
<Switch checked={s.labelsSidebar} onChange={(v) => update({ labelsSidebar: v })} label="Show labels in the sidebar" />
|
<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="Show unsubscribed (hidden) folders" />
|
<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="Collapse sidebar to icons" />
|
<Switch checked={s.sidebarCollapsed} onChange={(v) => update({ sidebarCollapsed: v })} label={translate("Collapse sidebar to icons")} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,84 +2,85 @@ import { useSettings } from "@/store/settings";
|
|||||||
import { ColorSwatches, CALENDAR_COLORS } from "@/ui/misc";
|
import { ColorSwatches, CALENDAR_COLORS } from "@/ui/misc";
|
||||||
import { promptDialog } from "@/ui/dialog";
|
import { promptDialog } from "@/ui/dialog";
|
||||||
import { Plus, Trash2 } from "lucide-react";
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
|
import { t } from "@/lib/i18n";
|
||||||
|
|
||||||
export function CalendarSettings() {
|
export function CalendarSettings() {
|
||||||
const s = useSettings((st) => st.settings);
|
const s = useSettings((st) => st.settings);
|
||||||
const update = useSettings((st) => st.update);
|
const update = useSettings((st) => st.update);
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1>Calendar & contacts</h1>
|
<h1>{t("Calendar & contacts")}</h1>
|
||||||
<p className="lead">Defaults for the calendar views and new events.</p>
|
<p className="lead">{t("Defaults for the calendar views and new events.")}</p>
|
||||||
<div className="field-row">
|
<div className="field-row">
|
||||||
<div className="field">
|
<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 })}>
|
<select className="select" value={s.calendarDefaultView} onChange={(e) => update({ calendarDefaultView: e.target.value as typeof s.calendarDefaultView })}>
|
||||||
<option value="day">Day</option>
|
<option value="day">{t("Day")}</option>
|
||||||
<option value="week">Week</option>
|
<option value="week">{t("Week")}</option>
|
||||||
<option value="month">Month</option>
|
<option value="month">{t("Month")}</option>
|
||||||
<option value="agenda">Agenda</option>
|
<option value="agenda">{t("Agenda")}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<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) })}>
|
<select className="select" value={String(s.defaultEventDuration)} onChange={(e) => update({ defaultEventDuration: Number(e.target.value) })}>
|
||||||
<option value="15">15 minutes</option>
|
<option value="15">{t("15 minutes")}</option>
|
||||||
<option value="30">30 minutes</option>
|
<option value="30">{t("30 minutes")}</option>
|
||||||
<option value="45">45 minutes</option>
|
<option value="45">{t("45 minutes")}</option>
|
||||||
<option value="60">1 hour</option>
|
<option value="60">{t("1 hour")}</option>
|
||||||
<option value="90">1.5 hours</option>
|
<option value="90">{t("1.5 hours")}</option>
|
||||||
<option value="120">2 hours</option>
|
<option value="120">{t("2 hours")}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<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) })}>
|
<select className="select" value={String(s.defaultAlertMinutes)} onChange={(e) => update({ defaultAlertMinutes: Number(e.target.value) })}>
|
||||||
<option value="-1">None</option>
|
<option value="-1">{t("None")}</option>
|
||||||
<option value="0">At time of event</option>
|
<option value="0">{t("At time of event")}</option>
|
||||||
<option value="5">5 minutes before</option>
|
<option value="5">{t("5 minutes before")}</option>
|
||||||
<option value="10">10 minutes before</option>
|
<option value="10">{t("10 minutes before")}</option>
|
||||||
<option value="15">15 minutes before</option>
|
<option value="15">{t("15 minutes before")}</option>
|
||||||
<option value="30">30 minutes before</option>
|
<option value="30">{t("30 minutes before")}</option>
|
||||||
<option value="60">1 hour before</option>
|
<option value="60">{t("1 hour before")}</option>
|
||||||
<option value="1440">1 day before</option>
|
<option value="1440">{t("1 day before")}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h2>Colour categories</h2>
|
<h2>{t("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>
|
<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) => (
|
{s.eventCategories.map((c, i) => (
|
||||||
<div key={c.name} className="card">
|
<div key={c.name} className="card">
|
||||||
<div className="card-head">
|
<div className="card-head">
|
||||||
<span className="label-dot" style={{ background: c.color, width: 14, height: 14 }} />
|
<span className="label-dot" style={{ background: c.color, width: 14, height: 14 }} />
|
||||||
<h3>{c.name}</h3>
|
<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" 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="Delete category" onClick={() => update({ eventCategories: s.eventCategories.filter((_, j) => j !== i) })}><Trash2 size={16} /></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>
|
||||||
<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 style={{ marginTop: 8 }}><ColorSwatches value={c.color} onChange={(col) => update({ eventCategories: s.eventCategories.map((x, j) => (j === i ? { ...x, color: col } : x)) })} /></div>
|
||||||
</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>
|
<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-row">
|
||||||
<div className="field">
|
<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) })}>
|
<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>)}
|
{[...Array(24)].map((_, h) => <option key={h} value={h}>{`${h}:00`}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<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) })}>
|
<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>)}
|
{[...Array(25)].map((_, h) => <option key={h} value={h}>{`${h}:00`}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<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 })}>
|
<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="1">{t("Monday")}</option>
|
||||||
<option value="0">Sunday</option>
|
<option value="0">{t("Sunday")}</option>
|
||||||
<option value="6">Saturday</option>
|
<option value="6">{t("Saturday")}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { confirmDialog, promptDialog } from "@/ui/dialog";
|
|||||||
import { Switch, Spinner } from "@/ui/misc";
|
import { Switch, Spinner } from "@/ui/misc";
|
||||||
import { toast } from "@/ui/toast";
|
import { toast } from "@/ui/toast";
|
||||||
import type { SieveScript } from "@/jmap/types";
|
import type { SieveScript } from "@/jmap/types";
|
||||||
|
import { t } from "@/lib/i18n";
|
||||||
|
|
||||||
export function FiltersSettings() {
|
export function FiltersSettings() {
|
||||||
const sieve = useSieve();
|
const sieve = useSieve();
|
||||||
@@ -21,16 +22,16 @@ export function FiltersSettings() {
|
|||||||
if (!sieve.available) {
|
if (!sieve.available) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1>Filters & rules</h1>
|
<h1>{t("Filters & rules")}</h1>
|
||||||
<p className="lead">Sieve filtering is not available for this account.</p>
|
<p className="lead">{t("Sieve filtering is not available for this account.")}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1>Filters & rules</h1>
|
<h1>{t("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>
|
<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 }}>
|
<div className="view-switch" style={{ marginBottom: 16 }}>
|
||||||
<button className={tab === "rules" ? "active" : ""} onClick={() => setTab("rules")}><Wand2 size={15} /> Rules</button>
|
<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>
|
<button className={tab === "scripts" ? "active" : ""} onClick={() => setTab("scripts")}><Code size={15} /> Scripts (advanced)</button>
|
||||||
@@ -86,9 +87,9 @@ function RulesEditor() {
|
|||||||
if (damage) {
|
if (damage) {
|
||||||
return (
|
return (
|
||||||
<div className="warn-box">
|
<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>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -97,8 +98,8 @@ function RulesEditor() {
|
|||||||
return (
|
return (
|
||||||
<div className="warn-box">
|
<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>
|
<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>
|
<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([]); }}>Start with rules</button>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -106,7 +107,7 @@ function RulesEditor() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{activeIsOther && <div className="warn-box mb-16">Another script (“{script?.name}”) is active. Saving rules here will activate the “ihasmail” script instead.</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) => (
|
{list.map((r, i) => (
|
||||||
<div
|
<div
|
||||||
key={r.id}
|
key={r.id}
|
||||||
@@ -132,7 +133,7 @@ function RulesEditor() {
|
|||||||
<div className="row">
|
<div className="row">
|
||||||
<span
|
<span
|
||||||
className="drag-handle"
|
className="drag-handle"
|
||||||
title="Drag to reorder"
|
title={t("Drag to reorder")}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
onPointerDown={() => setArmed(r.id)}
|
onPointerDown={() => setArmed(r.id)}
|
||||||
onPointerUp={() => setArmed(null)}
|
onPointerUp={() => setArmed(null)}
|
||||||
@@ -142,21 +143,21 @@ function RulesEditor() {
|
|||||||
<div style={{ fontWeight: 600 }}>{r.name}</div>
|
<div style={{ fontWeight: 600 }}>{r.name}</div>
|
||||||
<div className="hint truncate">{describeRule(r)}</div>
|
<div className="hint truncate">{describeRule(r)}</div>
|
||||||
</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 === 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="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" 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="Delete rule" onClick={() => setLocal(list.filter((x) => x.id !== r.id))}><Trash2 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>
|
</div>
|
||||||
))}
|
))}
|
||||||
<div className="row" style={{ marginTop: 12 }}>
|
<div className="row" style={{ marginTop: 12 }}>
|
||||||
<button className="btn" onClick={() => setEditing(newRule())}><Plus size={16} /> New rule</button>
|
<button className="btn" onClick={() => setEditing(newRule())}><Plus size={16} /> New rule</button>
|
||||||
<span className="spacer" />
|
<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>
|
<button className="btn btn-primary" disabled={!dirty || saving} onClick={() => void save(list)}>{saving ? "Saving…" : "Save filters"}</button>
|
||||||
</div>
|
</div>
|
||||||
{content && (
|
{content && (
|
||||||
<details style={{ marginTop: 20 }}>
|
<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>
|
<pre className="code notranslate" translate="no" style={{ minHeight: 120, marginTop: 8 }}>{rulesToSieve(list)}</pre>
|
||||||
</details>
|
</details>
|
||||||
)}
|
)}
|
||||||
@@ -227,18 +228,18 @@ function ScriptsEditor() {
|
|||||||
if (sel !== null || name !== "" || content !== "") {
|
if (sel !== null || name !== "" || content !== "") {
|
||||||
return (
|
return (
|
||||||
<div>
|
<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">
|
<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 }} />
|
<textarea className="code notranslate" translate="no" value={content} onChange={(e) => setContent(e.target.value)} spellCheck={false} style={{ minHeight: 320 }} />
|
||||||
</div>
|
</div>
|
||||||
{validation && <div className="error-box mb-16">{validation}</div>}
|
{validation && <div className="error-box mb-16">{validation}</div>}
|
||||||
<div className="row">
|
<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>
|
<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" />
|
<span className="spacer" />
|
||||||
<button className="btn" disabled={busy} onClick={() => void save(false)}>Save</button>
|
<button className="btn" disabled={busy} onClick={() => void save(false)}>{t("Save")}</button>
|
||||||
<button className="btn btn-primary" disabled={busy} onClick={() => void save(true)}>Save & activate</button>
|
<button className="btn btn-primary" disabled={busy} onClick={() => void save(true)}>{t("Save & activate")}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -247,14 +248,14 @@ function ScriptsEditor() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<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) => (
|
{sieve.scripts.map((s) => (
|
||||||
<div key={s.id} className="card">
|
<div key={s.id} className="card">
|
||||||
<div className="card-head">
|
<div className="card-head">
|
||||||
<h3><span>{s.name} </span>{s.isActive && <span className="tag" style={{ background: "var(--success)" }}>active</span>}</h3>
|
<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)}>Edit</button>
|
<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="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>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { toast } from "@/ui/toast";
|
|||||||
import { formatSize } from "@/lib/format";
|
import { formatSize } from "@/lib/format";
|
||||||
import { ShareDialog } from "./ShareDialog";
|
import { ShareDialog } from "./ShareDialog";
|
||||||
import type { Mailbox } from "@/jmap/types";
|
import type { Mailbox } from "@/jmap/types";
|
||||||
|
import { t } from "@/lib/i18n";
|
||||||
|
|
||||||
export function FoldersSettings() {
|
export function FoldersSettings() {
|
||||||
const mailboxes = useMail((s) => s.mailboxes);
|
const mailboxes = useMail((s) => s.mailboxes);
|
||||||
@@ -33,23 +34,23 @@ export function FoldersSettings() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<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>
|
<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>
|
<button className="btn mb-16" onClick={() => void create()}><Plus size={16} /> New folder</button>
|
||||||
<table className="sessions-table">
|
<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>
|
<tbody>
|
||||||
{list.map(({ m, path }) => (
|
{list.map(({ m, path }) => (
|
||||||
<tr key={m.id}>
|
<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.totalEmails.toLocaleString()}</td>
|
||||||
<td>{m.unreadEmails.toLocaleString()}</td>
|
<td>{m.unreadEmails.toLocaleString()}</td>
|
||||||
<td>
|
<td>
|
||||||
<div className="row" style={{ justifyContent: "flex-end", gap: 0 }}>
|
<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>
|
<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>}
|
{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="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>
|
<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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Switch } from "@/ui/misc";
|
|||||||
import { browserTimeZone, listTimeZones } from "@/lib/dates";
|
import { browserTimeZone, listTimeZones } from "@/lib/dates";
|
||||||
import { toast } from "@/ui/toast";
|
import { toast } from "@/ui/toast";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { t } from "@/lib/i18n";
|
||||||
import {
|
import {
|
||||||
canUnregisterMailtoHandler,
|
canUnregisterMailtoHandler,
|
||||||
isInstalledApp,
|
isInstalledApp,
|
||||||
@@ -45,109 +46,108 @@ export function GeneralSettings() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1>General</h1>
|
<h1>{t("General")}</h1>
|
||||||
<p className="lead">Reading, sending and list behaviour. Settings are stored in this browser.</p>
|
<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-row">
|
||||||
<div className="field">
|
<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 })}>
|
<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="right">{t("Right of the list")}</option>
|
||||||
<option value="bottom">Below the list</option>
|
<option value="bottom">{t("Below the list")}</option>
|
||||||
<option value="off">Off (open messages full width)</option>
|
<option value="off">{t("Off (open messages full width)")}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<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) })}>
|
<select className="select" value={String(s.markReadDelay)} onChange={(e) => update({ markReadDelay: Number(e.target.value) })}>
|
||||||
<option value="0">Immediately when opened</option>
|
<option value="0">{t("Immediately when opened")}</option>
|
||||||
<option value="2">After 2 seconds</option>
|
<option value="2">{t("After 2 seconds")}</option>
|
||||||
<option value="5">After 5 seconds</option>
|
<option value="5">{t("After 5 seconds")}</option>
|
||||||
<option value="-1">Never automatically</option>
|
<option value="-1">{t("Never automatically")}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<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 })}>
|
<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="list">{t("Go back to the list")}</option>
|
||||||
<option value="older">Open the next (older) conversation</option>
|
<option value="older">{t("Open the next (older) conversation")}</option>
|
||||||
<option value="newer">Open the previous (newer) conversation</option>
|
<option value="newer">{t("Open the previous (newer) conversation")}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<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 })}>
|
<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="ask">{t("Ask before showing (recommended)")}</option>
|
||||||
<option value="contacts">Show automatically from my contacts</option>
|
<option value="contacts">{t("Show automatically from my contacts")}</option>
|
||||||
<option value="always">Always show</option>
|
<option value="always">{t("Always show")}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</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.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="Show message snippets" hint="Preview the first line of each message in the list." />
|
<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="Show sender avatars" />
|
<Switch checked={s.showAvatars} onChange={(v) => update({ showAvatars: v })} label={t("Show sender avatars")} />
|
||||||
<Switch checked={s.confirmDelete} onChange={(v) => update({ confirmDelete: v })} label="Confirm before deleting" />
|
<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-row">
|
||||||
<div className="field">
|
<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 })}>
|
<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="html">{t("Rich text (HTML)")}</option>
|
||||||
<option value="text">Plain text</option>
|
<option value="text">{t("Plain text")}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<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) })}>
|
<select className="select" value={String(s.undoSendSeconds)} onChange={(e) => update({ undoSendSeconds: Number(e.target.value) })}>
|
||||||
<option value="0">Off</option>
|
<option value="0">{t("Off")}</option>
|
||||||
<option value="5">5 seconds</option>
|
<option value="5">{t("5 seconds")}</option>
|
||||||
<option value="8">8 seconds</option>
|
<option value="8">{t("8 seconds")}</option>
|
||||||
<option value="15">15 seconds</option>
|
<option value="15">{t("15 seconds")}</option>
|
||||||
<option value="30">30 seconds</option>
|
<option value="30">{t("30 seconds")}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Switch checked={s.includeQuote} onChange={(v) => update({ includeQuote: v })} label="Quote original message in replies" />
|
<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="Place signature above quoted text" />
|
<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="Attachment reminder" hint="Warn when the message mentions an attachment but none is attached." />
|
<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="Always request read receipts" />
|
<Switch checked={s.requestReadReceipt} onChange={(v) => update({ requestReadReceipt: v })} label={t("Always request read receipts")} />
|
||||||
<div className="field">
|
<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 })}>
|
<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="ask">{t("Ask me on each message")}</option>
|
||||||
<option value="never">Never send one</option>
|
<option value="never">{t("Never send one")}</option>
|
||||||
</select>
|
</select>
|
||||||
<p className="hint">
|
<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
|
{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.")}
|
||||||
auto-submitted are never offered one at all.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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-row">
|
||||||
<div className="field">
|
<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 })}>
|
<select className="select" value={s.timeZone ?? ""} onChange={(e) => update({ timeZone: e.target.value || null })}>
|
||||||
<option value="">Browser default ({browserTimeZone})</option>
|
<option value="">Browser default ({browserTimeZone})</option>
|
||||||
{listTimeZones().map((tz) => <option key={tz} value={tz}>{tz}</option>)}
|
{listTimeZones().map((tz) => <option key={tz} value={tz}>{tz}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<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 })}>
|
<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="1">{t("Monday")}</option>
|
||||||
<option value="0">Sunday</option>
|
<option value="0">{t("Sunday")}</option>
|
||||||
<option value="6">Saturday</option>
|
<option value="6">{t("Saturday")}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="field-row">
|
<div className="field-row">
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>Language & region</label>
|
<label>{t("Language & region")}</label>
|
||||||
<select className="select" value={s.locale} onChange={(e) => update({ locale: e.target.value })}>
|
<select className="select" value={s.locale} onChange={(e) => update({ locale: e.target.value })}>
|
||||||
<option value="">Automatic ({localeLabel(autoLocale)})</option>
|
<option value="">Automatic ({localeLabel(autoLocale)})</option>
|
||||||
{localeOptions().map((o) => <option key={o.tag} value={o.tag}>{o.label} — {o.tag}</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>
|
<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>
|
||||||
<div className="field">
|
<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 })}>
|
<select className="select" value={s.dateFormat} onChange={(e) => update({ dateFormat: e.target.value as DateFormat })}>
|
||||||
{DATE_FORMATS.map((f) => (
|
{DATE_FORMATS.map((f) => (
|
||||||
<option key={f.value} value={f.value}>
|
<option key={f.value} value={f.value}>
|
||||||
@@ -165,27 +165,27 @@ export function GeneralSettings() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<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 })}>
|
<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="auto">Automatic ({withPrefs({ locale: s.locale, timeFormat: "auto" }, () => formatClock(SAMPLE))})</option>
|
||||||
<option value="24">24-hour clock (18:23)</option>
|
<option value="24">{t("24-hour clock (18:23)")}</option>
|
||||||
<option value="12">12-hour clock (6:23 PM)</option>
|
<option value="12">{t("12-hour clock (6:23 PM)")}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="hint">Preview: {formatFullDateTime(SAMPLE)}</p>
|
<p className="hint">Preview: {formatFullDateTime(SAMPLE)}</p>
|
||||||
|
|
||||||
<h2>Default mail app</h2>
|
<h2>{t("Default mail app")}</h2>
|
||||||
<MailHandlerSettings />
|
<MailHandlerSettings />
|
||||||
|
|
||||||
<h2>Backup</h2>
|
<h2>{t("Backup")}</h2>
|
||||||
<div className="row wrap">
|
<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">
|
<label className="btn">
|
||||||
Import settings
|
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 = ""; }} />
|
<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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -231,12 +231,13 @@ function MailHandlerSettings() {
|
|||||||
</p>
|
</p>
|
||||||
<div className="row wrap">
|
<div className="row wrap">
|
||||||
<button className="btn btn-primary" onClick={ask}>{requested ? "Ask again" : "Make ihasmail the default mail app"}</button>
|
<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>
|
</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() && (
|
{!isInstalledApp() && (
|
||||||
<p className="hint mt-8">
|
<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>
|
</p>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { htmlToText } from "@/lib/text";
|
|||||||
import { sanitizeEditorHtml } from "@/lib/html";
|
import { sanitizeEditorHtml } from "@/lib/html";
|
||||||
import { externalizeDataImages, storeSignatureHtml, uploadSignatureImage } from "@/lib/signatureImages";
|
import { externalizeDataImages, storeSignatureHtml, uploadSignatureImage } from "@/lib/signatureImages";
|
||||||
import { buildMarkerSignature, byteLength, compactHtml, signatureTooLong, SIGNATURE_LIMIT } from "@/lib/signatureHtml";
|
import { buildMarkerSignature, byteLength, compactHtml, signatureTooLong, SIGNATURE_LIMIT } from "@/lib/signatureHtml";
|
||||||
|
import { t } from "@/lib/i18n";
|
||||||
|
|
||||||
export function IdentitiesSettings() {
|
export function IdentitiesSettings() {
|
||||||
const identities = useMail((s) => s.identities);
|
const identities = useMail((s) => s.identities);
|
||||||
@@ -30,12 +31,12 @@ export function IdentitiesSettings() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1>Identities & signatures</h1>
|
<h1>{t("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>
|
<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) => (
|
{identities.map((i) => (
|
||||||
<div key={i.id} className="card clickable" onClick={() => setEditing(i)}>
|
<div key={i.id} className="card clickable" onClick={() => setEditing(i)}>
|
||||||
<div className="card-head">
|
<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 && (
|
{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>
|
<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</>}
|
{hidden.includes(i.id) ? <><Eye size={14} /> Show when composing</> : <><EyeOff size={14} /> Hide when composing</>}
|
||||||
</button>
|
</button>
|
||||||
{i.mayDelete && (
|
{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>
|
</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.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}
|
{i.replyTo?.length ? <div className="hint">Reply-To: {formatAddressList(i.replyTo)}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<button className="btn" onClick={() => setEditing({ name: "", email: identities[0]?.email ?? "", textSignature: "", htmlSignature: "", replyTo: null, bcc: null })}><Plus size={16} /> Add identity</button>
|
<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 && (
|
{hidden.length > 0 && (
|
||||||
<p className="hint">
|
<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.`}
|
{`${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 (
|
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-row">
|
||||||
<div className="field"><label>Display name</label><input className="input" value={name} onChange={(e) => setName(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>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("Email address")}</label><input className="input" type="email" value={email} disabled={Boolean(identity.id)} onChange={(e) => setEmail(e.target.value)} /></div>
|
||||||
</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">
|
<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" }}>
|
<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>
|
||||||
<div className="row" style={{ justifyContent: "space-between" }}>
|
<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>
|
<span className="hint nowrap" style={tooLong ? { color: "var(--warn)", fontWeight: 600 } : undefined}>{sigLen.toLocaleString()} / {SIGNATURE_LIMIT.toLocaleString()}</span>
|
||||||
</div>
|
</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>}
|
{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>}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Plus, Trash2 } from "lucide-react";
|
|||||||
import { useSettings } from "@/store/settings";
|
import { useSettings } from "@/store/settings";
|
||||||
import { CALENDAR_COLORS, ColorSwatches } from "@/ui/misc";
|
import { CALENDAR_COLORS, ColorSwatches } from "@/ui/misc";
|
||||||
import { promptDialog } from "@/ui/dialog";
|
import { promptDialog } from "@/ui/dialog";
|
||||||
|
import { t } from "@/lib/i18n";
|
||||||
|
|
||||||
export function LabelsSettings() {
|
export function LabelsSettings() {
|
||||||
const labels = useSettings((s) => s.settings.labels);
|
const labels = useSettings((s) => s.settings.labels);
|
||||||
@@ -19,8 +20,8 @@ export function LabelsSettings() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1>Labels</h1>
|
<h1>{t("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>
|
<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) => (
|
{labels.map((l) => (
|
||||||
<div key={l.keyword} className="card">
|
<div key={l.keyword} className="card">
|
||||||
<div className="card-head">
|
<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>
|
<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>
|
||||||
<div style={{ marginTop: 8 }}>
|
<div style={{ marginTop: 8 }}>
|
||||||
<ColorSwatches value={l.color} onChange={(c) => update({ labels: labels.map((x) => (x.keyword === l.keyword ? { ...x, color: c } : x)) })} />
|
<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 { disableWebPush, enableWebPush, webPushActive } from "@/lib/webpushEnable";
|
||||||
import { supportsEmailPush, webPushAvailable } from "@/lib/webpush";
|
import { supportsEmailPush, webPushAvailable } from "@/lib/webpush";
|
||||||
import { toast } from "@/ui/toast";
|
import { toast } from "@/ui/toast";
|
||||||
|
import { t } from "@/lib/i18n";
|
||||||
|
|
||||||
export function NotificationsSettings() {
|
export function NotificationsSettings() {
|
||||||
const s = useSettings((st) => st.settings);
|
const s = useSettings((st) => st.settings);
|
||||||
@@ -23,8 +24,8 @@ export function NotificationsSettings() {
|
|||||||
}, [s.desktopNotifications]);
|
}, [s.desktopNotifications]);
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1>Notifications</h1>
|
<h1>{t("Notifications")}</h1>
|
||||||
<p className="lead">{`Live updates are delivered via JMAP push (${pushConnected ? "connected" : "reconnecting…"}).`}</p>
|
<p className="lead">{t("Live updates are delivered via JMAP push ({state}).", { state: pushConnected ? t("connected") : t("reconnecting…") })}</p>
|
||||||
<Switch
|
<Switch
|
||||||
checked={s.desktopNotifications}
|
checked={s.desktopNotifications}
|
||||||
onChange={async (v) => {
|
onChange={async (v) => {
|
||||||
@@ -35,8 +36,8 @@ export function NotificationsSettings() {
|
|||||||
}
|
}
|
||||||
update({ desktopNotifications: v });
|
update({ desktopNotifications: v });
|
||||||
}}
|
}}
|
||||||
label="Desktop notifications while ihasmail is open"
|
label={t("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."}
|
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"}
|
disabled={perm === "denied" || perm === "unsupported"}
|
||||||
/>
|
/>
|
||||||
{/*
|
{/*
|
||||||
@@ -57,7 +58,7 @@ export function NotificationsSettings() {
|
|||||||
const res = await enableWebPush();
|
const res = await enableWebPush();
|
||||||
if (!res.ok) { toast.error(res.reason); return; }
|
if (!res.ok) { toast.error(res.reason); return; }
|
||||||
setBackground(true);
|
setBackground(true);
|
||||||
toast.success("Background notifications are on");
|
toast.success(t("Background notifications are on"));
|
||||||
} else {
|
} else {
|
||||||
await disableWebPush();
|
await disableWebPush();
|
||||||
setBackground(false);
|
setBackground(false);
|
||||||
@@ -66,20 +67,20 @@ export function NotificationsSettings() {
|
|||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
label="Notify me even when ihasmail is closed"
|
label={t("Notify me even when ihasmail is closed")}
|
||||||
hint={
|
hint={
|
||||||
!canBackground
|
!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()
|
: 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."
|
? 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.")
|
||||||
: "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 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">
|
<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>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { HEADER_CHOICES, HEADER_OPS, type SieveAction, type SieveRule, type Siev
|
|||||||
import { Dialog, promptDialog } from "@/ui/dialog";
|
import { Dialog, promptDialog } from "@/ui/dialog";
|
||||||
import { toast } from "@/ui/toast";
|
import { toast } from "@/ui/toast";
|
||||||
import type { Id } from "@/jmap/types";
|
import type { Id } from "@/jmap/types";
|
||||||
|
import { t as translate } from "@/lib/i18n";
|
||||||
|
|
||||||
export interface RuleDialogProps {
|
export interface RuleDialogProps {
|
||||||
rule: SieveRule;
|
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>
|
<span>Also apply to existing messages in <b>{applyMailbox.name}</b></span>
|
||||||
</label>
|
</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></>}>
|
<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>Rule name</label><input className="input" value={r.name} onChange={(e) => setR({ ...r, name: e.target.value })} autoFocus /></div>
|
<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 }}>
|
<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" })}>
|
<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="allof">{translate("all of the following match")}</option>
|
||||||
<option value="anyof">any of the following match</option>
|
<option value="anyof">{translate("any of the following match")}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
{r.tests.map((t, i) => {
|
{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: "" });
|
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>)}
|
{HEADER_CHOICES.map((h) => <option key={h.value} value={h.value}>{h.label}</option>)}
|
||||||
<option value="address">Sender domain</option>
|
<option value="address">{translate("Sender domain")}</option>
|
||||||
<option value="size">Message size</option>
|
<option value="size">{translate("Message size")}</option>
|
||||||
<option value="body">Body text</option>
|
<option value="body">{translate("Body text")}</option>
|
||||||
<option value="true">Always (all messages)</option>
|
<option value="true">{translate("Always (all messages)")}</option>
|
||||||
</select>
|
</select>
|
||||||
{customHeader && t.type === "header" && (
|
{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" ? (
|
{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" ? (
|
) : 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 /> : (
|
) : 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 })}>
|
<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>)}
|
{HEADER_OPS.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||||
</select>
|
</select>
|
||||||
)}
|
)}
|
||||||
{t.type === "size" ? (
|
{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 /> : (
|
) : 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)} />
|
<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>
|
</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>
|
<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) => (
|
{r.actions.map((a, i) => (
|
||||||
<div key={i} className="rule-row actions">
|
<div key={i} className="rule-row actions">
|
||||||
<select className="select" value={a.type} onChange={(e) => {
|
<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);
|
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);
|
setAction(i, next);
|
||||||
}}>
|
}}>
|
||||||
<option value="fileinto">Move to folder</option>
|
<option value="fileinto">{translate("Move to folder")}</option>
|
||||||
<option value="markread">Mark as read</option>
|
<option value="markread">{translate("Mark as read")}</option>
|
||||||
<option value="flag">Star</option>
|
<option value="flag">{translate("Star")}</option>
|
||||||
<option value="addflag">Add label / keyword</option>
|
<option value="addflag">{translate("Add label / keyword")}</option>
|
||||||
<option value="redirect">Forward to</option>
|
<option value="redirect">{translate("Forward to")}</option>
|
||||||
<option value="keep">Keep in Inbox</option>
|
<option value="keep">{translate("Keep in Inbox")}</option>
|
||||||
<option value="discard">Delete</option>
|
<option value="discard">{translate("Delete")}</option>
|
||||||
<option value="reject">Reject with message</option>
|
<option value="reject">{translate("Reject with message")}</option>
|
||||||
<option value="stop">Stop processing more rules</option>
|
<option value="stop">{translate("Stop processing more rules")}</option>
|
||||||
</select>
|
</select>
|
||||||
{a.type === "fileinto" ? (
|
{a.type === "fileinto" ? (
|
||||||
<div className="row">
|
<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.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>}
|
{!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>
|
</select>
|
||||||
<label className="check nowrap"><input type="checkbox" checked={Boolean(a.copy)} onChange={(e) => setAction(i, { ...a, copy: e.target.checked })} /> keep copy</label>
|
<label className="check nowrap"><input type="checkbox" checked={Boolean(a.copy)} onChange={(e) => setAction(i, { ...a, copy: e.target.checked })} /> keep copy</label>
|
||||||
</div>
|
</div>
|
||||||
) : a.type === "redirect" ? (
|
) : a.type === "redirect" ? (
|
||||||
<div className="row">
|
<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>
|
<label className="check nowrap"><input type="checkbox" checked={Boolean(a.copy)} onChange={(e) => setAction(i, { ...a, copy: e.target.checked })} /> keep copy</label>
|
||||||
</div>
|
</div>
|
||||||
) : a.type === "reject" ? (
|
) : 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" ? (
|
) : 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 />}
|
) : <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>
|
</div>
|
||||||
))}
|
))}
|
||||||
<button className="btn btn-ghost btn-sm" onClick={() => setR({ ...r, actions: [...r.actions, { type: "stop" }] })}><Plus size={14} /> Add action</button>
|
<button className="btn btn-ghost btn-sm" onClick={() => setR({ ...r, actions: [...r.actions, { type: "stop" }] })}><Plus size={14} /> Add action</button>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useSession } from "@/store/session";
|
|||||||
import { formatFullDate } from "@/lib/format";
|
import { formatFullDate } from "@/lib/format";
|
||||||
import { toast } from "@/ui/toast";
|
import { toast } from "@/ui/toast";
|
||||||
import { confirmDialog, Dialog } from "@/ui/dialog";
|
import { confirmDialog, Dialog } from "@/ui/dialog";
|
||||||
|
import { t } from "@/lib/i18n";
|
||||||
|
|
||||||
interface SessionRow {
|
interface SessionRow {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -57,10 +58,10 @@ export function SecuritySettings() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<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>
|
<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 ? (
|
{unsupported ? (
|
||||||
<p className="hint">{unsupported}</p>
|
<p className="hint">{unsupported}</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -69,26 +70,26 @@ export function SecuritySettings() {
|
|||||||
|
|
||||||
{!unsupported && state?.otpEnabled && (
|
{!unsupported && state?.otpEnabled && (
|
||||||
<>
|
<>
|
||||||
<h2>Two-factor authentication</h2>
|
<h2>{t("Two-factor authentication")}</h2>
|
||||||
<TwoFactorOff reload={async () => { await loadSecurity(); await load(); }} />
|
<TwoFactorOff reload={async () => { await loadSecurity(); await load(); }} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<h2>App passwords</h2>
|
<h2>{t("App passwords")}</h2>
|
||||||
{unsupported ? (
|
{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} />
|
<AppPasswords state={state} reload={loadSecurity} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<h2>Active webmail sessions</h2>
|
<h2>{t("Active webmail sessions")}</h2>
|
||||||
{rows === null ? <p className="hint">Loading…</p> : (
|
{rows === null ? <p className="hint">{t("Loading…")}</p> : (
|
||||||
<table className="sessions-table">
|
<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>
|
<tbody>
|
||||||
{rows.map((r) => (
|
{rows.map((r) => (
|
||||||
<tr key={r.id}>
|
<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 className="mono small">{r.ip}</td>
|
||||||
<td>{formatFullDate(new Date(r.lastSeenAt).toISOString())}</td>
|
<td>{formatFullDate(new Date(r.lastSeenAt).toISOString())}</td>
|
||||||
<td>{`${formatFullDate(new Date(r.expiresAt).toISOString())}${r.remember ? " (remembered)" : ""}`}</td>
|
<td>{`${formatFullDate(new Date(r.expiresAt).toISOString())}${r.remember ? " (remembered)" : ""}`}</td>
|
||||||
@@ -99,8 +100,8 @@ export function SecuritySettings() {
|
|||||||
</table>
|
</table>
|
||||||
)}
|
)}
|
||||||
<div className="row mt-16">
|
<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" 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()}>Sign out here</button>
|
<button className="btn btn-ghost" onClick={() => void logout()}>{t("Sign out here")}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -139,24 +140,24 @@ function PasswordForm({ otpEnabled, onChanged }: { otpEnabled: boolean; onChange
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={submit}>
|
<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 }}>
|
<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 />
|
<input id="pw-current" type="password" autoComplete="current-password" value={current} onChange={(e) => setCurrent(e.target.value)} required />
|
||||||
</div>
|
</div>
|
||||||
{otpEnabled && (
|
{otpEnabled && (
|
||||||
<div className="field" style={{ maxWidth: 380 }}>
|
<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 />
|
<input id="pw-code" inputMode="numeric" autoComplete="one-time-code" value={code} onChange={(e) => setCode(e.target.value)} placeholder="123456" required />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="field-row" style={{ maxWidth: 780 }}>
|
<div className="field-row" style={{ maxWidth: 780 }}>
|
||||||
<div className="field">
|
<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 />
|
<input id="pw-new" type="password" autoComplete="new-password" value={next} onChange={(e) => setNext(e.target.value)} required />
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<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 />
|
<input id="pw-confirm" type="password" autoComplete="new-password" value={confirm} onChange={(e) => setConfirm(e.target.value)} required />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -198,27 +199,27 @@ function TwoFactorOff({ reload }: { reload: () => Promise<void> }) {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<p className="hint" style={{ marginBottom: 12 }}>
|
<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>
|
</p>
|
||||||
<div className="row" style={{ alignItems: "center", gap: 10 }}>
|
<div className="row" style={{ alignItems: "center", gap: 10 }}>
|
||||||
<ShieldCheck size={18} />
|
<ShieldCheck size={18} />
|
||||||
<b>Enabled</b>
|
<b>{t("Enabled")}</b>
|
||||||
<button className="btn btn-sm" onClick={() => { setDisabling(true); setCode(""); setPassword(""); }}>Turn off</button>
|
<button className="btn btn-sm" onClick={() => { setDisabling(true); setCode(""); setPassword(""); }}>{t("Turn off")}</button>
|
||||||
</div>
|
</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={<>
|
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>
|
<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">
|
<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)} />
|
<input id="tfa-off-pw" type="password" autoComplete="current-password" value={password} onChange={(e) => setPassword(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<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" />
|
<input id="tfa-off-code" inputMode="numeric" autoComplete="one-time-code" value={code} onChange={(e) => setCode(e.target.value)} placeholder="123456" />
|
||||||
</div>
|
</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
@@ -233,7 +234,7 @@ function AppPasswords({ state, reload }: { state: SecurityState | null; reload:
|
|||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [issued, setIssued] = useState<{ description: string; secret: string } | null>(null);
|
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) => {
|
const create = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -273,17 +274,18 @@ function AppPasswords({ state, reload }: { state: SecurityState | null; reload:
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<p className="hint" style={{ marginBottom: 12 }}>
|
<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>
|
</p>
|
||||||
{state.appPasswords.length > 0 && (
|
{state.appPasswords.length > 0 && (
|
||||||
<table className="sessions-table">
|
<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>
|
<tbody>
|
||||||
{state.appPasswords.map((row) => (
|
{state.appPasswords.map((row) => (
|
||||||
<tr key={row.id}>
|
<tr key={row.id}>
|
||||||
<td><KeyRound size={14} style={{ verticalAlign: "-2px", marginRight: 6 }} />{row.description}</td>
|
<td><KeyRound size={14} style={{ verticalAlign: "-2px", marginRight: 6 }} />{row.description}</td>
|
||||||
<td>{row.createdAt ? formatFullDate(row.createdAt) : "—"}</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>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</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" }}>
|
<form onSubmit={create} className="row mt-16" style={{ gap: 8, alignItems: "flex-end", flexWrap: "wrap" }}>
|
||||||
<div className="field" style={{ marginBottom: 0, minWidth: 240 }}>
|
<div className="field" style={{ marginBottom: 0, minWidth: 240 }}>
|
||||||
<label htmlFor="ap-name">New app password for</label>
|
<label htmlFor="ap-name">{t("New app password for")}</label>
|
||||||
<input id="ap-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Thunderbird on my laptop" required />
|
<input id="ap-name" value={name} onChange={(e) => setName(e.target.value)} placeholder={t("Thunderbird on my laptop")} required />
|
||||||
</div>
|
</div>
|
||||||
<button className="btn" disabled={busy || !name.trim()}>{busy ? "Creating…" : "Create"}</button>
|
<button className="btn" disabled={busy || !name.trim()}>{busy ? "Creating…" : "Create"}</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<Dialog open={Boolean(issued)} onClose={() => setIssued(null)} title="Your new app password" size="sm"
|
<Dialog open={Boolean(issued)} onClose={() => setIssued(null)} title={t("Your new app password")} size="sm"
|
||||||
footer={<button className="btn btn-primary" onClick={() => setIssued(null)}>Done</button>}>
|
footer={<button className="btn btn-primary" onClick={() => setIssued(null)}>{t("Done")}</button>}>
|
||||||
{issued && (
|
{issued && (
|
||||||
<div>
|
<div>
|
||||||
<p>Copy it into <b>{issued.description}</b> now — it isn't shown again.</p>
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-sm btn-ghost"
|
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"))}
|
onClick={() => void navigator.clipboard?.writeText(value).then(() => toast.success("Copied"), () => toast.error("Could not copy"))}
|
||||||
>
|
>
|
||||||
<Copy size={14} />
|
<Copy size={14} />
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { SecuritySettings } from "./SecuritySettings";
|
|||||||
import { AboutSettings } from "./AboutSettings";
|
import { AboutSettings } from "./AboutSettings";
|
||||||
import { ShortcutsSettings } from "./ShortcutsSettings";
|
import { ShortcutsSettings } from "./ShortcutsSettings";
|
||||||
import { CalendarSettings } from "./CalendarSettings";
|
import { CalendarSettings } from "./CalendarSettings";
|
||||||
|
import { t } from "@/lib/i18n";
|
||||||
|
|
||||||
const FiltersSettings = lazy(() => import("./FiltersSettings").then((m) => ({ default: m.FiltersSettings })));
|
const FiltersSettings = lazy(() => import("./FiltersSettings").then((m) => ({ default: m.FiltersSettings })));
|
||||||
const VacationSettings = lazy(() => import("./VacationSettings").then((m) => ({ default: m.VacationSettings })));
|
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);
|
const current = SECTIONS.find((s) => s.id === section);
|
||||||
return (
|
return (
|
||||||
<div className={`settings-layout ${section ? "section" : "root"}`}>
|
<div className={`settings-layout ${section ? "section" : "root"}`}>
|
||||||
<nav className="settings-nav" aria-label="Settings">
|
<nav className="settings-nav" aria-label={t("Settings")}>
|
||||||
<div className="nav-section" style={{ paddingLeft: 8 }}><span>Settings</span></div>
|
<div className="nav-section" style={{ paddingLeft: 8 }}><span>{t("Settings")}</span></div>
|
||||||
{SECTIONS.map((s) => (
|
{SECTIONS.map((s) => (
|
||||||
<Link key={s.id} href={`/settings/${s.id}`} className={`nav-item ${section === s.id ? "active" : ""}`}>
|
<Link key={s.id} href={`/settings/${s.id}`} className={`nav-item ${section === s.id ? "active" : ""}`}>
|
||||||
{s.icon}
|
{s.icon}
|
||||||
<span className="nav-label">{s.label}</span>
|
<span className="nav-label">{s.label}</span>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
<div className="nav-section" style={{ paddingLeft: 8 }}><span>Shortcuts</span></div>
|
<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">Address books</span></Link>
|
<Link href="/contacts" className="nav-item"><Users size={18} /><span className="nav-label">{t("Address books")}</span></Link>
|
||||||
</nav>
|
</nav>
|
||||||
<div className="settings-content">
|
<div className="settings-content">
|
||||||
{section && (
|
{section && (
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useFiles } from "@/store/files";
|
|||||||
import { client, setErrorMessage } from "@/jmap/client";
|
import { client, setErrorMessage } from "@/jmap/client";
|
||||||
import { toast } from "@/ui/toast";
|
import { toast } from "@/ui/toast";
|
||||||
import type { Id, Principal } from "@/jmap/types";
|
import type { Id, Principal } from "@/jmap/types";
|
||||||
|
import { t } from "@/lib/i18n";
|
||||||
|
|
||||||
/* The JMAP type name, used verbatim as the `/set` method prefix. */
|
/* The JMAP type name, used verbatim as the `/set` method prefix. */
|
||||||
type Kind = "Mailbox" | "Calendar" | "AddressBook" | "FileNode";
|
type Kind = "Mailbox" | "Calendar" | "AddressBook" | "FileNode";
|
||||||
@@ -103,7 +104,7 @@ export function ShareDialog({ kind, id, name, shareWith, onClose }: { kind: Kind
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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
|
{/* 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
|
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
|
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. */}
|
share could not be seen, let alone removed. */}
|
||||||
{!principals.length && (
|
{!principals.length && (
|
||||||
<p className="hint" style={{ marginBottom: 12 }}>
|
<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>
|
</p>
|
||||||
)}
|
)}
|
||||||
{principals.length > 0 && (
|
{principals.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="row" style={{ marginBottom: 12 }}>
|
<div className="row" style={{ marginBottom: 12 }}>
|
||||||
<select className="select" value={pick} onChange={(e) => setPick(e.target.value)}>
|
<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) => (
|
{available.map((p) => (
|
||||||
<option key={p.id} value={p.id}>{`${p.name}${p.email ? ` <${p.email}>` : ""}${p.type !== "individual" ? ` (${p.type})` : ""}`}</option>
|
<option key={p.id} value={p.id}>{`${p.name}${p.email ? ` <${p.email}>` : ""}${p.type !== "individual" ? ` (${p.type})` : ""}`}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</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" 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"); }}>Editor</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>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -134,7 +136,7 @@ export function ShareDialog({ kind, id, name, shareWith, onClose }: { kind: Kind
|
|||||||
<div key={pid} className="card">
|
<div key={pid} className="card">
|
||||||
<div className="card-head">
|
<div className="card-head">
|
||||||
<h3><span>{p?.name ?? pid}</span>{p?.email ? <span className="hint" style={{ fontWeight: 400 }}> · {p.email}</span> : null}</h3>
|
<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>
|
||||||
<div className="row wrap" style={{ marginTop: 8 }}>
|
<div className="row wrap" style={{ marginTop: 8 }}>
|
||||||
{RIGHTS[kind].map((rt) => (
|
{RIGHTS[kind].map((rt) => (
|
||||||
@@ -147,7 +149,7 @@ export function ShareDialog({ kind, id, name, shareWith, onClose }: { kind: Kind
|
|||||||
</div>
|
</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>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { keyboard } from "@/lib/keyboard";
|
import { keyboard } from "@/lib/keyboard";
|
||||||
import { Kbd } from "@/ui/misc";
|
import { Kbd } from "@/ui/misc";
|
||||||
|
import { t } from "@/lib/i18n";
|
||||||
|
|
||||||
export function ShortcutsSettings() {
|
export function ShortcutsSettings() {
|
||||||
const list = useMemo(() => keyboard.list(), []);
|
const list = useMemo(() => keyboard.list(), []);
|
||||||
@@ -15,7 +16,7 @@ export function ShortcutsSettings() {
|
|||||||
}, [list]);
|
}, [list]);
|
||||||
return (
|
return (
|
||||||
<div>
|
<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>
|
<p className="lead">Gmail-style shortcuts are always on. Press <kbd className="kbd">?</kbd> anywhere to see this list.</p>
|
||||||
<div className="shortcut-grid">
|
<div className="shortcut-grid">
|
||||||
{groups.map(([group, items]) => (
|
{groups.map(([group, items]) => (
|
||||||
@@ -26,7 +27,7 @@ export function ShortcutsSettings() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useSettings, type Template } from "@/store/settings";
|
|||||||
import { Dialog } from "@/ui/dialog";
|
import { Dialog } from "@/ui/dialog";
|
||||||
import { RichEditor } from "../compose/RichEditor";
|
import { RichEditor } from "../compose/RichEditor";
|
||||||
import { htmlToText } from "@/lib/text";
|
import { htmlToText } from "@/lib/text";
|
||||||
|
import { t as translate } from "@/lib/i18n";
|
||||||
|
|
||||||
export function TemplatesSettings() {
|
export function TemplatesSettings() {
|
||||||
const templates = useSettings((s) => s.settings.templates);
|
const templates = useSettings((s) => s.settings.templates);
|
||||||
@@ -11,13 +12,13 @@ export function TemplatesSettings() {
|
|||||||
const [editing, setEditing] = useState<Template | null>(null);
|
const [editing, setEditing] = useState<Template | null>(null);
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1>Templates</h1>
|
<h1>{translate("Templates")}</h1>
|
||||||
<p className="lead">Canned responses you can insert into any message from the composer's template button.</p>
|
<p className="lead">{translate("Canned responses you can insert into any message from the composer's template button.")}</p>
|
||||||
{templates.map((t) => (
|
{templates.map((t) => (
|
||||||
<div key={t.id} className="card clickable" onClick={() => setEditing(t)}>
|
<div key={t.id} className="card clickable" onClick={() => setEditing(t)}>
|
||||||
<div className="card-head">
|
<div className="card-head">
|
||||||
<h3>{t.name}</h3>
|
<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>
|
</div>
|
||||||
{t.subject && <div className="hint">Subject: {t.subject}</div>}
|
{t.subject && <div className="hint">Subject: {t.subject}</div>}
|
||||||
<div className="hint truncate">{htmlToText(t.html).slice(0, 140)}</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>
|
<button className="btn" onClick={() => setEditing({ id: `t${Date.now()}`, name: "", subject: "", html: "" })}><Plus size={16} /> New template</button>
|
||||||
{editing && (
|
{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-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>{translate("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("Subject (optional)")}</label><input className="input" value={editing.subject} onChange={(e) => setEditing({ ...editing, subject: e.target.value })} /></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<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" }}>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { toast } from "@/ui/toast";
|
|||||||
import { toInputDateTime, fromInputDateTime, toUTCDate } from "@/lib/dates";
|
import { toInputDateTime, fromInputDateTime, toUTCDate } from "@/lib/dates";
|
||||||
import { DateTimeField } from "@/ui/datefield";
|
import { DateTimeField } from "@/ui/datefield";
|
||||||
import { client, CAP } from "@/jmap/client";
|
import { client, CAP } from "@/jmap/client";
|
||||||
|
import { t } from "@/lib/i18n";
|
||||||
|
|
||||||
export function VacationSettings() {
|
export function VacationSettings() {
|
||||||
const vacation = useMail((s) => s.vacation);
|
const vacation = useMail((s) => s.vacation);
|
||||||
@@ -30,7 +31,7 @@ export function VacationSettings() {
|
|||||||
setTo(vacation.toDate ? toInputDateTime(new Date(vacation.toDate)) : "");
|
setTo(vacation.toDate ? toInputDateTime(new Date(vacation.toDate)) : "");
|
||||||
}, [vacation]);
|
}, [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 () => {
|
const submit = async () => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
@@ -53,16 +54,16 @@ export function VacationSettings() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1>Out of office</h1>
|
<h1>{t("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>
|
<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="Auto-reply enabled" />
|
<Switch checked={enabled} onChange={setEnabled} label={t("Auto-reply enabled")} />
|
||||||
<div className="field-row mt-16">
|
<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>{t("Starts (optional)")}</label><DateTimeField aria-label={t("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("Ends (optional)")}</label><DateTimeField aria-label={t("Ends")} value={to} onChange={setTo} /></div>
|
||||||
</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>{t("Subject")}</label><input className="input" value={subject} onChange={(e) => setSubject(e.target.value)} placeholder={t("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>
|
<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()}>Save</button>
|
<button className="btn btn-primary" disabled={busy} onClick={() => void submit()}>{t("Save")}</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user