@remit/web-client 0.0.13 → 0.0.15

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.
Files changed (41) hide show
  1. package/package.json +1 -1
  2. package/src/auth/better-auth-config.ts +4 -1
  3. package/src/components/compose/ComposeForm.tsx +3 -0
  4. package/src/components/mail/IntelligencePane.tsx +10 -8
  5. package/src/components/mail/MailboxPane.tsx +53 -2
  6. package/src/components/mail/MessageActionMenu.tsx +18 -25
  7. package/src/components/mail/MessageDetail.tsx +1 -0
  8. package/src/components/mail/MessageList.tsx +196 -78
  9. package/src/components/mail/MessageListItem.tsx +60 -13
  10. package/src/components/mail/SwipeableMessageRow.tsx +8 -0
  11. package/src/components/ui/ConfirmDialog.tsx +9 -7
  12. package/src/components/ui/ErrorBanner.tsx +6 -3
  13. package/src/components/ui/ErrorBannerProvider.render.test.ts +85 -0
  14. package/src/components/ui/ErrorBannerProvider.tsx +18 -0
  15. package/src/components/ui/error-banners.ts +9 -0
  16. package/src/hooks/useDeleteMessages.ts +14 -21
  17. package/src/hooks/useIntelligenceData.ts +14 -3
  18. package/src/hooks/useMarkAsRead.ts +14 -23
  19. package/src/hooks/useMessageBodyContent.ts +2 -1
  20. package/src/hooks/useMoveMessages.ts +14 -21
  21. package/src/hooks/useStaleAccountSync.test.ts +22 -1
  22. package/src/hooks/useToggleStar.test.ts +31 -0
  23. package/src/hooks/useToggleStar.ts +27 -31
  24. package/src/hooks/useTriageKeyboard.ts +13 -8
  25. package/src/hooks/useUpdateAddressFlags.ts +3 -1
  26. package/src/lib/api.ts +3 -1
  27. package/src/lib/client.ts +3 -0
  28. package/src/lib/error-classifier.test.ts +156 -8
  29. package/src/lib/error-classifier.ts +49 -9
  30. package/src/lib/keymap-dispatch.test.ts +78 -1
  31. package/src/lib/keymap-dispatch.ts +64 -7
  32. package/src/lib/keymap.ts +23 -0
  33. package/src/lib/list-focus.test.ts +25 -0
  34. package/src/lib/list-focus.ts +24 -0
  35. package/src/lib/network-error.ts +58 -0
  36. package/src/lib/query-error-handler.test.ts +6 -2
  37. package/src/lib/query-escalation.integration.test.ts +3 -2
  38. package/src/lib/sender-address.test.ts +60 -0
  39. package/src/lib/sender-address.ts +37 -0
  40. package/src/lib/thread-cache.test.ts +65 -0
  41. package/src/lib/thread-cache.ts +76 -0
package/src/lib/api.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { taggedFetch } from "./network-error";
2
+
1
3
  export class ApiError extends Error {
2
4
  constructor(
3
5
  message: string,
@@ -38,7 +40,7 @@ const request = async <T>(
38
40
 
39
41
  const url = buildUrl(path, params);
40
42
 
41
- const response = await fetch(url, {
43
+ const response = await taggedFetch(url, {
42
44
  method,
43
45
  headers: {
44
46
  "Content-Type": "application/json",
package/src/lib/client.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { client } from "@remit/api-http-client/client.gen.ts";
2
2
  import { getRuntimeConfig } from "../runtime-config";
3
3
  import { ApiError } from "./api";
4
+ import { taggedFetch } from "./network-error";
4
5
 
5
6
  // In production, the config.js apiUrl points at the deployed API Gateway.
6
7
  // In local dev, the Vite proxy forwards /api -> localhost:4321 (see vite.config.ts).
@@ -8,6 +9,8 @@ const baseUrl = getRuntimeConfig().apiUrl;
8
9
 
9
10
  client.setConfig({
10
11
  baseUrl,
12
+ // Transport failures are tagged where they happen; see `network-error.ts`.
13
+ fetch: taggedFetch,
11
14
  });
12
15
 
13
16
  /**
@@ -5,10 +5,18 @@ import {
5
5
  getErrorStatus,
6
6
  hasHttpStatus,
7
7
  isAbortError,
8
+ isClientBug,
8
9
  isNetworkError,
9
10
  isServerError,
10
11
  shouldEscalate,
11
12
  } from "./error-classifier";
13
+ import { NetworkError, taggedFetch } from "./network-error";
14
+
15
+ const timeoutError = (): Error => {
16
+ const error = new Error("The operation timed out.");
17
+ error.name = "TimeoutError";
18
+ return error;
19
+ };
12
20
 
13
21
  const abortError = (): Error => {
14
22
  const error = new Error("aborted");
@@ -75,9 +83,31 @@ describe("isAbortError", () => {
75
83
  });
76
84
 
77
85
  describe("isNetworkError", () => {
78
- it("is true for a statusless transport failure", () => {
79
- assert.equal(isNetworkError(new TypeError("Failed to fetch")), true);
80
- assert.equal(isNetworkError(new Error("network down")), true);
86
+ // The boundary tags the failure, so the browser's wording never matters.
87
+ // These are the real rejections `fetch` produces, across engines — every one
88
+ // of them has to stay soft, whatever it happens to say.
89
+ const transportFailures = [
90
+ new TypeError("Failed to fetch"), // Chrome, Edge
91
+ new TypeError("NetworkError when attempting to fetch resource."), // Firefox
92
+ new TypeError("Load failed"), // WebKit
93
+ new TypeError("The network connection was lost."), // WebKit, wifi dropped
94
+ new TypeError("The request timed out."), // WebKit
95
+ new TypeError("fetch failed"), // undici
96
+ new Error("something no engine has said yet"),
97
+ ];
98
+
99
+ it("is true for anything the fetch boundary tagged, whatever it says", () => {
100
+ for (const failure of transportFailures) {
101
+ assert.equal(
102
+ isNetworkError(new NetworkError(failure)),
103
+ true,
104
+ `"${failure.message}" must stay soft`,
105
+ );
106
+ }
107
+ });
108
+
109
+ it("is true for a timeout — a timeout is a transport failure", () => {
110
+ assert.equal(isNetworkError(new NetworkError(timeoutError())), true);
81
111
  });
82
112
 
83
113
  it("is false for an aborted request (its own category)", () => {
@@ -88,6 +118,108 @@ describe("isNetworkError", () => {
88
118
  assert.equal(isNetworkError(new ApiError("boom", 500)), false);
89
119
  assert.equal(isNetworkError(new ApiError("not found", 404)), false);
90
120
  });
121
+
122
+ it("is false for an exception thrown by our own code", () => {
123
+ assert.equal(
124
+ isNetworkError(
125
+ new TypeError('can\'t access property "map", x is undefined'),
126
+ ),
127
+ false,
128
+ );
129
+ });
130
+
131
+ it("does not mistake an untagged error for transport just because it reads like one", () => {
132
+ // A bug whose message happens to contain a fetch-failure phrase is still a
133
+ // bug. Only the boundary gets to say otherwise.
134
+ assert.equal(isNetworkError(new TypeError("Failed to fetch")), false);
135
+ });
136
+ });
137
+
138
+ describe("taggedFetch — where the decision is actually made", () => {
139
+ const withFetch = async (
140
+ impl: typeof fetch,
141
+ run: () => Promise<void>,
142
+ ): Promise<void> => {
143
+ const original = globalThis.fetch;
144
+ globalThis.fetch = impl;
145
+ try {
146
+ await run();
147
+ } finally {
148
+ globalThis.fetch = original;
149
+ }
150
+ };
151
+
152
+ it("tags every transport rejection, regardless of its message", async () => {
153
+ for (const message of [
154
+ "Failed to fetch",
155
+ "The network connection was lost.",
156
+ "fetch failed",
157
+ "anything at all",
158
+ ]) {
159
+ await withFetch(
160
+ () => Promise.reject(new TypeError(message)),
161
+ async () => {
162
+ const error = await taggedFetch("/x").catch((e: unknown) => e);
163
+ assert.ok(error instanceof NetworkError);
164
+ assert.equal(isNetworkError(error), true);
165
+ assert.equal(shouldEscalate(error), false);
166
+ },
167
+ );
168
+ }
169
+ });
170
+
171
+ it("tags a timeout, because a timeout never reached a server", async () => {
172
+ await withFetch(
173
+ () => Promise.reject(timeoutError()),
174
+ async () => {
175
+ const error = await taggedFetch("/x").catch((e: unknown) => e);
176
+ assert.ok(error instanceof NetworkError);
177
+ assert.equal(shouldEscalate(error), false);
178
+ },
179
+ );
180
+ });
181
+
182
+ it("leaves a deliberate abort untagged — it is not a failure", async () => {
183
+ await withFetch(
184
+ () => Promise.reject(abortError()),
185
+ async () => {
186
+ const error = await taggedFetch("/x").catch((e: unknown) => e);
187
+ assert.ok(!(error instanceof NetworkError));
188
+ assert.equal(isAbortError(error), true);
189
+ },
190
+ );
191
+ });
192
+
193
+ it("passes a response through untouched, including an error status", async () => {
194
+ const response = new Response("nope", { status: 500 });
195
+ await withFetch(
196
+ () => Promise.resolve(response),
197
+ async () => {
198
+ assert.equal(await taggedFetch("/x"), response);
199
+ },
200
+ );
201
+ });
202
+ });
203
+
204
+ describe("isClientBug", () => {
205
+ it("is true for an exception raised inside our own code", () => {
206
+ assert.equal(
207
+ isClientBug(
208
+ new TypeError('can\'t access property "map", x is undefined'),
209
+ ),
210
+ true,
211
+ );
212
+ });
213
+
214
+ it("is false for a request that reached, or failed to reach, a server", () => {
215
+ assert.equal(isClientBug(new ApiError("boom", 500)), false);
216
+ assert.equal(isClientBug(new ApiError("not found", 404)), false);
217
+ assert.equal(
218
+ isClientBug(new NetworkError(new TypeError("Failed to fetch"))),
219
+ false,
220
+ );
221
+ assert.equal(isClientBug(abortError()), false);
222
+ });
91
223
  });
92
224
 
93
225
  describe("shouldEscalate (the fail-fast decision table — #1059)", () => {
@@ -119,15 +251,31 @@ describe("shouldEscalate (the fail-fast decision table — #1059)", () => {
119
251
  assert.equal(shouldEscalate(abortError()), false);
120
252
  });
121
253
 
122
- it("does NOT escalate a statusless network/offline blip", () => {
123
- assert.equal(shouldEscalate(new TypeError("Failed to fetch")), false);
124
- assert.equal(shouldEscalate(new Error("network down")), false);
254
+ it("does NOT escalate a network/offline blip", () => {
255
+ assert.equal(
256
+ shouldEscalate(new NetworkError(new TypeError("Failed to fetch"))),
257
+ false,
258
+ );
259
+ assert.equal(
260
+ shouldEscalate(
261
+ new NetworkError(new TypeError("The network connection was lost.")),
262
+ ),
263
+ false,
264
+ );
125
265
  });
126
266
 
127
- it("a statusless error is soft regardless of meta", () => {
267
+ it("a network error is soft regardless of meta", () => {
128
268
  assert.equal(
129
- shouldEscalate(new TypeError("Failed to fetch"), { softError: false }),
269
+ shouldEscalate(new NetworkError(new TypeError("Failed to fetch")), {
270
+ softError: false,
271
+ }),
130
272
  false,
131
273
  );
132
274
  });
275
+
276
+ it("escalates an exception thrown by our own code, softError or not", () => {
277
+ const bug = new TypeError('can\'t access property "map", x is undefined');
278
+ assert.equal(shouldEscalate(bug), true);
279
+ assert.equal(shouldEscalate(bug, { softError: true }), true);
280
+ });
133
281
  });
@@ -1,4 +1,5 @@
1
1
  import { ApiError } from "./api";
2
+ import { NetworkError } from "./network-error";
2
3
 
3
4
  /**
4
5
  * Extract an HTTP status code from a thrown error, regardless of which client
@@ -55,14 +56,50 @@ export const isAbortError = (error: unknown): boolean => {
55
56
  return false;
56
57
  };
57
58
 
59
+ const isOffline = (): boolean =>
60
+ typeof navigator !== "undefined" && navigator.onLine === false;
61
+
62
+ /**
63
+ * A transport failure from a wifi drop, tab wake, captive portal, or a timeout:
64
+ * the request never reached a server, so it is environmental, not a proven
65
+ * first-party failure.
66
+ *
67
+ * This is decided at the fetch boundary, not inferred here. Every app-owned
68
+ * request goes through `taggedFetch`, which knows a `fetch()` rejection is
69
+ * transport-level because `fetch` rejects for no other reason, and tags it a
70
+ * `NetworkError`. Reading that tag is exact; matching browser failure strings
71
+ * was not — the list is open-ended (WebKit alone has four, undici another) and
72
+ * anything missed would put a full-screen fatal page in front of someone who
73
+ * had merely walked out of wifi range.
74
+ *
75
+ * `navigator.onLine === false` is kept as a second signal for an error that
76
+ * reached us from outside that boundary while the browser reports no
77
+ * connectivity at all.
78
+ */
79
+ export const isNetworkError = (error: unknown): boolean => {
80
+ if (isAbortError(error)) return false;
81
+ if (hasHttpStatus(error)) return false;
82
+ if (error instanceof NetworkError) return true;
83
+ return isOffline();
84
+ };
85
+
86
+ /**
87
+ * An exception raised by our own client code rather than by a request: it
88
+ * carries no HTTP status, is not an abort, and was not tagged as transport.
89
+ * That leaves a programming error — the one class the user can do nothing
90
+ * about and must never be asked to shrug off.
91
+ */
92
+ export const isClientBug = (error: unknown): boolean =>
93
+ !hasHttpStatus(error) && !isAbortError(error) && !isNetworkError(error);
94
+
58
95
  /**
59
- * A statusless transport/network failure (`TypeError: Failed to fetch` from a
60
- * wifi drop, tab wake, captive portal, or a background refetch): the request
61
- * never reached a server, so it is environmental, not a proven first-party
62
- * failure. Excludes deliberate aborts (also statusless, but their own category).
96
+ * Fatal with no opt-out: our API answered "I'm broken", or our own code threw.
97
+ * Neither is something a call site can reclassify as soft, and neither may be
98
+ * reduced to a dismissible banner both belong on the full-screen page, which
99
+ * offers a way forward and a bug report (issue #55).
63
100
  */
64
- export const isNetworkError = (error: unknown): boolean =>
65
- !isAbortError(error) && !hasHttpStatus(error);
101
+ export const isAlwaysFatal = (error: unknown): boolean =>
102
+ isServerError(error) || isClientBug(error);
66
103
 
67
104
  const isSoftErrorMeta = (meta: Record<string, unknown> | undefined): boolean =>
68
105
  meta?.softError === true;
@@ -76,10 +113,12 @@ const isSoftErrorMeta = (meta: Record<string, unknown> | undefined): boolean =>
76
113
  * 2. A 5xx (500–599) ALWAYS escalates — no opt-out, even on a background
77
114
  * refetch, even when the call site marked `meta.softError`. Our API
78
115
  * answered "I'm broken"; that is never benign.
79
- * 3. The ONLY soft (do-NOT-escalate) exemptions:
116
+ * 3. A client-side exception ALWAYS escalates, on the same terms. It is our
117
+ * bug; there is nothing for the user to retry and nothing to dismiss.
118
+ * 4. The ONLY soft (do-NOT-escalate) exemptions:
80
119
  * a. aborts / cancellations — never a failure;
81
- * b. statusless network/offline errors — environmental, recovered by React
82
- * Query's reconnect/retry;
120
+ * b. network/offline errors — environmental, recovered by React Query's
121
+ * reconnect/retry;
83
122
  * c. a non-5xx error on a query/mutation that opted out via
84
123
  * `meta.softError === true` — the call site owns that error's UX
85
124
  * (e.g. a 404 empty state, a 4xx "Reconnect" banner).
@@ -91,6 +130,7 @@ export const shouldEscalate = (
91
130
  if (isServerError(error)) return true;
92
131
  if (isAbortError(error)) return false;
93
132
  if (isNetworkError(error)) return false;
133
+ if (isAlwaysFatal(error)) return true;
94
134
  if (isSoftErrorMeta(meta)) return false;
95
135
  return true;
96
136
  };
@@ -12,6 +12,7 @@ const stroke = (partial: Partial<KeyStroke> & { key: string }): KeyStroke => ({
12
12
  ctrlKey: false,
13
13
  altKey: false,
14
14
  inEditable: false,
15
+ onControl: false,
15
16
  ...partial,
16
17
  });
17
18
 
@@ -61,6 +62,82 @@ describe("dispatchKey — plain bindings", () => {
61
62
  });
62
63
  });
63
64
 
65
+ describe("dispatchKey — list navigation", () => {
66
+ const cases: Array<[string, string]> = [
67
+ ["ArrowDown", "focusNext"],
68
+ ["ArrowUp", "focusPrevious"],
69
+ ["Home", "focusFirst"],
70
+ ["End", "focusLast"],
71
+ [" ", "toggleSelect"],
72
+ ["Delete", "delete"],
73
+ ["Backspace", "delete"],
74
+ ];
75
+
76
+ for (const [key, action] of cases) {
77
+ test(`'${key}' → ${action}`, () => {
78
+ const result = run({ key });
79
+ assert.strictEqual(result.action, action);
80
+ assert.strictEqual(result.preventDefault, true);
81
+ });
82
+ }
83
+
84
+ test("Shift+Arrow extends the selection instead of moving the cursor", () => {
85
+ assert.strictEqual(
86
+ run({ key: "ArrowDown", shiftKey: true }).action,
87
+ "extendSelectDown",
88
+ );
89
+ assert.strictEqual(
90
+ run({ key: "ArrowUp", shiftKey: true }).action,
91
+ "extendSelectUp",
92
+ );
93
+ });
94
+
95
+ test("⌘A / Ctrl+A selects every loaded row", () => {
96
+ assert.strictEqual(run({ key: "a", metaKey: true }).action, "selectAll");
97
+ assert.strictEqual(run({ key: "a", ctrlKey: true }).action, "selectAll");
98
+ assert.strictEqual(
99
+ run({ key: "a", metaKey: true }).preventDefault,
100
+ true,
101
+ "the browser's select-all-text default must be replaced",
102
+ );
103
+ });
104
+ });
105
+
106
+ describe("dispatchKey — controls keep their activation keys", () => {
107
+ // The regression behind #43: a global Enter binding cancelled the default
108
+ // action of whatever the user had tabbed to, so no button in the app could
109
+ // be activated from the keyboard.
110
+ test("Enter on a focused control is left to the control", () => {
111
+ const result = run({ key: "Enter", onControl: true });
112
+ assert.strictEqual(result.action, null);
113
+ assert.strictEqual(result.preventDefault, false);
114
+ });
115
+
116
+ test("Space on a focused control is left to the control", () => {
117
+ const result = run({ key: " ", onControl: true });
118
+ assert.strictEqual(result.action, null);
119
+ assert.strictEqual(result.preventDefault, false);
120
+ });
121
+
122
+ test("Enter and Space still drive the list away from a control", () => {
123
+ assert.strictEqual(run({ key: "Enter" }).action, "openFocused");
124
+ assert.strictEqual(run({ key: " " }).action, "toggleSelect");
125
+ });
126
+
127
+ test("non-activation keys still fire from a focused control", () => {
128
+ // Tabbing to a toolbar button must not strand the rest of the keymap.
129
+ assert.strictEqual(run({ key: "j", onControl: true }).action, "focusNext");
130
+ assert.strictEqual(run({ key: "r", onControl: true }).action, "reply");
131
+ });
132
+
133
+ test("an Enter released to a control still cancels a pending g prefix", () => {
134
+ assert.strictEqual(
135
+ run({ key: "Enter", onControl: true }, "g").nextPrefix,
136
+ null,
137
+ );
138
+ });
139
+ });
140
+
64
141
  describe("dispatchKey — input suppression", () => {
65
142
  test("plain keys are inert in an editable surface", () => {
66
143
  assert.strictEqual(run({ key: "j", inEditable: true }).action, null);
@@ -98,8 +175,8 @@ describe("dispatchKey — modifiers", () => {
98
175
  });
99
176
 
100
177
  test("other meta combos are left to the browser", () => {
101
- assert.strictEqual(run({ key: "a", metaKey: true }).action, null);
102
178
  assert.strictEqual(run({ key: "c", metaKey: true }).action, null);
179
+ assert.strictEqual(run({ key: "f", metaKey: true }).action, null);
103
180
  });
104
181
 
105
182
  test("Shift+J / Shift+K extend the selection", () => {
@@ -19,6 +19,15 @@ export interface KeyStroke {
19
19
  altKey: boolean;
20
20
  /** Whether the event originated inside an editable surface. */
21
21
  inEditable: boolean;
22
+ /**
23
+ * Whether the event originated on an activatable control that is not a
24
+ * message-list row — a button, link, `role="button"`, `<summary>`. Enter and
25
+ * Space are that control's activation keys, so the layer releases them; every
26
+ * other binding still fires. Without this a global Enter binding cancels the
27
+ * default action of whatever the user tabbed to, and no button in the app can
28
+ * be activated from the keyboard.
29
+ */
30
+ onControl: boolean;
22
31
  }
23
32
 
24
33
  /** Pending sequence-prefix state. `"g"` means a `g` was pressed recently. */
@@ -69,7 +78,14 @@ interface PlainBinding {
69
78
  const PLAIN_BINDINGS: Record<string, PlainBinding> = {
70
79
  j: { action: "focusNext" },
71
80
  k: { action: "focusPrevious" },
81
+ arrowdown: { action: "focusNext" },
82
+ arrowup: { action: "focusPrevious" },
83
+ home: { action: "focusFirst" },
84
+ end: { action: "focusLast" },
72
85
  enter: { action: "openFocused" },
86
+ " ": { action: "toggleSelect" },
87
+ delete: { action: "delete" },
88
+ backspace: { action: "delete" },
73
89
  u: { action: "toggleRead" },
74
90
  x: { action: "toggleSelect" },
75
91
  r: { action: "reply" },
@@ -118,12 +134,26 @@ export function dispatchKey(
118
134
  return { ...NONE, nextPrefix: prefix === "g" ? null : prefix };
119
135
  }
120
136
 
137
+ // Enter / Space belong to whatever control has focus. Releasing them here is
138
+ // what keeps Tab-to-a-button-then-Enter working anywhere in the app; the
139
+ // list's own rows are excluded from `onControl`, so the roving cursor still
140
+ // opens and selects.
141
+ if (stroke.onControl && (lower === "enter" || lower === " ")) {
142
+ return { ...NONE, nextPrefix: prefix === "g" ? null : prefix };
143
+ }
144
+
121
145
  // ⌘N / Ctrl+N → compose. Checked before the prefix/plain tables so the
122
146
  // browser's "new window" is the only thing we intercept among meta combos.
123
147
  if (meta && lower === "n") {
124
148
  return { action: "compose", nextPrefix: null, preventDefault: true };
125
149
  }
126
150
 
151
+ // ⌘A / Ctrl+A → select every loaded row, replacing the browser's
152
+ // select-all-text default.
153
+ if (meta && lower === "a") {
154
+ return { action: "selectAll", nextPrefix: null, preventDefault: true };
155
+ }
156
+
127
157
  // Any other meta/ctrl combo is left to the browser/OS.
128
158
  if (meta) return { ...NONE, nextPrefix: prefix === "g" ? null : prefix };
129
159
 
@@ -145,13 +175,17 @@ export function dispatchKey(
145
175
  return { action: null, nextPrefix: "g", preventDefault: true };
146
176
  }
147
177
 
148
- // Shift+J / Shift+K extend the selection.
149
- if (stroke.shiftKey && (lower === "j" || lower === "k")) {
150
- return {
151
- action: lower === "j" ? "extendSelectDown" : "extendSelectUp",
152
- nextPrefix: null,
153
- preventDefault: true,
154
- };
178
+ // Shift+J/K and Shift+Arrow extend the selection.
179
+ if (stroke.shiftKey) {
180
+ const down = lower === "j" || lower === "arrowdown";
181
+ const up = lower === "k" || lower === "arrowup";
182
+ if (down || up) {
183
+ return {
184
+ action: down ? "extendSelectDown" : "extendSelectUp",
185
+ nextPrefix: null,
186
+ preventDefault: true,
187
+ };
188
+ }
155
189
  }
156
190
 
157
191
  // Plain single-key bindings.
@@ -185,3 +219,26 @@ export function isEditableTarget(target: EventTarget | null): boolean {
185
219
  target.isContentEditable
186
220
  );
187
221
  }
222
+
223
+ /**
224
+ * Marks an element as a message-list row. Rows are anchors, so they would
225
+ * otherwise read as ordinary controls; the list owns Enter/Space on them.
226
+ */
227
+ export const ROW_ATTRIBUTE = "data-message-row";
228
+
229
+ const CONTROL_SELECTOR =
230
+ 'button, a[href], summary, [role="button"], [role="menuitem"], [role="tab"], [role="switch"], [role="checkbox"]';
231
+
232
+ /**
233
+ * Whether the event target sits inside an activatable control that is not a
234
+ * message-list row. See {@link KeyStroke.onControl}.
235
+ */
236
+ export function isControlTarget(target: EventTarget | null): boolean {
237
+ if (!(target instanceof HTMLElement)) return false;
238
+ const control = target.closest(CONTROL_SELECTOR);
239
+ if (!control) return false;
240
+ // The nearest control wins. A row is an anchor and so matches the selector,
241
+ // but the list owns Enter/Space on its rows; a control nested *inside* a row
242
+ // (the select checkbox) is a control like any other and keeps its own keys.
243
+ return !control.hasAttribute(ROW_ATTRIBUTE);
244
+ }
package/src/lib/keymap.ts CHANGED
@@ -13,12 +13,15 @@ export type TriageAction =
13
13
  // list navigation / focus model
14
14
  | "focusNext"
15
15
  | "focusPrevious"
16
+ | "focusFirst"
17
+ | "focusLast"
16
18
  | "openFocused"
17
19
  | "back"
18
20
  // selection
19
21
  | "toggleSelect"
20
22
  | "extendSelectDown"
21
23
  | "extendSelectUp"
24
+ | "selectAll"
22
25
  // message verbs (focused row / selection)
23
26
  | "reply"
24
27
  | "replyAll"
@@ -76,6 +79,14 @@ export const KEY_HINT_GROUPS: KeyHintGroup[] = [
76
79
  keys: ["k"],
77
80
  description: "Focus previous message",
78
81
  },
82
+ { action: "focusNext", keys: ["↓"], description: "Focus next message" },
83
+ {
84
+ action: "focusPrevious",
85
+ keys: ["↑"],
86
+ description: "Focus previous message",
87
+ },
88
+ { action: "focusFirst", keys: ["Home"], description: "Focus first" },
89
+ { action: "focusLast", keys: ["End"], description: "Focus last" },
79
90
  {
80
91
  action: "openFocused",
81
92
  keys: ["Enter"],
@@ -88,6 +99,7 @@ export const KEY_HINT_GROUPS: KeyHintGroup[] = [
88
99
  title: "Selection",
89
100
  hints: [
90
101
  { action: "toggleSelect", keys: ["x"], description: "Toggle select" },
102
+ { action: "toggleSelect", keys: ["Space"], description: "Toggle select" },
91
103
  {
92
104
  action: "extendSelectDown",
93
105
  keys: ["Shift", "j"],
@@ -98,6 +110,17 @@ export const KEY_HINT_GROUPS: KeyHintGroup[] = [
98
110
  keys: ["Shift", "k"],
99
111
  description: "Extend selection up",
100
112
  },
113
+ {
114
+ action: "extendSelectDown",
115
+ keys: ["Shift", "↓"],
116
+ description: "Extend selection down",
117
+ },
118
+ {
119
+ action: "extendSelectUp",
120
+ keys: ["Shift", "↑"],
121
+ description: "Extend selection up",
122
+ },
123
+ { action: "selectAll", keys: ["⌘", "A"], description: "Select all" },
101
124
  ],
102
125
  },
103
126
  {
@@ -0,0 +1,25 @@
1
+ import assert from "node:assert";
2
+ import { describe, test } from "node:test";
3
+ import { tabStopId } from "./list-focus.ts";
4
+
5
+ const ids = ["a", "b", "c"];
6
+
7
+ describe("tabStopId", () => {
8
+ test("the cursor row holds the tab stop", () => {
9
+ assert.strictEqual(tabStopId(ids, "b"), "b");
10
+ });
11
+
12
+ test("an untouched list puts the tab stop on the first row", () => {
13
+ assert.strictEqual(tabStopId(ids, undefined), "a");
14
+ });
15
+
16
+ test("a cursor that no longer exists falls back to the first row", () => {
17
+ // After a delete or a refetch the cursor can name a row that is gone; the
18
+ // list must keep a tab stop or Tab skips over it entirely.
19
+ assert.strictEqual(tabStopId(ids, "zzz"), "a");
20
+ });
21
+
22
+ test("an empty list has no tab stop", () => {
23
+ assert.strictEqual(tabStopId([], "a"), undefined);
24
+ });
25
+ });
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Roving-tabindex math for the message list.
3
+ *
4
+ * A virtualized list of hundreds of rows must expose exactly one tab stop:
5
+ * Tab then moves focus into the list at the keyboard cursor and Shift+Tab moves
6
+ * back out to the side panel, instead of walking every row. Pure so the rule is
7
+ * testable without a DOM.
8
+ */
9
+
10
+ /**
11
+ * The id of the row that holds the list's tab stop. The keyboard cursor owns it
12
+ * while it points at a loaded row; otherwise the first row does, so an untouched
13
+ * list is still reachable with one Tab.
14
+ */
15
+ export function tabStopId(
16
+ orderedIds: string[],
17
+ focusedId: string | undefined,
18
+ ): string | undefined {
19
+ if (orderedIds.length === 0) return undefined;
20
+ if (focusedId !== undefined && orderedIds.includes(focusedId)) {
21
+ return focusedId;
22
+ }
23
+ return orderedIds[0];
24
+ }