Files
ihasmail/web/src/views/settings/FoldersSettings.tsx
T
jcoffey-dev bd6a605d61 Group six more clusters out of web/src/lib
Takes the flat module count from 66 to 42, continuing what admin/ and
calendar/ started.

  lib/mailbox/  archiveDate, emptyFolder, folderMove, labelTree,
                mailboxName, mailboxRoute
  lib/sieve/    sieve, sieveApply, sieveFolders
  lib/input/    keyboard, swipe, touch, listSelection, dropUpload
  lib/notify/   notify, webpush, webpushEnable
  lib/sw/       swCache, swFacts, staleBuild
  lib/text/     html, markdown, text, emlName

FOUR THINGS THE FILENAMES GET WRONG, each checked by reading the file
rather than trusting what it is called:

  - appFolder is not a mailbox. It is the `ihasmail` folder in JMAP
    *Files*, where the client keeps signature images and synced settings.
    It stays flat.
  - format holds no formatting of text. It re-exports the date and clock
    formatters, so it belongs with dates/datetime, not with text/.
  - preview is the file viewer deciding what it can show without
    downloading, and source is where to point someone asking for this
    instance's AGPL source. Neither is about text.
  - notify is not Web Push. It is the tab title, the favicon badge and
    the new-mail sound -- in-app notification, which is why it sits with
    webpush rather than under sw/ with the service worker's own concerns.

threadScroll stays flat too: it decides where a conversation opens, which
is view state rather than a gesture, and input/ is honest only if
everything in it interprets something the reader did.

No behavior change. Almost every reference was on the @/ alias; eight
relative imports in files that did not move, or that moved away from a
sibling, needed rewriting by hand.
2026-09-15 23:17:50 -07:00

129 lines
7.3 KiB
TypeScript

import { useMemo, useState } from "react";
import { Eye, EyeOff, Folder, Pencil, Plus, Share2, Trash2, Inbox } from "lucide-react";
import { useMail } from "@/store/mail";
import { confirmDialog, promptDialog } from "@/ui/dialog";
import { toast } from "@/ui/toast";
import { formatSize } from "@/lib/format";
import { ShareDialog } from "./ShareDialog";
import type { Mailbox, MailboxRole } from "@/jmap/types";
import { plural, t } from "@/lib/i18n";
import { mailboxDisplayPath } from "@/lib/mailbox/mailboxName";
/*
* Roles a folder can be given here.
*
* These are the three that ihasmail's own behavior depends on and that
* Stalwart will let move. Inbox, Junk and Trash are absent on purpose: 0.16.20
* refuses them outright -- "You are not allowed to change the role of Inbox,
* Junk or Trash folders" -- so offering them would only produce an error.
*
* `label` rather than a bare string so the catalog sees them: they are
* translated where they render.
*/
const SETTABLE_ROLES: { value: Exclude<MailboxRole, null>; label: string }[] = [
{ value: "archive", label: "Archive" },
{ value: "drafts", label: "Drafts" },
{ value: "sent", label: "Sent" },
];
/** Roles the server keeps to itself, shown but not offered. */
const FIXED_ROLES = new Set<string>(["inbox", "junk", "trash"]);
export function FoldersSettings() {
const mailboxes = useMail((s) => s.mailboxes);
const mailboxPath = useMail((s) => s.mailboxPath);
const [share, setShare] = useState<Mailbox | null>(null);
const list = useMemo(() => Object.values(mailboxes).map((m) => ({ m, path: mailboxPath(m.id) })).sort((a, b) => a.path.localeCompare(b.path)), [mailboxes, mailboxPath]);
const quotas = useMail((s) => s.quotas);
const q = quotas.find((x) => x.resourceType === "octets");
/*
* A role belongs to exactly one folder -- Stalwart answers "A mailbox with
* role 'archive' already exists" -- so a role another folder holds is left
* out of the list rather than offered and refused. Clearing it there frees it
* here, which is two steps and no surprises.
*/
const taken = useMemo(() => {
const by = new Map<string, string>();
for (const m of Object.values(mailboxes)) if (m.role) by.set(m.role, m.id);
return by;
}, [mailboxes]);
const setRole = async (m: Mailbox, role: MailboxRole) => {
try {
await useMail.getState().updateMailbox(m.id, { role });
// Deliberately not naming the role: the value is the protocol's word
// ("archive"), and dropping an untranslated English token into nine
// languages reads worse than saying nothing about it. The select already
// shows what it now is.
toast.success(t("Folder role updated"));
} catch (err) {
toast.error((err as Error).message);
}
};
const create = async () => {
const name = await promptDialog({ title: t("New folder"), placeholder: t("Folder name (use / for subfolders, e.g. Work/Invoices)") });
if (!name?.trim()) return;
try {
const parts = name.split("/").map((p) => p.trim()).filter(Boolean);
let parentId: string | null = null;
for (const part of parts) {
const existing = Object.values(useMail.getState().mailboxes).find((m) => (m.parentId ?? null) === parentId && m.name.toLowerCase() === part.toLowerCase());
parentId = existing ? existing.id : await useMail.getState().createMailbox(part, parentId);
}
toast.success(t("Folder created"));
} catch (err) {
toast.error((err as Error).message);
}
};
return (
<div>
<h1>{t("Folders")}</h1>
<p className="lead">{`${t("Create, rename and hide folders.")} ${q && q.hardLimit ? t("Storage: {used} of {total} used.", { used: formatSize(q.used), total: formatSize(q.hardLimit) }) : ""}`}</p>
<button className="btn mb-16" onClick={() => void create()}><Plus size={16} /> {t("New folder")}</button>
<table className="sessions-table">
<thead><tr><th>{t("Folder")}</th><th>{t("Role")}</th><th>{t("Messages")}</th><th>{t("Unread")}</th><th /></tr></thead>
<tbody>
{list.map(({ m, path }) => (
<tr key={m.id}>
<td><div className="row gap-8">{m.role === "inbox" ? <Inbox size={16} /> : <Folder size={16} />}<span>{mailboxDisplayPath(m, mailboxes)}</span>{!m.isSubscribed && <span className="badge muted">{t("hidden")}</span>}</div></td>
<td>
<select
className="input"
value={m.role ?? ""}
disabled={FIXED_ROLES.has(m.role ?? "")}
title={FIXED_ROLES.has(m.role ?? "") ? t("The server does not allow this role to be changed.") : undefined}
onChange={(e) => void setRole(m, (e.target.value || null) as MailboxRole)}
>
<option value="">{t("None")}</option>
{SETTABLE_ROLES.filter((o) => o.value === m.role || !taken.has(o.value)).map((o) => (
<option key={o.value} value={o.value}>{t(o.label)}</option>
))}
{/* A role this build does not offer -- inbox, junk, trash, or
anything a future server invents -- still has to show as
what it is rather than as "None". */}
{m.role && !SETTABLE_ROLES.some((o) => o.value === m.role) && <option value={m.role}>{m.role}</option>}
</select>
</td>
<td>{m.totalEmails.toLocaleString()}</td>
<td>{m.unreadEmails.toLocaleString()}</td>
<td>
<div className="row" style={{ justifyContent: "flex-end", gap: 0 }}>
<button className="icon-btn sm" title={t("Rename")} disabled={Boolean(m.role) && m.role !== "subscribed"} onClick={async () => { const n = await // The server's own name, never the localized one: this box writes
// back whatever it is prefilled with.
promptDialog({ title: t("Rename folder"), defaultValue: m.name }); if (n?.trim() && n !== m.name) { try { await useMail.getState().updateMailbox(m.id, { name: n.trim() }); } catch (err) { toast.error((err as Error).message); } } }}><Pencil size={16} /></button>
<button className="icon-btn sm" title={m.isSubscribed ? t("Hide") : t("Show")} disabled={m.role === "inbox"} onClick={() => void useMail.getState().updateMailbox(m.id, { isSubscribed: !m.isSubscribed })}>{m.isSubscribed ? <EyeOff size={16} /> : <Eye size={16} />}</button>
{Object.keys(m.shareWith ?? {}).length > 0 && <button className="icon-btn sm" title={t("Stop sharing")} onClick={() => setShare(m)}><Share2 size={16} /></button>}
<button className="icon-btn sm danger" title={t("Delete")} disabled={Boolean(m.role) && m.role !== "subscribed"} onClick={async () => { if (await confirmDialog({ title: t("Delete “{name}”?", { name: m.name }), message: plural(m.totalEmails, { one: "{n} message will be permanently deleted.", other: "{n} messages will be permanently deleted." }), confirmLabel: t("Delete"), danger: true })) { try { await useMail.getState().destroyMailbox(m.id, true); } catch (err) { toast.error((err as Error).message); } } }}><Trash2 size={16} /></button>
</div>
</td>
</tr>
))}
</tbody>
</table>
{share && <ShareDialog kind="Mailbox" id={share.id} name={share.name} shareWith={share.shareWith ?? null} onClose={() => setShare(null)} />}
</div>
);
}