Files
ihasmail/web/src/lib/input/touch.ts
T
jcoffey-dev bd6a605d61 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.
2026-09-15 23:17:50 -07:00

619 lines
22 KiB
TypeScript

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]);
}