ihasmail's mail list was built for a mouse. A row is clicked, right-clicked and dragged into a folder, and on a touchscreen two of those three do not exist -- so the phone layout had the shape of a mail app and none of the handling, and the things people reach for first simply did nothing. Four gestures, all touch-only, so a mouse keeps drag-to-folder unchanged: - Swipe a row sideways to act on it. Each direction is a setting -- right archives and left deletes by default, matching the app the phone came with -- and the strip revealed behind the row names what will happen in the folder it is happening in: "Delete forever" out of Deleted Items, "Not spam" inside Junk Mail, and nothing at all where the action is a no-op, in which case the row will not move that way. - Hold a row to select it. Selection was reachable already, by aiming at a checkbox beside an avatar, which is not how anyone selects mail on a phone. The selection toolbar gained an overflow menu at the same time: report spam, mark unread and label were hidden on narrow screens and had nowhere else to be, so touch selection could not reach them at all. - Hold a folder for the menu its ⋮ button opens. - Pull the list down to refresh, and drag in from the left edge of a conversation to go back. The toolbar's button and arrow both stay: a gesture with no visible control is one only the people who already know about it can use. The arithmetic behind them is in lib/touch.ts, away from the components and under test, because the numbers are the whole thing: an axis lock biased towards the vertical, so a diagonal flick stays a scroll rather than deleting whatever it passes over. Two layout bugs turned up while checking this on a 390px screen, both older than the gestures. The app shell is a grid with only its rows named, so it took an implicit auto column sized to the top bar's min-content -- about 470px -- and every message row ran off the right of the glass with its date beyond the edge. The column is now stated as minmax(0, 1fr), and the search field is allowed to shrink. Full-screen surfaces measure in dvh rather than vh, and the tab bar, drawer and compose button keep out from under the notch and the home indicator.
405 lines
18 KiB
TypeScript
405 lines
18 KiB
TypeScript
import { useMemo, useState, type DragEvent, type ReactNode } from "react";
|
||
import { Link, useLocation } from "wouter";
|
||
import { AlertOctagon, Archive, ChevronDown, Clock, ChevronRight, File, Folder, FolderPlus, Inbox, Mail, MoreVertical, Palette, Send, Star, Tag, Trash2, Plus, Pencil, Eye, EyeOff, CheckCheck, Eraser, Share2, X } from "lucide-react";
|
||
import { useMail } from "@/store/mail";
|
||
import { canEmpty, confirmAndEmpty, emptyLabel } from "@/lib/emptyFolder";
|
||
import { isScheduledMailbox } from "@/store/scheduled";
|
||
import { useSettings } from "@/store/settings";
|
||
import type { Id, Mailbox } from "@/jmap/types";
|
||
import { MenuItem, MenuSep, MenuTitle, Popover, useMenu } from "@/ui/popover";
|
||
import { CALENDAR_COLORS, useIsTouch } from "@/ui/misc";
|
||
import { confirmDialog, promptDialog } from "@/ui/dialog";
|
||
import { toast } from "@/ui/toast";
|
||
import { ShareDialog } from "../settings/ShareDialog";
|
||
import { loadRaw, saveJson } from "@/lib/storage";
|
||
import { canDropFolder, folderColor, movable } from "@/lib/folderMove";
|
||
import { haptic, useTouchRow } from "@/lib/touch";
|
||
|
||
const ROLE_ICONS: Record<string, ReactNode> = {
|
||
inbox: <Inbox size={20} />,
|
||
drafts: <File size={20} />,
|
||
sent: <Send size={20} />,
|
||
trash: <Trash2 size={20} />,
|
||
junk: <AlertOctagon size={20} />,
|
||
archive: <Archive size={20} />,
|
||
all: <Mail size={20} />,
|
||
flagged: <Star size={20} />,
|
||
important: <Tag size={20} />,
|
||
};
|
||
|
||
/** Its own drag type, so a folder can only be dropped where folders belong. */
|
||
const FOLDER_MIME = "application/x-ihasmail-folder";
|
||
|
||
export function MailboxTree() {
|
||
const mailboxes = useMail((s) => s.mailboxes);
|
||
const loaded = useMail((s) => s.mailboxesLoaded);
|
||
const [location] = useLocation();
|
||
const currentId = location.startsWith("/mail/") ? location.split("/")[2] : undefined;
|
||
const showHidden = useSettings((s) => s.settings.showHiddenFolders);
|
||
const labels = useSettings((s) => s.settings.labels);
|
||
const labelsSidebar = useSettings((s) => s.settings.labelsSidebar);
|
||
const menu = useMenu();
|
||
const [menuTarget, setMenuTarget] = useState<Mailbox | null>(null);
|
||
const [shareTarget, setShareTarget] = useState<Mailbox | null>(null);
|
||
/**
|
||
* The folder being dragged. Held here rather than read from the drag itself:
|
||
* dataTransfer.getData is blocked during dragover, so a row cannot ask what
|
||
* is over it, and every row needs to know whether it is a legal target.
|
||
*/
|
||
const [draggingId, setDraggingId] = useState<Id | null>(null);
|
||
const [rootDrop, setRootDrop] = useState(false);
|
||
/** Whether the folder in flight may be dropped on this folder, or on the root. */
|
||
const canDropOn = (targetId: Id | null): boolean => Boolean(draggingId) && canDropFolder(mailboxes, draggingId!, targetId);
|
||
|
||
const moveFolder = async (id: Id, parentId: Id | null) => {
|
||
const m = mailboxes[id];
|
||
setDraggingId(null);
|
||
try {
|
||
await useMail.getState().updateMailbox(id, { parentId });
|
||
// Show where it landed rather than leaving it hidden in a closed parent.
|
||
if (parentId) {
|
||
const next = { ...expanded, [parentId]: true };
|
||
setExpanded(next);
|
||
saveJson("mbx-expanded", next);
|
||
}
|
||
toast.success(parentId ? `“${m?.name}” moved into “${mailboxes[parentId]?.name}”` : `“${m?.name}” moved to the top level`);
|
||
} catch (err) {
|
||
toast.error(`Could not move “${m?.name}”: ${(err as Error).message}`);
|
||
}
|
||
};
|
||
|
||
// Tree: A–Z at every level (Inbox pinned to the top of the root), subfolders nested and
|
||
// collapsed by default. Expansion state is remembered per folder.
|
||
const [expanded, setExpanded] = useState<Record<Id, boolean>>(() => loadRaw("mbx-expanded", {}));
|
||
const toggle = (id: Id) => {
|
||
const next = { ...expanded, [id]: !expanded[id] };
|
||
setExpanded(next);
|
||
saveJson("mbx-expanded", next);
|
||
};
|
||
const rows = useMemo(() => {
|
||
const all = Object.values(mailboxes).filter((m) => showHidden || m.isSubscribed || m.role === "inbox");
|
||
const byParent = new Map<Id | null, Mailbox[]>();
|
||
for (const m of all) {
|
||
const p = m.parentId && mailboxes[m.parentId] ? m.parentId : null;
|
||
byParent.set(p, [...(byParent.get(p) ?? []), m]);
|
||
}
|
||
const cmp = (a: Mailbox, b: Mailbox) => {
|
||
if ((a.role === "inbox") !== (b.role === "inbox")) return a.role === "inbox" ? -1 : 1;
|
||
return a.name.localeCompare(b.name, undefined, { sensitivity: "base", numeric: true });
|
||
};
|
||
const out: Array<{ m: Mailbox; depth: number; hasChildren: boolean; open: boolean; hiddenUnread: number; childUnread: number }> = [];
|
||
const subtreeUnread = (id: Id): number => (byParent.get(id) ?? []).reduce((n, c) => n + c.unreadEmails + subtreeUnread(c.id), 0);
|
||
const walk = (parent: Id | null, depth: number) => {
|
||
for (const m of (byParent.get(parent) ?? []).sort(cmp)) {
|
||
const kids = byParent.get(m.id) ?? [];
|
||
const open = Boolean(expanded[m.id]);
|
||
const childUnread = kids.length ? subtreeUnread(m.id) : 0;
|
||
out.push({ m, depth, hasChildren: kids.length > 0, open, hiddenUnread: kids.length && !open ? childUnread : 0, childUnread });
|
||
if (kids.length && open) walk(m.id, depth + 1);
|
||
}
|
||
};
|
||
walk(null, 0);
|
||
return out;
|
||
}, [mailboxes, showHidden, expanded]);
|
||
|
||
const createFolder = async (parentId: Id | null) => {
|
||
const name = await promptDialog({ title: parentId ? "New subfolder" : "New folder", placeholder: "Folder name" });
|
||
if (!name?.trim()) return;
|
||
try {
|
||
await useMail.getState().createMailbox(name.trim(), parentId);
|
||
toast.success(`Folder “${name.trim()}” created`);
|
||
} catch (err) {
|
||
toast.error((err as Error).message);
|
||
}
|
||
};
|
||
|
||
if (!loaded) {
|
||
return (
|
||
<div style={{ padding: "8px 12px", display: "flex", flexDirection: "column", gap: 8 }}>
|
||
{[...Array(6)].map((_, i) => (
|
||
<div key={i} className="skeleton" style={{ height: 28, width: `${70 + (i % 3) * 10}%` }} />
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<nav aria-label="Folders" style={{ marginTop: 6 }}>
|
||
<div
|
||
className={`nav-section${rootDrop ? " drop-target" : ""}`}
|
||
onDragOver={(e) => {
|
||
if (!e.dataTransfer.types.includes(FOLDER_MIME) || !canDropOn(null)) return;
|
||
e.preventDefault();
|
||
e.dataTransfer.dropEffect = "move";
|
||
if (!rootDrop) setRootDrop(true);
|
||
}}
|
||
onDragLeave={() => setRootDrop(false)}
|
||
onDrop={(e) => {
|
||
e.preventDefault();
|
||
setRootDrop(false);
|
||
const id = e.dataTransfer.getData(FOLDER_MIME);
|
||
if (id) void moveFolder(id, null);
|
||
}}
|
||
>
|
||
<span>{draggingId && canDropOn(null) ? "Drop here for the top level" : "Folders"}</span>
|
||
<button className="icon-btn" title="New folder" aria-label="New folder" onClick={() => void createFolder(null)}>
|
||
<Plus size={16} />
|
||
</button>
|
||
</div>
|
||
{rows.map(({ m, depth, hasChildren, open, hiddenUnread, childUnread }) => (
|
||
<FolderRow
|
||
key={m.id}
|
||
mailbox={m}
|
||
label={m.name}
|
||
depth={depth}
|
||
hasChildren={hasChildren}
|
||
open={open}
|
||
hiddenUnread={hiddenUnread}
|
||
childUnread={childUnread}
|
||
onToggle={() => toggle(m.id)}
|
||
currentId={currentId}
|
||
onMenu={(mb, e) => { setMenuTarget(mb); menu.open(e); }}
|
||
dragging={draggingId === m.id}
|
||
acceptsFolder={canDropOn(m.id)}
|
||
onFolderDragStart={() => setDraggingId(m.id)}
|
||
onFolderDragEnd={() => { setDraggingId(null); setRootDrop(false); }}
|
||
onFolderDrop={(id) => void moveFolder(id, m.id)}
|
||
/>
|
||
))}
|
||
{labelsSidebar && labels.length > 0 && (
|
||
<>
|
||
<div className="nav-section">
|
||
<span>Labels</span>
|
||
<Link href="/settings/labels" className="icon-btn" title="Manage labels" aria-label="Manage labels">
|
||
<Pencil size={14} />
|
||
</Link>
|
||
</div>
|
||
{labels.map((l) => (
|
||
<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={300}>
|
||
{menuTarget && <MailboxMenu mailbox={menuTarget} onClose={menu.close} 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)} />}
|
||
</>
|
||
);
|
||
}
|
||
|
||
function FolderRow({ mailbox: m, label, depth, hasChildren, open, hiddenUnread, childUnread, onToggle, currentId, onMenu, dragging, acceptsFolder, onFolderDragStart, onFolderDragEnd, onFolderDrop }: { mailbox: Mailbox; label: string; depth: number; hasChildren: boolean; open: boolean; hiddenUnread: number; childUnread: number; onToggle: () => void; currentId?: string; onMenu: (m: Mailbox, e: { currentTarget: Element }) => void; dragging: boolean; acceptsFolder: boolean; onFolderDragStart: () => void; onFolderDragEnd: () => void; onFolderDrop: (id: Id) => void }) {
|
||
const [dropping, setDropping] = useState(false);
|
||
// Scheduled counts like Drafts: everything in it is already read, so the
|
||
// useful number is how many messages are waiting, not how many are unseen.
|
||
const scheduled = isScheduledMailbox(m);
|
||
const own = m.role === "drafts" || scheduled ? m.totalEmails : m.unreadEmails;
|
||
const count = own + hiddenUnread;
|
||
// Bold when this folder has unread mail, or any folder beneath it does (parent + child both bold).
|
||
const unread = m.role !== "drafts" && m.role !== "trash" && m.role !== "junk" && m.role !== "sent" && !scheduled ? m.unreadEmails + childUnread > 0 : m.unreadEmails > 0 && m.role !== "drafts" && !scheduled;
|
||
const icon = m.role && ROLE_ICONS[m.role] ? ROLE_ICONS[m.role] : scheduled ? <Clock size={20} /> : <Folder size={20} />;
|
||
// A chosen colour tints the icon only; the label keeps the tree's own
|
||
// contrast, which a dozen arbitrary colours would not reliably give it.
|
||
// Subscribed, not read once: picking a colour has to repaint the row.
|
||
const tint = useSettings((s) => folderColor(s.settings.folderColors, m.id));
|
||
|
||
const onDragOver = (e: DragEvent) => {
|
||
const folder = e.dataTransfer.types.includes(FOLDER_MIME);
|
||
if (folder ? !acceptsFolder : !e.dataTransfer.types.includes("application/x-ihasmail-emails")) return;
|
||
e.preventDefault();
|
||
e.dataTransfer.dropEffect = "move";
|
||
if (!dropping) setDropping(true);
|
||
};
|
||
const onDrop = (e: DragEvent) => {
|
||
e.preventDefault();
|
||
setDropping(false);
|
||
const folderId = e.dataTransfer.getData(FOLDER_MIME);
|
||
if (folderId) {
|
||
if (acceptsFolder) onFolderDrop(folderId);
|
||
return;
|
||
}
|
||
const raw = e.dataTransfer.getData("application/x-ihasmail-emails");
|
||
if (!raw) return;
|
||
try {
|
||
const ids = JSON.parse(raw) as string[];
|
||
void useMail.getState().move(ids, m.id);
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
};
|
||
/*
|
||
* Hold a folder for its menu, which is the same menu the ⋮ opens.
|
||
*
|
||
* The button is already visible where there is no hover, so this is not the
|
||
* only way in — but a 24px target beside a folder name is not what a thumb
|
||
* aims at, and a right-click has no touchscreen equivalent to inherit.
|
||
*/
|
||
const isTouch = useIsTouch();
|
||
const press = useTouchRow({
|
||
enabled: isTouch,
|
||
onLongPress: (target) => {
|
||
haptic(15);
|
||
onMenu(m, { currentTarget: target });
|
||
},
|
||
});
|
||
|
||
const onDragStart = (e: DragEvent) => {
|
||
e.dataTransfer.setData(FOLDER_MIME, m.id);
|
||
e.dataTransfer.effectAllowed = "move";
|
||
// A folder row is a link, and a link drag would otherwise carry its URL.
|
||
e.stopPropagation();
|
||
onFolderDragStart();
|
||
};
|
||
|
||
return (
|
||
<Link
|
||
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" : ""}`}
|
||
title={label}
|
||
{...press}
|
||
// Dragging a folder is a mouse gesture; on a touchscreen the browser
|
||
// starts it from the same long press that now opens the menu.
|
||
draggable={movable(m) && !isTouch}
|
||
onDragStart={onDragStart}
|
||
onDragEnd={onFolderDragEnd}
|
||
onDragOver={onDragOver}
|
||
onDragLeave={() => setDropping(false)}
|
||
onDrop={onDrop}
|
||
onContextMenu={(e) => {
|
||
e.preventDefault();
|
||
onMenu(m, { currentTarget: e.currentTarget });
|
||
}}
|
||
>
|
||
<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>
|
||
<span className="folder-icon" style={tint ? ({ "--folder-color": tint } as React.CSSProperties) : undefined}>{icon}</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-dot" />}
|
||
<button
|
||
className="icon-btn nav-more"
|
||
aria-label="Folder options"
|
||
onClick={(e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
onMenu(m, e);
|
||
}}
|
||
>
|
||
<MoreVertical size={16} />
|
||
</button>
|
||
</Link>
|
||
);
|
||
}
|
||
|
||
function MailboxMenu({ mailbox: m, onClose, onCreateChild, onShare }: { mailbox: Mailbox; onClose: () => void; onCreateChild: () => void; onShare: () => void }) {
|
||
const shared = Object.keys(m.shareWith ?? {}).length > 0;
|
||
const [, navigate] = useLocation();
|
||
const colors = useSettings((s) => s.settings.folderColors);
|
||
const update = useSettings((s) => s.update);
|
||
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;
|
||
try {
|
||
await useMail.getState().updateMailbox(m.id, { name: name.trim() });
|
||
} catch (err) {
|
||
toast.error((err as Error).message);
|
||
}
|
||
};
|
||
const remove = async () => {
|
||
const ok = await confirmDialog({ title: `Delete “${m.name}”?`, message: `This permanently deletes the folder and its ${m.totalEmails} message(s).`, confirmLabel: "Delete", danger: true });
|
||
if (!ok) return;
|
||
try {
|
||
await useMail.getState().destroyMailbox(m.id, true);
|
||
toast.success("Folder deleted");
|
||
navigate(`/mail/${useMail.getState().roleId("inbox") ?? ""}`);
|
||
} catch (err) {
|
||
toast.error((err as Error).message);
|
||
}
|
||
};
|
||
const empty = () => confirmAndEmpty({ id: m.id, name: m.name, role: m.role, totalEmails: m.totalEmails });
|
||
const isSpecial = Boolean(m.role) && m.role !== "subscribed";
|
||
const color = folderColor(colors, m.id);
|
||
const setColor = (c: string | null) => {
|
||
onClose();
|
||
const next = { ...colors };
|
||
if (c) next[m.id] = c;
|
||
else delete next[m.id];
|
||
update({ folderColors: next });
|
||
};
|
||
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"} />
|
||
{/* Sharing a mail folder is withdrawn, not removed: Stalwart accepts and
|
||
stores the share, and it never reaches the other account -- its own
|
||
docs list calendars, address books and files as shareable and not mail
|
||
folders. Offering it produced shares that looked real and did nothing.
|
||
One that already exists can still be cleared here, which is the only
|
||
reason this entry survives at all. */}
|
||
{shared && <MenuItem icon={<Share2 size={16} />} label="Stop sharing" onClick={onShare} />}
|
||
<MenuSep />
|
||
<MenuTitle><span className="row gap-4"><Palette size={12} /> Colour</span></MenuTitle>
|
||
<div className="color-grid" style={{ gridTemplateColumns: "repeat(6, 26px)", padding: "4px 10px 8px" }}>
|
||
{CALENDAR_COLORS.map((c) => (
|
||
<button
|
||
key={c}
|
||
type="button"
|
||
style={{ background: c, width: 26, height: 26, outline: color?.toLowerCase() === c ? "2px solid var(--fg)" : undefined, outlineOffset: 1 }}
|
||
aria-label={c}
|
||
onClick={() => setColor(c)}
|
||
/>
|
||
))}
|
||
</div>
|
||
{color && <MenuItem icon={<X size={16} />} label="Use the default colour" onClick={() => setColor(null)} />}
|
||
<MenuSep />
|
||
{canEmpty(m.role) && <MenuItem icon={<Eraser size={16} />} label={emptyLabel(m)} onClick={() => void empty()} danger disabled={!m.totalEmails} />}
|
||
<MenuItem icon={<Trash2 size={16} />} label="Delete folder" onClick={() => void remove()} danger disabled={isSpecial || !m.myRights.mayDelete} />
|
||
</>
|
||
);
|
||
}
|