Files
ihasmail/web/src/lib/swipe.ts
T
jcoffey-dev b2769b9011 Give the mail list the gestures a phone already has
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.
2026-08-31 06:52:25 -07:00

91 lines
4.2 KiB
TypeScript

/**
* 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 colour 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" },
];