@remit/ui 0.0.18 → 0.0.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/ui",
3
- "version": "0.0.18",
3
+ "version": "0.0.20",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -18,9 +18,11 @@
18
18
  "@fontsource-variable/geist": "^5",
19
19
  "@fontsource-variable/hanken-grotesk": "^5",
20
20
  "@fontsource-variable/inter": "^5",
21
+ "@react-types/shared": "^3.36.0",
21
22
  "clsx": "^2",
22
23
  "dompurify": "^3.4.7",
23
24
  "lucide-react": "^0.468",
25
+ "react-aria": "^3.50.0",
24
26
  "react-resizable-panels": "^2.1.9",
25
27
  "react-simple-pull-to-refresh": "^1.3.4",
26
28
  "tailwind-merge": "^2",
@@ -33,7 +35,9 @@
33
35
  },
34
36
  "devDependencies": {
35
37
  "@storybook/react": "^9",
38
+ "@types/jsdom": "^28.0.3",
36
39
  "@types/react-dom": "^19",
40
+ "jsdom": "^29.1.1",
37
41
  "react": "^19",
38
42
  "react-dom": "^19",
39
43
  "typescript": "*"
@@ -15,7 +15,7 @@ export const quarantineDemoEntries: readonly QuarantineEntry[] = [
15
15
  mailboxRole: "inbox",
16
16
  mailboxPath: "INBOX",
17
17
  failureStage: "BodyParse",
18
- failureCode: "UnterminatedMultipartBoundary",
18
+ failureCode: "UnreadableBody",
19
19
  failureMessage: "multipart boundary was never closed",
20
20
  failurePartPath: undefined,
21
21
  quarantinedAt: Date.parse("2026-07-18T09:12:00Z"),
@@ -67,7 +67,7 @@ export const quarantineDemoEntries: readonly QuarantineEntry[] = [
67
67
  mailboxRole: undefined,
68
68
  mailboxPath: "Clients/Acme Holdings",
69
69
  failureStage: "BodyParse",
70
- failureCode: "TruncatedBody",
70
+ failureCode: "UnreadableBody",
71
71
  failureMessage: "stream ended before the declared body length",
72
72
  failurePartPath: undefined,
73
73
  quarantinedAt: Date.parse("2026-07-19T06:03:00Z"),
@@ -92,7 +92,7 @@ export const quarantineDemoEntries: readonly QuarantineEntry[] = [
92
92
  mailboxRole: "inbox",
93
93
  mailboxPath: "INBOX",
94
94
  failureStage: "BodyParse",
95
- failureCode: "TruncatedBody",
95
+ failureCode: "UnreadableBody",
96
96
  failureMessage: "connection closed mid-body",
97
97
  failurePartPath: undefined,
98
98
  quarantinedAt: Date.parse("2026-07-19T11:27:00Z"),
@@ -20,7 +20,7 @@ describe("formatQuarantineReport", () => {
20
20
  it("names the stage, the code and the build that failed", () => {
21
21
  const report = formatQuarantineReport(entry);
22
22
  assert.match(report, /BodyParse/);
23
- assert.match(report, /UnterminatedMultipartBoundary/);
23
+ assert.match(report, /UnreadableBody/);
24
24
  assert.match(report, /worker 1\.0\.0/);
25
25
  });
26
26
 
@@ -186,7 +186,7 @@ describe("quarantineReportSections", () => {
186
186
  describe("quarantineIssueTitle", () => {
187
187
  it("carries only the closed vocabulary", () => {
188
188
  const title = quarantineIssueTitle(entry);
189
- assert.match(title, /UnterminatedMultipartBoundary/);
189
+ assert.match(title, /UnreadableBody/);
190
190
  assert.match(title, /BodyParse/);
191
191
  assert.doesNotMatch(title, /joan@/);
192
192
  });
@@ -3,31 +3,17 @@ import type { FolderRole } from "./folder-role.js";
3
3
  /**
4
4
  * The pipeline step that refused the message.
5
5
  *
6
- * One member, because **no catch site on the sync path can currently tell a
7
- * parse failure from an infrastructure failure**. The per-message frame in
8
- * `body-sync.ts` wraps the S3 body write, the parsed-body cache write, the
9
- * body-part `pMap` and DynamoDB upsert, the placement move (SQS + DynamoDB)
10
- * and the label and counter writes, alongside the `simpleParser` call; only
11
- * connection drops are filtered out of it.
6
+ * One member, behind a real narrowing rather than a promise of one: the body
7
+ * parse has its own try block around the parse call and nothing else, so an
8
+ * error out of it can only be the message. S3, queue and database failures
9
+ * propagate and never become a record here quarantining one would set a
10
+ * message aside for an outage, tell the user it was unreadable, and invite a
11
+ * public GitHub issue about it.
12
12
  *
13
- * **Precondition on Phase 3**: narrow the try block to the parse call before
14
- * quarantining anything. Quarantining at the frame as it stands would set a
15
- * message aside for a DynamoDB throttle, tell the user it was unreadable, and
16
- * invite a public GitHub issue for an outage — the same defect as naming an
17
- * S3 write `AttachmentExtract`, one layer up.
18
- *
19
- * There are three distinct parse sites, and a quarantine would have to
20
- * attribute them differently: the fresh-fetch path, `backfillClassification`
21
- * (its own parse over a different result list), and the header parse in
22
- * `imapflow-connection.ts`, which is swallowed and treated as "no thread
23
- * parent". Stages are added as those sites are separated — never ahead of it.
24
- *
25
- * The stages this replaced could not fire at all: an unparseable Date falls
26
- * back to INTERNALDATE, and an unrecognized MIME type maps to a safe default
27
- * rather than throwing. Unparseable addresses are dropped with a `continue`
28
- * and the message is written anyway — so that defect is reached but discarded,
29
- * never raised, which is a reason to have no `AddressParse` stage but not the
30
- * reason that it cannot happen.
13
+ * A message that arrives with no ENVELOPE is deliberately not a member:
14
+ * nothing can tell that apart from the FETCH row glitching, and the IMAP
15
+ * client is far more often the cause than the message. The sync path holds its
16
+ * cursor and retries instead.
31
17
  */
32
18
  export type QuarantineFailureStage = "BodyParse";
33
19
 
@@ -36,13 +22,15 @@ export type QuarantineFailureStage = "BodyParse";
36
22
  *
37
23
  * Closed, because `quarantineIssueTitle` interpolates it into a public issue
38
24
  * title: a `string` here would make the one field whose publishability is
39
- * asserted rather than derived into free text. Grows with the narrowing
40
- * described on `QuarantineFailureStage`, and no faster.
25
+ * asserted rather than derived into free text.
26
+ *
27
+ * Total and small. The body parser reports its refusals as free text with no
28
+ * stable type or code, so a member exists only where the writer can identify
29
+ * the defect without reading parser prose; anything it cannot name is
30
+ * `UnreadableBody`, and the parser's own words stay in `failureMessage`, which
31
+ * is shown on screen and never published.
41
32
  */
42
- export type QuarantineFailureCode =
43
- | "UnterminatedMultipartBoundary"
44
- | "UnknownCharset"
45
- | "TruncatedBody";
33
+ export type QuarantineFailureCode = "UnknownCharset" | "UnreadableBody";
46
34
 
47
35
  /**
48
36
  * One node of the message's MIME tree, in a pre-order walk.
@@ -61,7 +61,7 @@ describe("QuarantineBugDialog", () => {
61
61
  issueUrl: ISSUE_URL,
62
62
  }),
63
63
  );
64
- assert.match(html, /UnterminatedMultipartBoundary/);
64
+ assert.match(html, /UnreadableBody/);
65
65
  assert.match(html, /attachment names, or the parser's own error text/);
66
66
  assert.match(html, /Copy report/);
67
67
  });
@@ -0,0 +1,258 @@
1
+ /**
2
+ * SwipeableRow — jsdom gesture tests against the real react-aria long-press
3
+ * wiring interacting with axis arbitration.
4
+ *
5
+ * The one this guards against: react-aria's `useLongPress` dispatches its
6
+ * own synthetic `pointercancel` right before calling `onLongPress` (to
7
+ * preempt other pointer consumers). SwipeableRow's `onPointerCancel` was
8
+ * originally aliased straight to `onPointerUp`, whose "no axis claimed"
9
+ * branch reads as a tap and calls `onOpen`/`onToggleCheck` — so a clean long
10
+ * press would fire onLongPress AND a spurious onOpen in the same gesture.
11
+ * The fix tags SwipeableRow's own axis-abort cancel so it can tell the two
12
+ * apart; these tests exercise both paths against the real hook, not a
13
+ * description of the fix.
14
+ */
15
+
16
+ import assert from "node:assert/strict";
17
+ import { after, afterEach, before, beforeEach, describe, it } from "node:test";
18
+ import type { JSDOM } from "jsdom";
19
+ import { act, createElement } from "react";
20
+ import { createRoot, type Root } from "react-dom/client";
21
+ import type { ThreadRowData } from "./app-shell-types.js";
22
+ import { SwipeableRow, type SwipePeek } from "./swipeable-row.js";
23
+
24
+ const THRESHOLD_WAIT = 560; // default react-aria threshold (500ms) + margin
25
+
26
+ const thread: ThreadRowData = {
27
+ id: "thread-1",
28
+ accountId: "account-1",
29
+ fromName: "Alex Rivera",
30
+ fromEmail: "alex@example.com",
31
+ subject: "Q3 planning notes",
32
+ snippet: "Notes from the planning session.",
33
+ timeLabel: "9:42",
34
+ isRead: false,
35
+ };
36
+
37
+ let dom: JSDOM;
38
+ let container: HTMLElement;
39
+ let root: Root;
40
+
41
+ before(async () => {
42
+ const { JSDOM: JSDOMCtor } = await import("jsdom");
43
+ dom = new JSDOMCtor(
44
+ "<!doctype html><html><body><div id=root></div></body></html>",
45
+ { url: "http://localhost/", pretendToBeVisual: true },
46
+ );
47
+ globalThis.window = dom.window as unknown as typeof globalThis.window;
48
+ globalThis.document = dom.window.document;
49
+ globalThis.HTMLElement = dom.window.HTMLElement;
50
+ globalThis.Element = dom.window.Element;
51
+ globalThis.SVGElement = dom.window.SVGElement;
52
+ globalThis.PointerEvent = dom.window.PointerEvent;
53
+ // jsdom does not implement the pointer-capture methods at all (not even
54
+ // as no-ops) — SwipeableRow calls setPointerCapture once it claims the
55
+ // horizontal axis, so an unpolyfilled call throws mid-gesture.
56
+ dom.window.Element.prototype.setPointerCapture = () => undefined;
57
+ dom.window.Element.prototype.releasePointerCapture = () => undefined;
58
+ dom.window.Element.prototype.hasPointerCapture = () => false;
59
+ Object.defineProperty(globalThis, "navigator", {
60
+ value: dom.window.navigator,
61
+ configurable: true,
62
+ });
63
+ (
64
+ globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
65
+ ).IS_REACT_ACT_ENVIRONMENT = true;
66
+ });
67
+
68
+ after(() => {
69
+ dom.window.close();
70
+ });
71
+
72
+ beforeEach(() => {
73
+ container = dom.window.document.getElementById(
74
+ "root",
75
+ ) as unknown as HTMLElement;
76
+ container.innerHTML = "";
77
+ root = createRoot(container);
78
+ });
79
+
80
+ afterEach(() => {
81
+ act(() => {
82
+ root.unmount();
83
+ });
84
+ });
85
+
86
+ interface Handlers {
87
+ onLongPress: () => void;
88
+ onOpen: () => void;
89
+ onPeek: (next: SwipePeek) => void;
90
+ }
91
+
92
+ function mount(handlers: Handlers) {
93
+ act(() => {
94
+ root.render(
95
+ createElement(SwipeableRow, {
96
+ thread,
97
+ selectionMode: false,
98
+ checked: false,
99
+ active: false,
100
+ peek: "none",
101
+ onPeek: handlers.onPeek,
102
+ onToggleCheck: () => undefined,
103
+ onLongPress: handlers.onLongPress,
104
+ onOpen: handlers.onOpen,
105
+ onAct: () => undefined,
106
+ }),
107
+ );
108
+ });
109
+ // The open affordance is the one button with no aria-label — the
110
+ // leading/trailing action buttons ("Mark as read" etc.) only render once
111
+ // peeked, but querying by absence of aria-label is stable at rest too.
112
+ const row = [...dom.window.document.querySelectorAll("button")].find(
113
+ (b) => !b.hasAttribute("aria-label"),
114
+ );
115
+ assert.ok(row, "open-affordance button did not mount");
116
+ return row;
117
+ }
118
+
119
+ // Each dispatch is wrapped in a synchronous act() so React commits the
120
+ // resulting state update before the next call reads it. Without this, a
121
+ // handler in the next dispatch can close over a stale pre-commit value (a
122
+ // real gap we hit developing this test: an unwrapped dispatch sequence read
123
+ // a stale `null` dragX in onPointerUp and mistook the just-completed swipe
124
+ // commit for a bare tap, firing a spurious onOpen).
125
+ function pointerDown(row: Element, x = 10, y = 10) {
126
+ act(() => {
127
+ row.dispatchEvent(
128
+ new dom.window.PointerEvent("pointerdown", {
129
+ bubbles: true,
130
+ pointerType: "touch",
131
+ pointerId: 1,
132
+ clientX: x,
133
+ clientY: y,
134
+ }),
135
+ );
136
+ });
137
+ }
138
+
139
+ function pointerMove(row: Element, x: number, y: number) {
140
+ act(() => {
141
+ row.dispatchEvent(
142
+ new dom.window.PointerEvent("pointermove", {
143
+ bubbles: true,
144
+ pointerType: "touch",
145
+ pointerId: 1,
146
+ clientX: x,
147
+ clientY: y,
148
+ }),
149
+ );
150
+ });
151
+ }
152
+
153
+ function pointerUp(row: Element) {
154
+ // Dispatched on the row, not document: SwipeableRow's own onPointerUp is a
155
+ // React prop on the row element, delegated via React's root-container
156
+ // listener — an event whose target is `document` (an ancestor of the
157
+ // root, not a descendant) never bubbles into that delegated listener.
158
+ // A real browser routes pointerup to the pointer-capturing element
159
+ // regardless of finger position once setPointerCapture has been called
160
+ // (the horizontal-swipe case here), so this also matches real behavior.
161
+ act(() => {
162
+ row.dispatchEvent(
163
+ new dom.window.PointerEvent("pointerup", {
164
+ bubbles: true,
165
+ pointerType: "touch",
166
+ pointerId: 1,
167
+ }),
168
+ );
169
+ });
170
+ }
171
+
172
+ function wait(ms: number) {
173
+ return act(() => new Promise((resolve) => setTimeout(resolve, ms)));
174
+ }
175
+
176
+ describe("SwipeableRow gesture wiring (react-aria long press + axis arbitration)", () => {
177
+ it("fires onLongPress on an unmoved press, with no spurious onOpen", async () => {
178
+ let longPressed = 0;
179
+ let opened = 0;
180
+ const row = mount({
181
+ onLongPress: () => longPressed++,
182
+ onOpen: () => opened++,
183
+ onPeek: () => undefined,
184
+ });
185
+
186
+ pointerDown(row);
187
+ await wait(THRESHOLD_WAIT);
188
+ pointerUp(row);
189
+
190
+ assert.equal(longPressed, 1);
191
+ assert.equal(
192
+ opened,
193
+ 0,
194
+ "react-aria's own pointercancel (dispatched right before onLongPress) must not be read as a tap-to-open",
195
+ );
196
+ });
197
+
198
+ it("a horizontal drag past the axis threshold cancels the long press and commits a swipe peek, not onOpen", async () => {
199
+ let longPressed = 0;
200
+ let opened = 0;
201
+ let committed: SwipePeek | undefined;
202
+ const row = mount({
203
+ onLongPress: () => longPressed++,
204
+ onOpen: () => opened++,
205
+ onPeek: (next) => {
206
+ committed = next;
207
+ },
208
+ });
209
+
210
+ pointerDown(row);
211
+ pointerMove(row, 50, 10); // dx=40, past SWIPE_AXIS_THRESHOLD(10) and >= half SWIPE_ACTION_WIDTH(36)
212
+ pointerUp(row);
213
+ await wait(THRESHOLD_WAIT);
214
+
215
+ assert.equal(
216
+ longPressed,
217
+ 0,
218
+ "long press must be cancelled once the horizontal axis is claimed",
219
+ );
220
+ assert.equal(opened, 0);
221
+ assert.equal(committed, "leading");
222
+ });
223
+
224
+ it("a vertical drag past the axis threshold cancels the long press and lets scroll win (no peek, no open)", async () => {
225
+ let longPressed = 0;
226
+ let opened = 0;
227
+ let peeked = 0;
228
+ const row = mount({
229
+ onLongPress: () => longPressed++,
230
+ onOpen: () => opened++,
231
+ onPeek: () => peeked++,
232
+ });
233
+
234
+ pointerDown(row);
235
+ pointerMove(row, 10, 50); // dy=40, past SWIPE_AXIS_THRESHOLD(10), vertical wins
236
+ pointerUp(row);
237
+ await wait(THRESHOLD_WAIT);
238
+
239
+ assert.equal(longPressed, 0);
240
+ assert.equal(opened, 0);
241
+ assert.equal(peeked, 0, "vertical scroll must not commit or reset a peek");
242
+ });
243
+
244
+ it("a small move within the axis threshold still allows the long press to fire", async () => {
245
+ let longPressed = 0;
246
+ const row = mount({
247
+ onLongPress: () => longPressed++,
248
+ onOpen: () => undefined,
249
+ onPeek: () => undefined,
250
+ });
251
+
252
+ pointerDown(row);
253
+ pointerMove(row, 13, 12); // dx=3, dy=2 — within SWIPE_AXIS_THRESHOLD(10)
254
+ await wait(THRESHOLD_WAIT);
255
+
256
+ assert.equal(longPressed, 1);
257
+ });
258
+ });
@@ -1,6 +1,9 @@
1
+ import type { DOMAttributes } from "@react-types/shared";
1
2
  import { Check, Mail, MailOpen, Trash2 } from "lucide-react";
2
3
  import { useRef, useState } from "react";
4
+ import { mergeProps } from "react-aria";
3
5
  import { cn } from "../lib/cn.js";
6
+ import { useLongPress } from "../lib/use-long-press.js";
4
7
  import type { ThreadRowData } from "./app-shell-types.js";
5
8
  import { Avatar } from "./avatar.js";
6
9
  import {
@@ -17,6 +20,19 @@ const SWIPE_ACTION_WIDTH = 72;
17
20
  * this a press is still a tap / long-press and vertical scroll wins. */
18
21
  const SWIPE_AXIS_THRESHOLD = 10;
19
22
 
23
+ /**
24
+ * Tags a pointercancel dispatched by this component's own axis arbitration
25
+ * (see `cancelLongPress` below) so `onPointerCancel` can tell it apart from
26
+ * one react-aria dispatches itself when its own long press fires, or a
27
+ * genuine browser-triggered cancel. Both of those arrive with no axis
28
+ * claimed yet and must reset gesture state silently; a tagged one arrives
29
+ * *because* `onPointerMove` just claimed an axis and is already handling
30
+ * gesture state inline, so `onPointerCancel` must ignore it — otherwise it
31
+ * re-reads a gesture with no axis claimed and mistakes the abort for a tap,
32
+ * firing a spurious onOpen/onToggleCheck.
33
+ */
34
+ const AXIS_CANCEL = "__swipeableRowAxisCancel";
35
+
20
36
  function peekOffset(peek: SwipePeek): number {
21
37
  if (peek === "leading") return SWIPE_ACTION_WIDTH;
22
38
  if (peek === "trailing") return -SWIPE_ACTION_WIDTH;
@@ -34,19 +50,17 @@ export function commitPeek(offset: number): SwipePeek {
34
50
 
35
51
  /**
36
52
  * Props the row's interactive (open) element must receive — the swipe gesture
37
- * handlers, the transform/transition style, the row body, plus an onClick that
38
- * suppresses navigation when the row is peeked. A consumer passes `linkComponent`
39
- * to render these on a real anchor (e.g. a router Link) so the open affordance is
40
- * a true `<a href>` keeping open-in-new-tab, middle-click, deep-link and a11y
41
- * instead of the default JS-only `<button onOpen>`.
53
+ * handlers (merged with react-aria's long-press props: pointer handlers,
54
+ * `aria-describedby`, and friends), the transform/transition style, the row
55
+ * body, plus an onClick that suppresses navigation when the row is peeked. A
56
+ * consumer passes `linkComponent` to render these on a real anchor (e.g. a
57
+ * router Link) so the open affordance is a true `<a href>` — keeping
58
+ * open-in-new-tab, middle-click, deep-link and a11y — instead of the default
59
+ * JS-only `<button onOpen>`.
42
60
  */
43
- export interface SwipeableRowOpenProps {
61
+ export interface SwipeableRowOpenProps extends DOMAttributes {
44
62
  className: string;
45
63
  style: React.CSSProperties;
46
- onPointerDown: (e: React.PointerEvent) => void;
47
- onPointerMove: (e: React.PointerEvent) => void;
48
- onPointerUp: () => void;
49
- onPointerCancel: () => void;
50
64
  /** Wire to the anchor's onClick. When the row is peeked it calls
51
65
  * preventDefault so a tap closes the peek instead of navigating; otherwise
52
66
  * it is a no-op and the anchor's native navigation proceeds. */
@@ -86,9 +100,6 @@ export function SwipeableRow({
86
100
  * deep-link/middle-click work); onOpen is not called for the tap. */
87
101
  linkComponent?: (props: SwipeableRowOpenProps) => React.ReactNode;
88
102
  }) {
89
- const pressTimer = useRef<ReturnType<typeof setTimeout> | undefined>(
90
- undefined,
91
- );
92
103
  const gesture = useRef<{
93
104
  startX: number;
94
105
  startY: number;
@@ -97,7 +108,23 @@ export function SwipeableRow({
97
108
  } | null>(null);
98
109
  const [dragX, setDragX] = useState<number | null>(null);
99
110
 
100
- const cancelLongPress = () => clearTimeout(pressTimer.current);
111
+ // Long-press timing/threshold and contextmenu/text-selection suppression
112
+ // are owned by react-aria; this component only arbitrates the swipe axis.
113
+ const { longPressProps } = useLongPress({
114
+ onLongPress,
115
+ isDisabled: selectionMode,
116
+ accessibilityDescription: "Select message",
117
+ });
118
+
119
+ // react-aria's usePress has no imperative "cancel" — a synthetic
120
+ // pointercancel is the mechanism it uses itself to abort other pointer
121
+ // consumers when its own long press fires. Reused here in reverse, tagged
122
+ // so onPointerCancel below can recognize it as ours (see AXIS_CANCEL).
123
+ const cancelLongPress = (e: React.PointerEvent) => {
124
+ const event = new PointerEvent("pointercancel", { bubbles: true });
125
+ (event as PointerEvent & Record<string, boolean>)[AXIS_CANCEL] = true;
126
+ e.currentTarget.dispatchEvent(event);
127
+ };
101
128
 
102
129
  const onPointerDown = (e: React.PointerEvent) => {
103
130
  gesture.current = {
@@ -106,13 +133,6 @@ export function SwipeableRow({
106
133
  axis: "none",
107
134
  moved: false,
108
135
  };
109
- // selection mode is tap-to-toggle only — no long-press, no swipe drag
110
- if (selectionMode) return;
111
- pressTimer.current = setTimeout(() => {
112
- gesture.current = null;
113
- setDragX(null);
114
- onLongPress();
115
- }, 500);
116
136
  };
117
137
 
118
138
  const onPointerMove = (e: React.PointerEvent) => {
@@ -125,14 +145,14 @@ export function SwipeableRow({
125
145
  if (Math.abs(dy) > SWIPE_AXIS_THRESHOLD && Math.abs(dy) > Math.abs(dx)) {
126
146
  // vertical scroll wins: abandon the swipe + long-press, let the list scroll
127
147
  g.axis = "vertical";
128
- cancelLongPress();
148
+ cancelLongPress(e);
129
149
  gesture.current = null;
130
150
  return;
131
151
  }
132
152
  if (Math.abs(dx) > SWIPE_AXIS_THRESHOLD) {
133
153
  g.axis = "horizontal";
134
154
  g.moved = true;
135
- cancelLongPress();
155
+ cancelLongPress(e);
136
156
  e.currentTarget.setPointerCapture(e.pointerId);
137
157
  }
138
158
  }
@@ -146,7 +166,6 @@ export function SwipeableRow({
146
166
  };
147
167
 
148
168
  const onPointerUp = () => {
149
- cancelLongPress();
150
169
  const g = gesture.current;
151
170
  gesture.current = null;
152
171
  const offset = dragX;
@@ -172,6 +191,28 @@ export function SwipeableRow({
172
191
  onOpen();
173
192
  };
174
193
 
194
+ // A genuine cancel — react-aria's own dispatch when its long press fires,
195
+ // or a real browser-triggered interruption — always resets silently, never
196
+ // as a tap-to-open/toggle/peek-commit. An axis-claim cancel (tagged, see
197
+ // AXIS_CANCEL) is a no-op here: onPointerMove already handled that gesture
198
+ // inline, either continuing to track it (horizontal) or nulling it itself
199
+ // (vertical) — see the comment there.
200
+ const onPointerCancel = (e: React.PointerEvent) => {
201
+ const tagged = (e.nativeEvent as unknown as Record<string, boolean>)[
202
+ AXIS_CANCEL
203
+ ];
204
+ if (tagged) return;
205
+ gesture.current = null;
206
+ setDragX(null);
207
+ };
208
+
209
+ const gestureProps = mergeProps(longPressProps, {
210
+ onPointerDown,
211
+ onPointerMove,
212
+ onPointerUp,
213
+ onPointerCancel,
214
+ });
215
+
175
216
  // A tap on a peeked anchor must close the peek, not navigate; suppress the
176
217
  // native click in that case. onPointerUp already snapped it closed.
177
218
  const onOpenClick = (e: { preventDefault: () => void }) => {
@@ -185,6 +226,12 @@ export function SwipeableRow({
185
226
  const interactiveClassName = cn(
186
227
  // opaque bg so the row occludes the action behind it until peeked
187
228
  "relative touch-pan-y bg-surface",
229
+ // This row's long press enters selection mode; without these, Android
230
+ // Chrome opens the link context menu / starts text selection and iOS
231
+ // Safari fires the callout, racing the app's handler. react-aria
232
+ // suppresses contextmenu/text-selection but not iOS's callout — it
233
+ // fires no cancelable event, so CSS is the only lever.
234
+ "select-none [-webkit-touch-callout:none]",
188
235
  comfortableRowClass({ active: checked || active }),
189
236
  );
190
237
  const interactiveStyle: React.CSSProperties = {
@@ -278,12 +325,9 @@ export function SwipeableRow({
278
325
  keep the button so a checkbox tap can't open a thread. */}
279
326
  {linkComponent && !selectionMode ? (
280
327
  linkComponent({
328
+ ...gestureProps,
281
329
  className: interactiveClassName,
282
330
  style: interactiveStyle,
283
- onPointerDown,
284
- onPointerMove,
285
- onPointerUp,
286
- onPointerCancel: onPointerUp,
287
331
  onOpenClick,
288
332
  children: body,
289
333
  })
@@ -293,10 +337,7 @@ export function SwipeableRow({
293
337
  type="button"
294
338
  role={selectionMode ? "checkbox" : undefined}
295
339
  aria-checked={selectionMode ? checked : undefined}
296
- onPointerDown={onPointerDown}
297
- onPointerMove={onPointerMove}
298
- onPointerUp={onPointerUp}
299
- onPointerCancel={onPointerUp}
340
+ {...gestureProps}
300
341
  className={interactiveClassName}
301
342
  style={interactiveStyle}
302
343
  >
package/src/index.ts CHANGED
@@ -1,3 +1,8 @@
1
+ // Re-exported so a consumer can compose useLongPress's longPressProps with its
2
+ // own DOM props (its pressProps include an onClick react-aria uses for its
3
+ // own bookkeeping; a plain object spread silently drops one side's handler
4
+ // instead of chaining them) without importing react-aria directly.
5
+ export { mergeProps } from "react-aria";
1
6
  export {
2
7
  AddressDisplay,
3
8
  type AddressDisplayProps,
@@ -446,3 +451,8 @@ export {
446
451
  sanitizeInlineStyle,
447
452
  sanitizeStyleElementCss,
448
453
  } from "./lib/email-sanitizer.js";
454
+ export {
455
+ type UseLongPressOptions,
456
+ type UseLongPressResult,
457
+ useLongPress,
458
+ } from "./lib/use-long-press.js";
@@ -0,0 +1,220 @@
1
+ /**
2
+ * use-long-press — exercises the real hook (react-aria's `useLongPress`)
3
+ * against a jsdom-mounted element, not a reimplementation of its logic. The
4
+ * hook this replaces (`packages/web-client/src/hooks/useLongPress.ts`) had a
5
+ * decoy test that reimplemented the timer/threshold logic locally and so
6
+ * gave zero regression coverage on the actual hook; these tests dispatch
7
+ * real PointerEvents at a real mounted node and assert on the callback and
8
+ * the DOM side effects react-aria owns (contextmenu suppression).
9
+ *
10
+ * jsdom is a devDependency scoped to this one test — react-aria's
11
+ * pointerdown → threshold timer → onLongPress path, its global
12
+ * pointerup/pointercancel listeners, and its contextmenu suppression all
13
+ * need a real `document`/`window`/`PointerEvent`, which `renderToString`
14
+ * (the pattern used elsewhere in this repo for presentational components)
15
+ * cannot exercise.
16
+ */
17
+
18
+ import assert from "node:assert/strict";
19
+ import { after, afterEach, before, beforeEach, describe, it } from "node:test";
20
+ import type { JSDOM } from "jsdom";
21
+ import { act, createElement } from "react";
22
+ import { createRoot, type Root } from "react-dom/client";
23
+ import { useLongPress } from "./use-long-press.js";
24
+
25
+ const THRESHOLD = 40;
26
+
27
+ let dom: JSDOM;
28
+ let container: HTMLElement;
29
+ let root: Root;
30
+
31
+ function Row(props: {
32
+ onLongPress: () => void;
33
+ isDisabled?: boolean;
34
+ accessibilityDescription?: string;
35
+ }) {
36
+ const { longPressProps } = useLongPress({
37
+ onLongPress: props.onLongPress,
38
+ isDisabled: props.isDisabled,
39
+ delayMs: THRESHOLD,
40
+ accessibilityDescription: props.accessibilityDescription,
41
+ });
42
+ return createElement(
43
+ "a",
44
+ { id: "row", href: "/thread/1", ...longPressProps },
45
+ "row",
46
+ );
47
+ }
48
+
49
+ function mount(props: {
50
+ onLongPress: () => void;
51
+ isDisabled?: boolean;
52
+ accessibilityDescription?: string;
53
+ }) {
54
+ act(() => {
55
+ root.render(createElement(Row, props));
56
+ });
57
+ const row = dom.window.document.getElementById("row");
58
+ assert.ok(row, "row did not mount");
59
+ return row;
60
+ }
61
+
62
+ function pointerDown(row: Element) {
63
+ row.dispatchEvent(
64
+ new dom.window.PointerEvent("pointerdown", {
65
+ bubbles: true,
66
+ pointerType: "touch",
67
+ pointerId: 1,
68
+ clientX: 10,
69
+ clientY: 10,
70
+ }),
71
+ );
72
+ }
73
+
74
+ function pointerUp() {
75
+ dom.window.document.dispatchEvent(
76
+ new dom.window.PointerEvent("pointerup", {
77
+ bubbles: true,
78
+ pointerType: "touch",
79
+ pointerId: 1,
80
+ clientX: 10,
81
+ clientY: 10,
82
+ }),
83
+ );
84
+ }
85
+
86
+ function pointerCancel(row: Element) {
87
+ row.dispatchEvent(
88
+ new dom.window.PointerEvent("pointercancel", { bubbles: true }),
89
+ );
90
+ }
91
+
92
+ function wait(ms: number) {
93
+ return act(() => new Promise((resolve) => setTimeout(resolve, ms)));
94
+ }
95
+
96
+ before(async () => {
97
+ const { JSDOM: JSDOMCtor } = await import("jsdom");
98
+ dom = new JSDOMCtor(
99
+ "<!doctype html><html><body><div id=root></div></body></html>",
100
+ { url: "http://localhost/", pretendToBeVisual: true },
101
+ );
102
+ globalThis.window = dom.window as unknown as typeof globalThis.window;
103
+ globalThis.document = dom.window.document;
104
+ globalThis.HTMLElement = dom.window.HTMLElement;
105
+ globalThis.Element = dom.window.Element;
106
+ globalThis.SVGElement = dom.window.SVGElement;
107
+ globalThis.PointerEvent = dom.window.PointerEvent;
108
+ Object.defineProperty(globalThis, "navigator", {
109
+ value: dom.window.navigator,
110
+ configurable: true,
111
+ });
112
+ (
113
+ globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
114
+ ).IS_REACT_ACT_ENVIRONMENT = true;
115
+ });
116
+
117
+ after(() => {
118
+ dom.window.close();
119
+ });
120
+
121
+ beforeEach(() => {
122
+ container = dom.window.document.getElementById(
123
+ "root",
124
+ ) as unknown as HTMLElement;
125
+ container.innerHTML = "";
126
+ root = createRoot(container);
127
+ });
128
+
129
+ afterEach(() => {
130
+ act(() => {
131
+ root.unmount();
132
+ });
133
+ });
134
+
135
+ describe("useLongPress (react-aria wrapper)", () => {
136
+ it("fires onLongPress after the threshold with no interruption", async () => {
137
+ let fired = 0;
138
+ const row = mount({ onLongPress: () => fired++ });
139
+
140
+ pointerDown(row);
141
+ await wait(THRESHOLD + 40);
142
+
143
+ assert.equal(fired, 1);
144
+ });
145
+
146
+ it("does not fire when released before the threshold", async () => {
147
+ let fired = 0;
148
+ const row = mount({ onLongPress: () => fired++ });
149
+
150
+ pointerDown(row);
151
+ await wait(THRESHOLD / 2);
152
+ pointerUp();
153
+ await wait(THRESHOLD + 40);
154
+
155
+ assert.equal(fired, 0);
156
+ });
157
+
158
+ it("does not fire when cancelled via a pointercancel before the threshold", async () => {
159
+ // This is the mechanism SwipeableRow's axis arbitration relies on: it
160
+ // dispatches a synthetic pointercancel to abort a pending long press
161
+ // once a horizontal or vertical drag claims the gesture.
162
+ let fired = 0;
163
+ const row = mount({ onLongPress: () => fired++ });
164
+
165
+ pointerDown(row);
166
+ await wait(THRESHOLD / 2);
167
+ pointerCancel(row);
168
+ await wait(THRESHOLD + 40);
169
+
170
+ assert.equal(fired, 0);
171
+ });
172
+
173
+ it("does not fire while isDisabled", async () => {
174
+ let fired = 0;
175
+ const row = mount({ onLongPress: () => fired++, isDisabled: true });
176
+
177
+ pointerDown(row);
178
+ await wait(THRESHOLD + 40);
179
+
180
+ assert.equal(fired, 0);
181
+ });
182
+
183
+ it("suppresses the native contextmenu that follows a touch long press", async () => {
184
+ let fired = 0;
185
+ const row = mount({ onLongPress: () => fired++ });
186
+
187
+ pointerDown(row);
188
+ await wait(THRESHOLD + 40);
189
+ assert.equal(
190
+ fired,
191
+ 1,
192
+ "long press must have fired for this to be meaningful",
193
+ );
194
+
195
+ const contextMenuEvent = new dom.window.MouseEvent("contextmenu", {
196
+ bubbles: true,
197
+ cancelable: true,
198
+ });
199
+ row.dispatchEvent(contextMenuEvent);
200
+
201
+ assert.equal(
202
+ contextMenuEvent.defaultPrevented,
203
+ true,
204
+ "react-aria suppresses the link context menu that Android/Chrome fires after a touch long press",
205
+ );
206
+ });
207
+
208
+ it("does not suppress contextmenu when no long press occurred", async () => {
209
+ mount({ onLongPress: () => undefined });
210
+ const row = dom.window.document.getElementById("row") as Element;
211
+
212
+ const contextMenuEvent = new dom.window.MouseEvent("contextmenu", {
213
+ bubbles: true,
214
+ cancelable: true,
215
+ });
216
+ row.dispatchEvent(contextMenuEvent);
217
+
218
+ assert.equal(contextMenuEvent.defaultPrevented, false);
219
+ });
220
+ });
@@ -0,0 +1,50 @@
1
+ import type { DOMAttributes } from "@react-types/shared";
2
+ import { useLongPress as useAriaLongPress } from "react-aria";
3
+
4
+ export interface UseLongPressOptions {
5
+ /** Called once the threshold elapses while the press stays over the target. */
6
+ onLongPress: () => void;
7
+ /** Long press is a no-op while true (e.g. a row already in selection mode). */
8
+ isDisabled?: boolean;
9
+ /** @default 500 */
10
+ delayMs?: number;
11
+ /**
12
+ * Announced to assistive technology as the long-press action, e.g.
13
+ * "Select message". TalkBack/VoiceOver have no gesture equivalent for a
14
+ * timed hold, so this description — not the gesture itself — is what
15
+ * makes the action discoverable to a screen reader user.
16
+ */
17
+ accessibilityDescription?: string;
18
+ }
19
+
20
+ export interface UseLongPressResult {
21
+ /** Spread onto the pressable element (anchor, button, or row container). */
22
+ longPressProps: DOMAttributes;
23
+ }
24
+
25
+ /**
26
+ * Long-press detection backed by react-aria's `useLongPress`. Owns
27
+ * `contextmenu` suppression and iOS text-selection suppression, and treats
28
+ * `<a href>` targets specially so link navigation and middle-click survive
29
+ * outside the press. It does not, and cannot, suppress iOS's native callout
30
+ * (share sheet) on an anchor — that still requires
31
+ * `-webkit-touch-callout: none` in CSS at the call site, since iOS fires no
32
+ * cancelable event for it.
33
+ *
34
+ * Single source of truth for the app's long-press threshold — both mobile
35
+ * row consumers (the plain row and the swipeable row) go through this hook
36
+ * so their timing can't drift apart again.
37
+ */
38
+ export function useLongPress({
39
+ onLongPress,
40
+ isDisabled,
41
+ delayMs = 500,
42
+ accessibilityDescription,
43
+ }: UseLongPressOptions): UseLongPressResult {
44
+ return useAriaLongPress({
45
+ isDisabled,
46
+ threshold: delayMs,
47
+ accessibilityDescription,
48
+ onLongPress,
49
+ });
50
+ }