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.
64 lines
3.0 KiB
TypeScript
64 lines
3.0 KiB
TypeScript
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 coloured 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();
|
|
}
|
|
});
|
|
});
|