Folder pane: align icons, mark-read incl. subfolders

- Move the expand chevron into a gutter left of the folder icon so folders
  with and without subfolders line up on their icon; labels share the column.
- Add "Mark all as read, incl. subfolders" to the folder context menu, with
  the affected count and a per-folder fallback for servers without filter
  operators.
- Re-measure the virtualised message list when row height changes.
- Mock: seed unread mail in a subfolder.
This commit is contained in:
2026-08-23 01:34:15 -07:00
parent d079e2ad88
commit de1b33e2aa
5 changed files with 104 additions and 30 deletions
+44 -6
View File
@@ -126,7 +126,10 @@ export interface MailState {
archive(ids: Id[]): Promise<void>;
spam(ids: Id[], isSpam: boolean): Promise<void>;
emptyMailbox(mailboxId: Id): Promise<void>;
markMailboxRead(mailboxId: Id): Promise<void>;
/** Mark every unread message in a mailbox read; optionally its subfolders too. */
markMailboxRead(mailboxId: Id, includeChildren?: boolean): Promise<void>;
/** The mailbox plus all of its descendants. */
descendantMailboxIds(mailboxId: Id): Id[];
createMailbox(name: string, parentId: Id | null): Promise<Id>;
updateMailbox(id: Id, patch: Partial<Mailbox>): Promise<void>;
@@ -568,16 +571,51 @@ export const useMail = create<MailState>((set, get) => ({
}
},
async markMailboxRead(mailboxId) {
descendantMailboxIds(mailboxId) {
const all = Object.values(get().mailboxes);
const out: Id[] = [mailboxId];
const walk = (parent: Id) => {
for (const m of all) {
if ((m.parentId ?? null) === parent) {
out.push(m.id);
walk(m.id);
}
}
};
walk(mailboxId);
return out;
},
async markMailboxRead(mailboxId, includeChildren = false) {
const accountId = get().accountId;
if (!accountId) return;
try {
const boxes = includeChildren ? get().descendantMailboxIds(mailboxId) : [mailboxId];
const unreadIn = async (filter: EmailFilter): Promise<Id[]> => {
const res = await client.chain([
["Email/query", { accountId, filter: { inMailbox: mailboxId, notKeyword: "$seen" }, limit: 5000 }, "q"],
["Email/query", { accountId, filter, limit: 5000 }, "q"],
["Email/get", { accountId, "#ids": { resultOf: "q", name: "Email/query", path: "/ids" }, properties: ["id"] }, "g"],
]);
const ids = ((res.get("g")?.[0] as unknown as GetResponse<Email>).list ?? []).map((e) => e.id);
if (ids.length) await get().markRead(ids, true);
return ((res.get("g")?.[0] as unknown as GetResponse<Email>).list ?? []).map((e) => e.id);
};
try {
let ids: Id[];
if (boxes.length === 1) {
ids = await unreadIn({ inMailbox: boxes[0]!, notKeyword: "$seen" });
} else {
try {
ids = await unreadIn({ operator: "AND", conditions: [{ notKeyword: "$seen" }, { operator: "OR", conditions: boxes.map((id) => ({ inMailbox: id })) }] });
} catch {
// Server without filter-operator support: one query per folder.
const per = await Promise.all(boxes.map((id) => unreadIn({ inMailbox: id, notKeyword: "$seen" }).catch(() => [] as Id[])));
ids = [...new Set(per.flat())];
}
}
if (!ids.length) {
toast.show("Nothing unread here");
return;
}
await get().markRead(ids, true);
toast.success(`Marked ${ids.length} message${ids.length === 1 ? "" : "s"} as read${includeChildren && boxes.length > 1 ? ` in ${boxes.length} folders` : ""}`);
void get().loadMailboxes();
} catch (err) {
toast.error(`Could not mark as read: ${(err as Error).message}`);
+12 -2
View File
@@ -330,9 +330,19 @@ img { max-width: 100%; }
.nav-item.active .nav-count { color: inherit; }
.nav-item .nav-more { opacity: 0; width: 24px; height: 24px; margin-right: -6px; }
.nav-item:hover .nav-more, .nav-item:focus-within .nav-more { opacity: 1; }
.nav-item .nav-twisty { width: 18px; height: 18px; margin-left: -8px; margin-right: -6px; display: inline-flex; align-items: center; justify-content: center; color: var(--fg-faint); border-radius: 4px; }
.nav-item .nav-twisty { width: 18px; height: 18px; display: inline-flex; align-items: center; justify-content: center; color: var(--fg-faint); border-radius: 4px; }
.nav-item .nav-twisty:hover { background: var(--bg-active); }
.nav-item.depth-1 { padding-left: 28px; } .nav-item.depth-2 { padding-left: 44px; } .nav-item.depth-3 { padding-left: 60px; } .nav-item.depth-4 { padding-left: 76px; }
/* Folder rows keep the expand chevron in a gutter to the LEFT of the icon, so a
folder with subfolders and one without still line up on their icon. */
.nav-item.folder-row { --folder-indent: 0px; position: relative; padding-left: calc(30px + var(--folder-indent)); }
.nav-item.folder-row.depth-1 { --folder-indent: 16px; }
.nav-item.folder-row.depth-2 { --folder-indent: 32px; }
.nav-item.folder-row.depth-3 { --folder-indent: 48px; }
.nav-item.folder-row.depth-4 { --folder-indent: 64px; }
.nav-item.folder-row .nav-twisty { position: absolute; left: calc(8px + var(--folder-indent)); top: 50%; transform: translateY(-50%); margin: 0; }
/* Labels sit in the same column: a 20px slot matching the folder icons. */
.nav-item.folder-row .nav-label-color { flex: 0 0 20px; width: 20px; height: 20px; border-radius: 0; display: inline-flex; align-items: center; justify-content: center; }
.nav-item.folder-row .nav-label-color::before { content: ""; width: 11px; height: 11px; border-radius: 3px; background: var(--label-color, var(--accent)); }
.collapsed .nav-item { justify-content: center; padding: 0; margin: 0 auto; width: 44px; border-radius: 999px; }
.collapsed .nav-item .nav-label, .collapsed .nav-item .nav-count, .collapsed .nav-item .nav-more, .collapsed .nav-item .nav-twisty { display: none; }
.collapsed .nav-item.depth-1, .collapsed .nav-item.depth-2, .collapsed .nav-item.depth-3 { display: none; }
+45 -21
View File
@@ -110,15 +110,15 @@ export function MailboxTree() {
</Link>
</div>
{labels.map((l) => (
<Link key={l.keyword} href={`/search?q=label:${encodeURIComponent(l.keyword)}`} className="nav-item" title={l.name}>
<span className="nav-label-color" style={{ background: l.color }} />
<Link key={l.keyword} href={`/search?q=label:${encodeURIComponent(l.keyword)}`} className="nav-item folder-row" title={l.name}>
<span className="nav-label-color" style={{ "--label-color": l.color } as React.CSSProperties} />
<span className="nav-label">{l.name}</span>
</Link>
))}
</>
)}
</nav>
<Popover anchor={menu.anchor} onClose={menu.close} width={240}>
<Popover anchor={menu.anchor} onClose={menu.close} width={300}>
{menuTarget && <MailboxMenu mailbox={menuTarget} onCreateChild={() => void createFolder(menuTarget.id)} onShare={() => setShareTarget(menuTarget)} />}
</Popover>
{shareTarget && <ShareDialog kind="Mailbox" id={shareTarget.id} name={shareTarget.name} shareWith={shareTarget.shareWith ?? null} onClose={() => setShareTarget(null)} />}
@@ -156,7 +156,7 @@ function FolderRow({ mailbox: m, label, depth, hasChildren, open, hiddenUnread,
return (
<Link
href={`/mail/${m.id}`}
className={`nav-item depth-${Math.min(depth, 4)} ${currentId === m.id ? "active" : ""} ${unread ? "unread" : ""} ${dropping ? "drop-target" : ""}`}
className={`nav-item folder-row depth-${Math.min(depth, 4)} ${currentId === m.id ? "active" : ""} ${unread ? "unread" : ""} ${dropping ? "drop-target" : ""}`}
title={label}
onDragOver={onDragOver}
onDragLeave={() => setDropping(false)}
@@ -166,23 +166,24 @@ function FolderRow({ mailbox: m, label, depth, hasChildren, open, hiddenUnread,
onMenu(m, { currentTarget: e.currentTarget });
}}
>
{hasChildren ? (
<span
className="nav-twisty"
role="button"
aria-label={open ? "Collapse" : "Expand"}
aria-expanded={open}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onToggle();
}}
>
{open ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</span>
) : (
depth > 0 && <span style={{ width: 4 }} />
)}
<span
className="nav-twisty"
role={hasChildren ? "button" : undefined}
aria-label={hasChildren ? (open ? "Collapse" : "Expand") : undefined}
aria-expanded={hasChildren ? open : undefined}
aria-hidden={hasChildren ? undefined : true}
onClick={
hasChildren
? (e) => {
e.preventDefault();
e.stopPropagation();
onToggle();
}
: undefined
}
>
{hasChildren ? open ? <ChevronDown size={14} /> : <ChevronRight size={14} /> : null}
</span>
{icon}
<span className="nav-label">{label}</span>
{count > 0 && <span className="nav-count" title={hiddenUnread ? `${own} here, ${hiddenUnread} in subfolders` : undefined}>{count > 9999 ? "9999+" : count}</span>}
@@ -204,6 +205,20 @@ function FolderRow({ mailbox: m, label, depth, hasChildren, open, hiddenUnread,
function MailboxMenu({ mailbox: m, onCreateChild, onShare }: { mailbox: Mailbox; onCreateChild: () => void; onShare: () => void }) {
const [, navigate] = useLocation();
const hasChildren = useMail((s) => Object.values(s.mailboxes).some((x) => (x.parentId ?? null) === m.id));
const subUnread = useMail((s) => {
const all = Object.values(s.mailboxes);
let n = 0;
const walk = (parent: Id) => {
for (const x of all)
if ((x.parentId ?? null) === parent) {
n += x.unreadEmails;
walk(x.id);
}
};
walk(m.id);
return n;
});
const rename = async () => {
const name = await promptDialog({ title: "Rename folder", defaultValue: m.name });
if (!name?.trim() || name.trim() === m.name) return;
@@ -232,6 +247,15 @@ function MailboxMenu({ mailbox: m, onCreateChild, onShare }: { mailbox: Mailbox;
return (
<>
<MenuItem icon={<CheckCheck size={16} />} label="Mark all as read" onClick={() => void useMail.getState().markMailboxRead(m.id)} disabled={!m.unreadEmails} />
{hasChildren && (
<MenuItem
icon={<CheckCheck size={16} />}
label="Mark all as read, incl. subfolders"
kbd={m.unreadEmails + subUnread ? String(m.unreadEmails + subUnread) : undefined}
onClick={() => void useMail.getState().markMailboxRead(m.id, true)}
disabled={!m.unreadEmails && !subUnread}
/>
)}
<MenuItem icon={<FolderPlus size={16} />} label="New subfolder" onClick={onCreateChild} disabled={!m.myRights.mayCreateChild} />
<MenuItem icon={<Pencil size={16} />} label="Rename" onClick={() => void rename()} disabled={isSpecial || !m.myRights.mayRename} />
<MenuItem icon={m.isSubscribed ? <EyeOff size={16} /> : <Eye size={16} />} label={m.isSubscribed ? "Hide from list" : "Show in list"} onClick={() => void useMail.getState().updateMailbox(m.id, { isSubscribed: !m.isSubscribed })} disabled={m.role === "inbox"} />