diff --git a/README.md b/README.md index acdffb5..8f2d3f4 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,7 @@ More, including the mobile layout, on [ihasmail.org](https://ihasmail.org/#scree - **Files** — JMAP FileNode: browse, upload, download, rename, move, delete - **Settings that follow the account**, not the browser — kept in a `settings.json` in the account's own JMAP Files, so ihasmail itself stays stateless - **Runs read-only** — one optional write path, and with it switched off the container needs no volume and no writable root. `IMMUTABLE=1` is checked at startup rather than trusted, so a half-applied switch refuses to boot instead of failing quietly. See [Running immutably](#running-immutably) +- **On a phone** — swipe a message to archive or delete it (either direction, your choice), hold one to select it, hold a folder for its menu, pull the list to refresh, swipe back from a conversation - **Platform** — installable PWA, Web Push with ihasmail closed, `mailto:` handler, no credentials in the browser, strict CSP, SSRF-safe image proxy The long version is on [ihasmail.org](https://ihasmail.org/#features); how to diff --git a/web/src/lib/__tests__/swipe.test.ts b/web/src/lib/__tests__/swipe.test.ts new file mode 100644 index 0000000..f34954c --- /dev/null +++ b/web/src/lib/__tests__/swipe.test.ts @@ -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 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(); + } + }); +}); diff --git a/web/src/lib/__tests__/touch.test.ts b/web/src/lib/__tests__/touch.test.ts new file mode 100644 index 0000000..1ce2ff0 --- /dev/null +++ b/web/src/lib/__tests__/touch.test.ts @@ -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 towards 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); + }); +}); diff --git a/web/src/lib/swipe.ts b/web/src/lib/swipe.ts new file mode 100644 index 0000000..e18c816 --- /dev/null +++ b/web/src/lib/swipe.ts @@ -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; + 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" }, +]; diff --git a/web/src/lib/touch.ts b/web/src/lib/touch.ts new file mode 100644 index 0000000..14df607 --- /dev/null +++ b/web/src/lib/touch.ts @@ -0,0 +1,492 @@ +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 towards 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 travelled `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 apologise 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(null); + const dir = useRef<-1 | 1>(1); + const armed = useRef(false); + const timer = useRef(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 cancelled 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 centre 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 virtualised + * list included -- comes down with the pull without knowing about it. + */ +export function usePullToRefresh( + el: HTMLElement | null, + onRefresh: () => Promise | 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]); +} diff --git a/web/src/store/settings.ts b/web/src/store/settings.ts index 1cf1c60..47a46ea 100644 --- a/web/src/store/settings.ts +++ b/web/src/store/settings.ts @@ -3,6 +3,7 @@ import { create } from "zustand"; import { loadJson, saveJson } from "@/lib/storage"; import { queueSettingsPush } from "@/lib/settingsSync"; import { setDateTimePrefs, type DateFormat, type TimeFormat } from "@/lib/datetime"; +import type { SwipeAction } from "@/lib/swipe"; /** * "ihasmail" is a dark theme carrying the palette from ihasmail.org. It is a @@ -65,6 +66,22 @@ export interface Settings { */ readReceiptPolicy: ReadReceiptPolicy; confirmDelete: boolean; + /** + * What dragging a message row sideways does, on a touchscreen. + * + * Two settings rather than one "swipe actions" toggle because the pair is + * the choice: which hand-side gets the destructive one is personal, and the + * usual complaint about swipe gestures is not that they exist but that the + * app picked the wrong ones. "none" turns a direction off; turning both off + * turns the gesture off. + * + * They follow the account rather than the device: someone who has decided + * that a left swipe deletes has decided it for their phone and their tablet + * both, and the setting is meaningless on the desktop that would otherwise + * be the odd one out. + */ + swipeRight: SwipeAction; + swipeLeft: SwipeAction; desktopNotifications: boolean; notificationSound: boolean; attachmentReminder: boolean; @@ -154,6 +171,12 @@ export const DEFAULT_SETTINGS: Settings = { requestReadReceipt: false, readReceiptPolicy: "ask", confirmDelete: false, + /* + * Right archives and left deletes, which is what the mail apps a phone came + * with already do. A default nobody has to learn beats a better one they do. + */ + swipeRight: "archive", + swipeLeft: "delete", desktopNotifications: false, notificationSound: false, attachmentReminder: true, diff --git a/web/src/styles/app.css b/web/src/styles/app.css index e61907e..106394e 100644 --- a/web/src/styles/app.css +++ b/web/src/styles/app.css @@ -350,13 +350,27 @@ a.menu-item:hover { color: var(--fg); } /* ========================================================================== App shell ========================================================================== */ -.app { height: 100%; display: grid; grid-template-rows: var(--topbar-h) 1fr; overflow: hidden; } +/* + * The column is stated, and stated as `minmax(0, 1fr)`. + * + * A grid with only rows named gets an implicit `auto` column, which sizes to + * its widest child's min-content -- and the top bar's min-content is the + * search field plus the account buttons, about 470px. On a desktop nobody sees + * it; on a 390px phone the whole app was 84px wider than the screen, so every + * message row ran off the right edge with its date beyond the glass. `1fr` + * alone would not have fixed it: that is `minmax(auto, 1fr)`, and the `auto` + * floor is the same min-content. The zero floor is the part that lets the top + * bar shrink instead of setting the width for everything below it. + */ +.app { height: 100%; display: grid; grid-template-rows: var(--topbar-h) 1fr; grid-template-columns: minmax(0, 1fr); overflow: hidden; } .topbar { display: flex; align-items: center; gap: 8px; padding: 0 12px; background: var(--bg); position: relative; z-index: 50; } .topbar .brand { display: flex; align-items: center; gap: 8px; font-weight: 700; font-size: 1.2em; letter-spacing: -.01em; color: var(--fg); text-decoration: none; padding-right: 8px; min-width: 0; } .topbar .brand img { width: 34px; height: 34px; object-fit: contain; } .topbar .brand .brand-name { color: #14b8a6; } .topbar .brand .brand-name span { color: var(--fg-muted); font-weight: 500; } -.searchbar { flex: 1 1 auto; max-width: 720px; margin: 0 auto; position: relative; } +/* `min-width: 0`, or the flex default of `auto` holds the search field at its + own min-content and the account buttons beside it are pushed off a phone. */ +.searchbar { flex: 1 1 auto; min-width: 0; max-width: 720px; margin: 0 auto; position: relative; } .searchbar .search-input { display: flex; align-items: center; gap: 8px; height: 44px; padding: 0 8px 0 14px; border-radius: 999px; background: var(--bg-sunken); border: 1px solid transparent; transition: background .12s, box-shadow .12s, border-color .12s; } .searchbar .search-input:focus-within { background: var(--bg-elev); box-shadow: var(--shadow-1); border-color: var(--border); } .searchbar input { flex: 1; border: 0; background: transparent; outline: none; min-width: 0; height: 100%; } @@ -388,8 +402,8 @@ a.menu-item:hover { color: var(--fg); } @keyframes push-pulse { 50% { opacity: .45; } } @media (prefers-reduced-motion: reduce) { .push-dot.connecting { animation: none; } } -.app-body { display: grid; grid-template-columns: var(--sidebar-w) 1fr; min-height: 0; transition: grid-template-columns .2s var(--ease); } -.app-body.collapsed { grid-template-columns: var(--sidebar-w-collapsed) 1fr; } +.app-body { display: grid; grid-template-columns: var(--sidebar-w) minmax(0, 1fr); min-height: 0; transition: grid-template-columns .2s var(--ease); } +.app-body.collapsed { grid-template-columns: var(--sidebar-w-collapsed) minmax(0, 1fr); } .sidebar { display: flex; flex-direction: column; min-height: 0; padding: 4px 8px 8px 8px; gap: 2px; overflow: hidden; } .sidebar-scroll { overflow-y: auto; overflow-x: hidden; flex: 1; min-height: 0; padding-bottom: 8px; } .compose-btn { display: flex; align-items: center; gap: 12px; height: 52px; padding: 0 22px 0 18px; margin: 6px 4px 12px; border-radius: 16px; background: var(--bg-elev); box-shadow: var(--shadow-1); font-weight: 600; font-size: 1em; color: var(--fg); transition: box-shadow .15s, transform .05s, background .12s; white-space: nowrap; } @@ -898,6 +912,56 @@ select optgroup { background-color: var(--bg-elev); color: var(--fg); } .login-card .pw-wrap { position: relative; } .login-card .pw-wrap .icon-btn { position: absolute; right: 2px; top: 1px; } +/* ========================================================================== + Touch gestures (see lib/touch.ts) + ========================================================================== */ + +/* + * `pan-y` is what makes a swipe possible without costing a scroll: the browser + * keeps the vertical drag for itself, at its own frame rate, and only sends us + * the horizontal movement it now knows it will not use. Without it every + * pointermove would arrive after the scroll had already started, and the list + * would stutter on the way past. + */ +.msg-row { touch-action: pan-y; -webkit-touch-callout: none; } +.msg-row.swiping { z-index: 4; box-shadow: var(--shadow-2); } +/* The row being dragged is the one thing on screen that must not lag. */ +.msg-row.swiping, .mail-list-pull { will-change: transform; } +/* Stop a pull at the top of the list from dragging the page behind it. */ +.mail-list { overscroll-behavior-y: contain; } + +/* The strip a swiped row slides off to reveal, painted into the row's slot. */ +.msg-swipe { position: absolute; left: 0; right: 0; z-index: 1; display: flex; align-items: center; padding: 0 22px; color: #fff; border-bottom: 1px solid var(--border); } +.msg-swipe.from-left { justify-content: flex-start; } +.msg-swipe.from-right { justify-content: flex-end; } +.msg-swipe.danger { background: var(--danger); } +.msg-swipe.warn { background: var(--warn); } +.msg-swipe.accent { background: var(--accent); color: var(--accent-fg); } +.msg-swipe.neutral { background: var(--fg-muted); } +/* + * Dim until the swipe is far enough to fire, then full strength and a little + * larger. It is the same promise the haptic tap makes, for anyone whose phone + * has no motor or has it switched off. + */ +.msg-swipe-act { display: inline-flex; align-items: center; gap: 10px; font-weight: 650; opacity: .72; transform: scale(.94); transition: opacity .12s var(--ease), transform .12s var(--ease); } +.msg-swipe.armed .msg-swipe-act { opacity: 1; transform: none; } + +/* Pull to refresh: a zero-height sticky perch for the dial, so an unpulled + list gives up no space to it. */ +.ptr { position: sticky; top: 0; left: 0; height: 0; z-index: 5; pointer-events: none; } +.ptr-dial { position: absolute; left: 50%; top: 0; display: inline-flex; align-items: center; justify-content: center; width: 34px; height: 34px; border-radius: 50%; background: var(--bg-elev); border: 1px solid var(--border); box-shadow: var(--shadow-2); color: var(--fg-muted); } +.ptr-dial.armed { color: var(--accent); border-color: var(--accent); } + +/* + * A tap that leaves a grey rectangle behind reads as a rendering glitch rather + * than as feedback, and every surface here has a :active or a ripple of its own. + */ +@media (hover: none) { + * { -webkit-tap-highlight-color: transparent; } + .msg-row:active { background: var(--bg-hover); } + .nav-item, .module-link, .mobile-tabbar a { -webkit-touch-callout: none; } +} + /* ========================================================================== Responsive ========================================================================== */ @@ -931,7 +995,7 @@ select optgroup { background-color: var(--bg-elev); color: var(--fg); } } @media (max-width: 768px) { :root { --topbar-h: 52px; } - .app-body, .app-body.collapsed { grid-template-columns: 1fr; } + .app-body, .app-body.collapsed { grid-template-columns: minmax(0, 1fr); } .sidebar { position: fixed; top: 0; bottom: 0; left: 0; width: min(300px, 85vw); background: var(--bg-elev); z-index: 950; transform: translateX(-105%); transition: transform .22s var(--ease); box-shadow: var(--shadow-3); padding-top: 12px; } .sidebar.open { transform: none; } .collapsed .sidebar .nav-item, .collapsed .sidebar .compose-btn { all: revert; } @@ -941,21 +1005,36 @@ select optgroup { background-color: var(--bg-elev); color: var(--fg); } .drawer-backdrop { display: block; position: fixed; inset: 0; z-index: 940; background: rgba(2,6,23,.45); opacity: 0; pointer-events: none; transition: opacity .2s; } .drawer-backdrop.open { opacity: 1; pointer-events: auto; } .main { border-radius: 0; border: 0; } - .topbar { padding: 0 6px; gap: 4px; } + /* + index.html asks for `viewport-fit=cover`, which is what lets the app paint + under a notch and behind the home indicator — and what makes it this + stylesheet's job to keep anything readable out from under them. The insets + are zero everywhere else, so these need no media query of their own. + */ + .topbar { padding: 0 max(6px, env(safe-area-inset-right)) 0 max(6px, env(safe-area-inset-left)); gap: 4px; } .topbar .brand .brand-name { display: none; } .searchbar .search-input { height: 40px; } .topbar-actions .hide-mobile { display: none; } .msg-row { padding-right: 8px; } .mail-list { padding-bottom: 72px; } - .composer { width: 100vw; max-width: 100vw; height: 100vh; max-height: 100vh; border-radius: 0; position: fixed; inset: 0; } - .composer.minimized { height: 44px; width: 100vw; top: auto; bottom: 0; } + /* + `dvh`, not `vh`. On a phone browser `100vh` is the tall layout viewport that + ignores the address bar, so a full-screen composer put its Send button + behind the toolbar until the page was scrolled — which a fixed element + cannot be. In an installed PWA there is no bar and the two agree. + */ + .composer { width: 100vw; max-width: 100vw; height: 100dvh; max-height: 100dvh; border-radius: 0; position: fixed; inset: 0; } + .composer.minimized { height: calc(44px + env(safe-area-inset-bottom)); width: 100vw; top: auto; bottom: 0; } .composer-dock { right: 0; } - .dialog { max-height: calc(100vh - 16px); } - .fab { display: flex; position: fixed; right: 18px; bottom: 78px; width: 56px; height: 56px; border-radius: 18px; background: var(--accent); color: var(--accent-fg); align-items: center; justify-content: center; box-shadow: var(--shadow-2); z-index: 700; } - .mobile-tabbar { display: grid; grid-template-columns: repeat(4, 1fr); position: fixed; left: 0; right: 0; bottom: 0; height: 60px; background: var(--bg-elev); border-top: 1px solid var(--border); z-index: 700; padding-bottom: env(safe-area-inset-bottom); } + .dialog { max-height: calc(100dvh - 16px); } + .fab { display: flex; position: fixed; right: calc(18px + env(safe-area-inset-right)); bottom: calc(78px + env(safe-area-inset-bottom)); width: 56px; height: 56px; border-radius: 18px; background: var(--accent); color: var(--accent-fg); align-items: center; justify-content: center; box-shadow: var(--shadow-2); z-index: 700; } + /* The bar grows by the home indicator rather than hiding behind it, and + `.main` below reserves exactly that much. */ + .mobile-tabbar { display: grid; grid-template-columns: repeat(4, 1fr); position: fixed; left: 0; right: 0; bottom: 0; height: calc(60px + env(safe-area-inset-bottom)); background: var(--bg-elev); border-top: 1px solid var(--border); z-index: 700; padding-bottom: env(safe-area-inset-bottom); } .mobile-tabbar a { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; color: var(--fg-muted); text-decoration: none; font-size: 11px; } .mobile-tabbar a.active { color: var(--accent); } - .main { padding-bottom: 60px; } + .main { padding-bottom: calc(60px + env(safe-area-inset-bottom)); } + .sidebar { padding-bottom: env(safe-area-inset-bottom); padding-left: env(safe-area-inset-left); } .thread-subject { padding: 14px 16px 8px; } .message { margin: 0 8px 8px; } .message-head { padding: 10px 12px; } diff --git a/web/src/ui/misc.tsx b/web/src/ui/misc.tsx index c618871..60456fb 100644 --- a/web/src/ui/misc.tsx +++ b/web/src/ui/misc.tsx @@ -67,6 +67,15 @@ export function useMediaQuery(q: string): boolean { export const useIsMobile = () => useMediaQuery("(max-width: 768px)"); export const useIsNarrow = () => useMediaQuery("(max-width: 900px)"); +/** + * Whether the thing doing the pointing is a finger. + * + * The touch gestures hang off this rather than off `useIsMobile`, because the + * two are different questions and both get asked: a tablet in landscape is a + * wide screen that swipes, and a phone plugged into a mouse is a narrow one + * that should not. Width decides the layout; this decides the gestures. + */ +export const useIsTouch = () => useMediaQuery("(pointer: coarse)"); export function Kbd({ keys }: { keys: string }) { return ( diff --git a/web/src/views/mail/MailboxTree.tsx b/web/src/views/mail/MailboxTree.tsx index 436f8ce..ae15920 100644 --- a/web/src/views/mail/MailboxTree.tsx +++ b/web/src/views/mail/MailboxTree.tsx @@ -7,12 +7,13 @@ 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 } from "@/ui/misc"; +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 = { inbox: , @@ -230,6 +231,22 @@ function FolderRow({ mailbox: m, label, depth, hasChildren, open, hiddenUnread, /* 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"; @@ -243,7 +260,10 @@ function FolderRow({ mailbox: m, label, depth, hasChildren, open, hiddenUnread, 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} - draggable={movable(m)} + {...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} diff --git a/web/src/views/mail/MessageList.tsx b/web/src/views/mail/MessageList.tsx index 72de482..9331f5b 100644 --- a/web/src/views/mail/MessageList.tsx +++ b/web/src/views/mail/MessageList.tsx @@ -1,6 +1,6 @@ -import { memo, useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type MouseEvent } from "react"; +import { Fragment, memo, useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type MouseEvent, type ReactNode } from "react"; import { useVirtualizer } from "@tanstack/react-virtual"; -import { Archive, ArrowLeft, CheckSquare, FolderInput, PanelRight, PanelBottom, PanelTop, Filter, Inbox, Mail, MailOpen, MoreVertical, Paperclip, RefreshCw, Reply, Search, Star, Tag, Trash2, AlertOctagon, Forward, Eraser, ShieldCheck } from "lucide-react"; +import { Archive, ArrowLeft, CheckSquare, FolderInput, PanelRight, PanelBottom, PanelTop, Filter, Inbox, Mail, MailOpen, MoreVertical, Paperclip, RefreshCw, Reply, Search, Star, Tag, Trash2, AlertOctagon, Forward, Eraser, ShieldCheck, X } from "lucide-react"; import { useLocation } from "wouter"; import { useMail, type ListState } from "@/store/mail"; import { dateTimeKey, useSettings } from "@/store/settings"; @@ -8,11 +8,29 @@ import type { Email, Id } from "@/jmap/types"; import { formatListDate } from "@/lib/format"; import { canEmpty, confirmAndEmpty, emptyLabel } from "@/lib/emptyFolder"; import { displayName, shortName } from "@/lib/address"; -import { Avatar, Empty, useIsMobile } from "@/ui/misc"; +import { Avatar, Empty, useIsMobile, useIsTouch } from "@/ui/misc"; import { MenuItem, MenuSep, MenuTitle, Popover, useMenu } from "@/ui/popover"; import { useCompose } from "@/store/compose"; +import { haptic, usePullToRefresh, useTouchRow, PULL_TRIGGER } from "@/lib/touch"; +import { describeSwipe, type SwipeAction, type SwipeDescriptor, type SwipeIcon } from "@/lib/swipe"; import { FilterFromMessageDialog } from "./FilterFromMessage"; +/** + * The glyph on the strip a swipe reveals. Sized larger than the toolbar's + * icons: it is read at arm's length, in motion, out of the corner of an eye. + */ +const SWIPE_ICON: Record = { + archive: , + delete: , + spam: , + "not-spam": , + read: , + unread: , + star: , + unstar: , + move: , +}; + export interface ListActions { archive: (rows?: Id[]) => Promise; trash: (rows?: Id[]) => Promise; @@ -50,10 +68,15 @@ export function MessageList({ title, list, openThreadId, focusId, setFocusId, on const settings = useSettings((s) => s.settings); const updateSettings = useSettings((s) => s.update); const parentRef = useRef(null); + // The same element as `parentRef`, held in state as well: the pull-to-refresh + // listeners have to be bound in an effect that re-runs when the element + // arrives, and a ref does not tell anybody it has been filled in. + const [scrollEl, setScrollEl] = useState(null); const isMobile = useIsMobile(); + const isTouch = useIsTouch(); const [paneWidth, setPaneWidth] = useState(0); useEffect(() => { - const el = parentRef.current; + const el = scrollEl; if (!el) return; const ro = new ResizeObserver((entries) => { const w = entries[0]?.contentRect.width ?? 0; @@ -61,7 +84,7 @@ export function MessageList({ title, list, openThreadId, focusId, setFocusId, on }); ro.observe(el); return () => ro.disconnect(); - }, []); + }, [scrollEl]); const twoLine = isMobile || (paneWidth > 0 && paneWidth < 640); const ctxMenu = useMenu(); const [ctxRow, setCtxRow] = useState(null); @@ -69,6 +92,11 @@ export function MessageList({ title, list, openThreadId, focusId, setFocusId, on const [refreshing, setRefreshing] = useState(false); const [filterFrom, setFilterFrom] = useState(null); const lastClick = useRef(null); + const selMenu = useMenu(); + /** The one row currently under a finger, and what letting go would do. */ + const [swiping, setSwiping] = useState<{ id: Id; dir: -1 | 1; armed: boolean; desc: SwipeDescriptor } | null>(null); + /** How far the list has been pulled down, and whether that is far enough. */ + const [pull, setPull] = useState<{ y: number; armed: boolean; live: boolean }>({ y: 0, armed: false, live: false }); const ids = list?.ids ?? []; const selCount = Object.keys(selected).length; @@ -98,13 +126,24 @@ export function MessageList({ title, list, openThreadId, focusId, setFocusId, on if (last.index >= ids.length - 5 && !list.loadingMore && !list.exhausted && !list.loading) void loadMore(); }, [items, ids.length, list, loadMore]); - // Pull-to-refresh-ish: manual refresh button - const doRefresh = async () => { + const doRefresh = useCallback(async () => { setRefreshing(true); await refreshList(); await useMail.getState().loadMailboxes(); setRefreshing(false); - }; + }, [refreshList]); + + /* + * Pull the top of the list down to refresh it. + * + * The toolbar button stays: it is the only way to do this with a mouse, and + * on a phone it is the way that works when the list is already scrolled down + * a thousand messages. This is the one a thumb reaches for first. + */ + usePullToRefresh(scrollEl, doRefresh, { + enabled: isTouch, + onPull: useCallback((y: number, armed: boolean, live: boolean) => setPull({ y, armed, live }), []), + }); const onRowClick = useCallback( (e: MouseEvent, rowId: Id) => { @@ -143,6 +182,46 @@ export function MessageList({ title, list, openThreadId, focusId, setFocusId, on [ctxMenu, setFocusId], ); + /* + * Hold a row to select it, the way the mail app the phone came with does. + * + * Selection was reachable on a touchscreen already -- the checkboxes are + * always visible where there is no hover -- but a checkbox is a small target + * beside an avatar, and nobody aims for it, because on a phone holding the + * row *is* how you select it. Once one row is selected, plain taps toggle + * the rest (see `onRowClick`), so this only has to open the mode. + */ + const onLongPress = useCallback( + (rowId: Id) => { + haptic(15); + setFocusId(rowId); + lastClick.current = rowId; + select([rowId], !useMail.getState().selected[rowId]); + }, + [select, setFocusId], + ); + + const onSwipeState = useCallback((rowId: Id, state: { dir: -1 | 1; armed: boolean; desc: SwipeDescriptor } | null) => { + // A row clearing itself must not clear a gesture that has since moved on + // to another row -- rows unmount as the list scrolls, at any moment. + setSwiping((cur) => (state ? { id: rowId, ...state } : cur?.id === rowId ? null : cur)); + }, []); + + const fireSwipe = useCallback( + async (rowId: Id, d: SwipeDescriptor) => { + switch (d.action) { + case "archive": await actions.archive([rowId]); break; + case "delete": await actions.trash([rowId]); break; + case "spam": await actions.spam([rowId]); break; + // `on` rather than a fresh look at the row: fire what the strip said. + case "read": await actions.read(d.on === true, [rowId]); break; + case "star": await actions.star(d.on === true, [rowId]); break; + case "move": actions.move([rowId]); break; + } + }, + [actions], + ); + const ctxTargets = useMemo(() => (ctxRow ? (selected[ctxRow] ? Object.keys(selected) : [ctxRow]) : []), [ctxRow, selected]); const allSelected = ids.length > 0 && ids.every((id) => selected[id]); const someUnread = ctxTargets.some((id) => !emails[id]?.keywords.$seen); @@ -178,6 +257,31 @@ export function MessageList({ title, list, openThreadId, focusId, setFocusId, on + {/* + The three buttons above marked hide-mobile have nowhere to go on + a phone, and used to simply not exist there: selecting mail on a + touchscreen could archive, delete, mark read and move, and could + not report spam, mark unread or label. Now that holding a row is + how selection starts, that gap is the first thing a thumb finds. + */} + {isMobile && ( + <> + + + + : } + label={mailbox?.role === "junk" ? "Not spam" : "Report spam"} + onClick={() => void actions.spam()} + /> + } label="Mark as unread" onClick={() => void actions.read(false)} /> + } label="Label…" onClick={() => actions.label(undefined, { x: window.innerWidth / 2, y: 100 })} /> + + } label="Select all" onClick={selectAll} /> + } label="Clear selection" onClick={clearSelection} /> + + + )} ) : ( <> @@ -237,65 +341,120 @@ export function MessageList({ title, list, openThreadId, focusId, setFocusId, on )} -
- {list?.loading && ids.length === 0 ? ( -
- {[...Array(12)].map((_, i) => ( -
- - - - -
- ))} -
- ) : ids.length === 0 && list && !list.loading ? ( - : } title={isSearch ? "No results" : mailbox?.role === "inbox" ? "You're all caught up" : "Nothing here"}> - {isSearch ? "Try different keywords or filters." : mailbox?.role === "inbox" ? "No new mail in your inbox." : "This folder is empty."} - - ) : ( -
- {items.map((vi) => { - const id = ids[vi.index]; - if (!id) { - return ( -
- {list?.loadingMore ? : ""} -
- ); - } - const e = emails[id]; - if (!e) return
; - const thread = list?.collapseThreads ? threads[e.threadId] : undefined; - return ( - emails[x]).filter((x): x is Email => Boolean(x)) : undefined} - top={vi.start} - height={vi.size} - selected={Boolean(selected[id])} - focused={focusId === id} - open={openThreadId === e.threadId} - twoLine={twoLine} - showAvatar={settings.showAvatars} - showPreview={settings.showPreview} - isDrafts={isDrafts} - mailboxId={mailboxId} - isSent={mailbox?.role === "sent"} - onClick={onRowClick} - onContext={onContext} - onSelect={(rowId, on) => { select([rowId], on); lastClick.current = rowId; }} - onStar={(rowId, on) => void actions.star(on, [rowId])} - onArchive={(rowId) => void actions.archive([rowId])} - onTrash={(rowId) => void actions.trash([rowId])} - onRead={(rowId, read) => void actions.read(read, [rowId])} - selectedIds={selected} - /> - ); - })} +
{ + parentRef.current = el; + setScrollEl(el); + }} + className={`mail-list ${selCount ? "has-selection" : ""} ${twoLine ? "two-line" : ""} ${settings.density === "compact" ? "compact" : ""}`} + tabIndex={-1} + > + {/* + The pull dial. Zero-height and sticky, so it rides the top of the + scroller without taking a row's worth of space from the list when + nobody is pulling — and so it stays put while the content below it + comes down. + */} + {isTouch && ( + )} +
+ {list?.loading && ids.length === 0 ? ( +
+ {[...Array(12)].map((_, i) => ( +
+ + + + +
+ ))} +
+ ) : ids.length === 0 && list && !list.loading ? ( + : } title={isSearch ? "No results" : mailbox?.role === "inbox" ? "You're all caught up" : "Nothing here"}> + {isSearch ? "Try different keywords or filters." : mailbox?.role === "inbox" ? "No new mail in your inbox." : "This folder is empty."} + + ) : ( +
+ {items.map((vi) => { + const id = ids[vi.index]; + if (!id) { + return ( +
+ {list?.loadingMore ? : ""} +
+ ); + } + const e = emails[id]; + if (!e) return
; + const thread = list?.collapseThreads ? threads[e.threadId] : undefined; + const strip = swiping?.id === id ? swiping : null; + return ( + + {/* + What the row is sliding off to reveal. Only ever one of + these exists, under the row being dragged, painted into the + same slot the row occupies — the row's own background is + opaque, so it covers this until the finger moves it. + */} + {strip && ( + + )} + emails[x]).filter((x): x is Email => Boolean(x)) : undefined} + top={vi.start} + height={vi.size} + selected={Boolean(selected[id])} + focused={focusId === id} + open={openThreadId === e.threadId} + twoLine={twoLine} + showAvatar={settings.showAvatars} + showPreview={settings.showPreview} + isDrafts={isDrafts} + mailboxId={mailboxId} + isSent={mailbox?.role === "sent"} + onClick={onRowClick} + onContext={onContext} + onSelect={(rowId, on) => { select([rowId], on); lastClick.current = rowId; }} + onStar={(rowId, on) => void actions.star(on, [rowId])} + onArchive={(rowId) => void actions.archive([rowId])} + onTrash={(rowId) => void actions.trash([rowId])} + onRead={(rowId, read) => void actions.read(read, [rowId])} + selectedIds={selected} + touch={isTouch} + role={mailbox?.role ?? null} + swipeLeft={settings.swipeLeft} + swipeRight={settings.swipeRight} + onLongPress={onLongPress} + onSwipeState={onSwipeState} + onSwipeFire={fireSwipe} + /> + + ); + })} +
+ )} +
} label="Reply" onClick={() => { const e = ctxRow ? emails[ctxRow] : undefined; if (e) void useCompose.getState().reply(e, "reply"); }} /> @@ -339,9 +498,17 @@ interface RowProps { onArchive: (id: Id) => void; onTrash: (id: Id) => void; onRead: (id: Id, read: boolean) => void; + /** Whether this list is being pointed at with a finger. */ + touch: boolean; + role: string | null; + swipeLeft: SwipeAction; + swipeRight: SwipeAction; + onLongPress: (id: Id) => void; + onSwipeState: (id: Id, state: { dir: -1 | 1; armed: boolean; desc: SwipeDescriptor } | null) => void; + onSwipeFire: (id: Id, desc: SwipeDescriptor) => Promise; } -const Row = memo(function Row({ email: e, threadEmails, top, height, selected, focused, open, twoLine, showAvatar, showPreview, isDrafts, isSent, mailboxId, selectedIds, onClick, onContext, onSelect, onStar, onArchive, onTrash, onRead }: RowProps) { +const Row = memo(function Row({ email: e, threadEmails, top, height, selected, focused, open, twoLine, showAvatar, showPreview, isDrafts, isSent, mailboxId, selectedIds, onClick, onContext, onSelect, onStar, onArchive, onTrash, onRead, touch, role, swipeLeft, swipeRight, onLongPress, onSwipeState, onSwipeFire }: RowProps) { const labels = useSettings((s) => s.settings.labels); // Subscribed purely so the row re-renders when the date format changes. useSettings((s) => dateTimeKey(s.settings)); @@ -371,6 +538,67 @@ const Row = memo(function Row({ email: e, threadEmails, top, height, selected, f const who = (isSent || isDrafts ? (names.length ? `To: ${names.join(", ")}` : "(no recipients)") : names.join(", ")) || "(unknown)"; const rowLabels = labels.filter((l) => scope.some((x) => x.keywords[l.keyword])); + /* + * How far this row has been dragged from home, and whether it is currently + * animating back. Kept here rather than in the list so that a moving finger + * re-renders one row instead of the whole virtualised list; the list is told + * only the three things the strip behind the row needs -- which row, which + * way, and whether letting go now would fire. + */ + const [dx, setDx] = useState(0); + const [gliding, setGliding] = useState(false); + const rowRef = useRef(null); + const descFor = useCallback( + (dir: -1 | 1) => describeSwipe(dir === 1 ? swipeRight : swipeLeft, { role, unread, starred }), + [swipeLeft, swipeRight, role, unread, starred], + ); + // A row that scrolls out from under a live gesture takes its strip with it. + useEffect(() => () => onSwipeState(e.id, null), [e.id, onSwipeState]); + + const gesture = useTouchRow({ + enabled: touch, + onLongPress: () => onLongPress(e.id), + canSwipe: (dir) => Boolean(descFor(dir)), + onSwipeMove: (offset, dir, armed) => { + const desc = descFor(dir); + if (!desc) return; + setGliding(false); + setDx(offset); + onSwipeState(e.id, { dir, armed, desc }); + }, + onSwipeEnd: (dir) => { + setGliding(true); + const desc = dir ? descFor(dir) : null; + if (!desc) { + setDx(0); + onSwipeState(e.id, null); + return; + } + const settle = () => { + setDx(0); + onSwipeState(e.id, null); + }; + if (!desc.removes) { + settle(); + void onSwipeFire(e.id, desc); + return; + } + /* + * An action that empties the row sees it out first: the row leaves the + * way the finger was taking it, and the list closes the gap behind it. + * Snapping home and vanishing a frame later reads as a misfire. + * + * It still settles afterwards, because "removes" is what the action + * means to do rather than what it did -- a delete the reader cancels at + * the confirmation leaves the row here, and it has to come back. + */ + setDx(dir * (rowRef.current?.offsetWidth ?? 400)); + window.setTimeout(() => { + void Promise.resolve(onSwipeFire(e.id, desc)).finally(settle); + }, 160); + }, + }); + const onDragStart = (ev: DragEvent) => { const ids = selectedIds[e.id] ? Object.keys(selectedIds) : [e.id]; // include thread emails in scope @@ -391,13 +619,20 @@ const Row = memo(function Row({ email: e, threadEmails, top, height, selected, f return (
onClick(ev, e.id)} onContextMenu={(ev) => onContext(ev, e.id)} - draggable + /* + * Dragging a row into a folder is a mouse gesture, and on a touchscreen + * it is the browser's idea of a long press — the same press that now + * opens selection. Only one of them can have it. + */ + draggable={!touch} onDragStart={onDragStart} + {...gesture} role="row" aria-selected={selected} > diff --git a/web/src/views/mail/ThreadView.tsx b/web/src/views/mail/ThreadView.tsx index 11e2ce8..73c9b00 100644 --- a/web/src/views/mail/ThreadView.tsx +++ b/web/src/views/mail/ThreadView.tsx @@ -7,10 +7,11 @@ import type { Email, Id } from "@/jmap/types"; import { MessageView } from "./MessageView"; import type { ListActions } from "./MessageList"; import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover"; -import { Spinner } from "@/ui/misc"; +import { Spinner, useIsNarrow, useIsTouch } from "@/ui/misc"; import { client } from "@/jmap/client"; import { LabelPicker } from "./LabelPicker"; import { threadScrollTarget } from "@/lib/threadScroll"; +import { useEdgeBack } from "@/lib/touch"; /** How long the opening scroll keeps its place while bodies and images land. */ const HOLD_MS = 2000; @@ -42,6 +43,18 @@ export function ThreadView({ threadId, mailboxId, onBack, actions, onNavigate, h const moreMenu = useMenu(); const scrollRef = useRef(null); const markTimer = useRef(null); + const isTouch = useIsTouch(); + const narrow = useIsNarrow(); + /* + * Drag in from the left edge to go back to the list. + * + * Only where back means something: on a wide screen the list is still + * beside the conversation and there is nowhere to go. The toolbar's arrow + * stays regardless — a gesture with no visible control is a gesture only + * the people who already know about it can use. + */ + const [viewEl, setViewEl] = useState(null); + useEdgeBack(viewEl, onBack, isTouch && narrow); // Load useEffect(() => { @@ -215,7 +228,7 @@ export function ThreadView({ threadId, mailboxId, onBack, actions, onNavigate, h const accountId = useMail((s) => s.accountId); return ( -
+
+

Swiping

+

+ On a touchscreen, drag a message sideways to act on it. Each direction can do one thing, or nothing. These follow your account, so a phone and a tablet agree; a mouse ignores them and keeps dragging messages into folders instead. +

+
+
+ + +
+
+ + +
+
+ {/* + Said once, where it is relevant, rather than greying the pickers out on + a desktop: the settings are real and worth setting here for the phone + that will read them, and a disabled control invites a hunt for whatever + would enable it. + */} + {!isTouch && ( +

+ This screen has no touchscreen, so nothing here changes what it does. Your phone or tablet will pick these up. +

+ )} +

+ Holding a message selects it, and holding a folder opens its menu. Pull the top of the message list down to check for new mail. +

+

Sidebar

update({ labelsSidebar: v })} label="Show labels in the sidebar" /> update({ showHiddenFolders: v })} label="Show unsubscribed (hidden) folders" />