Merge public ihasmail: folder reordering, full-screen composer, Dutch update

- Reorder folders by dragging, with special folders first (#402, #405).
- Open the composer full screen, as a setting (#401, #404).
- Dutch translation update (#403).

FEATURES.md stays deleted here, as in bb25355.
This commit is contained in:
2026-09-19 14:11:50 -07:00
20 changed files with 532 additions and 70 deletions
@@ -0,0 +1,121 @@
import { describe, expect, it } from "vitest";
import { canPlaceFolder, compareFolders, neighbour, placeFolder, siblingsOf } from "../folderOrder";
import type { Id, Mailbox } from "@/jmap/types";
const RIGHTS = { mayRename: true, mayCreateChild: true } as Mailbox["myRights"];
const mb = (id: string, name: string, parentId: string | null, role: Mailbox["role"] = null, sortOrder = 0, over: Partial<Mailbox> = {}): Mailbox =>
({ id, name, parentId, role, sortOrder, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0, isSubscribed: true, myRights: RIGHTS, ...over });
const tree = (...list: Mailbox[]): Record<Id, Mailbox> => Object.fromEntries(list.map((m) => [m.id, m]));
/** As Stalwart hands it over before anybody orders anything: every sortOrder 0. */
const fresh = tree(
mb("zeta", "Zeta", null),
mb("trash", "Deleted Items", null, "trash"),
mb("sent", "Sent Items", null, "sent"),
mb("inbox", "Inbox", null, "inbox"),
mb("alpha", "Alpha", null),
mb("junk", "Junk Mail", null, "junk"),
mb("drafts", "Drafts", null, "drafts"),
mb("work", "Work", null),
mb("clients", "Clients", "work"),
);
const names = (all: Record<Id, Mailbox>, parentId: Id | null = null) => siblingsOf(all, parentId).map((m) => m.id);
/** Apply what `placeFolder` asks for, as the server would. */
function apply(all: Record<Id, Mailbox>, updates: Record<Id, Partial<Mailbox>> | null): Record<Id, Mailbox> {
const next = { ...all };
for (const [id, patch] of Object.entries(updates ?? {})) next[id] = { ...next[id]!, ...patch };
return next;
}
describe("compareFolders", () => {
it("lists Inbox, then the special folders in mail-client order, then the rest AZ, when nothing is ordered yet", () => {
// #402: Sent landed fourth from the bottom among the reporter's 88 folders.
expect(names(fresh)).toEqual(["inbox", "drafts", "sent", "junk", "trash", "alpha", "work", "zeta"]);
});
it("puts a saved order ahead of the special-folder default", () => {
const ordered = apply(fresh, { zeta: { sortOrder: 10 }, sent: { sortOrder: 20 }, alpha: { sortOrder: 30 }, drafts: { sortOrder: 40 }, junk: { sortOrder: 50 }, trash: { sortOrder: 60 }, work: { sortOrder: 70 } });
expect(names(ordered)).toEqual(["inbox", "zeta", "sent", "alpha", "drafts", "junk", "trash", "work"]);
});
it("keeps Inbox first whatever its sortOrder says", () => {
const a = mb("inbox", "Inbox", null, "inbox", 99);
const b = mb("alpha", "Alpha", null, null, 1);
expect(compareFolders(a, b)).toBeLessThan(0);
});
it("sorts names numerically, not by character", () => {
const all = tree(mb("f10", "Folder 10", null), mb("f9", "Folder 9", null));
expect(names(all)).toEqual(["f9", "f10"]);
});
});
describe("placeFolder", () => {
it("numbers the whole level 10 apart, with the folder where it was dropped", () => {
const next = apply(fresh, placeFolder(fresh, "zeta", "drafts", "before"));
expect(names(next)).toEqual(["inbox", "zeta", "drafts", "sent", "junk", "trash", "alpha", "work"]);
expect(siblingsOf(next, null).map((m) => m.sortOrder)).toEqual([10, 20, 30, 40, 50, 60, 70, 80]);
});
it("writes only the folders whose number changes", () => {
const once = apply(fresh, placeFolder(fresh, "zeta", "drafts", "before"));
// Swapping the last two leaves everything above them where it was.
expect(Object.keys(placeFolder(once, "work", "alpha", "before")!).sort()).toEqual(["alpha", "work"]);
});
it("asks for nothing when the folder is dropped where it already is", () => {
expect(placeFolder(fresh, "sent", "drafts", "after")).toBeNull();
expect(placeFolder(fresh, "sent", "junk", "before")).toBeNull();
});
it("moves a folder to another level, and gives it a place there", () => {
const updates = placeFolder(fresh, "alpha", "clients", "before")!;
expect(updates.alpha).toEqual({ sortOrder: 10, parentId: "work" });
expect(names(apply(fresh, updates), "work")).toEqual(["alpha", "clients"]);
});
});
describe("canPlaceFolder", () => {
it("lets a special folder be reordered among its siblings", () => {
expect(canPlaceFolder(fresh, "sent", "alpha", "after")).toBe(true);
});
it("does not let a special folder move to another level", () => {
expect(canPlaceFolder(fresh, "sent", "clients", "before")).toBe(false);
});
it("puts nothing above Inbox", () => {
expect(canPlaceFolder(fresh, "sent", "inbox", "before")).toBe(false);
expect(canPlaceFolder(fresh, "sent", "inbox", "after")).toBe(true);
});
it("does not put a folder inside its own subtree", () => {
expect(canPlaceFolder(fresh, "work", "clients", "before")).toBe(false);
});
it("needs the right to rename, which RFC 8621 folds moving into", () => {
const locked = apply(fresh, { alpha: { myRights: { ...RIGHTS, mayRename: false } } });
expect(canPlaceFolder(locked, "alpha", "zeta", "after")).toBe(false);
});
});
describe("neighbour", () => {
it("steps past the folder above or below", () => {
expect(neighbour(fresh, "alpha", "up")).toEqual({ targetId: "trash", placement: "before" });
expect(neighbour(fresh, "alpha", "down")).toEqual({ targetId: "work", placement: "after" });
});
it("has nowhere to go past either end, or above Inbox", () => {
expect(neighbour(fresh, "zeta", "down")).toBeNull();
expect(neighbour(fresh, "drafts", "up")).toBeNull();
});
it("skips folders that aren't on screen, so every step visibly moves", () => {
const hidden = apply(fresh, { trash: { isSubscribed: false } });
expect(neighbour(hidden, "alpha", "up", (m) => m.isSubscribed)).toEqual({ targetId: "junk", placement: "before" });
});
});
+101
View File
@@ -0,0 +1,101 @@
import type { Id, Mailbox } from "@/jmap/types";
import { ROLE_ORDER } from "@/store/mail/mailboxes";
import { canDropFolder, descendantIds } from "./folderMove";
/**
* The order folders are listed in, at every level of the tree (#402).
*
* Inbox always comes first. After that the folder's own `sortOrder` decides,
* which is where a folder dragged into place keeps its position, and where
* any other JMAP client that orders folders keeps its choice too. Stalwart
* gives every folder 0 until somebody orders it, so for everyone who never
* has, the tie-breaks decide: special folders first, in the usual mail-client
* order (Drafts, Sent, Archive, Junk, Trash), then the rest AZ.
*/
export function compareFolders(a: Mailbox, b: Mailbox): number {
if ((a.role === "inbox") !== (b.role === "inbox")) return a.role === "inbox" ? -1 : 1;
if (a.sortOrder !== b.sortOrder) return a.sortOrder - b.sortOrder;
const ra = roleRank(a);
const rb = roleRank(b);
if (ra !== rb) return ra - rb;
return a.name.localeCompare(b.name, undefined, { sensitivity: "base", numeric: true });
}
function roleRank(m: Mailbox): number {
return m.role && m.role in ROLE_ORDER ? ROLE_ORDER[m.role]! : Number.MAX_SAFE_INTEGER;
}
/** Every folder under `parentId` (null: the top level), in list order. */
export function siblingsOf(mailboxes: Record<Id, Mailbox>, parentId: Id | null): Mailbox[] {
return Object.values(mailboxes)
.filter((m) => (m.parentId && mailboxes[m.parentId] ? m.parentId : null) === parentId)
.sort(compareFolders);
}
export type Placement = "before" | "after";
/**
* Whether `draggedId` may be put just above or below `targetId`.
*
* Special folders can be reordered but not reparented, so they may only land
* among their own siblings. Nothing goes above Inbox, which stays first.
*/
export function canPlaceFolder(mailboxes: Record<Id, Mailbox>, draggedId: Id, targetId: Id, placement: Placement): boolean {
const dragged = mailboxes[draggedId];
const target = mailboxes[targetId];
if (!dragged || !target || draggedId === targetId) return false;
if (!dragged.myRights.mayRename) return false;
if (target.role === "inbox" && placement === "before") return false;
if (descendantIds(mailboxes, draggedId).has(targetId)) return false;
const from = parentOf(mailboxes, dragged);
const to = parentOf(mailboxes, target);
return from === to || canDropFolder(mailboxes, draggedId, to);
}
/**
* The updates that put `draggedId` just above or below `targetId`, or null when
* it is already there.
*
* The new level is numbered afresh, 10 apart, so that another client can put
* a folder between two of them without renumbering. Only folders whose number
* actually changes are written.
*/
export function placeFolder(mailboxes: Record<Id, Mailbox>, draggedId: Id, targetId: Id, placement: Placement): Record<Id, Partial<Mailbox>> | null {
const dragged = mailboxes[draggedId]!;
const parentId = parentOf(mailboxes, mailboxes[targetId]!);
const reparent = parentOf(mailboxes, dragged) !== parentId;
const current = siblingsOf(mailboxes, parentId);
const order = current.filter((m) => m.id !== draggedId);
const at = order.findIndex((m) => m.id === targetId) + (placement === "after" ? 1 : 0);
order.splice(at, 0, dragged);
// Dropped where it already was. Renumbering would change nothing anyone sees.
if (!reparent && order.every((m, i) => m.id === current[i]!.id)) return null;
const updates: Record<Id, Partial<Mailbox>> = {};
order.forEach((m, i) => {
const sortOrder = (i + 1) * 10;
if (m.sortOrder !== sortOrder) updates[m.id] = { sortOrder };
});
if (reparent) updates[draggedId] = { ...updates[draggedId], parentId };
return updates;
}
/**
* The neighbour to place a folder against for "Move up" / "Move down", if it
* has one. Only folders on screen count (`shown`), so each step visibly moves
* the folder rather than passing a hidden one.
*/
export function neighbour(mailboxes: Record<Id, Mailbox>, id: Id, direction: "up" | "down", shown: (m: Mailbox) => boolean = () => true): { targetId: Id; placement: Placement } | null {
const m = mailboxes[id];
if (!m) return null;
const level = siblingsOf(mailboxes, parentOf(mailboxes, m)).filter((x) => x.id === id || shown(x));
const i = level.findIndex((x) => x.id === id);
const other = level[direction === "up" ? i - 1 : i + 1];
if (!other) return null;
const placement = direction === "up" ? "before" : "after";
return canPlaceFolder(mailboxes, id, other.id, placement) ? { targetId: other.id, placement } : null;
}
function parentOf(mailboxes: Record<Id, Mailbox>, m: Mailbox): Id | null {
return m.parentId && mailboxes[m.parentId] ? m.parentId : null;
}
+1
View File
@@ -753,6 +753,7 @@ export const catalog: Catalog = {
"Attachment reminder": "Anhang-Erinnerung", "Attachment reminder": "Anhang-Erinnerung",
"Warn when the message mentions an attachment but none is attached.": "Warnen, wenn die Nachricht einen Anhang erwähnt, aber keiner angehängt ist.", "Warn when the message mentions an attachment but none is attached.": "Warnen, wenn die Nachricht einen Anhang erwähnt, aber keiner angehängt ist.",
"Spell check while typing": "Rechtschreibprüfung während der Eingabe", "Spell check while typing": "Rechtschreibprüfung während der Eingabe",
"Open the composer full screen": "Nachrichten im Vollbild verfassen",
"Confirm before deleting": "Vor dem Löschen bestätigen", "Confirm before deleting": "Vor dem Löschen bestätigen",
"Show message snippets": "Nachrichtenvorschau anzeigen", "Show message snippets": "Nachrichtenvorschau anzeigen",
"Preview the first line of each message in the list.": "Die erste Zeile jeder Nachricht in der Liste anzeigen.", "Preview the first line of each message in the list.": "Die erste Zeile jeder Nachricht in der Liste anzeigen.",
+1
View File
@@ -748,6 +748,7 @@ export const catalog: Catalog = {
"Attachment reminder": "Aviso de adjunto", "Attachment reminder": "Aviso de adjunto",
"Warn when the message mentions an attachment but none is attached.": "Avisar cuando el mensaje menciona un adjunto pero no hay ninguno.", "Warn when the message mentions an attachment but none is attached.": "Avisar cuando el mensaje menciona un adjunto pero no hay ninguno.",
"Spell check while typing": "Corrección ortográfica al escribir", "Spell check while typing": "Corrección ortográfica al escribir",
"Open the composer full screen": "Redactar mensajes a pantalla completa",
"Confirm before deleting": "Confirmar antes de eliminar", "Confirm before deleting": "Confirmar antes de eliminar",
"Show message snippets": "Mostrar un fragmento de los mensajes", "Show message snippets": "Mostrar un fragmento de los mensajes",
"Preview the first line of each message in the list.": "Mostrar la primera línea de cada mensaje en la lista.", "Preview the first line of each message in the list.": "Mostrar la primera línea de cada mensaje en la lista.",
+1
View File
@@ -754,6 +754,7 @@ export const catalog: Catalog = {
"Attachment reminder": "Rappel de pièce jointe", "Attachment reminder": "Rappel de pièce jointe",
"Warn when the message mentions an attachment but none is attached.": "Avertir lorsque le message mentionne une pièce jointe alors qu'aucune n'est jointe.", "Warn when the message mentions an attachment but none is attached.": "Avertir lorsque le message mentionne une pièce jointe alors qu'aucune n'est jointe.",
"Spell check while typing": "Vérification orthographique pendant la saisie", "Spell check while typing": "Vérification orthographique pendant la saisie",
"Open the composer full screen": "Rédiger les messages en plein écran",
"Confirm before deleting": "Confirmer avant de supprimer", "Confirm before deleting": "Confirmer avant de supprimer",
"Show message snippets": "Afficher un aperçu des messages", "Show message snippets": "Afficher un aperçu des messages",
"Preview the first line of each message in the list.": "Afficher la première ligne de chaque message dans la liste.", "Preview the first line of each message in the list.": "Afficher la première ligne de chaque message dans la liste.",
+1
View File
@@ -748,6 +748,7 @@ export const catalog: Catalog = {
"Attachment reminder": "添付忘れの確認", "Attachment reminder": "添付忘れの確認",
"Warn when the message mentions an attachment but none is attached.": "本文で添付に触れているのにファイルが添付されていないとき警告します。", "Warn when the message mentions an attachment but none is attached.": "本文で添付に触れているのにファイルが添付されていないとき警告します。",
"Spell check while typing": "入力中にスペルチェックする", "Spell check while typing": "入力中にスペルチェックする",
"Open the composer full screen": "メールを全画面で作成",
"Confirm before deleting": "削除前に確認する", "Confirm before deleting": "削除前に確認する",
"Show message snippets": "本文の抜粋を表示する", "Show message snippets": "本文の抜粋を表示する",
"Preview the first line of each message in the list.": "一覧に各メールの 1 行目を表示します。", "Preview the first line of each message in the list.": "一覧に各メールの 1 行目を表示します。",
+46 -46
View File
@@ -22,7 +22,6 @@ import type { Catalog } from "@/lib/i18n";
* which is ordinary good Dutch UI and sidesteps it entirely. * which is ordinary good Dutch UI and sidesteps it entirely.
* *
* Terminology, fixed once so it cannot drift: * Terminology, fixed once so it cannot drift:
* --- Used capitals on all words for unity ---
* Inbox Postvak IN Archive (verb) Archiveren * Inbox Postvak IN Archive (verb) Archiveren
* Drafts Concepten Delete Verwijderen * Drafts Concepten Delete Verwijderen
* Sent Verzonden Move to Verplaatsen naar * Sent Verzonden Move to Verplaatsen naar
@@ -747,6 +746,7 @@ export const catalog: Catalog = {
"Attachment reminder": "Bijlageherinnering", "Attachment reminder": "Bijlageherinnering",
"Warn when the message mentions an attachment but none is attached.": "Waarschuwen wanneer het bericht een bijlage noemt maar er geen is bijgevoegd.", "Warn when the message mentions an attachment but none is attached.": "Waarschuwen wanneer het bericht een bijlage noemt maar er geen is bijgevoegd.",
"Spell check while typing": "Spellingcontrole tijdens het typen", "Spell check while typing": "Spellingcontrole tijdens het typen",
"Open the composer full screen": "Berichten opstellen op volledig scherm",
"Confirm before deleting": "Bevestigen voor verwijderen", "Confirm before deleting": "Bevestigen voor verwijderen",
"Show message snippets": "Berichtfragmenten tonen", "Show message snippets": "Berichtfragmenten tonen",
"Preview the first line of each message in the list.": "De eerste regel van elk bericht in de lijst tonen.", "Preview the first line of each message in the list.": "De eerste regel van elk bericht in de lijst tonen.",
@@ -1136,7 +1136,7 @@ export const catalog: Catalog = {
"This browser cannot register apps for {scheme} links. Safari, in particular, has no such API — you can still make ihasmail the default from your operating system if you install it as an app.": "Deze browser kan geen programma's registreren voor {scheme}-links. Safari heeft daar in het bijzonder geen voorziening voor — u kunt ihasmail nog steeds als standaard instellen via uw besturingssysteem als u het als app installeert.", "This browser cannot register apps for {scheme} links. Safari, in particular, has no such API — you can still make ihasmail the default from your operating system if you install it as an app.": "Deze browser kan geen programma's registreren voor {scheme}-links. Safari heeft daar in het bijzonder geen voorziening voor — u kunt ihasmail nog steeds als standaard instellen via uw besturingssysteem als u het als app installeert.",
"Registering for {scheme} links requires a secure (HTTPS) connection.": "Registreren voor {scheme}-links vereist een beveiligde (HTTPS-)verbinding.", "Registering for {scheme} links requires a secure (HTTPS) connection.": "Registreren voor {scheme}-links vereist een beveiligde (HTTPS-)verbinding.",
"Open {scheme} links — in web pages, documents and other apps — in ihasmail instead of a desktop mail client. Your browser will ask you to confirm, and you can change it later in its own settings (Chrome: Settings Privacy and security Site settings Protocol handlers; Firefox: Settings General Applications).": "{scheme}-links — op webpagina's, in documenten en in andere programma's — openen in ihasmail in plaats van in een lokaal e-mailprogramma. Uw browser vraagt om bevestiging, en u kunt dit later wijzigen in zijn eigen instellingen (Chrome: Instellingen Privacy en beveiliging Site-instellingen Protocol-handlers; Firefox: Instellingen Algemeen Programma's).", "Open {scheme} links — in web pages, documents and other apps — in ihasmail instead of a desktop mail client. Your browser will ask you to confirm, and you can change it later in its own settings (Chrome: Settings Privacy and security Site settings Protocol handlers; Firefox: Settings General Applications).": "{scheme}-links — op webpagina's, in documenten en in andere programma's — openen in ihasmail in plaats van in een lokaal e-mailprogramma. Uw browser vraagt om bevestiging, en u kunt dit later wijzigen in zijn eigen instellingen (Chrome: Instellingen Privacy en beveiliging Site-instellingen Protocol-handlers; Firefox: Instellingen Algemeen Programma's).",
"Requested in this browser. Whether it took effect is up to the browser — check its settings if mail links still open elsewhere.": "Aangevraagd in deze browser. Of het effect heeft gehad, bepaalt de browser — controleer zijn instellingen als e-maillinks nog elders openen.", "Requested in this browser. Whether it took effect is up to the browser — check its settings if mail links still open elsewhere.": "Aangevraagd in deze browser. Of het effect heeft gehad, bepaalt de browser — controleer zijn instellingen als e-mail links nog elders openen.",
"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.": "Installeer ihasmail eerst als app voor een systeembrede standaard (in Chrome: het installatiepictogram in de adresbalk). Uw besturingssysteem kan ihasmail dan overal direct aanbieden waar het vraagt welk e-mailprogramma gebruikt moet worden.", "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.": "Installeer ihasmail eerst als app voor een systeembrede standaard (in Chrome: het installatiepictogram in de adresbalk). Uw besturingssysteem kan ihasmail dan overal direct aanbieden waar het vraagt welk e-mailprogramma gebruikt moet worden.",
"Needs a browser with the Push API and a mail server that publishes a push key.": "Vereist een browser met de Push-API en een mailserver die een push-sleutel publiceert.", "Needs a browser with the Push API and a mail server that publishes a push key.": "Vereist een browser met de Push-API en een mailserver die een push-sleutel publiceert.",
"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.": "Uw mailserver levert deze rechtstreeks bij uw browser af, dus ze komen aan zonder geopend ihasmail-tabblad, met afzender en onderwerp erbij. Uw browser moet wel draaien — sluit u hem helemaal af, dan wachten de meldingen en komen ze binnen zodra u hem weer opent.", "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.": "Uw mailserver levert deze rechtstreeks bij uw browser af, dus ze komen aan zonder geopend ihasmail-tabblad, met afzender en onderwerp erbij. Uw browser moet wel draaien — sluit u hem helemaal af, dan wachten de meldingen en komen ze binnen zodra u hem weer opent.",
@@ -1161,7 +1161,7 @@ export const catalog: Catalog = {
"ihasmail's own version is the date of the commit it was built from, followed by where that commit came from: {example} 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 — {sha}. The version deliberately says nothing about the mail server; what this build needs from the server is the line above.": "De eigen versie van ihasmail is de datum van de commit waaruit het is gebouwd, gevolgd door waar die commit vandaan kwam: {example} is gebouwd uit een commit van 30 augustus 2026 die via pull request 129 binnenkwam. Een commit die niet via zo'n verzoek kwam, draagt in plaats daarvan zijn korte SHA — {sha}. De versie zegt bewust niets over de mailserver; wat deze build van de server nodig heeft, staat op de regel hierboven.", "ihasmail's own version is the date of the commit it was built from, followed by where that commit came from: {example} 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 — {sha}. The version deliberately says nothing about the mail server; what this build needs from the server is the line above.": "De eigen versie van ihasmail is de datum van de commit waaruit het is gebouwd, gevolgd door waar die commit vandaan kwam: {example} is gebouwd uit een commit van 30 augustus 2026 die via pull request 129 binnenkwam. Een commit die niet via zo'n verzoek kwam, draagt in plaats daarvan zijn korte SHA — {sha}. De versie zegt bewust niets over de mailserver; wat deze build van de server nodig heeft, staat op de regel hierboven.",
// ── Composer status, calendar title ──────────────────────────────── // ── Composer status, calendar title ────────────────────────────────
"New message": "Nieuw bericht", "New message": "Nieuw bericht",
"Start a new message with what was shared?": "Een nieuw bericht beginnen met wat is gedeeld?", "Start a new message with what was shared?": "Een nieuw bericht beginnen met wat er is gedeeld?",
"Something was shared with ihasmail. Nothing is sent until you choose Send. If you didn't just share this, discard it.": "Er is iets met ihasmail gedeeld. Er wordt niets verzonden totdat u Verzenden kiest. Hebt u dit niet zelf zojuist gedeeld, gooi het dan weg.", "Something was shared with ihasmail. Nothing is sent until you choose Send. If you didn't just share this, discard it.": "Er is iets met ihasmail gedeeld. Er wordt niets verzonden totdat u Verzenden kiest. Hebt u dit niet zelf zojuist gedeeld, gooi het dan weg.",
"Start a message": "Bericht beginnen", "Start a message": "Bericht beginnen",
"New mail": "Nieuwe e-mail", "New mail": "Nieuwe e-mail",
@@ -1180,8 +1180,8 @@ export const catalog: Catalog = {
"Add a contact or import a vCard file.": "Voeg een contact toe of importeer een vCard-bestand.", "Add a contact or import a vCard file.": "Voeg een contact toe of importeer een vCard-bestand.",
"Added to your calendar": "Toegevoegd aan uw agenda", "Added to your calendar": "Toegevoegd aan uw agenda",
"All events in this calendar will be deleted.": "Alle afspraken in deze agenda worden verwijderd.", "All events in this calendar will be deleted.": "Alle afspraken in deze agenda worden verwijderd.",
"All occurrences": "Alle herhalingen", "All occurrences": "Alle gebeurtenissen",
"An occurrence cannot be moved to another calendar on its own": "Eén herhaling kan niet los naar een andere agenda worden verplaatst", "An occurrence cannot be moved to another calendar on its own": "Een gebeurtenis kan niet zelfstandig naar een andere agenda worden verplaatst",
"Anything signed in with this password stops working immediately.": "Alles wat met dit wachtwoord is aangemeld, werkt meteen niet meer.", "Anything signed in with this password stops working immediately.": "Alles wat met dit wachtwoord is aangemeld, werkt meteen niet meer.",
"App password revoked": "App-wachtwoord ingetrokken", "App password revoked": "App-wachtwoord ingetrokken",
"Applies to every date in the series.": "Geldt voor elke datum in de reeks.", "Applies to every date in the series.": "Geldt voor elke datum in de reeks.",
@@ -1238,7 +1238,7 @@ export const catalog: Catalog = {
"Deactivate": "Deactiveren", "Deactivate": "Deactiveren",
"Delete failed: {error}": "Verwijderen mislukt: {error}", "Delete failed: {error}": "Verwijderen mislukt: {error}",
"Delete forever": "Definitief verwijderen", "Delete forever": "Definitief verwijderen",
"Delete script “{name}”?": "Script {name} verwijderen?", "Delete script “{name}”?": "Script {name} verwijderen?",
"Delete this event?": "Deze afspraak verwijderen?", "Delete this event?": "Deze afspraak verwijderen?",
"Delete this identity?": "Deze afzender verwijderen?", "Delete this identity?": "Deze afzender verwijderen?",
"Delete {name}?": "{name} verwijderen?", "Delete {name}?": "{name} verwijderen?",
@@ -1252,12 +1252,12 @@ export const catalog: Catalog = {
"Edit identity": "Afzender bewerken", "Edit identity": "Afzender bewerken",
"Edit template": "Sjabloon bewerken", "Edit template": "Sjabloon bewerken",
"End must be after start": "Het einde moet na het begin liggen", "End must be after start": "Het einde moet na het begin liggen",
"Event duplicated": "Afspraak gedupliceerd", "Event duplicated": "Afspraak dubbel aangemaakt",
"Everything inside it goes too.": "Alles wat erin zit gaat mee.", "Everything inside it goes too.": "Alles wat erin zit gaat mee.",
"Filter created": "Filter aangemaakt", "Filter created": "Filter aangemaakt",
"Filter created — it will run on new mail": "Filter aangemaakt — het draait op nieuwe berichten", "Filter created — it will run on new mail": "Filter aangemaakt — het werkt op nieuwe berichten",
"Filter saved": "Filter opgeslagen", "Filter saved": "Filter opgeslagen",
"Filter saved — it will run on new mail": "Filter opgeslagen — het draait op nieuwe berichten", "Filter saved — it will run on new mail": "Filter opgeslagen — het werkt op nieuwe berichten",
"Filter saved, but applying it failed: {error}": "Filter opgeslagen, maar toepassen is mislukt: {error}", "Filter saved, but applying it failed: {error}": "Filter opgeslagen, maar toepassen is mislukt: {error}",
"Filters saved": "Filters opgeslagen", "Filters saved": "Filters opgeslagen",
"Folder changed, but its filter rules could not be updated: {error}": "Map gewijzigd, maar de filterregels konden niet worden bijgewerkt: {error}", "Folder changed, but its filter rules could not be updated: {error}": "Map gewijzigd, maar de filterregels konden niet worden bijgewerkt: {error}",
@@ -1276,12 +1276,12 @@ export const catalog: Catalog = {
"Images in signatures need the Files feature, which this account doesn't have.": "Afbeeldingen in handtekeningen vereisen de functie Bestanden, die dit account niet heeft.", "Images in signatures need the Files feature, which this account doesn't have.": "Afbeeldingen in handtekeningen vereisen de functie Bestanden, die dit account niet heeft.",
"Invalid address: {address}": "Ongeldig adres: {address}", "Invalid address: {address}": "Ongeldig adres: {address}",
"Invalid username or password.": "Gebruikersnaam of wachtwoord is onjuist.", "Invalid username or password.": "Gebruikersnaam of wachtwoord is onjuist.",
"It belongs to a change that was applied to this and all later occurrences, which the server will only edit as a whole. Apply to the entire series instead?": "Het hoort bij een wijziging die op deze en alle latere herhalingen is toegepast, en die de server alleen in zijn geheel bewerkt. In plaats daarvan op de hele reeks toepassen?", "It belongs to a change that was applied to this and all later occurrences, which the server will only edit as a whole. Apply to the entire series instead?": "Het hoort bij een wijziging die op deze en alle latere gebeurtenissen is toegepast, en die de server alleen in zijn geheel bewerkt. In plaats daarvan op de hele reeks toepassen?",
"Label name": "Labelnaam", "Label name": "Labelnaam",
"Larger than {size} MB limit": "Groter dan de limiet van {size} MB", "Larger than {size} MB limit": "Groter dan de limiet van {size} MB",
"Message sent": "Bericht verzonden", "Message sent": "Bericht verzonden",
"Move failed: {error}": "Verplaatsen mislukt: {error}", "Move failed: {error}": "Verplaatsen mislukt: {error}",
"Move “{name}”": "{name} verplaatsen", "Move “{name}”": "{name} verplaatsen",
"Moved": "Verplaatst", "Moved": "Verplaatst",
"Network error. Please check your connection.": "Netwerkfout. Controleer uw verbinding.", "Network error. Please check your connection.": "Netwerkfout. Controleer uw verbinding.",
"New all-day event on {date}": "Nieuwe hele dag durende afspraak op {date}", "New all-day event on {date}": "Nieuwe hele dag durende afspraak op {date}",
@@ -1294,11 +1294,11 @@ export const catalog: Catalog = {
"Drafts": "Concepten", "Drafts": "Concepten",
"Sent": "Verzonden", "Sent": "Verzonden",
"The server does not allow this role to be changed.": "De server staat niet toe deze rol te wijzigen.", "The server does not allow this role to be changed.": "De server staat niet toe deze rol te wijzigen.",
"Folder role updated": "Maprol bijgewerkt", "Folder role updated": "Map rol bijgewerkt",
"No contacts yet": "Nog geen contacten", "No contacts yet": "Nog geen contacten",
"No longer shared": "Niet langer gedeeld", "No longer shared": "Niet langer gedeeld",
"No matches": "Geen overeenkomsten", "No matches": "Geen overeenkomsten",
"No new mail in your inbox.": "Geen nieuwe berichten in uw postvak IN.", "No new mail in your inbox.": "Geen nieuwe berichten in uw Postvak IN.",
"No results": "Geen resultaten", "No results": "Geen resultaten",
"Nothing here": "Hier is niets", "Nothing here": "Hier is niets",
"Only Deleted Items and Junk Mail can be emptied.": "Alleen de prullenbak en ongewenste e-mail kunnen worden geleegd.", "Only Deleted Items and Junk Mail can be emptied.": "Alleen de prullenbak en ongewenste e-mail kunnen worden geleegd.",
@@ -1314,7 +1314,7 @@ export const catalog: Catalog = {
"Replied": "Beantwoord", "Replied": "Beantwoord",
"Report spam (!)": "Als spam melden (!)", "Report spam (!)": "Als spam melden (!)",
"Response sent": "Antwoord verzonden", "Response sent": "Antwoord verzonden",
"Revoke “{name}”?": "{name} intrekken?", "Revoke “{name}”?": "{name} intrekken?",
"Script has errors": "Het script bevat fouten", "Script has errors": "Het script bevat fouten",
"Script is valid": "Het script is geldig", "Script is valid": "Het script is geldig",
"Script name is required": "Een scriptnaam is verplicht", "Script name is required": "Een scriptnaam is verplicht",
@@ -1325,15 +1325,15 @@ export const catalog: Catalog = {
"Send failed: {error}": "Verzenden mislukt: {error}", "Send failed: {error}": "Verzenden mislukt: {error}",
"Send invites": "Uitnodigingen verzenden", "Send invites": "Uitnodigingen verzenden",
"Send scheduled for {when}": "Verzenden gepland voor {when}", "Send scheduled for {when}": "Verzenden gepland voor {when}",
"Send without a subject?": "Verzenden zonder onderwerp?", "Send without a subject?": "Verzenden zonder een onderwerp?",
"Share “{name}”": "{name} delen", "Share “{name}”": "{name} delen",
"Sharing updated": "Delen bijgewerkt", "Sharing updated": "Delen bijgewerkt",
"Show": "Tonen", "Show": "Tonen",
"Show quoted text": "Geciteerde tekst tonen", "Show quoted text": "Geciteerde tekst tonen",
"Show this in the compose picker": "Aanbieden bij het opstellen", "Show this in the compose picker": "Aanbieden bij het opstellen",
"Sign out other sessions?": "Andere sessies afmelden?", "Sign out other sessions?": "Andere sessies afmelden?",
"Sign out others": "Andere afmelden", "Sign out others": "Anderen afmelden",
"Stop sharing “{name}”?": "Delen van {name} stoppen?", "Stop sharing “{name}”?": "Delen van {name} stoppen?",
"Switch to {theme}": "Overschakelen naar {theme}", "Switch to {theme}": "Overschakelen naar {theme}",
"Template": "Sjabloon", "Template": "Sjabloon",
"Template name": "Sjabloonnaam", "Template name": "Sjabloonnaam",
@@ -1342,7 +1342,7 @@ export const catalog: Catalog = {
"The new passwords don't match": "De nieuwe wachtwoorden komen niet overeen", "The new passwords don't match": "De nieuwe wachtwoorden komen niet overeen",
"The server scheduled this for {when}, not the time requested.": "De server heeft dit gepland voor {when}, niet voor de gevraagde tijd.", "The server scheduled this for {when}, not the time requested.": "De server heeft dit gepland voor {when}, niet voor de gevraagde tijd.",
"This date cannot be changed on its own": "Deze datum kan niet los worden gewijzigd", "This date cannot be changed on its own": "Deze datum kan niet los worden gewijzigd",
"This occurrence": "Deze herhaling", "This occurrence": "Dit geval",
"Too many attempts. Please wait a few minutes and try again.": "Te veel pogingen. Wacht een paar minuten en probeer het opnieuw.", "Too many attempts. Please wait a few minutes and try again.": "Te veel pogingen. Wacht een paar minuten en probeer het opnieuw.",
"Try another search.": "Probeer een andere zoekopdracht.", "Try another search.": "Probeer een andere zoekopdracht.",
"Try different keywords or filters.": "Probeer andere zoekwoorden of filters.", "Try different keywords or filters.": "Probeer andere zoekwoorden of filters.",
@@ -1350,13 +1350,13 @@ export const catalog: Catalog = {
"Undo window: {seconds}s": "Tijd om ongedaan te maken: {seconds} s", "Undo window: {seconds}s": "Tijd om ongedaan te maken: {seconds} s",
"You're all caught up": "U bent helemaal bij", "You're all caught up": "U bent helemaal bij",
"Your browser refused the request: {error}": "Uw browser heeft het verzoek geweigerd: {error}", "Your browser refused the request: {error}": "Uw browser heeft het verzoek geweigerd: {error}",
"Your browser will ask whether to open mail links in ihasmail": "Uw browser vraagt of e-maillinks in ihasmail moeten worden geopend", "Your browser will ask whether to open mail links in ihasmail": "Uw browser vraagt of e-mail links in ihasmail moeten worden geopend",
"Your message mentions an attachment, but nothing is attached.": "Uw bericht noemt een bijlage, maar er is niets bijgevoegd.", "Your message mentions an attachment, but nothing is attached.": "Uw bericht noemt een bijlage, maar er is niets bijgevoegd.",
"event": "afspraak", "event": "afspraak",
"Hide password": "Wachtwoord verbergen", "Hide password": "Wachtwoord verbergen",
"Show password": "Wachtwoord tonen", "Show password": "Wachtwoord tonen",
"Settings reset to defaults": "Instellingen teruggezet naar de standaardwaarden", "Settings reset to defaults": "Instellingen teruggezet naar de standaardwaarden",
"Removed. Mail links will open in whatever your browser falls back to.": "Verwijderd. E-maillinks worden geopend in waar uw browser op terugvalt.", "Removed. Mail links will open in whatever your browser falls back to.": "Verwijderd. E-mail links worden geopend in waar uw browser op terugvalt.",
"Edit rule": "Regel bewerken", "Edit rule": "Regel bewerken",
"Address copied": "Adres gekopieerd", "Address copied": "Adres gekopieerd",
"Search or create label": "Label zoeken of aanmaken", "Search or create label": "Label zoeken of aanmaken",
@@ -1366,7 +1366,7 @@ export const catalog: Catalog = {
"Unsubscribe message prepared — just hit Send": "Afmeldbericht klaargezet — u hoeft alleen op Verzenden te klikken", "Unsubscribe message prepared — just hit Send": "Afmeldbericht klaargezet — u hoeft alleen op Verzenden te klikken",
"Maximize": "Maximaliseren", "Maximize": "Maximaliseren",
"Full screen": "Volledig scherm", "Full screen": "Volledig scherm",
"Resize panes": "Vensterdelen verslepen", "Resize panes": "Grootte van vensterdelen wijzigen",
"Resize message list": "Grootte van de berichtenlijst wijzigen", "Resize message list": "Grootte van de berichtenlijst wijzigen",
"Resize contact list": "Grootte van de contactenlijst wijzigen", "Resize contact list": "Grootte van de contactenlijst wijzigen",
"Resize sidebar": "Grootte van de zijbalk wijzigen", "Resize sidebar": "Grootte van de zijbalk wijzigen",
@@ -1402,7 +1402,7 @@ export const catalog: Catalog = {
// had the same gap. The keyboard bindings among them register their // had the same gap. The keyboard bindings among them register their
// group and description in English at the call site and are translated // group and description in English at the call site and are translated
// at render. // at render.
" and {count} more": " en nog {count}", " and {count} more": " en nog {count} meer",
"10 people or more": "10 personen of meer", "10 people or more": "10 personen of meer",
"20 people or more": "20 personen of meer", "20 people or more": "20 personen of meer",
"5 people or more": "5 personen of meer", "5 people or more": "5 personen of meer",
@@ -1415,12 +1415,12 @@ export const catalog: Catalog = {
"Added from a message, and removable here — previously the only way to undo one was to find another message from the same sender.": "Toegevoegd vanuit een bericht en hier te verwijderen: voorheen kon dit alleen ongedaan worden gemaakt door een ander bericht van dezelfde afzender op te zoeken.", "Added from a message, and removable here — previously the only way to undo one was to find another message from the same sender.": "Toegevoegd vanuit een bericht en hier te verwijderen: voorheen kon dit alleen ongedaan worden gemaakt door een ander bericht van dezelfde afzender op te zoeken.",
"Added here, or from the dialog when a link is opened. A domain also covers its subdomains.": "Hier toegevoegd, of vanuit het venster bij het openen van een link. Een domein omvat ook de subdomeinen.", "Added here, or from the dialog when a link is opened. A domain also covers its subdomains.": "Hier toegevoegd, of vanuit het venster bij het openen van een link. Een domein omvat ook de subdomeinen.",
"Agenda view": "Agendaweergave", "Agenda view": "Agendaweergave",
"All three start switched off. A client that begins by interrupting is one people learn to click through, and a warning clicked through without reading costs the same attention and buys nothing.": "Alle drie staan aanvankelijk uit. Een client die begint met onderbreken is er een die mensen leren weg te klikken, en een waarschuwing die ongelezen wordt weggeklikt kost dezelfde aandacht en levert niets op.", "All three start switched off. A client that begins by interrupting is one people learn to click through, and a warning clicked through without reading costs the same attention and buys nothing.": "Alle drie staan aanvankelijk uit. Een melding die begint met onderbreken, is er een waar mensen al snel gedachteloos doorheen klikken, en een waarschuwing waar je zonder te lezen doorheen klikt, kost net zoveel aandacht en levert niets op.",
"All {n} in {folder} are selected.": "Alle {n} in {folder} zijn geselecteerd.", "All {n} in {folder} are selected.": "Alle {n} in {folder} zijn geselecteerd.",
"All {n} on this page are selected.": "Alle {n} op deze pagina zijn geselecteerd.", "All {n} on this page are selected.": "Alle {n} op deze pagina zijn geselecteerd.",
"Also count these domains as inside": "Deze domeinen ook als intern beschouwen", "Also count these domains as inside": "Deze domeinen ook als intern beschouwen",
"Always": "Altijd", "Always": "Altijd",
"Always showing images from": "Afbeeldingen altijd tonen van", "Always showing images from": "Altijd afbeeldingen tonen van",
"An image loaded from a sender's server tells them the message was opened, when, and from roughly where. Approved images are fetched by ihasmail's own server rather than the browser, so the sender learns none of those.": "Een afbeelding die van de server van de afzender wordt geladen, vertelt die afzender dat het bericht is geopend, wanneer en ongeveer waarvandaan. Goedgekeurde afbeeldingen worden opgehaald door de server van ihasmail zelf en niet door de browser, dus de afzender komt daar niets van te weten.", "An image loaded from a sender's server tells them the message was opened, when, and from roughly where. Approved images are fetched by ihasmail's own server rather than the browser, so the sender learns none of those.": "Een afbeelding die van de server van de afzender wordt geladen, vertelt die afzender dat het bericht is geopend, wanneer en ongeveer waarvandaan. Goedgekeurde afbeeldingen worden opgehaald door de server van ihasmail zelf en niet door de browser, dus de afzender komt daar niets van te weten.",
"Applies to": "Geldt voor", "Applies to": "Geldt voor",
"Archive and next": "Archiveren en volgende", "Archive and next": "Archiveren en volgende",
@@ -1448,17 +1448,17 @@ export const catalog: Catalog = {
"Could not import this file: {error}": "Kon dit bestand niet importeren: {error}", "Could not import this file: {error}": "Kon dit bestand niet importeren: {error}",
"Could not load this file.": "Kon dit bestand niet laden.", "Could not load this file.": "Kon dit bestand niet laden.",
"Could not read this calendar: {reason}": "Kon deze agenda niet lezen: {reason}", "Could not read this calendar: {reason}": "Kon deze agenda niet lezen: {reason}",
"Could not read winmail.dat. The original is still attached below.": "Kon winmail.dat niet lezen. Het origineel zit hieronder nog als bijlage.", "Could not read winmail.dat. The original is still attached below.": "Kon winmail.dat niet lezen. Het origineel is nog steeds toegoevegd als bijlage.",
"Counts people rather than headers, so one address in To and nine in Cc is a message to ten. Catches a reply-all onto a long thread.": "Telt personen in plaats van kopregels, dus één adres in Aan en negen in Cc is een bericht aan tien. Vangt een allen-beantwoorden op een lang gesprek af.", "Counts people rather than headers, so one address in To and nine in Cc is a message to ten. Catches a reply-all onto a long thread.": "Telt personen in plaats van kopregels, dus één adres in Aan en negen in Cc is een bericht aan tien. Vangt een allen-beantwoorden op een lang gesprek af.",
"Date received": "Ontvangstdatum", "Date received": "Ontvangstdatum",
"Date sent": "Verzenddatum", "Date sent": "Verzenddatum",
"Day view": "Dagweergave", "Day view": "Dagweergave",
"Palettes named after another project are that project's work, used under its own license; the shades between their published colors are derived, and every one is checked for contrast. The accent color below still applies over any of them.": "Paletten die naar een ander project zijn genoemd, zijn het werk van dat project en worden gebruikt onder de eigen licentie daarvan; de tinten tussen de gepubliceerde kleuren zijn afgeleid en elk daarvan wordt op contrast gecontroleerd. De accentkleur hieronder geldt nog steeds over elk ervan.", "Palettes named after another project are that project's work, used under its own license; the shades between their published colors are derived, and every one is checked for contrast. The accent color below still applies over any of them.": "Paletten die naar een ander project zijn genoemd, zijn het werk van dat project en worden gebruikt onder de eigen licentie daarvan; de tinten tussen de gepubliceerde kleuren zijn afgeleid en elk daarvan wordt op contrast gecontroleerd. De accentkleur hieronder wordt nog steeds toegepast op elk palet.",
"Earlier": "Eerder", "Earlier": "Eerder",
"Every folder": "Elke map", "Every folder": "Elke map",
"Everyone addressed will receive this.": "Iedereen die is geadresseerd ontvangt dit.", "Everyone addressed will receive this.": "Elke geadresseerde ontvangt dit.",
"File contents": "Bestandsinhoud", "File contents": "Bestandsinhoud",
"Filled in when the template is inserted, so you can edit the result before sending. One that cannot be answered yet — a recipient's name on a message you have not addressed — is left in the body as written, rather than becoming a blank.": "Wordt ingevuld wanneer de sjabloon wordt ingevoegd, zodat u het resultaat vóór verzending kunt bewerken. Een veld dat nog niet kan worden ingevuld — de naam van een ontvanger op een bericht dat u nog niet hebt geadresseerd — blijft in de tekst staan zoals het geschreven is, in plaats van een leegte te worden.", "Filled in when the template is inserted, so you can edit the result before sending. One that cannot be answered yet — a recipient's name on a message you have not addressed — is left in the body as written, rather than becoming a blank.": "Wordt ingevuld wanneer de sjabloon wordt ingevoegd, zodat u het resultaat vóór verzending kunt bewerken. Een veld dat nog niet kan worden ingevuld — de naam van een ontvanger op een bericht dat u nog niet hebt geadresseerd — blijft in de tekst staan zoals het geschreven is, in plaats van niets te tonen.",
"Forward as attachment": "Doorsturen als bijlage", "Forward as attachment": "Doorsturen als bijlage",
"From the birthdays on your contacts. Nothing is stored.": "Uit de verjaardagen van uw contacten. Er wordt niets opgeslagen.", "From the birthdays on your contacts. Nothing is stored.": "Uit de verjaardagen van uw contacten. Er wordt niets opgeslagen.",
"Go to Calendar": "Ga naar Agenda", "Go to Calendar": "Ga naar Agenda",
@@ -1501,7 +1501,7 @@ export const catalog: Catalog = {
"Open links to these domains without asking": "Links naar deze domeinen openen zonder te vragen", "Open links to these domains without asking": "Links naar deze domeinen openen zonder te vragen",
"Open, and stop asking about {domain}": "Openen en niet meer vragen over {domain}", "Open, and stop asking about {domain}": "Openen en niet meer vragen over {domain}",
"Opening…": "Bezig met openen…", "Opening…": "Bezig met openen…",
"Ordered by the server over the whole folder, not just the messages loaded so far. Ties always fall back to newest first, so the order never shuffles between two looks at the same folder.": "Gesorteerd door de server over de hele map, niet alleen over de tot nu toe geladen berichten. Bij gelijke waarden geldt altijd nieuwste eerst, zodat de volgorde nooit verschuift tussen twee blikken op dezelfde map.", "Ordered by the server over the whole folder, not just the messages loaded so far. Ties always fall back to newest first, so the order never shuffles between two looks at the same folder.": "Gesorteerd door de server over de hele map, niet alleen over de tot nu toe geladen berichten. Bij gelijke waarden wordt altijd teruggevallen op nieuwste eerst, zodat de volgorde nooit verandert tussen twee keer bekijken van dezelfde map.",
"Placeholders": "Tijdelijke aanduidingen", "Placeholders": "Tijdelijke aanduidingen",
"Previous conversation": "Vorig gesprek", "Previous conversation": "Vorig gesprek",
"Previous period": "Vorige periode", "Previous period": "Vorige periode",
@@ -1546,10 +1546,10 @@ export const catalog: Catalog = {
"Then nothing": "Daarna niets", "Then nothing": "Daarna niets",
"There is no preview for this kind of file.": "Voor dit soort bestand is er geen voorbeeld.", "There is no preview for this kind of file.": "Voor dit soort bestand is er geen voorbeeld.",
"There is nothing in it to export": "Er zit niets in om te exporteren", "There is nothing in it to export": "Er zit niets in om te exporteren",
"This file is not UTF-8 text, so editing it here would corrupt it — download it instead.": "Dit bestand is geen UTF-8-tekst, dus het hier bewerken zou het beschadigen: download het in plaats daarvan.", "This file is not UTF-8 text, so editing it here would corrupt it — download it instead.": "Dit bestand is geen UTF-8-tekst, dus het hier bewerken zou het beschadigen: download het in plaats van openen.",
"This file is too big to show here ({size}) — download it to read it.": "Dit bestand is te groot om hier te tonen ({size}): download het om het te lezen.", "This file is too big to show here ({size}) — download it to read it.": "Dit bestand is te groot om hier te tonen ({size}): download het om het te lezen.",
"This goes to {recipients}{rest}.": "Dit gaat naar {recipients}{rest}.", "This goes to {recipients}{rest}.": "Dit gaat naar {recipients}{rest}.",
"This link does not go where it says": "Deze link gaat niet waarheen hij zegt", "This link does not go where it says": "Deze link gaat niet naar de aangegeven bestemming",
"This message packs its attachments into a winmail.dat, which most clients cannot open.": "Dit bericht verpakt zijn bijlagen in een winmail.dat, die de meeste clients niet kunnen openen.", "This message packs its attachments into a winmail.dat, which most clients cannot open.": "Dit bericht verpakt zijn bijlagen in een winmail.dat, die de meeste clients niet kunnen openen.",
"Throw away your changes?": "Uw wijzigingen weggooien?", "Throw away your changes?": "Uw wijzigingen weggooien?",
"Today, in your date format": "Vandaag, in uw datumnotatie", "Today, in your date format": "Vandaag, in uw datumnotatie",
@@ -1576,11 +1576,11 @@ export const catalog: Catalog = {
"Your filter rules have changes that have not been saved.": "Uw filterregels bevatten wijzigingen die niet zijn opgeslagen.", "Your filter rules have changes that have not been saved.": "Uw filterregels bevatten wijzigingen die niet zijn opgeslagen.",
"Your own identity domains are always inside and do not need listing. A domain here also covers its subdomains.": "De domeinen van uw eigen identiteiten zijn altijd intern en hoeven niet te worden vermeld. Een domein hier omvat ook zijn subdomeinen.", "Your own identity domains are always inside and do not need listing. A domain here also covers its subdomains.": "De domeinen van uw eigen identiteiten zijn altijd intern en hoeven niet te worden vermeld. Een domein hier omvat ook zijn subdomeinen.",
"Your own:": "Uw eigen:", "Your own:": "Uw eigen:",
"dark mode": "de donkere modus", "dark mode": "donkere modus",
"file": "bestand", "file": "bestand",
"light mode": "de lichte modus", "light mode": "lichte modus",
"scored {score} against a threshold of {threshold}": "scoorde {score} bij een drempel van {threshold}", "scored {score} against a threshold of {threshold}": "scoorde {score} bij een drempel van {threshold}",
"scored {score}, with no threshold stated": "scoorde {score}, zonder vermelde drempel", "scored {score}, with no threshold stated": "scoorde {score}, zonder een vermelde drempel",
"this view": "deze weergave", "this view": "deze weergave",
"{count} conversations moved to {folder}": "{count} gesprekken verplaatst naar {folder}", "{count} conversations moved to {folder}": "{count} gesprekken verplaatst naar {folder}",
"{count} folders": "{count} mappen", "{count} folders": "{count} mappen",
@@ -1602,10 +1602,10 @@ export const catalog: Catalog = {
"Remove star": "Ster verwijderen", "Remove star": "Ster verwijderen",
"Requested, to {address}. Never sent automatically.": "Gevraagd, aan {address}. Wordt nooit automatisch verzonden.", "Requested, to {address}. Never sent automatically.": "Gevraagd, aan {address}. Wordt nooit automatisch verzonden.",
"The sender did not request a read receipt.": "De afzender heeft geen leesbevestiging gevraagd.", "The sender did not request a read receipt.": "De afzender heeft geen leesbevestiging gevraagd.",
"This is bulk or list mail; read receipts for it only confirm the address is live.": "Dit is bulk- of lijstpost; een leesbevestiging bevestigt daarvoor alleen dat het adres actief is.", "This is bulk or list mail; read receipts for it only confirm the address is live.": "Dit is bulk- of lijst e-mail; een leesbevestiging bevestigt daarvoor alleen dat het adres actief is.",
"This message has not been received, so there is nothing to report.": "Dit bericht is niet ontvangen, dus er valt niets te melden.", "This message has not been received, so there is nothing to report.": "Dit bericht is niet ontvangen, dus er valt niets te melden.",
"This message was sent automatically, so no read receipt is offered.": "Dit bericht is automatisch verzonden, dus er wordt geen leesbevestiging aangeboden.", "This message was sent automatically, so no read receipt is offered.": "Dit bericht is automatisch verzonden, dus er wordt geen leesbevestiging aangeboden.",
"This server will not hold a message longer than {span}.": "Deze server houdt een bericht niet langer dan {span} vast.", "This server will not hold a message longer than {span}.": "Deze server bewaart een bericht niet langer dan {span}.",
"Upload failed": "Uploaden mislukt", "Upload failed": "Uploaden mislukt",
// ── Third pass ────────────────────────────────────────────────────── // ── Third pass ──────────────────────────────────────────────────────
// Sentences that lib/ and store/ were building in English, and the two // Sentences that lib/ and store/ were building in English, and the two
@@ -1629,7 +1629,7 @@ export const catalog: Catalog = {
"fourth": "vierde", "fourth": "vierde",
"keep it": "behouden", "keep it": "behouden",
"last": "laatste", "last": "laatste",
"mark it read": "als gelezen markeren", "mark it read": "markeren als gelezen",
"move to {folder}": "verplaatsen naar {folder}", "move to {folder}": "verplaatsen naar {folder}",
"reject it": "weigeren", "reject it": "weigeren",
"remove {flag}": "{flag} verwijderen", "remove {flag}": "{flag} verwijderen",
@@ -1653,7 +1653,7 @@ export const catalog: Catalog = {
"It was not deleted": "Het is niet verwijderd", "It was not deleted": "Het is niet verwijderd",
"Empty address book": "Dit adresboek leegmaken", "Empty address book": "Dit adresboek leegmaken",
"There is nothing in it to delete": "Er staat niets in om te verwijderen", "There is nothing in it to delete": "Er staat niets in om te verwijderen",
"Empty “{name}”?": "{name}” leegmaken?", "Empty “{name}”?": "{name}” leegmaken?",
"Delete them": "Verwijderen", "Delete them": "Verwijderen",
"Nothing was deleted": "Er is niets verwijderd", "Nothing was deleted": "Er is niets verwijderd",
// ── Checking an S/MIME signature, and what may be said about it ── // ── Checking an S/MIME signature, and what may be said about it ──
@@ -1665,7 +1665,7 @@ export const catalog: Catalog = {
"Issued by": "Uitgegeven door", "Issued by": "Uitgegeven door",
"It is signed with OpenPGP, and ihasmail has no way to fetch the sender's public key.": "Het is ondertekend met OpenPGP, en ihasmail kan de openbare sleutel van de afzender niet ophalen.", "It is signed with OpenPGP, and ihasmail has no way to fetch the sender's public key.": "Het is ondertekend met OpenPGP, en ihasmail kan de openbare sleutel van de afzender niet ophalen.",
"It uses a signature algorithm ihasmail cannot check yet.": "Het gebruikt een ondertekeningsalgoritme dat ihasmail nog niet kan controleren.", "It uses a signature algorithm ihasmail cannot check yet.": "Het gebruikt een ondertekeningsalgoritme dat ihasmail nog niet kan controleren.",
"It was made with a certificate belonging to {name}, which does not cover this address.": "Hij is gemaakt met een certificaat van {name}, dat dit adres niet dekt.", "It was made with a certificate belonging to {name}, which does not cover this address.": "Het is gemaakt met een certificaat dat toebehoort aan {name}, maar dit certificaat is niet geldig voor dit adres.",
"Previous fingerprint": "Vorige vingerafdruk", "Previous fingerprint": "Vorige vingerafdruk",
"Signed at": "Ondertekend op", "Signed at": "Ondertekend op",
"Signed by {name} — the same signer as before.": "Ondertekend door {name} — dezelfde ondertekenaar als eerder.", "Signed by {name} — the same signer as before.": "Ondertekend door {name} — dezelfde ondertekenaar als eerder.",
@@ -1689,13 +1689,13 @@ export const catalog: Catalog = {
"as claimed by the signer": "volgens de ondertekenaar", "as claimed by the signer": "volgens de ondertekenaar",
"first seen {date}": "voor het eerst gezien op {date}", "first seen {date}": "voor het eerst gezien op {date}",
"ihasmail will tell you if a later message from this address is signed by anybody else.": "ihasmail laat het weten als een later bericht van dit adres door iemand anders is ondertekend.", "ihasmail will tell you if a later message from this address is signed by anybody else.": "ihasmail laat het weten als een later bericht van dit adres door iemand anders is ondertekend.",
"itself, or an issuer it does not name": "zichzelf, of een uitgever die het niet noemt", "itself, or an issuer it does not name": "zichzelf, of een uitgever die niet wordt genoemd",
"no address": "geen adres", "no address": "geen adres",
}, },
plurals: { plurals: {
// ── Administration: domains ──────────────────────────────────── // ── Administration: domains ────────────────────────────────────
"{n} accounts use this domain. Move or delete them first.": { one: "{n} account gebruikt dit domein. Verplaats of verwijder het eerst.", other: "{n} accounts gebruiken dit domein. Verplaats of verwijder ze eerst." }, "{n} accounts use this domain. Move or delete them first.": { one: "{n} account gebruikt dit domein. Verplaats of verwijder deze eerst.", other: "{n} accounts gebruiken dit domein. Verplaats of verwijder ze eerst." },
"The server stops accepting mail for this domain, and its {n} DKIM keys are deleted. This can't be undone.": { one: "De server accepteert geen e-mail meer voor dit domein en de {n} DKIM-sleutel wordt verwijderd. Dit kan niet ongedaan worden gemaakt.", other: "De server accepteert geen e-mail meer voor dit domein en de {n} DKIM-sleutels worden verwijderd. Dit kan niet ongedaan worden gemaakt." }, "The server stops accepting mail for this domain, and its {n} DKIM keys are deleted. This can't be undone.": { one: "De server accepteert geen e-mails meer voor dit domein en de {n} DKIM-sleutel wordt verwijderd. Dit kan niet ongedaan worden gemaakt.", other: "De server accepteert geen e-mails meer voor dit domein en de {n} DKIM-sleutels worden verwijderd. Dit kan niet ongedaan worden gemaakt." },
"{n} domains": { one: "{n} domein", other: "{n} domeinen" }, "{n} domains": { one: "{n} domein", other: "{n} domeinen" },
"{n} groups": { one: "{n} groep", other: "{n} groepen" }, "{n} groups": { one: "{n} groep", other: "{n} groepen" },
"Its {n} members are taken out of the group first, and lose what was shared with it. The group's own mail is removed in the background, and it can't be undone.": { one: "Het {n} lid wordt eerst uit de groep gehaald en verliest wat ermee gedeeld was. De e-mail van de groep wordt op de achtergrond verwijderd, en dit kan niet ongedaan worden gemaakt.", other: "De {n} leden worden eerst uit de groep gehaald en verliezen wat ermee gedeeld was. De e-mail van de groep wordt op de achtergrond verwijderd, en dit kan niet ongedaan worden gemaakt." }, "Its {n} members are taken out of the group first, and lose what was shared with it. The group's own mail is removed in the background, and it can't be undone.": { one: "Het {n} lid wordt eerst uit de groep gehaald en verliest wat ermee gedeeld was. De e-mail van de groep wordt op de achtergrond verwijderd, en dit kan niet ongedaan worden gemaakt.", other: "De {n} leden worden eerst uit de groep gehaald en verliezen wat ermee gedeeld was. De e-mail van de groep wordt op de achtergrond verwijderd, en dit kan niet ongedaan worden gemaakt." },
@@ -1704,7 +1704,7 @@ export const catalog: Catalog = {
"Grants {n} permissions": { one: "Kent {n} recht toe", other: "Kent {n} rechten toe" }, "Grants {n} permissions": { one: "Kent {n} recht toe", other: "Kent {n} rechten toe" },
"{n} roles": { one: "{n} rol", other: "{n} rollen" }, "{n} roles": { one: "{n} rol", other: "{n} rollen" },
"{n} tenants": { one: "{n} tenant", other: "{n} tenants" }, "{n} tenants": { one: "{n} tenant", other: "{n} tenants" },
"{n} accounts in this tenant are still on {domain}. Move them or delete them before taking the domain out.": { one: "{n} account van deze tenant staat nog op {domain}. Verplaats of verwijder het voordat u het domein eruit haalt.", other: "{n} accounts van deze tenant staan nog op {domain}. Verplaats of verwijder ze voordat u het domein eruit haalt." }, "{n} accounts in this tenant are still on {domain}. Move them or delete them before taking the domain out.": { one: "{n} account van deze tenant staat nog op {domain}. Verplaats of verwijder deze voordat u het domein eruit haalt.", other: "{n} accounts van deze tenant staan nog op {domain}. Verplaats of verwijder ze voordat u het domein eruit haalt." },
"{n} DKIM keys": { one: "{n} DKIM-sleutel", other: "{n} DKIM-sleutels" }, "{n} DKIM keys": { one: "{n} DKIM-sleutel", other: "{n} DKIM-sleutels" },
"{n} other items": { one: "{n} ander item", other: "{n} andere items" }, "{n} other items": { one: "{n} ander item", other: "{n} andere items" },
// ── Administration ──────────────────────────────────────────────── // ── Administration ────────────────────────────────────────────────
@@ -1728,13 +1728,13 @@ export const catalog: Catalog = {
"Every {n} years": { one: "Elk jaar", other: "Elke {n} jaar" }, "Every {n} years": { one: "Elk jaar", other: "Elke {n} jaar" },
"{rule}, {n} times": { one: "{rule}, {n} keer", other: "{rule}, {n} keer" }, "{rule}, {n} times": { one: "{rule}, {n} keer", other: "{rule}, {n} keer" },
// ── Third pass ───────────────────────────────────────────────────── // ── Third pass ─────────────────────────────────────────────────────
"Move {n} messages to Trash?": { one: "{n} bericht naar de Prullenbak verplaatsen?", other: "{n} berichten naar de Prullenbak verplaatsen?" }, "Move {n} messages to Trash?": { one: "{n} bericht naar de prullenbak verplaatsen?", other: "{n} berichten naar de prullenbak verplaatsen?" },
"{n} days": { one: "{n} dag", other: "{n} dagen" }, "{n} days": { one: "{n} dag", other: "{n} dagen" },
"{n} hours": { one: "{n} uur", other: "{n} uur" }, "{n} hours": { one: "{n} uur", other: "{n} uur" },
"Updated {n} contacts, nothing new": { one: "{n} contact bijgewerkt, niets nieuws", other: "{n} contacten bijgewerkt, niets nieuws" }, "Updated {n} contacts, nothing new": { one: "{n} contact bijgewerkt, niets nieuws", other: "{n} contacten bijgewerkt, niets nieuws" },
"{n} updated": { one: "{n} bijgewerkt", other: "{n} bijgewerkt" }, "{n} updated": { one: "{n} bijgewerkt", other: "{n} bijgewerkt" },
"Updated {n} contacts you already had": { one: "Bestaand contact bijgewerkt", other: "{n} bestaande contacten bijgewerkt" }, "Updated {n} contacts you already had": { one: "Bestaand contact bijgewerkt", other: "{n} bestaande contacten bijgewerkt" },
"{n} of them look like contacts you already had": { one: "{n} daarvan lijkt op een contact dat u al had", other: "{n} daarvan lijken op contacten die u al had" }, "{n} of them look like contacts you already had": { one: "{n} daarvan lijkt op een bestaand contact", other: "{n} daarvan lijken op een bestaand contact" },
"Your administrator changed {n} settings": { one: "Uw beheerder heeft {n} instelling gewijzigd", other: "Uw beheerder heeft {n} instellingen gewijzigd" }, "Your administrator changed {n} settings": { one: "Uw beheerder heeft {n} instelling gewijzigd", other: "Uw beheerder heeft {n} instellingen gewijzigd" },
"Exported {n} events": { one: "{n} afspraak geëxporteerd", other: "{n} afspraken geëxporteerd" }, "Exported {n} events": { one: "{n} afspraak geëxporteerd", other: "{n} afspraken geëxporteerd" },
"Imported {n} events": { one: "{n} afspraak geïmporteerd", other: "{n} afspraken geïmporteerd" }, "Imported {n} events": { one: "{n} afspraak geïmporteerd", other: "{n} afspraken geïmporteerd" },
+1
View File
@@ -751,6 +751,7 @@ export const catalog: Catalog = {
"Attachment reminder": "Aviso de anexo", "Attachment reminder": "Aviso de anexo",
"Warn when the message mentions an attachment but none is attached.": "Avisar quando a mensagem mencionar um anexo mas nenhum estiver anexado.", "Warn when the message mentions an attachment but none is attached.": "Avisar quando a mensagem mencionar um anexo mas nenhum estiver anexado.",
"Spell check while typing": "Verificação ortográfica ao digitar", "Spell check while typing": "Verificação ortográfica ao digitar",
"Open the composer full screen": "Escrever mensagens em tela cheia",
"Confirm before deleting": "Confirmar antes de excluir", "Confirm before deleting": "Confirmar antes de excluir",
"Show message snippets": "Mostrar um trecho das mensagens", "Show message snippets": "Mostrar um trecho das mensagens",
"Preview the first line of each message in the list.": "Mostrar a primeira linha de cada mensagem na lista.", "Preview the first line of each message in the list.": "Mostrar a primeira linha de cada mensagem na lista.",
+1
View File
@@ -751,6 +751,7 @@ export const catalog: Catalog = {
"Attachment reminder": "Напоминание о вложении", "Attachment reminder": "Напоминание о вложении",
"Warn when the message mentions an attachment but none is attached.": "Предупреждать, если письмо упоминает вложение, но его нет.", "Warn when the message mentions an attachment but none is attached.": "Предупреждать, если письмо упоминает вложение, но его нет.",
"Spell check while typing": "Проверять орфографию при вводе", "Spell check while typing": "Проверять орфографию при вводе",
"Open the composer full screen": "Писать письма во весь экран",
"Confirm before deleting": "Спрашивать перед удалением", "Confirm before deleting": "Спрашивать перед удалением",
"Show message snippets": "Показывать начало письма", "Show message snippets": "Показывать начало письма",
"Preview the first line of each message in the list.": "Показывать первую строку каждого письма в списке.", "Preview the first line of each message in the list.": "Показывать первую строку каждого письма в списке.",
+1
View File
@@ -745,6 +745,7 @@ export const catalog: Catalog = {
"Attachment reminder": "Нагадування про вкладення", "Attachment reminder": "Нагадування про вкладення",
"Warn when the message mentions an attachment but none is attached.": "Попереджати, якщо лист згадує вкладення, але його немає.", "Warn when the message mentions an attachment but none is attached.": "Попереджати, якщо лист згадує вкладення, але його немає.",
"Spell check while typing": "Перевіряти орфографію під час введення", "Spell check while typing": "Перевіряти орфографію під час введення",
"Open the composer full screen": "Писати листи на весь екран",
"Confirm before deleting": "Питати перед видаленням", "Confirm before deleting": "Питати перед видаленням",
"Show message snippets": "Показувати початок листа", "Show message snippets": "Показувати початок листа",
"Preview the first line of each message in the list.": "Показувати перший рядок кожного листа у списку.", "Preview the first line of each message in the list.": "Показувати перший рядок кожного листа у списку.",
+1
View File
@@ -747,6 +747,7 @@ export const catalog: Catalog = {
"Attachment reminder": "附件提醒", "Attachment reminder": "附件提醒",
"Warn when the message mentions an attachment but none is attached.": "邮件提到附件但未添加时提醒。", "Warn when the message mentions an attachment but none is attached.": "邮件提到附件但未添加时提醒。",
"Spell check while typing": "输入时检查拼写", "Spell check while typing": "输入时检查拼写",
"Open the composer full screen": "全屏写邮件",
"Confirm before deleting": "删除前确认", "Confirm before deleting": "删除前确认",
"Show message snippets": "显示邮件摘要", "Show message snippets": "显示邮件摘要",
"Preview the first line of each message in the list.": "在列表中显示每封邮件的首行。", "Preview the first line of each message in the list.": "在列表中显示每封邮件的首行。",
@@ -0,0 +1,41 @@
import { beforeEach, describe, expect, it } from "vitest";
import { useCompose } from "@/store/compose";
import { useMail } from "@/store/mail";
import { DEFAULT_SETTINGS, useSettings } from "@/store/settings";
/**
* "Open the composer full screen" (#401): the size a new composer starts at.
*
* Only new composers follow it. A draft put back after an undone or failed
* send keeps the size it had, since that is the window somebody was already
* looking at.
*/
beforeEach(() => {
useCompose.setState({ drafts: [], activeKey: null, pendingSends: {} });
useMail.setState({ accountId: "a1", identities: [] as never });
useSettings.setState({ settings: { ...DEFAULT_SETTINGS } });
});
const opened = () => {
const key = useCompose.getState().open();
return useCompose.getState().drafts.find((d) => d.key === key)!;
};
describe("the size a new composer opens at", () => {
it("is the usual window by default", () => {
expect(opened().maximized).toBe(false);
});
it("is full screen with the setting on", () => {
useSettings.setState((s) => ({ settings: { ...s.settings, composeMaximized: true } }));
expect(opened().maximized).toBe(true);
});
it("can still be restored to a window once open", () => {
useSettings.setState((s) => ({ settings: { ...s.settings, composeMaximized: true } }));
const d = opened();
useCompose.getState().update(d.key, { maximized: false });
expect(useCompose.getState().drafts.find((x) => x.key === d.key)!.maximized).toBe(false);
});
});
+1 -1
View File
@@ -137,7 +137,7 @@ function blankDraft(init: Partial<Draft> = {}): Draft {
showBcc: false, showBcc: false,
showReplyTo: false, showReplyTo: false,
minimized: false, minimized: false,
maximized: false, maximized: s.composeMaximized,
dirty: false, dirty: false,
savedAt: null, savedAt: null,
saving: false, saving: false,
+16 -1
View File
@@ -29,6 +29,7 @@ import { plural, t } from "@/lib/i18n";
import { withBase } from "@/lib/basePath"; import { withBase } from "@/lib/basePath";
import { isDeviceTrusted, loadRaw, saveJson } from "@/lib/storage"; import { isDeviceTrusted, loadRaw, saveJson } from "@/lib/storage";
import { MAILBOX_PROPS, LIST_PROPS, FULL_PROPS, BODY_PROPS } from "./props"; import { MAILBOX_PROPS, LIST_PROPS, FULL_PROPS, BODY_PROPS } from "./props";
import { compareFolders } from "@/lib/mailbox/folderOrder";
import { type ListQuery, type MailState } from "./types"; import { type ListQuery, type MailState } from "./types";
import { playNewMailSound, showNotification } from "@/lib/notify/notify"; import { playNewMailSound, showNotification } from "@/lib/notify/notify";
import { pushEnabledHere } from "@/lib/notify/webpush"; import { pushEnabledHere } from "@/lib/notify/webpush";
@@ -161,7 +162,7 @@ export const useMail = create<MailState>((set, get) => ({
childrenOf(parentId) { childrenOf(parentId) {
return Object.values(get().mailboxes) return Object.values(get().mailboxes)
.filter((m) => (m.parentId ?? null) === parentId) .filter((m) => (m.parentId ?? null) === parentId)
.sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name)); .sort(compareFolders);
}, },
async query(q, opts = {}) { async query(q, opts = {}) {
@@ -762,6 +763,20 @@ export const useMail = create<MailState>((set, get) => ({
if (before.length) await followFolders(before); if (before.length) await followFolders(before);
}, },
async arrangeMailboxes(updates) {
const accountId = get().accountId!;
const moved = Object.keys(updates).filter((id) => updates[id]!.parentId !== undefined);
const before = moved.flatMap((id) => folderRefs(get(), id));
// One request for the whole level rather than one per folder. JMAP applies
// each update on its own, so a refusal can leave the level part-numbered;
// reloading shows whatever order the server actually kept.
const res = await client.call<SetResponse>("Mailbox/set", { accountId, update: updates });
const failed = Object.values(res.notUpdated ?? {})[0];
await get().loadMailboxes();
if (failed) throw new Error(setErrorMessage(failed));
if (before.length) await followFolders(before);
},
async destroyMailbox(id, removeEmails = true) { async destroyMailbox(id, removeEmails = true) {
const accountId = get().accountId!; const accountId = get().accountId!;
const before = folderRefs(get(), id); const before = folderRefs(get(), id);
+2
View File
@@ -99,6 +99,8 @@ export interface MailState {
/** Give something the Archive role -- adopting a folder already named for it, or making one. */ /** Give something the Archive role -- adopting a folder already named for it, or making one. */
ensureArchiveFolder(): Promise<Id>; ensureArchiveFolder(): Promise<Id>;
updateMailbox(id: Id, patch: Partial<Mailbox>): Promise<void>; updateMailbox(id: Id, patch: Partial<Mailbox>): Promise<void>;
/** Several folders' `sortOrder` (and at most a new parent) in one request: a reorder from the tree. */
arrangeMailboxes(updates: Record<Id, Partial<Mailbox>>): Promise<void>;
destroyMailbox(id: Id, removeEmails?: boolean): Promise<void>; destroyMailbox(id: Id, removeEmails?: boolean): Promise<void>;
loadIdentities(): Promise<Identity[]>; loadIdentities(): Promise<Identity[]>;
+6
View File
@@ -251,6 +251,11 @@ export interface Settings {
archiveOnReply: boolean; archiveOnReply: boolean;
autoAdvance: "newer" | "older" | "list"; autoAdvance: "newer" | "older" | "list";
spellcheck: boolean; spellcheck: boolean;
/**
* Open every new composer full screen (#401). Desktop only: on a phone the
* composer fills the screen already and has no size to choose.
*/
composeMaximized: boolean;
sendAndArchive: boolean; sendAndArchive: boolean;
/** Width (px) of the message list when the reading pane is on the right. */ /** Width (px) of the message list when the reading pane is on the right. */
listPaneWidth: number; listPaneWidth: number;
@@ -376,6 +381,7 @@ export const DEFAULT_SETTINGS: Settings = {
archiveOnReply: false, archiveOnReply: false,
autoAdvance: "list", autoAdvance: "list",
spellcheck: true, spellcheck: true,
composeMaximized: false,
sendAndArchive: false, sendAndArchive: false,
listPaneWidth: 520, listPaneWidth: 520,
listPaneHeight: 340, listPaneHeight: 340,
+3
View File
@@ -1310,6 +1310,9 @@ a.menu-item:hover { color: var(--fg); }
.nav-item.active.unread .nav-label, .nav-item.active.unread .nav-count { color: inherit; } .nav-item.active.unread .nav-label, .nav-item.active.unread .nav-count { color: inherit; }
.nav-item.drop-target { background: var(--accent-soft); outline: 2px dashed var(--accent); outline-offset: -2px; } .nav-item.drop-target { background: var(--accent-soft); outline: 2px dashed var(--accent); outline-offset: -2px; }
.nav-item.folder-row.dragging { opacity: .45; } .nav-item.folder-row.dragging { opacity: .45; }
/* A folder dragged between two others: a line where it will land. */
.nav-item.folder-row.drop-before { box-shadow: inset 0 2px 0 var(--accent); }
.nav-item.folder-row.drop-after { box-shadow: inset 0 -2px 0 var(--accent); }
/* A folder color tints its icon; the label keeps the sidebar's contrast. */ /* A folder color tints its icon; the label keeps the sidebar's contrast. */
.folder-row .folder-icon { display: inline-flex; align-items: center; } .folder-row .folder-icon { display: inline-flex; align-items: center; }
/* .nav-item svg sets color on the svg itself, so inheriting from the span is /* .nav-item svg sets color on the svg itself, so inheriting from the span is
+74 -22
View File
@@ -1,6 +1,6 @@
import { lazy, Suspense, useEffect, useMemo, useState, type DragEvent, type ReactNode } from "react"; import { lazy, Suspense, useEffect, useMemo, useState, type DragEvent, type ReactNode } from "react";
import { Link, useLocation } from "wouter"; import { Link, useLocation } from "wouter";
import { AlertOctagon, Archive, ChevronDown, ChevronLeft, Clock, ChevronRight, File, Folder, FolderPlus, Inbox, Mail, MoreVertical, Palette, Send, Star, Tag, Trash2, Plus, Pencil, Eye, EyeOff, CheckCheck, Eraser, Share2, X, FolderInput } from "lucide-react"; import { AlertOctagon, Archive, ArrowDown, ArrowUp, ChevronDown, ChevronLeft, Clock, ChevronRight, File, Folder, FolderPlus, Inbox, Mail, MoreVertical, Palette, Send, Star, Tag, Trash2, Plus, Pencil, Eye, EyeOff, CheckCheck, Eraser, Share2, X, FolderInput } from "lucide-react";
import { useMail } from "@/store/mail"; import { useMail } from "@/store/mail";
import { canEmpty, confirmAndEmpty, emptyLabel } from "@/lib/mailbox/emptyFolder"; import { canEmpty, confirmAndEmpty, emptyLabel } from "@/lib/mailbox/emptyFolder";
import { labelTree, visibleLabels } from "@/lib/mailbox/labelTree"; import { labelTree, visibleLabels } from "@/lib/mailbox/labelTree";
@@ -14,6 +14,7 @@ import { toast } from "@/ui/toast";
import { MailboxPicker } from "./MailboxPicker"; import { MailboxPicker } from "./MailboxPicker";
import { loadRaw, saveJson } from "@/lib/storage"; import { loadRaw, saveJson } from "@/lib/storage";
import { canDropFolder, canMoveFolderTo, folderColor, movable } from "@/lib/mailbox/folderMove"; import { canDropFolder, canMoveFolderTo, folderColor, movable } from "@/lib/mailbox/folderMove";
import { canPlaceFolder, compareFolders, neighbour, placeFolder, type Placement } from "@/lib/mailbox/folderOrder";
import { haptic, useTouchRow } from "@/lib/input/touch"; import { haptic, useTouchRow } from "@/lib/input/touch";
import { plural, t } from "@/lib/i18n"; import { plural, t } from "@/lib/i18n";
import { mailboxDisplayName } from "@/lib/mailbox/mailboxName"; import { mailboxDisplayName } from "@/lib/mailbox/mailboxName";
@@ -78,8 +79,32 @@ export function MailboxTree() {
} }
}; };
// Tree: AZ at every level (Inbox pinned to the top of the root), subfolders nested and /** Whether the folder in flight may go just above or below this folder. */
// collapsed by default. Expansion state is remembered per folder. const canPlace = (targetId: Id, placement: Placement): boolean => Boolean(draggingId) && canPlaceFolder(mailboxes, draggingId!, targetId, placement);
/** Put a folder just above or below another: a drag between rows, or Move up / Move down. */
const placeFolderAt = async (id: Id, targetId: Id, placement: Placement) => {
setDraggingId(null);
const updates = placeFolder(mailboxes, id, targetId, placement);
if (!updates) return;
try {
await useMail.getState().arrangeMailboxes(updates);
const parentId = updates[id]?.parentId;
if (parentId) {
const next = { ...expanded, [parentId]: true };
setExpanded(next);
saveJson("mbx-expanded", next);
}
} catch (err) {
toast.error(t("Could not move “{name}”: {reason}", { name: mailboxDisplayName(mailboxes[id]!), reason: (err as Error).message }));
}
};
const shown = (m: Mailbox) => showHidden || m.isSubscribed || m.role === "inbox";
// Tree: in `compareFolders` order at every level (Inbox, then any order the
// user has dragged into place, then the special folders, then AZ),
// subfolders nested and collapsed by default. Expansion state is remembered
// per folder.
const [expanded, setExpanded] = useState<Record<Id, boolean>>(() => loadRaw("mbx-expanded", {})); const [expanded, setExpanded] = useState<Record<Id, boolean>>(() => loadRaw("mbx-expanded", {}));
const toggle = (id: Id) => { const toggle = (id: Id) => {
const next = { ...expanded, [id]: !expanded[id] }; const next = { ...expanded, [id]: !expanded[id] };
@@ -87,17 +112,13 @@ export function MailboxTree() {
saveJson("mbx-expanded", next); saveJson("mbx-expanded", next);
}; };
const { rows, childrenOf, subtreeUnread } = useMemo(() => { const { rows, childrenOf, subtreeUnread } = useMemo(() => {
const all = Object.values(mailboxes).filter((m) => showHidden || m.isSubscribed || m.role === "inbox"); const all = Object.values(mailboxes).filter(shown);
const byParent = new Map<Id | null, Mailbox[]>(); const byParent = new Map<Id | null, Mailbox[]>();
for (const m of all) { for (const m of all) {
const p = m.parentId && mailboxes[m.parentId] ? m.parentId : null; const p = m.parentId && mailboxes[m.parentId] ? m.parentId : null;
byParent.set(p, [...(byParent.get(p) ?? []), m]); byParent.set(p, [...(byParent.get(p) ?? []), m]);
} }
const cmp = (a: Mailbox, b: Mailbox) => { for (const list of byParent.values()) list.sort(compareFolders);
if ((a.role === "inbox") !== (b.role === "inbox")) return a.role === "inbox" ? -1 : 1;
return a.name.localeCompare(b.name, undefined, { sensitivity: "base", numeric: true });
};
for (const list of byParent.values()) list.sort(cmp);
const out: Array<{ m: Mailbox; depth: number; hasChildren: boolean; open: boolean; hiddenUnread: number; childUnread: number }> = []; const out: Array<{ m: Mailbox; depth: number; hasChildren: boolean; open: boolean; hiddenUnread: number; childUnread: number }> = [];
const unreadBelow = (id: Id): number => (byParent.get(id) ?? []).reduce((n, c) => n + c.unreadEmails + unreadBelow(c.id), 0); const unreadBelow = (id: Id): number => (byParent.get(id) ?? []).reduce((n, c) => n + c.unreadEmails + unreadBelow(c.id), 0);
const walk = (parent: Id | null, depth: number) => { const walk = (parent: Id | null, depth: number) => {
@@ -207,6 +228,8 @@ export function MailboxTree() {
onFolderDragStart={() => {}} onFolderDragStart={() => {}}
onFolderDragEnd={() => {}} onFolderDragEnd={() => {}}
onFolderDrop={() => {}} onFolderDrop={() => {}}
canPlace={() => false}
onFolderPlace={() => {}}
/> />
</> </>
)} )}
@@ -229,6 +252,8 @@ export function MailboxTree() {
onFolderDragStart={() => setDraggingId(m.id)} onFolderDragStart={() => setDraggingId(m.id)}
onFolderDragEnd={() => { setDraggingId(null); setRootDrop(false); }} onFolderDragEnd={() => { setDraggingId(null); setRootDrop(false); }}
onFolderDrop={(id) => void moveFolder(id, m.id)} onFolderDrop={(id) => void moveFolder(id, m.id)}
canPlace={(placement) => canPlace(m.id, placement) && !(placement === "after" && open && hasChildren)}
onFolderPlace={(id, placement) => void placeFolderAt(id, m.id, placement)}
/> />
))} ))}
{/* Labels are a flat list that belongs to the mailbox, not to whichever {/* Labels are a flat list that belongs to the mailbox, not to whichever
@@ -260,7 +285,11 @@ export function MailboxTree() {
)} )}
</nav> </nav>
<Popover anchor={menu.anchor} onClose={menu.close} width={300}> <Popover anchor={menu.anchor} onClose={menu.close} width={300}>
{menuTarget && <MailboxMenu mailbox={menuTarget} onClose={menu.close} onCreateChild={() => void createFolder(menuTarget.id)} onShare={() => setShareTarget(menuTarget)} onMove={() => { menu.close(); setMoveTarget(menuTarget); }} />} {menuTarget && <MailboxMenu mailbox={menuTarget} onClose={menu.close} onCreateChild={() => void createFolder(menuTarget.id)} onShare={() => setShareTarget(menuTarget)} onMove={() => { menu.close(); setMoveTarget(menuTarget); }} onStep={(direction) => {
menu.close();
const to = neighbour(mailboxes, menuTarget.id, direction, shown);
if (to) void placeFolderAt(menuTarget.id, to.targetId, to.placement);
}} canStep={(direction) => Boolean(neighbour(mailboxes, menuTarget.id, direction, shown))} />}
</Popover> </Popover>
{moveTarget && ( {moveTarget && (
<MailboxPicker <MailboxPicker
@@ -280,8 +309,9 @@ export function MailboxTree() {
); );
} }
function FolderRow({ mailbox: m, label, depth, hasChildren, open, hiddenUnread, childUnread, onToggle, onDrillIn, currentId, onMenu, dragging, acceptsFolder, onFolderDragStart, onFolderDragEnd, onFolderDrop }: { mailbox: Mailbox; label: string; depth: number; hasChildren: boolean; open: boolean; hiddenUnread: number; childUnread: number; onToggle: () => void; onDrillIn?: () => void; currentId?: string; onMenu: (m: Mailbox, e: { currentTarget: Element }) => void; dragging: boolean; acceptsFolder: boolean; onFolderDragStart: () => void; onFolderDragEnd: () => void; onFolderDrop: (id: Id) => void }) { function FolderRow({ mailbox: m, label, depth, hasChildren, open, hiddenUnread, childUnread, onToggle, onDrillIn, currentId, onMenu, dragging, acceptsFolder, onFolderDragStart, onFolderDragEnd, onFolderDrop, canPlace, onFolderPlace }: { mailbox: Mailbox; label: string; depth: number; hasChildren: boolean; open: boolean; hiddenUnread: number; childUnread: number; onToggle: () => void; onDrillIn?: () => void; currentId?: string; onMenu: (m: Mailbox, e: { currentTarget: Element }) => void; dragging: boolean; acceptsFolder: boolean; onFolderDragStart: () => void; onFolderDragEnd: () => void; onFolderDrop: (id: Id) => void; canPlace: (placement: Placement) => boolean; onFolderPlace: (id: Id, placement: Placement) => void }) {
const [dropping, setDropping] = useState(false); /** Where a drop here would land: in this folder, or just above or below it. */
const [drop, setDrop] = useState<"into" | Placement | null>(null);
/** Expanding in place and drilling in are the same relationship; only one shows. */ /** Expanding in place and drilling in are the same relationship; only one shows. */
const twisty = hasChildren && !onDrillIn; const twisty = hasChildren && !onDrillIn;
// Scheduled counts like Drafts: everything in it is already read, so the // Scheduled counts like Drafts: everything in it is already read, so the
@@ -297,19 +327,37 @@ function FolderRow({ mailbox: m, label, depth, hasChildren, open, hiddenUnread,
// Subscribed, not read once: picking a color has to repaint the row. // Subscribed, not read once: picking a color has to repaint the row.
const tint = useSettings((s) => folderColor(s.settings.folderColors, m.id)); const tint = useSettings((s) => folderColor(s.settings.folderColors, m.id));
/*
* A folder dropped on the top or bottom quarter of a row goes above or below
* it; anywhere else, into it. Where "into" isn't allowed -- a special folder,
* which can be reordered but never nested -- the whole row reorders, by
* whichever half the pointer is in.
*/
const folderZone = (e: DragEvent): "into" | Placement | null => {
const r = e.currentTarget.getBoundingClientRect();
const y = e.clientY - r.top;
const edge = r.height / 4;
const zone = y < edge ? "before" : y > r.height - edge ? "after" : "into";
if (zone !== "into" && canPlace(zone)) return zone;
if (acceptsFolder) return "into";
const half = y < r.height / 2 ? "before" : "after";
return canPlace(half) ? half : null;
};
const onDragOver = (e: DragEvent) => { const onDragOver = (e: DragEvent) => {
const folder = e.dataTransfer.types.includes(FOLDER_MIME); const zone = e.dataTransfer.types.includes(FOLDER_MIME) ? folderZone(e) : e.dataTransfer.types.includes("application/x-ihasmail-emails") ? "into" : null;
if (folder ? !acceptsFolder : !e.dataTransfer.types.includes("application/x-ihasmail-emails")) return; if (!zone) return;
e.preventDefault(); e.preventDefault();
e.dataTransfer.dropEffect = "move"; e.dataTransfer.dropEffect = "move";
if (!dropping) setDropping(true); if (drop !== zone) setDrop(zone);
}; };
const onDrop = (e: DragEvent) => { const onDrop = (e: DragEvent) => {
e.preventDefault(); e.preventDefault();
setDropping(false); setDrop(null);
const folderId = e.dataTransfer.getData(FOLDER_MIME); const folderId = e.dataTransfer.getData(FOLDER_MIME);
if (folderId) { if (folderId) {
if (acceptsFolder) onFolderDrop(folderId); const zone = folderZone(e);
if (zone === "into") onFolderDrop(folderId);
else if (zone) onFolderPlace(folderId, zone);
return; return;
} }
const raw = e.dataTransfer.getData("application/x-ihasmail-emails"); const raw = e.dataTransfer.getData("application/x-ihasmail-emails");
@@ -348,16 +396,17 @@ function FolderRow({ mailbox: m, label, depth, hasChildren, open, hiddenUnread,
return ( return (
<Link <Link
href={`/mail/${m.id}`} href={`/mail/${m.id}`}
className={`nav-item folder-row depth-${Math.min(depth, 4)} ${currentId === m.id ? "active" : ""} ${unread ? "unread" : ""} ${dropping ? "drop-target" : ""} ${dragging ? "dragging" : ""}`} className={`nav-item folder-row depth-${Math.min(depth, 4)} ${currentId === m.id ? "active" : ""} ${unread ? "unread" : ""} ${drop === "into" ? "drop-target" : drop ? `drop-${drop}` : ""} ${dragging ? "dragging" : ""}`}
title={label} title={label}
{...press} {...press}
// Dragging a folder is a mouse gesture; on a touchscreen the browser // Dragging a folder is a mouse gesture; on a touchscreen the browser
// starts it from the same long press that now opens the menu. // starts it from the same long press that now opens the menu. Special
draggable={movable(m) && !isTouch} // folders drag too, to be reordered; only Inbox, always first, stays put.
draggable={m.role !== "inbox" && !isTouch}
onDragStart={onDragStart} onDragStart={onDragStart}
onDragEnd={onFolderDragEnd} onDragEnd={onFolderDragEnd}
onDragOver={onDragOver} onDragOver={onDragOver}
onDragLeave={() => setDropping(false)} onDragLeave={() => setDrop(null)}
onDrop={onDrop} onDrop={onDrop}
onContextMenu={(e) => { onContextMenu={(e) => {
e.preventDefault(); e.preventDefault();
@@ -424,7 +473,7 @@ function FolderRow({ mailbox: m, label, depth, hasChildren, open, hiddenUnread,
); );
} }
function MailboxMenu({ mailbox: m, onClose, onCreateChild, onShare, onMove }: { mailbox: Mailbox; onClose: () => void; onCreateChild: () => void; onShare: () => void; onMove: () => void }) { function MailboxMenu({ mailbox: m, onClose, onCreateChild, onShare, onMove, onStep, canStep }: { mailbox: Mailbox; onClose: () => void; onCreateChild: () => void; onShare: () => void; onMove: () => void; onStep: (direction: "up" | "down") => void; canStep: (direction: "up" | "down") => boolean }) {
const shared = Object.keys(m.shareWith ?? {}).length > 0; const shared = Object.keys(m.shareWith ?? {}).length > 0;
const [, navigate] = useLocation(); const [, navigate] = useLocation();
const colors = useSettings((s) => s.settings.folderColors); const colors = useSettings((s) => s.settings.folderColors);
@@ -490,6 +539,9 @@ function MailboxMenu({ mailbox: m, onClose, onCreateChild, onShare, onMove }: {
<MenuItem icon={<FolderPlus size={16} />} label={t("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={t("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={<FolderInput size={16} />} label={t("Move to…")} onClick={onMove} disabled={!movable(m) || !m.myRights.mayRename} /> <MenuItem icon={<FolderInput size={16} />} label={t("Move to…")} onClick={onMove} disabled={!movable(m) || !m.myRights.mayRename} />
{/* The way to reorder without a drag: from the keyboard, and on touch. */}
<MenuItem icon={<ArrowUp size={16} />} label={t("Move up")} onClick={() => onStep("up")} disabled={!canStep("up")} />
<MenuItem icon={<ArrowDown size={16} />} label={t("Move down")} onClick={() => onStep("down")} disabled={!canStep("down")} />
<MenuItem icon={m.isSubscribed ? <EyeOff size={16} /> : <Eye size={16} />} label={m.isSubscribed ? t("Hide from list") : t("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 ? t("Hide from list") : t("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
@@ -0,0 +1,112 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { MailboxTree } from "../MailboxTree";
import { useMail } from "@/store/mail";
import { useSettings } from "@/store/settings";
import type { Mailbox, MailboxRole } from "@/jmap/types";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
/**
* Reordering folders in the sidebar (#402), driven through the real tree.
*
* The placement arithmetic has its own tests in lib/mailbox; these are about
* the row deciding where a drop lands from where the pointer is, which only
* the component knows.
*/
window.matchMedia = ((q: string) => ({ matches: false, media: q, addEventListener() {}, removeEventListener() {} })) as unknown as typeof window.matchMedia;
const rights = { mayReadItems: true, mayAddItems: true, mayRemoveItems: true, maySetSeen: true, maySetKeywords: true, mayCreateChild: true, mayRename: true, mayDelete: true, maySubmit: true };
const box = (id: string, name: string, parentId: string | null, role: MailboxRole = null): Mailbox => ({
id, name, parentId, role, sortOrder: 0, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0, myRights: rights, isSubscribed: true,
});
const MAILBOXES = {
zeta: box("zeta", "Zeta", null),
trash: box("trash", "Deleted Items", null, "trash"),
sent: box("sent", "Sent", null, "sent"),
inbox: box("inbox", "Inbox", null, "inbox"),
alpha: box("alpha", "Alpha", null),
drafts: box("drafts", "Drafts", null, "drafts"),
};
/** jsdom has no DataTransfer; this is the part of one the tree touches. */
function transfer() {
const data: Record<string, string> = {};
return {
get types() { return Object.keys(data); },
setData: (k: string, v: string) => { data[k] = v; },
getData: (k: string) => data[k] ?? "",
effectAllowed: "", dropEffect: "",
};
}
function fire(el: Element, type: string, dataTransfer: ReturnType<typeof transfer>, clientY = 0) {
const e = new Event(type, { bubbles: true, cancelable: true });
Object.assign(e, { dataTransfer, clientY });
act(() => { el.dispatchEvent(e); });
}
describe("reordering folders in the tree", () => {
let host: HTMLDivElement;
let root: Root;
const arrange = vi.fn(async (_updates: Record<string, Partial<Mailbox>>) => {});
const updateMailbox = vi.fn(async (_id: string, _patch: Partial<Mailbox>) => {});
const rows = () => Array.from(document.querySelectorAll(".nav-item.folder-row")).map((r) => r.querySelector(".nav-label")?.textContent);
const rowFor = (name: string) => Array.from(document.querySelectorAll<HTMLElement>(".nav-item.folder-row")).find((r) => r.querySelector(".nav-label")?.textContent === name)!;
/** Drag `from` over `to` at a fraction of its height, drop, and say what the row showed. */
function drag(from: string, to: string, frac: number) {
const dt = transfer();
const target = rowFor(to);
// Every row is 36px tall, from 100px down the page.
target.getBoundingClientRect = () => ({ top: 100, height: 36, bottom: 136, left: 0, right: 200, width: 200, x: 0, y: 100, toJSON() {} });
fire(rowFor(from), "dragstart", dt);
fire(target, "dragover", dt, 100 + 36 * frac);
const shown = /drop-(before|after|target)/.exec(target.className)?.[1] ?? null;
fire(target, "drop", dt, 100 + 36 * frac);
return shown;
}
beforeEach(() => {
arrange.mockClear();
updateMailbox.mockClear();
window.history.replaceState({}, "", "/mail/inbox");
useMail.setState({ mailboxes: MAILBOXES, mailboxesLoaded: true, arrangeMailboxes: arrange, updateMailbox });
useSettings.setState((s) => ({ settings: { ...s.settings, showHiddenFolders: false, labelsSidebar: false } }));
host = document.createElement("div");
document.body.appendChild(host);
root = createRoot(host);
act(() => root.render(<MailboxTree />));
});
afterEach(() => { act(() => root.unmount()); host.remove(); });
it("lists special folders under Inbox before the rest, until something is dragged", () => {
expect(rows()).toEqual(["Inbox", "Drafts", "Sent", "Deleted Items", "Alpha", "Zeta"]);
});
it("puts a folder above the row when it's dropped on the row's top edge", () => {
expect(drag("Zeta", "Drafts", 0.1)).toBe("before");
expect(arrange).toHaveBeenCalledWith({
zeta: { sortOrder: 20 }, drafts: { sortOrder: 30 }, sent: { sortOrder: 40 }, trash: { sortOrder: 50 }, alpha: { sortOrder: 60 }, inbox: { sortOrder: 10 },
});
});
it("nests a folder dropped on the middle of an ordinary folder, as before", () => {
expect(drag("Zeta", "Alpha", 0.5)).toBe("target");
expect(updateMailbox).toHaveBeenCalledWith("zeta", { parentId: "alpha" });
expect(arrange).not.toHaveBeenCalled();
});
it("reorders a special folder by the nearer half, since it can't be nested", () => {
expect(drag("Deleted Items", "Drafts", 0.4)).toBe("before");
expect(Object.keys(arrange.mock.calls[0]![0])).toContain("trash");
});
it("drops nothing above Inbox", () => {
expect(drag("Sent", "Inbox", 0.1)).toBeNull();
expect(arrange).not.toHaveBeenCalled();
});
});
@@ -183,6 +183,7 @@ export function GeneralSettings() {
<Switch locked={isEnforced("includeQuote")} checked={s.includeQuote} onChange={(v) => update({ includeQuote: v })} label={t("Quote original message in replies")} /> <Switch locked={isEnforced("includeQuote")} checked={s.includeQuote} onChange={(v) => update({ includeQuote: v })} label={t("Quote original message in replies")} />
<Switch locked={isEnforced("signatureAboveQuote")} checked={s.signatureAboveQuote} onChange={(v) => update({ signatureAboveQuote: v })} label={t("Place signature above quoted text")} /> <Switch locked={isEnforced("signatureAboveQuote")} checked={s.signatureAboveQuote} onChange={(v) => update({ signatureAboveQuote: v })} label={t("Place signature above quoted text")} />
<Switch locked={isEnforced("spellcheck")} checked={s.spellcheck} onChange={(v) => update({ spellcheck: v })} label={t("Spell check while typing")} /> <Switch locked={isEnforced("spellcheck")} checked={s.spellcheck} onChange={(v) => update({ spellcheck: v })} label={t("Spell check while typing")} />
<Switch locked={isEnforced("composeMaximized")} checked={s.composeMaximized} onChange={(v) => update({ composeMaximized: v })} label={t("Open the composer full screen")} />
<h2>{t("Locale")}</h2> <h2>{t("Locale")}</h2>
<div className="field-row"> <div className="field-row">