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:
@@ -34,7 +34,7 @@ ihasmail is a JMAP-first web client: mail, calendars, contacts, files, filters a
|
|||||||
- Search with Gmail operators (`from:`, `to:`, `subject:`, `has:attachment`, `is:unread`, `is:starred`, `in:`, `label:`, `before:`, `after:`, `larger:`, `smaller:` …) plus an advanced-search panel
|
- Search with Gmail operators (`from:`, `to:`, `subject:`, `has:attachment`, `is:unread`, `is:starred`, `in:`, `label:`, `before:`, `after:`, `larger:`, `smaller:` …) plus an advanced-search panel
|
||||||
- Composer: multiple floating/minimised/maximised composers, rich-text editor (formatting, lists, links, colours, images pasted/dropped inline, emoji), plain-text mode, recipient chips with autocomplete from **contacts, the directory (GAL) and recent recipients**, multiple identities with HTML signatures, Cc/Bcc, priority, read-receipt request, templates/canned responses, attachment upload with progress, drag & drop, attachment reminder, **undo send**, autosaved drafts, reply/reply-all/forward with quoting and inline images preserved
|
- Composer: multiple floating/minimised/maximised composers, rich-text editor (formatting, lists, links, colours, images pasted/dropped inline, emoji), plain-text mode, recipient chips with autocomplete from **contacts, the directory (GAL) and recent recipients**, multiple identities with HTML signatures, Cc/Bcc, priority, read-receipt request, templates/canned responses, attachment upload with progress, drag & drop, attachment reminder, **undo send**, autosaved drafts, reply/reply-all/forward with quoting and inline images preserved
|
||||||
- Live updates via JMAP push (EventSource proxied server-side) with polling fallback; desktop notifications, sound, title/favicon unread badge
|
- Live updates via JMAP push (EventSource proxied server-side) with polling fallback; desktop notifications, sound, title/favicon unread badge
|
||||||
- A–Z folder list with Inbox pinned on top (other special folders mixed in), subfolders nested and collapsed by default, folder management (create/rename/hide/share/empty), quota bar, Outlook-style module bar (Mail · Calendar · Contacts · Files) at the bottom of the pane, multi-account switching for shared accounts
|
- A–Z folder list with Inbox pinned on top (other special folders mixed in), subfolders nested and collapsed by default with chevrons in their own gutter so every icon lines up; unread folders are bold (a parent is bold when a subfolder has unread mail); right-click a folder to mark it read *including subfolders*, create/rename/hide/share/empty, quota bar, Outlook-style module bar (Mail · Calendar · Contacts · Files) at the bottom of the pane, multi-account switching for shared accounts
|
||||||
|
|
||||||
**Calendar** (JMAP Calendars / JSCalendar)
|
**Calendar** (JMAP Calendars / JSCalendar)
|
||||||
- Month / week / day / agenda views, mini calendar, multiple calendars with colours, show/hide, create/edit/share calendars
|
- Month / week / day / agenda views, mini calendar, multiple calendars with colours, show/hide, create/edit/share calendars
|
||||||
|
|||||||
@@ -95,6 +95,8 @@ for (let i = 0; i < 45; i++) {
|
|||||||
}
|
}
|
||||||
addEmail({ from: ["Demo User", USER], to: "[email protected]", subject: "Draft: ideas for the retreat", daysAgo: 0.1, mailbox: "drafts", html: true }).keywords = { $draft: true, $seen: true };
|
addEmail({ from: ["Demo User", USER], to: "[email protected]", subject: "Draft: ideas for the retreat", daysAgo: 0.1, mailbox: "drafts", html: true }).keywords = { $draft: true, $seen: true };
|
||||||
addEmail({ from: ["Spammy", "[email protected]"], subject: "You have WON!!!", daysAgo: 2, mailbox: "junk", unread: true });
|
addEmail({ from: ["Spammy", "[email protected]"], subject: "You have WON!!!", daysAgo: 2, mailbox: "junk", unread: true });
|
||||||
|
addEmail({ from: ["Finance Team", "[email protected]"], subject: "Invoice 2201 approved", daysAgo: 1, mailbox: "work-inv", unread: true });
|
||||||
|
addEmail({ from: ["Finance Team", "[email protected]"], subject: "Invoice 2202 pending", daysAgo: 2, mailbox: "work-inv", unread: true });
|
||||||
// Invitation email
|
// Invitation email
|
||||||
{
|
{
|
||||||
const ics = `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//mock//EN\r\nMETHOD:REQUEST\r\nBEGIN:VEVENT\r\nUID:inv-1@mock\r\nDTSTAMP:20260820T100000Z\r\nDTSTART:20260825T140000Z\r\nDTEND:20260825T150000Z\r\nSUMMARY:Project kickoff\r\nORGANIZER;CN=Ada Lovelace:mailto:[email protected]\r\nATTENDEE;CN=Demo User;RSVP=TRUE;PARTSTAT=NEEDS-ACTION:mailto:${USER}\r\nLOCATION:Room 4B\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n`;
|
const ics = `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//mock//EN\r\nMETHOD:REQUEST\r\nBEGIN:VEVENT\r\nUID:inv-1@mock\r\nDTSTAMP:20260820T100000Z\r\nDTSTART:20260825T140000Z\r\nDTEND:20260825T150000Z\r\nSUMMARY:Project kickoff\r\nORGANIZER;CN=Ada Lovelace:mailto:[email protected]\r\nATTENDEE;CN=Demo User;RSVP=TRUE;PARTSTAT=NEEDS-ACTION:mailto:${USER}\r\nLOCATION:Room 4B\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n`;
|
||||||
|
|||||||
+44
-6
@@ -126,7 +126,10 @@ export interface MailState {
|
|||||||
archive(ids: Id[]): Promise<void>;
|
archive(ids: Id[]): Promise<void>;
|
||||||
spam(ids: Id[], isSpam: boolean): Promise<void>;
|
spam(ids: Id[], isSpam: boolean): Promise<void>;
|
||||||
emptyMailbox(mailboxId: Id): 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>;
|
createMailbox(name: string, parentId: Id | null): Promise<Id>;
|
||||||
updateMailbox(id: Id, patch: Partial<Mailbox>): Promise<void>;
|
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;
|
const accountId = get().accountId;
|
||||||
if (!accountId) return;
|
if (!accountId) return;
|
||||||
try {
|
const boxes = includeChildren ? get().descendantMailboxIds(mailboxId) : [mailboxId];
|
||||||
|
const unreadIn = async (filter: EmailFilter): Promise<Id[]> => {
|
||||||
const res = await client.chain([
|
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"],
|
["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);
|
return ((res.get("g")?.[0] as unknown as GetResponse<Email>).list ?? []).map((e) => e.id);
|
||||||
if (ids.length) await get().markRead(ids, true);
|
};
|
||||||
|
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();
|
void get().loadMailboxes();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(`Could not mark as read: ${(err as Error).message}`);
|
toast.error(`Could not mark as read: ${(err as Error).message}`);
|
||||||
|
|||||||
+12
-2
@@ -330,9 +330,19 @@ img { max-width: 100%; }
|
|||||||
.nav-item.active .nav-count { color: inherit; }
|
.nav-item.active .nav-count { color: inherit; }
|
||||||
.nav-item .nav-more { opacity: 0; width: 24px; height: 24px; margin-right: -6px; }
|
.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: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 .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 { 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 .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; }
|
.collapsed .nav-item.depth-1, .collapsed .nav-item.depth-2, .collapsed .nav-item.depth-3 { display: none; }
|
||||||
|
|||||||
@@ -110,15 +110,15 @@ export function MailboxTree() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
{labels.map((l) => (
|
{labels.map((l) => (
|
||||||
<Link key={l.keyword} href={`/search?q=label:${encodeURIComponent(l.keyword)}`} className="nav-item" title={l.name}>
|
<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={{ background: l.color }} />
|
<span className="nav-label-color" style={{ "--label-color": l.color } as React.CSSProperties} />
|
||||||
<span className="nav-label">{l.name}</span>
|
<span className="nav-label">{l.name}</span>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</nav>
|
</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)} />}
|
{menuTarget && <MailboxMenu mailbox={menuTarget} onCreateChild={() => void createFolder(menuTarget.id)} onShare={() => setShareTarget(menuTarget)} />}
|
||||||
</Popover>
|
</Popover>
|
||||||
{shareTarget && <ShareDialog kind="Mailbox" id={shareTarget.id} name={shareTarget.name} shareWith={shareTarget.shareWith ?? null} onClose={() => setShareTarget(null)} />}
|
{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 (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={`/mail/${m.id}`}
|
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}
|
title={label}
|
||||||
onDragOver={onDragOver}
|
onDragOver={onDragOver}
|
||||||
onDragLeave={() => setDropping(false)}
|
onDragLeave={() => setDropping(false)}
|
||||||
@@ -166,23 +166,24 @@ function FolderRow({ mailbox: m, label, depth, hasChildren, open, hiddenUnread,
|
|||||||
onMenu(m, { currentTarget: e.currentTarget });
|
onMenu(m, { currentTarget: e.currentTarget });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{hasChildren ? (
|
|
||||||
<span
|
<span
|
||||||
className="nav-twisty"
|
className="nav-twisty"
|
||||||
role="button"
|
role={hasChildren ? "button" : undefined}
|
||||||
aria-label={open ? "Collapse" : "Expand"}
|
aria-label={hasChildren ? (open ? "Collapse" : "Expand") : undefined}
|
||||||
aria-expanded={open}
|
aria-expanded={hasChildren ? open : undefined}
|
||||||
onClick={(e) => {
|
aria-hidden={hasChildren ? undefined : true}
|
||||||
|
onClick={
|
||||||
|
hasChildren
|
||||||
|
? (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onToggle();
|
onToggle();
|
||||||
}}
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{open ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
{hasChildren ? open ? <ChevronDown size={14} /> : <ChevronRight size={14} /> : null}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
|
||||||
depth > 0 && <span style={{ width: 4 }} />
|
|
||||||
)}
|
|
||||||
{icon}
|
{icon}
|
||||||
<span className="nav-label">{label}</span>
|
<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>}
|
{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 }) {
|
function MailboxMenu({ mailbox: m, onCreateChild, onShare }: { mailbox: Mailbox; onCreateChild: () => void; onShare: () => void }) {
|
||||||
const [, navigate] = useLocation();
|
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 rename = async () => {
|
||||||
const name = await promptDialog({ title: "Rename folder", defaultValue: m.name });
|
const name = await promptDialog({ title: "Rename folder", defaultValue: m.name });
|
||||||
if (!name?.trim() || name.trim() === m.name) return;
|
if (!name?.trim() || name.trim() === m.name) return;
|
||||||
@@ -232,6 +247,15 @@ function MailboxMenu({ mailbox: m, onCreateChild, onShare }: { mailbox: Mailbox;
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<MenuItem icon={<CheckCheck size={16} />} label="Mark all as read" onClick={() => void useMail.getState().markMailboxRead(m.id)} disabled={!m.unreadEmails} />
|
<MenuItem icon={<CheckCheck size={16} />} label="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={<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={<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"} />
|
<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"} />
|
||||||
|
|||||||
Reference in New Issue
Block a user