Merge pull request #141 from Coffey-Labs/mobile-gestures

Give the mail list the gestures a phone already has
This commit is contained in:
Coffey Labs
2026-08-31 06:57:26 -07:00
committed by GitHub
12 changed files with 1235 additions and 86 deletions
+1
View File
@@ -51,6 +51,7 @@ More, including the mobile layout, on [ihasmail.org](https://ihasmail.org/#scree
- **Files** — JMAP FileNode: browse, upload, download, rename, move, delete - **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}
+304 -69
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,65 +341,120 @@ 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
{list?.loading && ids.length === 0 ? ( ref={(el) => {
<div style={{ padding: 8 }}> parentRef.current = el;
{[...Array(12)].map((_, i) => ( setScrollEl(el);
<div key={i} className="row" style={{ height: rowHeight, padding: "0 8px", gap: 12 }}> }}
<span className="skeleton" style={{ width: 32, height: 32, borderRadius: 16 }} /> className={`mail-list ${selCount ? "has-selection" : ""} ${twoLine ? "two-line" : ""} ${settings.density === "compact" ? "compact" : ""}`}
<span className="skeleton" style={{ width: 140, height: 14 }} /> tabIndex={-1}
<span className="skeleton grow" style={{ height: 14 }} /> >
<span className="skeleton" style={{ width: 50, height: 12 }} /> {/*
</div> 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
</div> nobody is pulling — and so it stays put while the content below it
) : ids.length === 0 && list && !list.loading ? ( comes down.
<Empty icon={isSearch ? <Search size={40} /> : <Inbox size={40} />} title={isSearch ? "No results" : mailbox?.role === "inbox" ? "You're all caught up" : "Nothing here"}> */}
{isSearch ? "Try different keywords or filters." : mailbox?.role === "inbox" ? "No new mail in your inbox." : "This folder is empty."} {isTouch && (
</Empty> <div className="ptr" aria-hidden="true">
) : ( <span
<div className="mail-list-inner" style={{ height: virtualizer.getTotalSize() }}> className={`ptr-dial ${pull.armed || refreshing ? "armed" : ""}`}
{items.map((vi) => { 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" }}
const id = ids[vi.index]; >
if (!id) { <RefreshCw size={18} className={refreshing ? "spin" : ""} style={refreshing ? undefined : { transform: `rotate(${Math.round((pull.y / PULL_TRIGGER) * 270)}deg)` }} />
return ( </span>
<div key="loader" className="list-footer" style={{ position: "absolute", top: vi.start, left: 0, right: 0, height: vi.size }}>
{list?.loadingMore ? <span className="spinner" style={{ display: "inline-block" }} /> : ""}
</div>
);
}
const e = emails[id];
if (!e) return <div key={id} style={{ position: "absolute", top: vi.start, height: vi.size }} />;
const thread = list?.collapseThreads ? threads[e.threadId] : undefined;
return (
<Row
key={id}
email={e}
threadEmails={thread ? thread.emailIds.map((x) => emails[x]).filter((x): x is Email => Boolean(x)) : undefined}
top={vi.start}
height={vi.size}
selected={Boolean(selected[id])}
focused={focusId === id}
open={openThreadId === e.threadId}
twoLine={twoLine}
showAvatar={settings.showAvatars}
showPreview={settings.showPreview}
isDrafts={isDrafts}
mailboxId={mailboxId}
isSent={mailbox?.role === "sent"}
onClick={onRowClick}
onContext={onContext}
onSelect={(rowId, on) => { select([rowId], on); lastClick.current = rowId; }}
onStar={(rowId, on) => void actions.star(on, [rowId])}
onArchive={(rowId) => void actions.archive([rowId])}
onTrash={(rowId) => void actions.trash([rowId])}
onRead={(rowId, read) => void actions.read(read, [rowId])}
selectedIds={selected}
/>
);
})}
</div> </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 ? (
<div style={{ padding: 8 }}>
{[...Array(12)].map((_, i) => (
<div key={i} className="row" style={{ height: rowHeight, padding: "0 8px", gap: 12 }}>
<span className="skeleton" style={{ width: 32, height: 32, borderRadius: 16 }} />
<span className="skeleton" style={{ width: 140, height: 14 }} />
<span className="skeleton grow" style={{ height: 14 }} />
<span className="skeleton" style={{ width: 50, height: 12 }} />
</div>
))}
</div>
) : ids.length === 0 && list && !list.loading ? (
<Empty icon={isSearch ? <Search size={40} /> : <Inbox size={40} />} title={isSearch ? "No results" : mailbox?.role === "inbox" ? "You're all caught up" : "Nothing here"}>
{isSearch ? "Try different keywords or filters." : mailbox?.role === "inbox" ? "No new mail in your inbox." : "This folder is empty."}
</Empty>
) : (
<div className="mail-list-inner" style={{ height: virtualizer.getTotalSize() }}>
{items.map((vi) => {
const id = ids[vi.index];
if (!id) {
return (
<div key="loader" className="list-footer" style={{ position: "absolute", top: vi.start, left: 0, right: 0, height: vi.size }}>
{list?.loadingMore ? <span className="spinner" style={{ display: "inline-block" }} /> : ""}
</div>
);
}
const e = emails[id];
if (!e) return <div key={id} style={{ position: "absolute", top: vi.start, height: vi.size }} />;
const thread = list?.collapseThreads ? threads[e.threadId] : undefined;
const strip = swiping?.id === id ? swiping : null;
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
email={e}
threadEmails={thread ? thread.emailIds.map((x) => emails[x]).filter((x): x is Email => Boolean(x)) : undefined}
top={vi.start}
height={vi.size}
selected={Boolean(selected[id])}
focused={focusId === id}
open={openThreadId === e.threadId}
twoLine={twoLine}
showAvatar={settings.showAvatars}
showPreview={settings.showPreview}
isDrafts={isDrafts}
mailboxId={mailboxId}
isSent={mailbox?.role === "sent"}
onClick={onRowClick}
onContext={onContext}
onSelect={(rowId, on) => { select([rowId], on); lastClick.current = rowId; }}
onStar={(rowId, on) => void actions.star(on, [rowId])}
onArchive={(rowId) => void actions.archive([rowId])}
onTrash={(rowId) => void actions.trash([rowId])}
onRead={(rowId, read) => void actions.read(read, [rowId])}
selectedIds={selected}
touch={isTouch}
role={mailbox?.role ?? null}
swipeLeft={settings.swipeLeft}
swipeRight={settings.swipeRight}
onLongPress={onLongPress}
onSwipeState={onSwipeState}
onSwipeFire={fireSwipe}
/>
</Fragment>
);
})}
</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"); }} />
@@ -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" />