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