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.
This commit is contained in:
2026-09-15 23:17:50 -07:00
parent 5cc31037c1
commit bd6a605d61
95 changed files with 104 additions and 104 deletions
@@ -0,0 +1,102 @@
import { describe, expect, it } from "vitest";
import { foldersNeeded, hasDirectory, planUpload } from "@/lib/input/dropUpload";
/**
* Dropping a folder in, reduced to the two things the DataTransfer entry API
* gets wrong if you take it at face value.
*
* `readEntries` answers with *up to* some number of entries and signals the end
* of a directory with an empty array, so a single call quietly loses everything
* past the first batch — a real folder of a few hundred files would upload the
* first hundred and look like it had finished. And a directory tree that cycles
* has to stop somewhere the tab is still alive.
*/
const file = (name: string) => new File([name], name);
/** A directory whose contents arrive a batch at a time, as a real one does. */
const dir = (name: string, children: unknown[], batch = 2) => {
let at = 0;
return {
isFile: false,
isDirectory: true,
name,
createReader: () => ({
readEntries: (cb: (e: never[]) => void) => {
const slice = children.slice(at, at + batch);
at += slice.length;
cb(slice as never[]);
},
}),
};
};
const leaf = (name: string) => ({
isFile: true,
isDirectory: false,
name,
file: (cb: (f: File) => void) => cb(file(name)),
});
describe("walking a dropped folder", () => {
it("reads a directory across as many batches as it takes", async () => {
// Five children, two per readEntries call: a single read would find two.
const plan = await planUpload([dir("docs", ["a", "b", "c", "d", "e"].map(leaf))] as never[]);
expect(plan.map((p) => p.file.name)).toEqual(["a", "b", "c", "d", "e"]);
expect(plan.every((p) => p.path.join("/") === "docs")).toBe(true);
});
it("keeps the folder each file came from", async () => {
const plan = await planUpload([dir("outer", [leaf("top"), dir("inner", [leaf("deep")])])] as never[]);
expect(plan.map((p) => [p.path.join("/"), p.file.name])).toEqual([
["outer", "top"],
["outer/inner", "deep"],
]);
});
it("puts a loose file at the drop itself", async () => {
const plan = await planUpload([leaf("loose")] as never[]);
expect(plan).toEqual([expect.objectContaining({ path: [] })]);
});
it("stops rather than following a cycle for ever", async () => {
const loop: Record<string, unknown> = {};
Object.assign(loop, dir("loop", []));
(loop as { createReader: () => unknown }).createReader = () => ({
readEntries: (cb: (e: unknown[]) => void) => cb([loop]),
});
// Terminating at all is the assertion; the caps decide where. Both are set
// low so the test does not have to read twenty thousand phantom entries.
const plan = await planUpload([loop] as never[], { maxDepth: 4, maxEntries: 50 });
expect(plan).toEqual([]);
});
});
describe("the folders a plan needs", () => {
it("lists parents before their children", () => {
const needed = foldersNeeded([
{ file: file("x"), path: ["a", "b", "c"] },
{ file: file("y"), path: ["a"] },
]);
expect(needed).toEqual([["a"], ["a", "b"], ["a", "b", "c"]]);
});
it("names each folder once, however many files are in it", () => {
const needed = foldersNeeded([
{ file: file("x"), path: ["a"] },
{ file: file("y"), path: ["a"] },
]);
expect(needed).toEqual([["a"]]);
});
it("asks for nothing when everything lands at the drop", () => {
expect(foldersNeeded([{ file: file("x"), path: [] }])).toEqual([]);
});
});
describe("spotting a folder in the drop", () => {
it("is true when any entry is a directory", () => {
expect(hasDirectory([leaf("a"), dir("d", [])] as never[])).toBe(true);
expect(hasDirectory([leaf("a")] as never[])).toBe(false);
});
});
@@ -0,0 +1,104 @@
import { describe, expect, it } from "vitest";
import { rowClick, type RowClick } from "@/lib/input/listSelection";
const IDS = ["a", "b", "c", "d", "e"];
const click = (over: Partial<Parameters<typeof rowClick>[0]> = {}): RowClick =>
rowClick({
rowId: "c", ids: IDS, anchor: null, selected: {},
modifiers: { shift: false, ctrl: false }, isMobile: false,
...over,
});
describe("a plain click", () => {
it("opens the message rather than selecting it", () => {
expect(click()).toEqual({ kind: "open" });
});
it("opens it even when another message is already open", () => {
expect(click({ anchor: "a" })).toEqual({ kind: "open" });
});
it("goes on selecting on a touchscreen once a selection exists", () => {
// There is no modifier to hold on a phone, and opening a message in the
// middle of picking several is almost never what the tap meant.
expect(click({ isMobile: true, selected: { a: true } })).toEqual({ kind: "select", ids: ["c"], on: true, moveAnchor: true });
});
it("still opens on a touchscreen when nothing is selected", () => {
expect(click({ isMobile: true })).toEqual({ kind: "open" });
});
});
describe("ctrl-clicking", () => {
it("takes the message that was already current with it", () => {
// Issue #186: this used to select only the row clicked, leaving the open
// message highlighted but unticked, so actions applied to one of two.
expect(click({ anchor: "a", modifiers: { shift: false, ctrl: true } }))
.toEqual({ kind: "select", ids: ["a", "c"], on: true, moveAnchor: true });
});
it("toggles one row once there is a selection, and leaves the rest alone", () => {
expect(click({ anchor: "a", selected: { a: true, c: true }, modifiers: { shift: false, ctrl: true } }))
.toEqual({ kind: "select", ids: ["c"], on: false, moveAnchor: true });
expect(click({ anchor: "a", selected: { a: true }, modifiers: { shift: false, ctrl: true } }))
.toEqual({ kind: "select", ids: ["c"], on: true, moveAnchor: true });
});
it("selects just the row when there is nothing current to bring along", () => {
expect(click({ anchor: null, modifiers: { shift: false, ctrl: true } }))
.toEqual({ kind: "select", ids: ["c"], on: true, moveAnchor: true });
});
it("does not bring along a row that has scrolled out of the list", () => {
// The anchor can name a message from a folder that is no longer shown.
expect(click({ anchor: "gone", modifiers: { shift: false, ctrl: true } }))
.toEqual({ kind: "select", ids: ["c"], on: true, moveAnchor: true });
});
it("does not pair a row with itself", () => {
expect(click({ rowId: "a", anchor: "a", modifiers: { shift: false, ctrl: true } }))
.toEqual({ kind: "select", ids: ["a"], on: true, moveAnchor: true });
});
});
describe("shift-clicking", () => {
it("takes the whole run, including the row it started from", () => {
expect(click({ rowId: "d", anchor: "b", modifiers: { shift: true, ctrl: false } }))
.toEqual({ kind: "select", ids: ["b", "c", "d"], on: true, moveAnchor: false });
});
it("works the same way backwards", () => {
expect(click({ rowId: "b", anchor: "d", modifiers: { shift: true, ctrl: false } }))
.toEqual({ kind: "select", ids: ["b", "c", "d"], on: true, moveAnchor: false });
});
it("leaves the anchor where it is, so the range grows from one place", () => {
const first = click({ rowId: "c", anchor: "a", modifiers: { shift: true, ctrl: false } });
expect(first).toMatchObject({ moveAnchor: false });
// Extending again still starts at "a" rather than at "c".
expect(click({ rowId: "e", anchor: "a", modifiers: { shift: true, ctrl: false } }))
.toMatchObject({ ids: ["a", "b", "c", "d", "e"] });
});
it("falls back to opening when there is nothing to extend from", () => {
expect(click({ anchor: null, modifiers: { shift: true, ctrl: false } })).toEqual({ kind: "open" });
});
it("falls back when the anchor is no longer in the list", () => {
expect(click({ anchor: "gone", modifiers: { shift: true, ctrl: false } })).toEqual({ kind: "open" });
});
});
describe("the two rules agree with each other", () => {
it("both include the row the selection started from", () => {
// The bug was that only one of them did. Whatever else changes, a modifier
// click that begins a selection has to contain the anchor.
const withCtrl = click({ rowId: "d", anchor: "b", modifiers: { shift: false, ctrl: true } });
const withShift = click({ rowId: "d", anchor: "b", modifiers: { shift: true, ctrl: false } });
for (const result of [withCtrl, withShift]) {
expect(result.kind, JSON.stringify(result)).toBe("select");
expect((result as { ids: string[] }).ids).toContain("b");
expect((result as { ids: string[] }).ids).toContain("d");
}
});
});
+63
View File
@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import { SWIPE_CHOICES, describeSwipe, type SwipeAction } from "../swipe";
/**
* A swipe names what it is about to do on a colored strip the reader sees for
* about a third of a second before letting go. These check that the name is
* true in the folder it is being read in — which is the whole reason the
* descriptor exists rather than a fixed label per setting.
*/
const inbox = { role: "inbox", unread: false, starred: false };
describe("describeSwipe", () => {
it("offers nothing for a direction turned off", () => {
expect(describeSwipe("none", inbox)).toBe(null);
});
it("refuses to archive out of the archive", () => {
expect(describeSwipe("archive", { ...inbox, role: "archive" })).toBe(null);
expect(describeSwipe("archive", inbox)).toMatchObject({ label: "Archive", removes: true });
});
it("says out loud that a delete from Deleted Items is permanent", () => {
expect(describeSwipe("delete", inbox)?.label).toBe("Delete");
expect(describeSwipe("delete", { ...inbox, role: "trash" })?.label).toBe("Delete forever");
});
it("turns the spam action around inside the junk folder", () => {
expect(describeSwipe("spam", inbox)).toMatchObject({ label: "Report spam", icon: "spam" });
expect(describeSwipe("spam", { ...inbox, role: "junk" })).toMatchObject({ label: "Not spam", icon: "not-spam" });
});
it("has no opinion on whether your own mail is spam", () => {
expect(describeSwipe("spam", { ...inbox, role: "drafts" })).toBe(null);
expect(describeSwipe("spam", { ...inbox, role: "sent" })).toBe(null);
});
it("names the state a toggle is about to set, and carries it", () => {
expect(describeSwipe("read", { ...inbox, unread: true })).toMatchObject({ label: "Mark as read", icon: "read", on: true });
expect(describeSwipe("read", { ...inbox, unread: false })).toMatchObject({ label: "Mark as unread", icon: "unread", on: false });
expect(describeSwipe("star", { ...inbox, starred: false })).toMatchObject({ label: "Add star", on: true });
expect(describeSwipe("star", { ...inbox, starred: true })).toMatchObject({ label: "Remove star", on: false });
});
it("brings a row home for the actions that open something instead", () => {
// The row has to be back under the finger before the folder picker covers
// the list, or it is still hanging half-open when the picker closes again.
expect(describeSwipe("move", inbox)).toMatchObject({ label: "Move to…", removes: false });
for (const action of ["archive", "delete", "spam"] as const) {
expect(describeSwipe(action, inbox)?.removes).toBe(true);
}
});
it("can describe everything the settings picker offers", () => {
// A choice the picker offers and the list cannot describe is a direction
// that silently does nothing — the one failure nobody would report.
for (const { value } of SWIPE_CHOICES) {
const d = describeSwipe(value as SwipeAction, inbox);
if (value === "none") expect(d).toBe(null);
else expect(d?.label).toBeTruthy();
}
});
});
+85
View File
@@ -0,0 +1,85 @@
import { describe, expect, it } from "vitest";
import { AXIS_SLOP, PULL_MAX, PULL_TRIGGER, lockAxis, pullDistance, swipeOffset, swipeThreshold } from "../touch";
/**
* The arithmetic behind the touch gestures, checked without a touchscreen.
*
* These are the numbers that decide whether a finger meant to scroll the list
* or to act on a message, and getting them wrong is not a crash — it is an app
* that deletes mail when someone tried to scroll past it. Worth pinning down.
*/
describe("lockAxis", () => {
it("stays undecided until the finger has committed", () => {
expect(lockAxis(0, 0)).toBe(null);
expect(lockAxis(AXIS_SLOP - 1, AXIS_SLOP - 1)).toBe(null);
});
it("reads a clearly sideways drag as a swipe", () => {
expect(lockAxis(40, 4)).toBe("x");
expect(lockAxis(-40, 4)).toBe("x");
});
it("gives a diagonal to the scroller, not the swipe", () => {
// 45 degrees is more sideways than not, and is still a scroll: someone
// flicking down a list does not travel straight down the glass.
expect(lockAxis(30, 30)).toBe("y");
expect(lockAxis(30, 25)).toBe("y");
});
it("counts distance on either axis toward committing", () => {
expect(lockAxis(0, AXIS_SLOP)).toBe("y");
expect(lockAxis(AXIS_SLOP, 0)).toBe("x");
});
});
describe("swipeThreshold", () => {
it("scales with the row but never off either end", () => {
expect(swipeThreshold(300)).toBeCloseTo(84); // a phone: a share of the row
expect(swipeThreshold(160)).toBe(56); // a narrow row: a fixed floor
expect(swipeThreshold(2000)).toBe(96); // a tablet: not the whole reach
});
});
describe("swipeOffset", () => {
const width = 360;
const limit = swipeThreshold(width);
it("follows the finger exactly until the action would fire", () => {
expect(swipeOffset(20, width)).toBe(20);
expect(swipeOffset(-20, width)).toBe(-20);
expect(swipeOffset(limit, width)).toBe(limit);
});
it("resists past the threshold, in both directions", () => {
const over = swipeOffset(limit + 100, width);
expect(over).toBeGreaterThan(limit);
expect(over).toBeLessThan(limit + 100);
expect(swipeOffset(-(limit + 100), width)).toBeCloseTo(-over);
});
it("never travels further than the row is wide", () => {
// Past the row's own width there is nothing left to reveal, so a hard
// flick stops there rather than accumulating travel with nowhere to show.
expect(Math.abs(swipeOffset(2000, width))).toBe(width);
expect(Math.abs(swipeOffset(-2000, width))).toBe(width);
});
});
describe("pullDistance", () => {
it("ignores an upward drag", () => {
expect(pullDistance(0)).toBe(0);
expect(pullDistance(-50)).toBe(0);
});
it("asks for a deliberate pull, not the overscroll at the top of a list", () => {
expect(pullDistance(40)).toBeLessThan(PULL_TRIGGER);
expect(pullDistance(60)).toBeLessThan(PULL_TRIGGER);
expect(pullDistance(140)).toBeGreaterThanOrEqual(PULL_TRIGGER);
});
it("stops coming down however hard it is pulled", () => {
expect(pullDistance(400)).toBe(PULL_MAX);
expect(pullDistance(4000)).toBe(PULL_MAX);
});
});
Binary file not shown.
+172
View File
@@ -0,0 +1,172 @@
/**
* Gmail-style keyboard shortcut manager with two-key sequences ("g i").
* Handlers are registered in scopes; the most recently pushed scope wins.
*/
export type KeyHandler = (e: KeyboardEvent) => void | boolean;
interface Binding {
keys: string; // e.g. "j", "shift+i", "g i", "mod+enter"
handler: KeyHandler;
description: string;
group: string;
allowInInput?: boolean;
}
interface Scope {
name: string;
bindings: Binding[];
}
class Keyboard {
private scopes: Scope[] = [];
private pendingPrefix: string | null = null;
private prefixTimer: number | null = null;
enabled = true;
constructor() {
if (typeof window !== "undefined") window.addEventListener("keydown", this.onKeyDown, true);
}
pushScope(name: string, bindings: Binding[]): () => void {
const scope = { name, bindings };
this.scopes.push(scope);
return () => {
this.scopes = this.scopes.filter((s) => s !== scope);
};
}
/** All bindings with descriptions, for the help overlay. */
list(): Array<{ group: string; keys: string; description: string }> {
const seen = new Set<string>();
const out: Array<{ group: string; keys: string; description: string }> = [];
for (const s of [...this.scopes].reverse()) {
for (const b of s.bindings) {
if (!b.description || seen.has(b.keys)) continue;
seen.add(b.keys);
out.push({ group: b.group, keys: b.keys, description: b.description });
}
}
return out;
}
private onKeyDown = (e: KeyboardEvent) => {
if (!this.enabled) return;
// Let modal dialogs and popovers handle their own keys (Escape, arrows, ...).
if (document.querySelector(".dialog-backdrop, .popover")) return;
const target = e.target as HTMLElement | null;
const inInput = isTextEntry(target);
const combo = comboOf(e);
if (!combo) return;
// Try sequence completion first.
const candidates: Binding[] = [];
for (let i = this.scopes.length - 1; i >= 0; i--) {
for (const b of this.scopes[i]!.bindings) candidates.push(b);
}
if (this.pendingPrefix) {
const seq = `${this.pendingPrefix} ${combo}`;
const b = candidates.find((x) => x.keys === seq && (!inInput || x.allowInInput));
this.clearPrefix();
if (b) {
const r = b.handler(e);
if (r !== false) {
e.preventDefault();
e.stopPropagation();
}
return;
}
}
// Is this combo the first half of any sequence?
if (!inInput && candidates.some((x) => x.keys.startsWith(`${combo} `))) {
this.pendingPrefix = combo;
this.prefixTimer = window.setTimeout(() => this.clearPrefix(), 1200);
e.preventDefault();
return;
}
const b = candidates.find((x) => x.keys === combo && (!inInput || x.allowInInput));
if (b) {
const r = b.handler(e);
if (r !== false) {
e.preventDefault();
e.stopPropagation();
}
}
};
private clearPrefix() {
this.pendingPrefix = null;
if (this.prefixTimer) window.clearTimeout(this.prefixTimer);
this.prefixTimer = null;
}
}
/**
* Is the focused element somewhere the reader is typing?
*
* This guard exists so that pressing "a" in the search box searches for "a"
* rather than archiving the message behind it. The test used to be
* `tagName === "INPUT"`, which is true of a checkbox — and a checkbox keeps
* focus after you click it, so ticking "select all" silently disabled every
* shortcut until the reader clicked somewhere else (#260). Nothing about a
* checkbox swallows a keystroke: space toggles it and the browser handles
* that before this listener ever runs.
*
* So the question is not "is this an input" but "does this input take text".
* A `<select>` does, in the sense that matters here: typing a letter jumps to
* the option beginning with it, which a shortcut would steal.
*/
const TEXT_ENTRY_TYPES = new Set([
"text", "search", "email", "url", "tel", "password", "number",
"date", "datetime-local", "month", "time", "week",
]);
export function isTextEntry(el: Element | null): boolean {
if (!el) return false;
const node = el as HTMLElement;
if (node.isContentEditable) return true;
const tag = node.tagName;
if (tag === "TEXTAREA" || tag === "SELECT") return true;
if (tag !== "INPUT") return false;
// An <input> with no type attribute is a text field.
const type = (node as HTMLInputElement).type?.toLowerCase() || "text";
return TEXT_ENTRY_TYPES.has(type);
}
const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform);
export function comboOf(e: KeyboardEvent): string | null {
const key = e.key;
// Chrome's password autofill dispatches a plain Event named "keydown" when a
// saved login is picked: no key, nothing to match, and reading its length
// threw on every sign-in.
if (!key) return null;
if (key === "Shift" || key === "Control" || key === "Alt" || key === "Meta") return null;
const parts: string[] = [];
const mod = isMac ? e.metaKey : e.ctrlKey;
if (mod) parts.push("mod");
if (e.altKey) parts.push("alt");
if (e.shiftKey && key.length > 1) parts.push("shift");
let k = key;
if (k === " ") k = "space";
else if (k === "Escape") k = "esc";
else if (k.length === 1) {
// Single chars: shift is encoded by the character itself (e.g. "#", "!").
k = k.length === 1 && !e.shiftKey ? k.toLowerCase() : k;
} else k = k.toLowerCase();
parts.push(k);
return parts.join("+");
}
export function formatKeys(keys: string): string {
return keys
.split(" ")
.map((k) =>
k
.split("+")
.map((p) => (p === "mod" ? (isMac ? "⌘" : "Ctrl") : p === "shift" ? "⇧" : p === "alt" ? (isMac ? "⌥" : "Alt") : p === "enter" ? "↵" : p === "esc" ? "Esc" : p === "space" ? "Space" : p === "arrowup" ? "↑" : p === "arrowdown" ? "↓" : p === "arrowleft" ? "←" : p === "arrowright" ? "→" : p.length === 1 ? p : p[0]!.toUpperCase() + p.slice(1)))
.join(isMac ? "" : "+"),
)
.join(" then ");
}
export const keyboard = new Keyboard();
+70
View File
@@ -0,0 +1,70 @@
import type { Id } from "@/jmap/types";
/**
* What a click on a message row means.
*
* Lifted out of the list so the rules sit together and can be tested. They had
* drifted apart while they were two branches of one handler: shift-click
* selected the whole range including the row it started from, and ctrl-click
* selected only the row clicked, leaving the message you had open highlighted
* but unticked. Both looked picked; one was. That is issue #186, and the reason
* this is a function rather than a comment asking the next person to be careful.
*/
export type RowClick =
| { kind: "open" }
| { kind: "select"; ids: Id[]; on: boolean; moveAnchor: boolean };
export function rowClick(opts: {
/** The row clicked. */
rowId: Id;
/** Every row on screen, in the order they are shown. */
ids: Id[];
/** The row a range would extend from: the last one clicked without shift. */
anchor: Id | null;
selected: Record<Id, boolean>;
modifiers: { shift: boolean; ctrl: boolean };
isMobile: boolean;
}): RowClick {
const { rowId, ids, anchor, selected, modifiers, isMobile } = opts;
const selectedCount = Object.keys(selected).length;
// A range, from the anchor to here, inclusive at both ends.
if (modifiers.shift && anchor) {
const from = ids.indexOf(anchor);
const to = ids.indexOf(rowId);
if (from >= 0 && to >= 0) {
const [start, end] = from < to ? [from, to] : [to, from];
// The anchor stays where it is, so extending the range again grows it
// from the same place rather than from wherever it last reached.
return { kind: "select", ids: ids.slice(start, end + 1), on: true, moveAnchor: false };
}
}
if (modifiers.ctrl) {
/*
* The row that was already current joins the selection.
*
* Opening a message does not select it -- it is highlighted because it is
* the one being read, which is a different state -- so picking a second one
* with ctrl used to select only the second, and every action that followed
* quietly applied to half of what the screen showed.
*
* Only while nothing is selected yet. Once there is a selection, ctrl-click
* toggles exactly one row, which is the whole point of it.
*/
if (!selectedCount && anchor && anchor !== rowId && ids.includes(anchor)) {
return { kind: "select", ids: [anchor, rowId], on: true, moveAnchor: true };
}
return { kind: "select", ids: [rowId], on: !selected[rowId], moveAnchor: true };
}
// On a touchscreen, once anything is selected a plain tap goes on selecting:
// there is no modifier to hold, and opening a message mid-selection is almost
// never what the tap meant.
if (selectedCount > 0 && isMobile) {
return { kind: "select", ids: [rowId], on: !selected[rowId], moveAnchor: true };
}
return { kind: "open" };
}
+90
View File
@@ -0,0 +1,90 @@
/**
* What a swipe on a message row does, and what it should say it is about to do.
*
* The reader picks one action for each direction in Settings, but an action is
* not a fixed thing: "delete" out of Deleted Items is permanent, "report spam"
* inside Junk Mail is the opposite request, and "archive" while looking at the
* archive is nothing at all. The strip revealed behind the row has to name the
* thing that will actually happen, in the folder it is happening in -- a row
* that slides open to reveal the word "Archive" and then does nothing is worse
* than one that does not slide.
*
* So a direction with no meaning here resolves to `null`, and a null direction
* is one the row simply will not move in.
*/
export type SwipeAction = "archive" | "delete" | "spam" | "read" | "star" | "move" | "none";
export interface SwipeContext {
/** The role of the folder on screen, where it has one. */
role?: string | null;
/** Whether the row is unread — "mark as read" is a toggle, and says so. */
unread: boolean;
starred: boolean;
}
/**
* Which glyph the strip shows. Named for the state it is offering rather than
* the setting it came from: "mark as unread" and "not spam" are the same two
* settings as their opposites but nothing like the same icon, and a strip that
* says "Not spam" beside a spam icon is asking to be misread at a glance.
*/
export type SwipeIcon = "archive" | "delete" | "spam" | "not-spam" | "read" | "unread" | "star" | "unstar" | "move";
export interface SwipeDescriptor {
action: Exclude<SwipeAction, "none">;
label: string;
icon: SwipeIcon;
/** Which color the strip behind the row takes. */
tone: "danger" | "warn" | "accent" | "neutral";
/**
* Whether firing it takes the row out of the list. Those slide the rest of
* the way off before they fire, so the message is gone from under the finger
* rather than snapping home and then vanishing a frame later.
*/
removes: boolean;
/**
* For the two actions that are toggles, the state this swipe sets — so the
* caller fires exactly what the strip promised. Read back off the row
* instead and a slow finger can invert it: the strip that said "Mark as
* read" would mark as unread if a push update landed mid-gesture.
*/
on?: boolean;
}
export function describeSwipe(action: SwipeAction, ctx: SwipeContext): SwipeDescriptor | null {
switch (action) {
case "archive":
// Archiving out of the archive is the one no-op worth refusing outright.
return ctx.role === "archive" ? null : { action, label: "Archive", icon: "archive", tone: "accent", removes: true };
case "delete":
return { action, label: ctx.role === "trash" ? "Delete forever" : "Delete", icon: "delete", tone: "danger", removes: true };
case "spam":
// Nothing you wrote is spam you received, so the gesture stays inert in
// the two folders that hold your own mail.
if (ctx.role === "drafts" || ctx.role === "sent") return null;
return ctx.role === "junk"
? { action, label: "Not spam", icon: "not-spam", tone: "warn", removes: true }
: { action, label: "Report spam", icon: "spam", tone: "warn", removes: true };
case "read":
return { action, label: ctx.unread ? "Mark as read" : "Mark as unread", icon: ctx.unread ? "read" : "unread", tone: "neutral", removes: false, on: ctx.unread };
case "star":
return { action, label: ctx.starred ? "Remove star" : "Add star", icon: ctx.starred ? "unstar" : "star", tone: "warn", removes: false, on: !ctx.starred };
case "move":
// The folder picker opens over the list, so the row comes home first.
return { action, label: "Move to…", icon: "move", tone: "accent", removes: false };
case "none":
return null;
}
}
/** The Settings picker's options, in the order they are offered. */
export const SWIPE_CHOICES: ReadonlyArray<{ value: SwipeAction; label: string }> = [
{ value: "archive", label: "Archive" },
{ value: "delete", label: "Delete" },
{ value: "read", label: "Mark as read / unread" },
{ value: "star", label: "Star / unstar" },
{ value: "spam", label: "Report spam / not spam" },
{ value: "move", label: "Move to…" },
{ value: "none", label: "Nothing" },
];
+618
View File
@@ -0,0 +1,618 @@
import { useCallback, useEffect, useRef } from "react";
import type { PointerEvent as ReactPointerEvent, MouseEvent as ReactMouseEvent } from "react";
/**
* The gestures a phone expects, and the arithmetic behind them.
*
* ihasmail's mail list was built for a mouse: a row is clicked, right-clicked
* and dragged into a folder. None of those exist on a phone, which instead has
* three conventions so settled that their absence reads as the app being
* broken -- swipe a row to act on it, hold a row to select it, and pull the
* top of a list to refresh it.
*
* The numbers and the decisions live here rather than in the components so
* they can be tested without a touchscreen, and so the three gestures agree
* with each other: the same slop that says "this finger is holding still"
* decides whether a long press survives, and the same axis lock keeps a swipe
* from stealing a scroll.
*
* Everything here is touch-only by design. A mouse keeps drag-to-folder, which
* shares the same pointer stream and would otherwise be fighting a swipe for
* every drag.
*/
/** A press held this long, with the finger still, is a long press. */
export const LONG_PRESS_MS = 450;
/**
* How far a finger may drift and still count as holding still.
*
* A thumb resting on glass wanders a few pixels on its own, so zero would mean
* a long press almost never fires; much more than this and a slow deliberate
* drag starts opening a selection instead of moving the row.
*/
export const PRESS_SLOP = 10;
/** How far a drag travels before it commits to being horizontal or vertical. */
export const AXIS_SLOP = 12;
export type Axis = "x" | "y" | null;
/**
* Which way a drag has committed, once it has moved far enough to tell.
*
* Deliberately biased toward the vertical. Scrolling is what a finger on a
* message list is doing almost every time, and a scroll misread as a swipe
* grabs the list out from under the reader, while a swipe misread as a scroll
* costs them a second attempt. So `x` has to win clearly -- a drag that is
* merely more sideways than not stays a scroll.
*/
export function lockAxis(dx: number, dy: number): Axis {
const ax = Math.abs(dx);
const ay = Math.abs(dy);
if (Math.max(ax, ay) < AXIS_SLOP) return null;
return ax > ay * 1.3 ? "x" : "y";
}
/**
* How far past the row's edge a swipe must reach before letting go fires it.
*
* A share of the row rather than a fixed distance, so the gesture feels the
* same on a phone and on a tablet, but bounded at both ends: on a narrow
* screen a percentage is a flick nobody meant, and on a wide one it is a
* reach across the whole device.
*/
export function swipeThreshold(width: number): number {
return Math.max(56, Math.min(96, width * 0.28));
}
/**
* How far the row actually moves for a finger that has traveled `dx`.
*
* One-to-one until the action would fire, and increasingly reluctant after
* that. The resistance is the only thing that tells a thumb, without the
* reader looking down at the exact moment, that it has gone far enough.
*
* Stopped dead at the row's own width, because there is nothing past it: the
* row is already fully off screen, and a curve with no ceiling would go on
* accumulating travel that has nowhere to show. That only bites on a flick
* that outruns the screen, which is exactly when the row is moving too fast
* for anyone to see it stop.
*/
export function swipeOffset(dx: number, width: number): number {
const limit = swipeThreshold(width);
const over = Math.abs(dx) - limit;
if (over <= 0) return dx;
return Math.sign(dx) * Math.min(width, limit + over * 0.35);
}
/** Pull-to-refresh: how far the list comes down before letting go refreshes. */
export const PULL_TRIGGER = 64;
/** Where the list rests while the refresh it asked for is running. */
export const PULL_REST = 48;
/** As far as the list will ever come down, however hard it is pulled. */
export const PULL_MAX = 110;
/**
* How far the list follows a finger that has pulled down `dy`.
*
* Under half, so the trigger sits at about 116px of travel: far enough that
* the overscroll at the top of a list -- which happens constantly, to nobody's
* intent -- does not keep firing refreshes.
*/
export function pullDistance(dy: number): number {
if (dy <= 0) return 0;
return Math.min(PULL_MAX, dy * 0.55);
}
/**
* A short tap of the vibration motor, where there is one.
*
* Confirmation that a gesture landed, for the hand rather than the eye: a
* swipe fires at the moment the finger crosses a threshold it cannot see, and
* without this the only feedback arrives after the row has already gone.
*
* iOS supports none of this and never has, so this is silently nothing there
* rather than something to apologize for. Wrapped because a vibration inside
* a cross-origin iframe throws rather than returning false.
*/
export function haptic(pattern: number | number[] = 8): void {
try {
navigator.vibrate?.(pattern);
} catch {
/* A device that will not buzz is not a failure worth reporting. */
}
}
export interface RowGesture {
/** Whether to listen at all — false on a mouse, and while a menu is open. */
enabled: boolean;
/**
* The finger held still long enough, on `target` — handed over rather than
* left to the caller's own ref, because the element a press lands on is not
* always one a ref can reach. A router's `Link` renders the anchor itself
* and forwards nothing.
*/
onLongPress?: (target: Element) => void;
/** Whether a swipe that way leads anywhere; a `false` here never starts one. */
canSwipe?: (dir: -1 | 1) => boolean;
/** The row should now sit `dx` from home. Fired continuously while dragging. */
onSwipeMove?: (dx: number, dir: -1 | 1, armed: boolean) => void;
/** Let go: `dir` is the direction to act on, or 0 to snap back untouched. */
onSwipeEnd?: (dir: -1 | 1 | 0) => void;
}
/**
* Long press and horizontal swipe over one pointer stream.
*
* One hook rather than two because they are the same gesture until they are
* not: a press that moves is no longer a press, and a swipe that does not move
* is a press. Splitting them meant both hooks watching the same events and
* disagreeing at the boundary.
*
* The element needs `touch-action: pan-y`, which is what makes this possible
* without breaking the list: the browser keeps handling vertical scrolling
* itself, at its own frame rate, and hands us the horizontal movement it now
* knows it is not going to use.
*/
export function useTouchRow({ enabled, onLongPress, canSwipe, onSwipeMove, onSwipeEnd }: RowGesture) {
const start = useRef<{ x: number; y: number; width: number; id: number; target: Element } | null>(null);
const axis = useRef<Axis>(null);
const dir = useRef<-1 | 1>(1);
const armed = useRef(false);
const timer = useRef<number | null>(null);
/*
* A gesture that did anything must not also be a tap. The row's click
* handler opens the conversation, and a swipe or a long press both end with
* the finger lifting off the row -- which is a click as far as the browser is
* concerned, arriving after every pointer event we could cancel from.
*/
const swallowClick = useRef(false);
const fromTouch = useRef(false);
const clearTimer = () => {
if (timer.current !== null) window.clearTimeout(timer.current);
timer.current = null;
};
useEffect(() => clearTimer, []);
const reset = useCallback(() => {
clearTimer();
start.current = null;
axis.current = null;
armed.current = false;
}, []);
const onPointerDown = useCallback(
(e: ReactPointerEvent) => {
fromTouch.current = e.pointerType === "touch";
if (!enabled || e.pointerType !== "touch") return;
// Read out now: `currentTarget` is only meaningful during dispatch, and
// the long-press timer runs long after this handler has returned.
const target = e.currentTarget;
start.current = { x: e.clientX, y: e.clientY, width: target.getBoundingClientRect().width, id: e.pointerId, target };
axis.current = null;
armed.current = false;
swallowClick.current = false;
if (onLongPress) {
timer.current = window.setTimeout(() => {
timer.current = null;
// Still here, still not moving: nothing has canceled us.
if (!start.current || axis.current) return;
swallowClick.current = true;
onLongPress(start.current.target);
}, LONG_PRESS_MS);
}
},
[enabled, onLongPress],
);
const onPointerMove = useCallback(
(e: ReactPointerEvent) => {
const s = start.current;
if (!s || e.pointerId !== s.id) return;
const dx = e.clientX - s.x;
const dy = e.clientY - s.y;
if (axis.current === null) {
if (Math.abs(dx) > PRESS_SLOP || Math.abs(dy) > PRESS_SLOP) clearTimer();
const locked = lockAxis(dx, dy);
if (!locked) return;
/*
* A vertical drag is the browser's, and it has already started
* scrolling with it. Letting go of the whole gesture here -- rather
* than remembering that we lost -- matters, because the finger will go
* on to travel a long way sideways during a diagonal flick, and this
* row would otherwise catch up with it mid-scroll.
*/
if (locked === "y" || !onSwipeMove) {
reset();
return;
}
const d: -1 | 1 = dx < 0 ? -1 : 1;
if (canSwipe && !canSwipe(d)) {
reset();
return;
}
axis.current = "x";
dir.current = d;
swallowClick.current = true;
try {
// Throws if the pointer is already gone -- a flick fast enough to
// have lifted between this event being queued and being handled.
// The gesture works perfectly well without the capture.
e.currentTarget.setPointerCapture(s.id);
} catch {
/* nothing left to capture */
}
}
const d: -1 | 1 = dx < 0 ? -1 : 1;
/*
* Crossing back the other way mid-gesture. The direction is re-read
* rather than held from the lock, so a reader who overshoots, thinks
* better of it and drags back past center gets the other action offered
* instead of the row refusing to move.
*/
if (d !== dir.current) {
if (canSwipe && !canSwipe(d)) {
onSwipeMove?.(0, dir.current, false);
return;
}
dir.current = d;
}
const offset = swipeOffset(dx, s.width);
const nowArmed = Math.abs(dx) >= swipeThreshold(s.width);
if (nowArmed !== armed.current) {
armed.current = nowArmed;
if (nowArmed) haptic();
}
onSwipeMove?.(offset, d, nowArmed);
},
[canSwipe, onSwipeMove, reset],
);
const onPointerUp = useCallback(
(e: ReactPointerEvent) => {
const s = start.current;
clearTimer();
if (!s || e.pointerId !== s.id) return;
if (axis.current === "x") onSwipeEnd?.(armed.current ? dir.current : 0);
reset();
},
[onSwipeEnd, reset],
);
const onPointerCancel = useCallback(
(e: ReactPointerEvent) => {
if (start.current && e.pointerId !== start.current.id) return;
if (axis.current === "x") onSwipeEnd?.(0);
reset();
},
[onSwipeEnd, reset],
);
const onClickCapture = useCallback((e: ReactMouseEvent) => {
if (!swallowClick.current) return;
swallowClick.current = false;
e.preventDefault();
e.stopPropagation();
}, []);
/*
* Android fires `contextmenu` for a long press of its own, a little after
* ours, and would open the desktop right-click menu on top of whatever the
* long press just did. The desktop handler stays untouched for an actual
* right-click, which is the only thing that reaches it now.
*/
const onContextMenuCapture = useCallback(
(e: ReactMouseEvent) => {
if (!enabled || !fromTouch.current) return;
e.preventDefault();
e.stopPropagation();
},
[enabled],
);
return { onPointerDown, onPointerMove, onPointerUp, onPointerCancel, onClickCapture, onContextMenuCapture };
}
/**
* Pull the top of a scroller down to refresh it.
*
* Native listeners rather than React props because the move handler has to be
* able to call `preventDefault`, and React attaches its own passively. Bound
* to the scroll container itself so that everything inside it -- a virtualized
* list included -- comes down with the pull without knowing about it.
*/
export function usePullToRefresh(
el: HTMLElement | null,
onRefresh: () => Promise<void> | void,
{ enabled, onPull }: { enabled: boolean; onPull: (distance: number, armed: boolean, live: boolean) => void },
) {
const refresh = useRef(onRefresh);
refresh.current = onRefresh;
const pull = useRef(onPull);
pull.current = onPull;
useEffect(() => {
if (!el || !enabled) return;
let startY: number | null = null;
let distance = 0;
let armed = false;
let running = false;
const onStart = (e: TouchEvent) => {
// Only from a list already at the top, and only one finger: a pinch that
// happens to begin near the top is not a pull.
if (running || e.touches.length !== 1 || el.scrollTop > 0) return;
startY = e.touches[0]!.clientY;
distance = 0;
armed = false;
};
const onMove = (e: TouchEvent) => {
if (startY === null || e.touches.length !== 1) return;
const dy = e.touches[0]!.clientY - startY;
if (dy <= 0) {
// Pulled back up, or the gesture was a scroll all along.
if (distance > 0) pull.current((distance = 0), (armed = false), true);
if (el.scrollTop > 0) startY = null;
return;
}
distance = pullDistance(dy);
const nowArmed = distance >= PULL_TRIGGER;
if (nowArmed !== armed) {
armed = nowArmed;
if (nowArmed) haptic();
}
/*
* Only once the list is visibly following the finger. Calling this on
* the first pixel would cancel the tap that starts every scroll, and
* `cancelable` is false once the browser has already committed the
* gesture to scrolling -- calling it then is a console warning and
* nothing else.
*/
if (distance > 2 && e.cancelable) e.preventDefault();
pull.current(distance, armed, true);
};
const onEnd = () => {
if (startY === null) return;
startY = null;
if (!armed) {
if (distance > 0) pull.current((distance = 0), false, false);
return;
}
running = true;
armed = false;
pull.current(PULL_REST, true, false);
void Promise.resolve(refresh.current()).finally(() => {
running = false;
distance = 0;
pull.current(0, false, false);
});
};
el.addEventListener("touchstart", onStart, { passive: true });
el.addEventListener("touchmove", onMove, { passive: false });
el.addEventListener("touchend", onEnd);
el.addEventListener("touchcancel", onEnd);
return () => {
el.removeEventListener("touchstart", onStart);
el.removeEventListener("touchmove", onMove);
el.removeEventListener("touchend", onEnd);
el.removeEventListener("touchcancel", onEnd);
};
}, [el, enabled]);
}
/** How far in from the left edge a drag must start to count as going back. */
export const EDGE_ZONE = 28;
/**
* Drag in from the left edge to go back, the way every phone does it.
*
* Only from the edge. A back gesture that started anywhere would fight the
* horizontal scrolling that wide HTML mail needs, and mail is exactly the
* content nobody controls the width of.
*/
export function useEdgeBack(el: HTMLElement | null, onBack: () => void, enabled: boolean) {
const back = useRef(onBack);
back.current = onBack;
useEffect(() => {
if (!el || !enabled) return;
let startX: number | null = null;
let startY = 0;
let live = false;
const settle = (offset: number, animate: boolean) => {
el.style.transition = animate ? "transform .18s var(--ease, ease)" : "";
el.style.transform = offset ? `translateX(${offset}px)` : "";
};
const onStart = (e: TouchEvent) => {
if (e.touches.length !== 1) return;
const t = e.touches[0]!;
if (t.clientX - el.getBoundingClientRect().left > EDGE_ZONE) return;
startX = t.clientX;
startY = t.clientY;
live = false;
};
const onMove = (e: TouchEvent) => {
if (startX === null || e.touches.length !== 1) return;
const t = e.touches[0]!;
const dx = t.clientX - startX;
const dy = t.clientY - startY;
if (!live) {
if (lockAxis(dx, dy) === "y") {
startX = null;
return;
}
if (lockAxis(dx, dy) !== "x" || dx < 0) return;
live = true;
}
if (e.cancelable) e.preventDefault();
settle(Math.max(0, dx * 0.9), false);
};
const onEnd = () => {
if (startX === null) return;
const offset = parseFloat(el.style.transform.replace(/[^\d.-]/g, "")) || 0;
startX = null;
if (!live) return;
live = false;
// A third of the way across is enough: a back gesture is a flick, and
// asking for half the screen makes it feel like the app is resisting.
if (offset > el.clientWidth / 3) {
haptic();
settle(0, false);
back.current();
} else {
settle(0, true);
window.setTimeout(() => (el.style.transition = ""), 200);
}
};
el.addEventListener("touchstart", onStart, { passive: true });
el.addEventListener("touchmove", onMove, { passive: false });
el.addEventListener("touchend", onEnd);
el.addEventListener("touchcancel", onEnd);
return () => {
el.removeEventListener("touchstart", onStart);
el.removeEventListener("touchmove", onMove);
el.removeEventListener("touchend", onEnd);
el.removeEventListener("touchcancel", onEnd);
el.style.transform = "";
el.style.transition = "";
};
}, [el, enabled]);
}
/**
* How far a horizontal drag must travel before it moves the calendar to
* another day or month.
*
* Further than a row swipe, and not because the consequence is bigger --
* stepping a calendar is undone by stepping back, while a swiped row has
* already been archived. It is because this gesture has no way to change its
* mind. A row slides open as it goes, so the strip underneath names what is
* about to happen and letting go early calls it off, and a toast offers Undo
* afterwards. Stepping the calendar shows nothing on the way and offers
* nothing after, so the distance is the only chance to not mean it.
*/
export function navSwipeThreshold(width: number): number {
return Math.max(80, Math.min(180, width * 0.3));
}
/**
* Which way a finished drag sends the view: -1 back, +1 forward, 0 nowhere.
*
* Dragging left pulls the next period in from the right, which is how paper,
* phones and every other calendar behave. (It would need mirroring for a
* right-to-left interface; there is not one yet, and the day there is, this is
* one of the places that has to know.)
*/
export function swipeNavDirection(dx: number, width: number): -1 | 0 | 1 {
const threshold = navSwipeThreshold(width);
if (dx <= -threshold) return 1;
if (dx >= threshold) return -1;
return 0;
}
/**
* Swipe sideways across a calendar to step it a period at a time.
*
* Three things it deliberately does not do:
*
* - **No visual drag.** The row swipe slides the row open because the strip
* underneath has to name which of six actions is about to happen. Stepping
* a calendar has two outcomes and the direction of the finger already says
* which, so there is nothing to reveal -- and translating the grid would
* break the sticky day header, since a transform makes a containing block.
* The threshold is reported by the vibration motor instead, which is what
* the haptics are for: a swipe fires as the finger passes a line it cannot
* see.
* - **It does not start on an event.** A drag beginning on an event chip is
* left alone, so that moving an event by dragging it stays available to be
* built without having to be untangled from this first. Which gesture is
* meant is decidable at the moment the finger lands, and that is the only
* moment it can be decided cleanly.
* - **It does not start on the toolbar.** Buttons live there.
*
* The axis lock is the shared one, so it keeps the same bias toward the
* vertical: the day grid scrolls through the hours, and a scroll misread as a
* swipe throws the reader into another day.
*/
export function useSwipeNav(
el: HTMLElement | null,
opts: { onStep: (n: -1 | 1) => void; enabled: boolean; ignore?: string },
) {
const step = useRef(opts.onStep);
step.current = opts.onStep;
const { enabled, ignore } = opts;
useEffect(() => {
if (!el || !enabled) return;
let startX: number | null = null;
let startY = 0;
let axis: Axis = null;
let fired = false;
const onStart = (e: TouchEvent) => {
if (e.touches.length !== 1) return;
const t = e.touches[0]!;
if (ignore && (t.target as Element | null)?.closest?.(ignore)) return;
startX = t.clientX;
startY = t.clientY;
axis = null;
fired = false;
};
const onMove = (e: TouchEvent) => {
if (startX === null || e.touches.length !== 1) return;
const t = e.touches[0]!;
const dx = t.clientX - startX;
const dy = t.clientY - startY;
if (!axis) {
axis = lockAxis(dx, dy);
// Committed to scrolling: stay out of the way for the rest of the drag.
if (axis === "y") startX = null;
return;
}
if (axis !== "x") return;
// Once sideways, the browser must not also scroll.
if (e.cancelable) e.preventDefault();
if (!fired && swipeNavDirection(dx, el.clientWidth || window.innerWidth) !== 0) {
fired = true;
haptic();
}
};
const onEnd = (e: TouchEvent) => {
if (startX === null) return;
const t = e.changedTouches[0];
const dx = t ? t.clientX - startX : 0;
const wasX = axis === "x";
startX = null;
axis = null;
fired = false;
if (!wasX) return;
const dir = swipeNavDirection(dx, el.clientWidth || window.innerWidth);
if (dir !== 0) step.current(dir);
};
el.addEventListener("touchstart", onStart, { passive: true });
el.addEventListener("touchmove", onMove, { passive: false });
el.addEventListener("touchend", onEnd);
el.addEventListener("touchcancel", onEnd);
return () => {
el.removeEventListener("touchstart", onStart);
el.removeEventListener("touchmove", onMove);
el.removeEventListener("touchend", onEnd);
el.removeEventListener("touchcancel", onEnd);
};
}, [el, enabled, ignore]);
}