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.
This commit is contained in:
2026-08-31 06:52:25 -07:00
parent 0fb504748d
commit b2769b9011
12 changed files with 1235 additions and 86 deletions
+1
View File
@@ -50,6 +50,7 @@ More, including the mobile layout, on [ihasmail.org](https://ihasmail.org/#scree
- **Files** — JMAP FileNode: browse, upload, download, rename, move, delete - **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 - **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) - **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 - **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 The long version is on [ihasmail.org](https://ihasmail.org/#features); how to
+63
View File
@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import { SWIPE_CHOICES, describeSwipe, type SwipeAction } from "../swipe";
/**
* A swipe names what it is about to do on a 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();
}
});
});
+85
View File
@@ -0,0 +1,85 @@
import { describe, expect, it } from "vitest";
import { AXIS_SLOP, PULL_MAX, PULL_TRIGGER, lockAxis, pullDistance, swipeOffset, swipeThreshold } from "../touch";
/**
* The arithmetic behind the touch gestures, checked without a touchscreen.
*
* These are the numbers that decide whether a finger meant to scroll the list
* or to act on a message, and getting them wrong is not a crash — it is an app
* that deletes mail when someone tried to scroll past it. Worth pinning down.
*/
describe("lockAxis", () => {
it("stays undecided until the finger has committed", () => {
expect(lockAxis(0, 0)).toBe(null);
expect(lockAxis(AXIS_SLOP - 1, AXIS_SLOP - 1)).toBe(null);
});
it("reads a clearly sideways drag as a swipe", () => {
expect(lockAxis(40, 4)).toBe("x");
expect(lockAxis(-40, 4)).toBe("x");
});
it("gives a diagonal to the scroller, not the swipe", () => {
// 45 degrees is more sideways than not, and is still a scroll: someone
// flicking down a list does not travel straight down the glass.
expect(lockAxis(30, 30)).toBe("y");
expect(lockAxis(30, 25)).toBe("y");
});
it("counts distance on either axis 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);
});
});
+90
View File
@@ -0,0 +1,90 @@
/**
* What a swipe on a message row does, and what it should say it is about to do.
*
* The reader picks one action for each direction in Settings, but an action is
* not a fixed thing: "delete" out of Deleted Items is permanent, "report spam"
* inside Junk Mail is the opposite request, and "archive" while looking at the
* archive is nothing at all. The strip revealed behind the row has to name the
* thing that will actually happen, in the folder it is happening in -- a row
* that slides open to reveal the word "Archive" and then does nothing is worse
* than one that does not slide.
*
* So a direction with no meaning here resolves to `null`, and a null direction
* is one the row simply will not move in.
*/
export type SwipeAction = "archive" | "delete" | "spam" | "read" | "star" | "move" | "none";
export interface SwipeContext {
/** The role of the folder on screen, where it has one. */
role?: string | null;
/** Whether the row is unread — "mark as read" is a toggle, and says so. */
unread: boolean;
starred: boolean;
}
/**
* Which glyph the strip shows. Named for the state it is offering rather than
* the setting it came from: "mark as unread" and "not spam" are the same two
* settings as their opposites but nothing like the same icon, and a strip that
* says "Not spam" beside a spam icon is asking to be misread at a glance.
*/
export type SwipeIcon = "archive" | "delete" | "spam" | "not-spam" | "read" | "unread" | "star" | "unstar" | "move";
export interface SwipeDescriptor {
action: Exclude<SwipeAction, "none">;
label: string;
icon: SwipeIcon;
/** Which 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" },
];
+492
View File
@@ -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<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 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> | 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]);
}
+23
View File
@@ -3,6 +3,7 @@ import { create } from "zustand";
import { loadJson, saveJson } from "@/lib/storage"; import { loadJson, saveJson } from "@/lib/storage";
import { queueSettingsPush } from "@/lib/settingsSync"; import { queueSettingsPush } from "@/lib/settingsSync";
import { setDateTimePrefs, type DateFormat, type TimeFormat } from "@/lib/datetime"; 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 * "ihasmail" is a dark theme carrying the palette from ihasmail.org. It is a
@@ -65,6 +66,22 @@ export interface Settings {
*/ */
readReceiptPolicy: ReadReceiptPolicy; readReceiptPolicy: ReadReceiptPolicy;
confirmDelete: boolean; 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; desktopNotifications: boolean;
notificationSound: boolean; notificationSound: boolean;
attachmentReminder: boolean; attachmentReminder: boolean;
@@ -154,6 +171,12 @@ export const DEFAULT_SETTINGS: Settings = {
requestReadReceipt: false, requestReadReceipt: false,
readReceiptPolicy: "ask", readReceiptPolicy: "ask",
confirmDelete: false, 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, desktopNotifications: false,
notificationSound: false, notificationSound: false,
attachmentReminder: true, attachmentReminder: true,
+91 -12
View File
@@ -350,13 +350,27 @@ a.menu-item:hover { color: var(--fg); }
/* ========================================================================== /* ==========================================================================
App shell 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 { 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 { 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 img { width: 34px; height: 34px; object-fit: contain; }
.topbar .brand .brand-name { color: #14b8a6; } .topbar .brand .brand-name { color: #14b8a6; }
.topbar .brand .brand-name span { color: var(--fg-muted); font-weight: 500; } .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 { 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 .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%; } .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; } } @keyframes push-pulse { 50% { opacity: .45; } }
@media (prefers-reduced-motion: reduce) { .push-dot.connecting { animation: none; } } @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 { 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) 1fr; } .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 { 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; } .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; } .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 { position: relative; }
.login-card .pw-wrap .icon-btn { position: absolute; right: 2px; top: 1px; } .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 Responsive
========================================================================== */ ========================================================================== */
@@ -931,7 +995,7 @@ select optgroup { background-color: var(--bg-elev); color: var(--fg); }
} }
@media (max-width: 768px) { @media (max-width: 768px) {
:root { --topbar-h: 52px; } :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 { 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; } .sidebar.open { transform: none; }
.collapsed .sidebar .nav-item, .collapsed .sidebar .compose-btn { all: revert; } .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 { 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; } .drawer-backdrop.open { opacity: 1; pointer-events: auto; }
.main { border-radius: 0; border: 0; } .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; } .topbar .brand .brand-name { display: none; }
.searchbar .search-input { height: 40px; } .searchbar .search-input { height: 40px; }
.topbar-actions .hide-mobile { display: none; } .topbar-actions .hide-mobile { display: none; }
.msg-row { padding-right: 8px; } .msg-row { padding-right: 8px; }
.mail-list { padding-bottom: 72px; } .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; } .composer-dock { right: 0; }
.dialog { max-height: calc(100vh - 16px); } .dialog { max-height: calc(100dvh - 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; } .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; }
.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); } /* 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 { 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); } .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; } .thread-subject { padding: 14px 16px 8px; }
.message { margin: 0 8px 8px; } .message { margin: 0 8px 8px; }
.message-head { padding: 10px 12px; } .message-head { padding: 10px 12px; }
+9
View File
@@ -67,6 +67,15 @@ export function useMediaQuery(q: string): boolean {
export const useIsMobile = () => useMediaQuery("(max-width: 768px)"); export const useIsMobile = () => useMediaQuery("(max-width: 768px)");
export const useIsNarrow = () => useMediaQuery("(max-width: 900px)"); 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 }) { export function Kbd({ keys }: { keys: string }) {
return ( return (
+22 -2
View File
@@ -7,12 +7,13 @@ import { isScheduledMailbox } from "@/store/scheduled";
import { useSettings } from "@/store/settings"; import { useSettings } from "@/store/settings";
import type { Id, Mailbox } from "@/jmap/types"; import type { Id, Mailbox } from "@/jmap/types";
import { MenuItem, MenuSep, MenuTitle, Popover, useMenu } from "@/ui/popover"; 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 { confirmDialog, promptDialog } from "@/ui/dialog";
import { toast } from "@/ui/toast"; import { toast } from "@/ui/toast";
import { ShareDialog } from "../settings/ShareDialog"; import { ShareDialog } from "../settings/ShareDialog";
import { loadRaw, saveJson } from "@/lib/storage"; import { loadRaw, saveJson } from "@/lib/storage";
import { canDropFolder, folderColor, movable } from "@/lib/folderMove"; import { canDropFolder, folderColor, movable } from "@/lib/folderMove";
import { haptic, useTouchRow } from "@/lib/touch";
const ROLE_ICONS: Record<string, ReactNode> = { const ROLE_ICONS: Record<string, ReactNode> = {
inbox: <Inbox size={20} />, inbox: <Inbox size={20} />,
@@ -230,6 +231,22 @@ function FolderRow({ mailbox: m, label, depth, hasChildren, open, hiddenUnread,
/* ignore */ /* 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) => { const onDragStart = (e: DragEvent) => {
e.dataTransfer.setData(FOLDER_MIME, m.id); e.dataTransfer.setData(FOLDER_MIME, m.id);
e.dataTransfer.effectAllowed = "move"; e.dataTransfer.effectAllowed = "move";
@@ -243,7 +260,10 @@ function FolderRow({ mailbox: m, label, depth, hasChildren, open, hiddenUnread,
href={`/mail/${m.id}`} 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" : ""}`} className={`nav-item folder-row depth-${Math.min(depth, 4)} ${currentId === m.id ? "active" : ""} ${unread ? "unread" : ""} ${dropping ? "drop-target" : ""} ${dragging ? "dragging" : ""}`}
title={label} 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} onDragStart={onDragStart}
onDragEnd={onFolderDragEnd} onDragEnd={onFolderDragEnd}
onDragOver={onDragOver} onDragOver={onDragOver}
+249 -14
View File
@@ -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 { 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 { useLocation } from "wouter";
import { useMail, type ListState } from "@/store/mail"; import { useMail, type ListState } from "@/store/mail";
import { dateTimeKey, useSettings } from "@/store/settings"; import { dateTimeKey, useSettings } from "@/store/settings";
@@ -8,11 +8,29 @@ import type { Email, Id } from "@/jmap/types";
import { formatListDate } from "@/lib/format"; import { formatListDate } from "@/lib/format";
import { canEmpty, confirmAndEmpty, emptyLabel } from "@/lib/emptyFolder"; import { canEmpty, confirmAndEmpty, emptyLabel } from "@/lib/emptyFolder";
import { displayName, shortName } from "@/lib/address"; 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 { MenuItem, MenuSep, MenuTitle, Popover, useMenu } from "@/ui/popover";
import { useCompose } from "@/store/compose"; 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"; 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<SwipeIcon, ReactNode> = {
archive: <Archive size={22} />,
delete: <Trash2 size={22} />,
spam: <AlertOctagon size={22} />,
"not-spam": <ShieldCheck size={22} />,
read: <MailOpen size={22} />,
unread: <Mail size={22} />,
star: <Star size={22} fill="currentColor" />,
unstar: <Star size={22} />,
move: <FolderInput size={22} />,
};
export interface ListActions { export interface ListActions {
archive: (rows?: Id[]) => Promise<void>; archive: (rows?: Id[]) => Promise<void>;
trash: (rows?: Id[]) => Promise<void>; trash: (rows?: Id[]) => Promise<void>;
@@ -50,10 +68,15 @@ export function MessageList({ title, list, openThreadId, focusId, setFocusId, on
const settings = useSettings((s) => s.settings); const settings = useSettings((s) => s.settings);
const updateSettings = useSettings((s) => s.update); const updateSettings = useSettings((s) => s.update);
const parentRef = useRef<HTMLDivElement>(null); const parentRef = useRef<HTMLDivElement>(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<HTMLDivElement | null>(null);
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const isTouch = useIsTouch();
const [paneWidth, setPaneWidth] = useState(0); const [paneWidth, setPaneWidth] = useState(0);
useEffect(() => { useEffect(() => {
const el = parentRef.current; const el = scrollEl;
if (!el) return; if (!el) return;
const ro = new ResizeObserver((entries) => { const ro = new ResizeObserver((entries) => {
const w = entries[0]?.contentRect.width ?? 0; const w = entries[0]?.contentRect.width ?? 0;
@@ -61,7 +84,7 @@ export function MessageList({ title, list, openThreadId, focusId, setFocusId, on
}); });
ro.observe(el); ro.observe(el);
return () => ro.disconnect(); return () => ro.disconnect();
}, []); }, [scrollEl]);
const twoLine = isMobile || (paneWidth > 0 && paneWidth < 640); const twoLine = isMobile || (paneWidth > 0 && paneWidth < 640);
const ctxMenu = useMenu(); const ctxMenu = useMenu();
const [ctxRow, setCtxRow] = useState<Id | null>(null); const [ctxRow, setCtxRow] = useState<Id | null>(null);
@@ -69,6 +92,11 @@ export function MessageList({ title, list, openThreadId, focusId, setFocusId, on
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const [filterFrom, setFilterFrom] = useState<Email | null>(null); const [filterFrom, setFilterFrom] = useState<Email | null>(null);
const lastClick = useRef<Id | null>(null); const lastClick = useRef<Id | null>(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 ids = list?.ids ?? [];
const selCount = Object.keys(selected).length; 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(); if (last.index >= ids.length - 5 && !list.loadingMore && !list.exhausted && !list.loading) void loadMore();
}, [items, ids.length, list, loadMore]); }, [items, ids.length, list, loadMore]);
// Pull-to-refresh-ish: manual refresh button const doRefresh = useCallback(async () => {
const doRefresh = async () => {
setRefreshing(true); setRefreshing(true);
await refreshList(); await refreshList();
await useMail.getState().loadMailboxes(); await useMail.getState().loadMailboxes();
setRefreshing(false); 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( const onRowClick = useCallback(
(e: MouseEvent, rowId: Id) => { (e: MouseEvent, rowId: Id) => {
@@ -143,6 +182,46 @@ export function MessageList({ title, list, openThreadId, focusId, setFocusId, on
[ctxMenu, setFocusId], [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 ctxTargets = useMemo(() => (ctxRow ? (selected[ctxRow] ? Object.keys(selected) : [ctxRow]) : []), [ctxRow, selected]);
const allSelected = ids.length > 0 && ids.every((id) => selected[id]); const allSelected = ids.length > 0 && ids.every((id) => selected[id]);
const someUnread = ctxTargets.some((id) => !emails[id]?.keywords.$seen); const someUnread = ctxTargets.some((id) => !emails[id]?.keywords.$seen);
@@ -178,6 +257,31 @@ export function MessageList({ title, list, openThreadId, focusId, setFocusId, on
<button className="icon-btn hide-mobile" title="Mark as unread (Shift+U)" onClick={() => void actions.read(false)}><Mail size={19} /></button> <button className="icon-btn hide-mobile" title="Mark as unread (Shift+U)" onClick={() => void actions.read(false)}><Mail size={19} /></button>
<button className="icon-btn" title="Move to (v)" onClick={() => actions.move()}><FolderInput size={19} /></button> <button className="icon-btn" title="Move to (v)" onClick={() => actions.move()}><FolderInput size={19} /></button>
<button className="icon-btn hide-mobile" title="Labels (l)" onClick={(e) => actions.label(undefined, { x: e.clientX, y: e.clientY })}><Tag size={19} /></button> <button className="icon-btn hide-mobile" title="Labels (l)" onClick={(e) => actions.label(undefined, { x: e.clientX, y: e.clientY })}><Tag size={19} /></button>
{/*
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 && (
<>
<span className="spacer" />
<button className="icon-btn" onClick={selMenu.open} aria-label="More actions"><MoreVertical size={19} /></button>
<Popover anchor={selMenu.anchor} onClose={selMenu.close} align="end" width={240}>
<MenuItem
icon={mailbox?.role === "junk" ? <ShieldCheck size={16} /> : <AlertOctagon size={16} />}
label={mailbox?.role === "junk" ? "Not spam" : "Report spam"}
onClick={() => void actions.spam()}
/>
<MenuItem icon={<Mail size={16} />} label="Mark as unread" onClick={() => void actions.read(false)} />
<MenuItem icon={<Tag size={16} />} label="Label…" onClick={() => actions.label(undefined, { x: window.innerWidth / 2, y: 100 })} />
<MenuSep />
<MenuItem icon={<CheckSquare size={16} />} label="Select all" onClick={selectAll} />
<MenuItem icon={<X size={16} />} label="Clear selection" onClick={clearSelection} />
</Popover>
</>
)}
</> </>
) : ( ) : (
<> <>
@@ -237,7 +341,34 @@ export function MessageList({ title, list, openThreadId, focusId, setFocusId, on
<button onClick={() => void confirmAndEmpty(mailbox)}>Delete all spam now</button> <button onClick={() => void confirmAndEmpty(mailbox)}>Delete all spam now</button>
</div> </div>
)} )}
<div ref={parentRef} className={`mail-list ${selCount ? "has-selection" : ""} ${twoLine ? "two-line" : ""} ${settings.density === "compact" ? "compact" : ""}`} tabIndex={-1}> <div
ref={(el) => {
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 && (
<div className="ptr" aria-hidden="true">
<span
className={`ptr-dial ${pull.armed || refreshing ? "armed" : ""}`}
style={{ transform: `translate(-50%, ${Math.max(0, pull.y - 36)}px)`, opacity: Math.min(1, pull.y / 20), transition: pull.live ? "none" : "transform .22s var(--ease), opacity .22s" }}
>
<RefreshCw size={18} className={refreshing ? "spin" : ""} style={refreshing ? undefined : { transform: `rotate(${Math.round((pull.y / PULL_TRIGGER) * 270)}deg)` }} />
</span>
</div>
)}
<div
className="mail-list-pull"
style={pull.y ? { transform: `translateY(${pull.y}px)`, transition: pull.live ? "none" : "transform .22s var(--ease)" } : { transition: "transform .22s var(--ease)" }}
>
{list?.loading && ids.length === 0 ? ( {list?.loading && ids.length === 0 ? (
<div style={{ padding: 8 }}> <div style={{ padding: 8 }}>
{[...Array(12)].map((_, i) => ( {[...Array(12)].map((_, i) => (
@@ -267,9 +398,28 @@ export function MessageList({ title, list, openThreadId, focusId, setFocusId, on
const e = emails[id]; const e = emails[id];
if (!e) return <div key={id} style={{ position: "absolute", top: vi.start, height: vi.size }} />; if (!e) return <div key={id} style={{ position: "absolute", top: vi.start, height: vi.size }} />;
const thread = list?.collapseThreads ? threads[e.threadId] : undefined; const thread = list?.collapseThreads ? threads[e.threadId] : undefined;
const strip = swiping?.id === id ? swiping : null;
return ( return (
<Fragment key={id}>
{/*
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 && (
<div
className={`msg-swipe ${strip.desc.tone} ${strip.armed ? "armed" : ""} ${strip.dir === 1 ? "from-left" : "from-right"}`}
style={{ top: vi.start, height: vi.size }}
aria-hidden="true"
>
<span className="msg-swipe-act">
{SWIPE_ICON[strip.desc.icon]}
<span>{strip.desc.label}</span>
</span>
</div>
)}
<Row <Row
key={id}
email={e} email={e}
threadEmails={thread ? thread.emailIds.map((x) => emails[x]).filter((x): x is Email => Boolean(x)) : undefined} threadEmails={thread ? thread.emailIds.map((x) => emails[x]).filter((x): x is Email => Boolean(x)) : undefined}
top={vi.start} top={vi.start}
@@ -291,12 +441,21 @@ export function MessageList({ title, list, openThreadId, focusId, setFocusId, on
onTrash={(rowId) => void actions.trash([rowId])} onTrash={(rowId) => void actions.trash([rowId])}
onRead={(rowId, read) => void actions.read(read, [rowId])} onRead={(rowId, read) => void actions.read(read, [rowId])}
selectedIds={selected} selectedIds={selected}
touch={isTouch}
role={mailbox?.role ?? null}
swipeLeft={settings.swipeLeft}
swipeRight={settings.swipeRight}
onLongPress={onLongPress}
onSwipeState={onSwipeState}
onSwipeFire={fireSwipe}
/> />
</Fragment>
); );
})} })}
</div> </div>
)} )}
</div> </div>
</div>
<Popover anchor={ctxMenu.anchor} onClose={ctxMenu.close} width={250}> <Popover anchor={ctxMenu.anchor} onClose={ctxMenu.close} width={250}>
<MenuItem icon={<Reply size={16} />} label="Reply" onClick={() => { const e = ctxRow ? emails[ctxRow] : undefined; if (e) void useCompose.getState().reply(e, "reply"); }} /> <MenuItem icon={<Reply size={16} />} label="Reply" onClick={() => { const e = ctxRow ? emails[ctxRow] : undefined; if (e) void useCompose.getState().reply(e, "reply"); }} />
<MenuItem icon={<Forward size={16} />} label="Forward" onClick={() => { const e = ctxRow ? emails[ctxRow] : undefined; if (e) void useCompose.getState().reply(e, "forward"); }} /> <MenuItem icon={<Forward size={16} />} label="Forward" onClick={() => { const e = ctxRow ? emails[ctxRow] : undefined; if (e) void useCompose.getState().reply(e, "forward"); }} />
@@ -339,9 +498,17 @@ interface RowProps {
onArchive: (id: Id) => void; onArchive: (id: Id) => void;
onTrash: (id: Id) => void; onTrash: (id: Id) => void;
onRead: (id: Id, read: boolean) => 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<void>;
} }
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); const labels = useSettings((s) => s.settings.labels);
// Subscribed purely so the row re-renders when the date format changes. // Subscribed purely so the row re-renders when the date format changes.
useSettings((s) => dateTimeKey(s.settings)); 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 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])); 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<HTMLDivElement>(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 onDragStart = (ev: DragEvent) => {
const ids = selectedIds[e.id] ? Object.keys(selectedIds) : [e.id]; const ids = selectedIds[e.id] ? Object.keys(selectedIds) : [e.id];
// include thread emails in scope // include thread emails in scope
@@ -391,13 +619,20 @@ const Row = memo(function Row({ email: e, threadEmails, top, height, selected, f
return ( return (
<div <div
className={`msg-row ${unread ? "unread" : ""} ${selected ? "selected" : ""} ${focused ? "focused" : ""} ${open ? "open" : ""}`} ref={rowRef}
style={{ top, height }} className={`msg-row ${unread ? "unread" : ""} ${selected ? "selected" : ""} ${focused ? "focused" : ""} ${open ? "open" : ""} ${dx ? "swiping" : ""}`}
style={{ top, height, ...(dx ? { transform: `translateX(${dx}px)` } : {}), transition: gliding ? "transform .18s var(--ease)" : "none" }}
data-row-id={e.id} data-row-id={e.id}
onClick={(ev) => onClick(ev, e.id)} onClick={(ev) => onClick(ev, e.id)}
onContextMenu={(ev) => onContext(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} onDragStart={onDragStart}
{...gesture}
role="row" role="row"
aria-selected={selected} aria-selected={selected}
> >
+15 -2
View File
@@ -7,10 +7,11 @@ import type { Email, Id } from "@/jmap/types";
import { MessageView } from "./MessageView"; import { MessageView } from "./MessageView";
import type { ListActions } from "./MessageList"; import type { ListActions } from "./MessageList";
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover"; 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 { client } from "@/jmap/client";
import { LabelPicker } from "./LabelPicker"; import { LabelPicker } from "./LabelPicker";
import { threadScrollTarget } from "@/lib/threadScroll"; import { threadScrollTarget } from "@/lib/threadScroll";
import { useEdgeBack } from "@/lib/touch";
/** How long the opening scroll keeps its place while bodies and images land. */ /** How long the opening scroll keeps its place while bodies and images land. */
const HOLD_MS = 2000; const HOLD_MS = 2000;
@@ -42,6 +43,18 @@ export function ThreadView({ threadId, mailboxId, onBack, actions, onNavigate, h
const moreMenu = useMenu(); const moreMenu = useMenu();
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const markTimer = useRef<number | null>(null); const markTimer = useRef<number | null>(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<HTMLDivElement | null>(null);
useEdgeBack(viewEl, onBack, isTouch && narrow);
// Load // Load
useEffect(() => { useEffect(() => {
@@ -215,7 +228,7 @@ export function ThreadView({ threadId, mailboxId, onBack, actions, onNavigate, h
const accountId = useMail((s) => s.accountId); const accountId = useMail((s) => s.accountId);
return ( return (
<div className="thread-view"> <div className="thread-view" ref={setViewEl}>
<div className="thread-toolbar"> <div className="thread-toolbar">
<button className="icon-btn" onClick={onBack} aria-label="Back to list" title="Back (u)"> <button className="icon-btn" onClick={onBack} aria-label="Back to list" title="Back (u)">
<ArrowLeft size={20} /> <ArrowLeft size={20} />
+40 -1
View File
@@ -1,5 +1,6 @@
import { useSettings } from "@/store/settings"; import { useSettings } from "@/store/settings";
import { Switch } from "@/ui/misc"; import { Switch, useIsTouch } from "@/ui/misc";
import { SWIPE_CHOICES, type SwipeAction } from "@/lib/swipe";
/** /**
* The theme cards, each previewing the background it actually paints. Kept as * The theme cards, each previewing the background it actually paints. Kept as
@@ -27,6 +28,7 @@ const ACCENTS = [
export function AppearanceSettings() { export function AppearanceSettings() {
const s = useSettings((st) => st.settings); const s = useSettings((st) => st.settings);
const update = useSettings((st) => st.update); const update = useSettings((st) => st.update);
const isTouch = useIsTouch();
return ( return (
<div> <div>
<h1>Appearance</h1> <h1>Appearance</h1>
@@ -75,6 +77,43 @@ export function AppearanceSettings() {
</select> </select>
</div> </div>
</div> </div>
<h2>Swiping</h2>
<p className="hint">
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.
</p>
<div className="field-row">
<div className="field">
<label htmlFor="swipe-right">Swipe right</label>
<select id="swipe-right" className="select" value={s.swipeRight} onChange={(e) => update({ swipeRight: e.target.value as SwipeAction })}>
{SWIPE_CHOICES.map((c) => (
<option key={c.value} value={c.value}>{c.label}</option>
))}
</select>
</div>
<div className="field">
<label htmlFor="swipe-left">Swipe left</label>
<select id="swipe-left" className="select" value={s.swipeLeft} onChange={(e) => update({ swipeLeft: e.target.value as SwipeAction })}>
{SWIPE_CHOICES.map((c) => (
<option key={c.value} value={c.value}>{c.label}</option>
))}
</select>
</div>
</div>
{/*
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 && (
<p className="hint">
This screen has no touchscreen, so nothing here changes what it does. Your phone or tablet will pick these up.
</p>
)}
<p className="hint">
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.
</p>
<h2>Sidebar</h2> <h2>Sidebar</h2>
<Switch checked={s.labelsSidebar} onChange={(v) => update({ labelsSidebar: v })} label="Show labels in the sidebar" /> <Switch checked={s.labelsSidebar} onChange={(v) => update({ labelsSidebar: v })} label="Show labels in the sidebar" />
<Switch checked={s.showHiddenFolders} onChange={(v) => update({ showHiddenFolders: v })} label="Show unsubscribed (hidden) folders" /> <Switch checked={s.showHiddenFolders} onChange={(v) => update({ showHiddenFolders: v })} label="Show unsubscribed (hidden) folders" />