@remit/web-client 0.0.85 → 0.0.87

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 (74) hide show
  1. package/package.json +1 -1
  2. package/src/auth/BetterAuthShell.tsx +2 -2
  3. package/src/components/compose/AddressField.tsx +60 -77
  4. package/src/components/mail/AutoMovedIndicator.tsx +7 -9
  5. package/src/components/mail/BriefPane.tsx +4 -2
  6. package/src/components/mail/DailyBrief.selection.test.ts +49 -0
  7. package/src/components/mail/DailyBrief.tsx +484 -76
  8. package/src/components/mail/FlaggedList.tsx +3 -2
  9. package/src/components/mail/FlaggedPane.tsx +4 -2
  10. package/src/components/mail/MailListHeader.tsx +155 -34
  11. package/src/components/mail/MailViewChrome.tsx +18 -1
  12. package/src/components/mail/MailboxPane.tsx +55 -20
  13. package/src/components/mail/MessageCard.tsx +0 -1
  14. package/src/components/mail/MessageList.tsx +70 -15
  15. package/src/components/mail/MessageListItem.tsx +4 -21
  16. package/src/components/mail/MessageRow.tsx +13 -31
  17. package/src/components/mail/SwipeableMessageRow.tsx +13 -6
  18. package/src/components/mail/ThreadListInteraction.tsx +51 -6
  19. package/src/components/mail/organize/OrganizeRuleEditor.tsx +57 -2
  20. package/src/components/mail/organize/SearchFilterDialog.render.test.ts +13 -2
  21. package/src/components/mail/organize/SearchFilterDialog.tsx +3 -1
  22. package/src/components/mail/organize/SearchFilterEditor.render.test.ts +37 -3
  23. package/src/components/mail/organize/SearchFilterEditor.tsx +8 -2
  24. package/src/components/mail/organize/smart-organize.stories.tsx +86 -5
  25. package/src/components/mail/useModifierSelect.render.test.ts +247 -0
  26. package/src/components/mail/useModifierSelect.ts +109 -0
  27. package/src/components/onboarding/OnboardingWizard.tsx +53 -9
  28. package/src/components/settings/AccountFormPanel.tsx +10 -5
  29. package/src/components/settings/TlsRootCaDownload.render.test.ts +37 -0
  30. package/src/components/settings/TlsRootCaDownload.tsx +33 -0
  31. package/src/hooks/bulk-chunking.render.test.ts +157 -0
  32. package/src/hooks/useApplyLabel.ts +6 -3
  33. package/src/hooks/useClauseSuggestions.ts +57 -0
  34. package/src/hooks/useDeleteMessages.ts +6 -3
  35. package/src/hooks/useFollowFocusOpen.render.test.ts +155 -0
  36. package/src/hooks/useFollowFocusOpen.ts +81 -0
  37. package/src/hooks/useInitialSyncProgress.render.test.ts +280 -0
  38. package/src/hooks/useInitialSyncProgress.ts +134 -0
  39. package/src/hooks/useListCursor.ts +36 -5
  40. package/src/hooks/useMarkAsRead.ts +6 -3
  41. package/src/hooks/useMoveMessages.ts +6 -3
  42. package/src/hooks/useRulePreview.ts +11 -1
  43. package/src/hooks/useSearchFilterSeed.render.test.ts +50 -15
  44. package/src/hooks/useSearchFilterSeed.ts +21 -3
  45. package/src/hooks/useSearchSuggestions.ts +126 -0
  46. package/src/hooks/useSelectedSubjects.ts +0 -0
  47. package/src/hooks/useSemanticSearch.ts +26 -7
  48. package/src/hooks/useThreadActions.ts +11 -2
  49. package/src/lib/brief.test.ts +67 -0
  50. package/src/lib/brief.ts +11 -1
  51. package/src/lib/bulk-actions.ts +29 -0
  52. package/src/lib/drafts.ts +1 -1
  53. package/src/lib/organize/clause-suggestions.test.ts +113 -0
  54. package/src/lib/organize/clause-suggestions.ts +90 -0
  55. package/src/lib/organize/property-prefill.test.ts +173 -0
  56. package/src/lib/organize/property-prefill.ts +151 -0
  57. package/src/lib/organize/rule-model.test.ts +57 -0
  58. package/src/lib/organize/rule-model.ts +110 -31
  59. package/src/lib/organize/search-to-rule.ts +12 -4
  60. package/src/lib/organize/sender-fallback.ts +11 -1
  61. package/src/lib/search-result.ts +2 -0
  62. package/src/lib/search-suggestions.test.ts +249 -0
  63. package/src/lib/search-suggestions.ts +296 -0
  64. package/src/lib/search-token-index.test.ts +99 -0
  65. package/src/lib/search-token-index.ts +67 -1
  66. package/src/lib/search-tokens.test.ts +236 -0
  67. package/src/lib/search-tokens.ts +303 -36
  68. package/src/lib/thread-cache.ts +1 -1
  69. package/src/lib/thread-search-tokens.test.ts +193 -0
  70. package/src/lib/thread-search-tokens.ts +148 -0
  71. package/src/routes/onboarding.tsx +9 -4
  72. package/src/routes/settings/advanced.tsx +2 -0
  73. package/src/runtime-config.test.ts +26 -0
  74. package/src/runtime-config.ts +15 -0
@@ -120,10 +120,21 @@ describe("SearchFilterEditor — pre-filled conversion", () => {
120
120
  });
121
121
 
122
122
  it("seeds the live count from the converted predicate", () => {
123
- const dom = mount("receipts");
123
+ const dom = mount("from:receipts@stripe.com");
124
124
  assert.match(dom.text(), /12 messages match/);
125
125
  });
126
126
 
127
+ it("never asks the matcher to count a body-text rule it cannot evaluate", async () => {
128
+ // A free-text search converts to a `HasWords` clause and carries no anchor,
129
+ // so the vector-free matcher rejects it outright (organize.ts
130
+ // `assertNoBodyContentClause`). The count is unavailable and says so; the
131
+ // request is never sent.
132
+ const dom = mount("receipts");
133
+ await settlePreview(dom);
134
+ assert.match(dom.text(), /can't count matches/i);
135
+ assert.equal((http?.to("/organize/preview") ?? []).length, 0);
136
+ });
137
+
127
138
  it("does not offer the semantic widen on a search-derived rule", () => {
128
139
  const dom = mount("receipts");
129
140
  assert.doesNotMatch(dom.text(), /and similar/i);
@@ -186,7 +197,7 @@ describe("SearchFilterEditor — commit gate", () => {
186
197
  it("stales the count on an edit and holds the save until it settles", async () => {
187
198
  // The seed count (12) is passed directly; the first network preview is the
188
199
  // re-count after the edit, so it returns 40.
189
- const dom = mount("receipts", previewCounts([40]));
200
+ const dom = mount("from:receipts@stripe.com", previewCounts([40]));
190
201
  dom.select(dom.byLabel("Destination folder"), "mbx-archive");
191
202
  await dom.flush();
192
203
  dom.type(dom.byLabel("Rule name"), "Receipts");
@@ -208,7 +219,7 @@ describe("SearchFilterEditor — commit gate", () => {
208
219
  });
209
220
 
210
221
  it("runs a one-time apply as a back-apply job when the scope is 'just once'", async () => {
211
- const dom = mount("receipts");
222
+ const dom = mount("from:receipts@stripe.com");
212
223
  dom.select(dom.byLabel("Destination folder"), "mbx-archive");
213
224
  await dom.flush();
214
225
  const radio = dom.query('input[name="rule-scope"][value="once"]');
@@ -223,4 +234,27 @@ describe("SearchFilterEditor — commit gate", () => {
223
234
  );
224
235
  assert.equal(jobs.length, 1);
225
236
  });
237
+
238
+ it("holds the one-time apply for a body-text rule, offering the saved rule instead", async () => {
239
+ // The one-time apply runs the same vector-free matcher the count does. A
240
+ // `HasWords` clause has no reader there, so the apply is held with the
241
+ // reason stated — saved as a standing rule the same clause works, because
242
+ // the index-time matcher reads the whole body.
243
+ const dom = mount("receipts");
244
+ dom.select(dom.byLabel("Destination folder"), "mbx-archive");
245
+ await dom.flush();
246
+ const radio = dom.query('input[name="rule-scope"][value="once"]');
247
+ if (!radio) throw new Error("no once scope option");
248
+ dom.click(radio);
249
+ await dom.flush();
250
+
251
+ assert.equal(primaryButton(dom, "Apply now").disabled, true);
252
+ assert.match(dom.text(), /can't read message bodies/i);
253
+ assert.equal(
254
+ (http?.calls ?? []).filter(
255
+ (call) => call.path.endsWith("/organize") && call.method === "POST",
256
+ ).length,
257
+ 0,
258
+ );
259
+ });
226
260
  });
@@ -34,8 +34,14 @@ interface SearchFilterEditorProps {
34
34
  accountId: string;
35
35
  /** The converted search — its clauses seed the rule, its drops seed the notice. */
36
36
  conversion: SearchConversion;
37
- /** The converted literal predicate's live count, seeding the editor without a re-fetch. */
38
- seedCount: number;
37
+ /**
38
+ * The converted literal predicate's live count, seeding the editor without a
39
+ * re-fetch. Absent when the predicate is one the vector-free matcher refuses —
40
+ * a `HasWords` clause from the search's free text — in which case the count
41
+ * region says so and the one-time apply is held; saving it as a standing rule
42
+ * still works, since the index-time matcher does read message bodies.
43
+ */
44
+ seedCount?: number;
39
45
  onClose: () => void;
40
46
  }
41
47
 
@@ -1,4 +1,7 @@
1
- import { mailboxOperationsListMailboxesQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
1
+ import {
2
+ mailboxOperationsListMailboxesQueryKey,
3
+ threadOperationsListThreadsQueryKey,
4
+ } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
2
5
  import type { RemitImapMailboxResponse } from "@remit/api-http-client/types.gen.ts";
3
6
  import { BottomSheet } from "@remit/ui";
4
7
  import type { Meta, StoryObj } from "@storybook/react-vite";
@@ -57,9 +60,14 @@ const FOLDER_OPTIONS: FolderOption[] = [
57
60
 
58
61
  /**
59
62
  * A QueryClient pre-seeded with the account's mailboxes, so the editor's folder
60
- * picker renders its real options without a network round-trip.
63
+ * picker renders its real options without a network round-trip, and optionally
64
+ * with the thread rows the properties prefill reads its subjects off — the same
65
+ * rows the mailbox list renders behind the sheet.
61
66
  */
62
- function seededClient(): QueryClient {
67
+ function seededClient(
68
+ subjects?: Record<string, string>,
69
+ senderEmail = "receipts@example.com",
70
+ ): QueryClient {
63
71
  const client = new QueryClient({
64
72
  defaultOptions: { queries: { retry: false } },
65
73
  });
@@ -67,13 +75,32 @@ function seededClient(): QueryClient {
67
75
  mailboxOperationsListMailboxesQueryKey({ path: { accountId: ACCOUNT_ID } }),
68
76
  { items: MAILBOXES },
69
77
  );
78
+ if (subjects) {
79
+ client.setQueryData(
80
+ threadOperationsListThreadsQueryKey({ path: { mailboxId: "mbx-inbox" } }),
81
+ {
82
+ items: Object.entries(subjects).map(([messageId, subject]) => ({
83
+ messageId,
84
+ threadId: messageId,
85
+ subject,
86
+ fromEmail: senderEmail,
87
+ })),
88
+ },
89
+ );
90
+ }
70
91
  return client;
71
92
  }
72
93
 
73
94
  /** Phone frame + a bottom sheet holding the editor, mirroring the mobile home. */
74
- function SheetStage({ children }: { children: ReactNode }) {
95
+ function SheetStage({
96
+ children,
97
+ subjects,
98
+ }: {
99
+ children: ReactNode;
100
+ subjects?: Record<string, string>;
101
+ }) {
75
102
  return (
76
- <QueryClientProvider client={seededClient()}>
103
+ <QueryClientProvider client={seededClient(subjects)}>
77
104
  <ErrorBannerProvider>
78
105
  <div className="relative mx-auto h-dvh w-full shrink-0 overflow-hidden bg-surface sm:my-6 sm:h-[760px] sm:w-[390px] sm:rounded-[2rem] sm:border sm:border-line sm:shadow-sm">
79
106
  <div className="divide-y divide-line opacity-50">
@@ -247,6 +274,60 @@ export const SenderFallbackStanding: Story = {
247
274
  ),
248
275
  };
249
276
 
277
+ /**
278
+ * Matching on properties instead of similarity — the third way to build a rule,
279
+ * with no semantics in it at all. Every selected message came from one sender,
280
+ * so the rule opens on a single editable `From` chip. The mode control stays on
281
+ * screen: "Anything similar" is one tap away and is still where the flow opens.
282
+ */
283
+ export const MatchOnPropertiesSender: Story = {
284
+ name: "Match on Properties (sender)",
285
+ render: () => (
286
+ <SheetStage>
287
+ <OrganizeRuleEditor
288
+ accountId={ACCOUNT_ID}
289
+ selectedMessageIds={["msg-1", "msg-2", "msg-3"]}
290
+ seedCount={47}
291
+ seedMatchMode="properties"
292
+ senders={["receipts@example.com"]}
293
+ onClose={() => undefined}
294
+ />
295
+ </SheetStage>
296
+ ),
297
+ };
298
+
299
+ /**
300
+ * The same mode where the selection's senders differ: there is no one sender to
301
+ * name, so the prefill drops to the part the subjects share — "Your receipt
302
+ * from" out of three differently-numbered receipts — as an ordinary `Subject`
303
+ * chip the user can widen or narrow.
304
+ */
305
+ export const MatchOnPropertiesSubject: Story = {
306
+ name: "Match on Properties (subject)",
307
+ render: () => (
308
+ <SheetStage
309
+ subjects={{
310
+ "msg-1": "Your receipt from Blue Bottle #4821",
311
+ "msg-2": "Re: Your receipt from Ritual Coffee #119",
312
+ "msg-3": "Your receipt from Sightglass #77",
313
+ }}
314
+ >
315
+ <OrganizeRuleEditor
316
+ accountId={ACCOUNT_ID}
317
+ selectedMessageIds={["msg-1", "msg-2", "msg-3"]}
318
+ seedCount={62}
319
+ seedMatchMode="properties"
320
+ senders={[
321
+ "receipts@bluebottle.example",
322
+ "hello@ritual.example",
323
+ "no-reply@sightglass.example",
324
+ ]}
325
+ onClose={() => undefined}
326
+ />
327
+ </SheetStage>
328
+ ),
329
+ };
330
+
250
331
  /** The "Something else" fallback: shortcuts derived from folders + an input. */
251
332
  export const SomethingElse: Story = {
252
333
  render: () => (
@@ -0,0 +1,247 @@
1
+ /**
2
+ * Modifier-click selection on a message row.
3
+ *
4
+ * The rule under test is that a modifier is an input signal, not a layout one:
5
+ * shift, cmd and ctrl select a row at every window width, because they can only
6
+ * come from a real keyboard. Gating them on the desktop media query is what left
7
+ * a half-screen browser or a tablet opening the message instead of selecting it.
8
+ *
9
+ * A row is an `<a href>`, so what "selection took the press" means concretely is
10
+ * that the browser's own gesture — new window, new tab, context menu — is
11
+ * prevented, and the click the same gesture delivers is claimed rather than
12
+ * opening the row on top of the selection.
13
+ */
14
+
15
+ import assert from "node:assert/strict";
16
+ import { afterEach, describe, it } from "node:test";
17
+ import { createElement, type MouseEvent } from "react";
18
+ import type { SelectionModifiers } from "@/hooks/useSelection";
19
+ import { createDomHarness, type DomHarness } from "../../test-support/dom";
20
+ import { isModified, useModifierSelect } from "./useModifierSelect";
21
+
22
+ let harness: DomHarness | undefined;
23
+
24
+ afterEach(() => {
25
+ harness?.close();
26
+ harness = undefined;
27
+ });
28
+
29
+ interface Log {
30
+ /** Every (messageId, modifiers) selection was offered. */
31
+ offers: SelectionModifiers[];
32
+ /** Clicks that reached the row's open handler. */
33
+ opens: number;
34
+ }
35
+
36
+ interface ProbeProps {
37
+ log: Log;
38
+ /** Whether selection takes the press it is offered. */
39
+ takes: boolean;
40
+ /** A row with no selection at all — the reading pane's, for instance. */
41
+ unselectable?: boolean;
42
+ }
43
+
44
+ const ROW = "[data-row]";
45
+
46
+ function Probe({ log, takes, unselectable }: ProbeProps) {
47
+ const select = useModifierSelect(
48
+ "m1",
49
+ unselectable
50
+ ? undefined
51
+ : (_messageId, modifiers) => {
52
+ log.offers.push(modifiers);
53
+ return takes;
54
+ },
55
+ );
56
+ return createElement(
57
+ "a",
58
+ {
59
+ // A fragment rather than a route: still an `<a href>`, which is what
60
+ // makes a modified click a browser gesture, without jsdom trying to
61
+ // navigate on the clicks the test deliberately leaves unclaimed.
62
+ href: "#m1",
63
+ "data-row": "",
64
+ onMouseDown: select.onMouseDown,
65
+ onContextMenu: select.onContextMenu,
66
+ onClick: (e: MouseEvent) => {
67
+ if (select.claimClick(e)) return;
68
+ log.opens += 1;
69
+ },
70
+ },
71
+ "Quarterly numbers are in",
72
+ );
73
+ }
74
+
75
+ const mount = (props: ProbeProps, viewportWidth: number): DomHarness => {
76
+ harness = createDomHarness({ viewportWidth });
77
+ harness.render(createElement(Probe, props));
78
+ return harness;
79
+ };
80
+
81
+ const emptyLog = (): Log => ({ offers: [], opens: 0 });
82
+
83
+ /** Dispatches a real mouse event and answers whether the default was taken. */
84
+ const press = (
85
+ dom: DomHarness,
86
+ type: "mousedown" | "click" | "contextmenu",
87
+ init: MouseEventInit = {},
88
+ ): boolean => {
89
+ const event = new dom.window.MouseEvent(type, {
90
+ bubbles: true,
91
+ cancelable: true,
92
+ button: 0,
93
+ ...init,
94
+ });
95
+ dom.dispatch(dom.query(ROW) as Element, event);
96
+ return event.defaultPrevented;
97
+ };
98
+
99
+ /** The full gesture a browser delivers: the press, then the click after it. */
100
+ const gesture = (
101
+ dom: DomHarness,
102
+ init: MouseEventInit,
103
+ ): { pressTaken: boolean; clickTaken: boolean } => ({
104
+ pressTaken: press(dom, "mousedown", init),
105
+ clickTaken: press(dom, "click", init),
106
+ });
107
+
108
+ // Both single-pane tiers and the multi-pane one. The narrow widths are the
109
+ // regression: they used to fall through to the touch branch, which reads no
110
+ // modifiers at all.
111
+ const WIDTHS = [390, 800, 1280];
112
+
113
+ describe("useModifierSelect", () => {
114
+ for (const width of WIDTHS) {
115
+ it(`takes a shift-press for selection at ${width}px`, () => {
116
+ const log = emptyLog();
117
+ const dom = mount({ log, takes: true }, width);
118
+
119
+ const { pressTaken, clickTaken } = gesture(dom, { shiftKey: true });
120
+
121
+ assert.deepEqual(log.offers, [
122
+ { shiftKey: true, metaKey: false, ctrlKey: false },
123
+ ]);
124
+ assert.equal(pressTaken, true, "the browser's new-window gesture is off");
125
+ assert.equal(clickTaken, true, "the click the gesture delivers is spent");
126
+ assert.equal(
127
+ log.opens,
128
+ 0,
129
+ "the row does not open on top of the selection",
130
+ );
131
+ });
132
+
133
+ it(`takes a cmd-press for selection at ${width}px`, () => {
134
+ const log = emptyLog();
135
+ const dom = mount({ log, takes: true }, width);
136
+
137
+ const { pressTaken } = gesture(dom, { metaKey: true });
138
+
139
+ assert.deepEqual(log.offers, [
140
+ { shiftKey: false, metaKey: true, ctrlKey: false },
141
+ ]);
142
+ assert.equal(pressTaken, true);
143
+ assert.equal(log.opens, 0);
144
+ });
145
+
146
+ it(`leaves a plain click alone at ${width}px`, () => {
147
+ const log = emptyLog();
148
+ const dom = mount({ log, takes: true }, width);
149
+
150
+ const { pressTaken, clickTaken } = gesture(dom, {});
151
+
152
+ assert.deepEqual(log.offers, [], "a tap carries no modifier to read");
153
+ assert.equal(pressTaken, false);
154
+ assert.equal(clickTaken, false);
155
+ assert.equal(log.opens, 1, "the row opens, as it always did");
156
+ });
157
+ }
158
+
159
+ it("claims a modified click on its own, for engines that deliver no usable press", () => {
160
+ const log = emptyLog();
161
+ const dom = mount({ log, takes: true }, 390);
162
+
163
+ const clickTaken = press(dom, "click", { metaKey: true });
164
+
165
+ assert.equal(clickTaken, true);
166
+ assert.equal(log.opens, 0);
167
+ assert.deepEqual(log.offers, [
168
+ { shiftKey: false, metaKey: true, ctrlKey: false },
169
+ ]);
170
+ });
171
+
172
+ it("leaves the browser's gesture standing when selection declines the press", () => {
173
+ const log = emptyLog();
174
+ const dom = mount({ log, takes: false }, 1280);
175
+
176
+ const { pressTaken, clickTaken } = gesture(dom, { metaKey: true });
177
+
178
+ assert.equal(pressTaken, false, "cmd-click still opens a new tab");
179
+ assert.equal(clickTaken, false);
180
+ });
181
+
182
+ it("reads no modifier on a row that cannot be selected", () => {
183
+ const log = emptyLog();
184
+ const dom = mount({ log, takes: true, unselectable: true }, 1280);
185
+
186
+ const { pressTaken, clickTaken } = gesture(dom, { shiftKey: true });
187
+
188
+ assert.equal(pressTaken, false);
189
+ assert.equal(clickTaken, false);
190
+ assert.deepEqual(log.offers, []);
191
+ });
192
+
193
+ it("ignores a press from any button but the primary one", () => {
194
+ const log = emptyLog();
195
+ const dom = mount({ log, takes: true }, 1280);
196
+
197
+ const pressTaken = press(dom, "mousedown", { button: 2, ctrlKey: true });
198
+
199
+ assert.equal(pressTaken, false, "a right-click keeps its context menu");
200
+ assert.deepEqual(log.offers, []);
201
+ });
202
+
203
+ it("suppresses the context menu a ctrl-press already spent on selection", () => {
204
+ const log = emptyLog();
205
+ const dom = mount({ log, takes: true }, 1280);
206
+
207
+ assert.equal(press(dom, "contextmenu", { ctrlKey: true }), true);
208
+ });
209
+
210
+ it("leaves a plain right-click its context menu", () => {
211
+ const log = emptyLog();
212
+ const dom = mount({ log, takes: true }, 1280);
213
+
214
+ assert.equal(press(dom, "contextmenu", {}), false);
215
+ });
216
+
217
+ it("leaves the context menu alone on a row that cannot be selected", () => {
218
+ const log = emptyLog();
219
+ const dom = mount({ log, takes: true, unselectable: true }, 1280);
220
+
221
+ assert.equal(press(dom, "contextmenu", { ctrlKey: true }), false);
222
+ });
223
+ });
224
+
225
+ describe("isModified", () => {
226
+ it("is true for any of the three keys a keyboard can add", () => {
227
+ assert.equal(
228
+ isModified({ shiftKey: true, metaKey: false, ctrlKey: false }),
229
+ true,
230
+ );
231
+ assert.equal(
232
+ isModified({ shiftKey: false, metaKey: true, ctrlKey: false }),
233
+ true,
234
+ );
235
+ assert.equal(
236
+ isModified({ shiftKey: false, metaKey: false, ctrlKey: true }),
237
+ true,
238
+ );
239
+ });
240
+
241
+ it("is false for the bare press a tap delivers", () => {
242
+ assert.equal(
243
+ isModified({ shiftKey: false, metaKey: false, ctrlKey: false }),
244
+ false,
245
+ );
246
+ });
247
+ });
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Modifier-click selection for a message row, shared by every row shape.
3
+ *
4
+ * Selection on modifiers is not gated on the layout tier. `isDesktop` is a media
5
+ * query at 1024px: it says how the app is laid out, not what is driving it.
6
+ * While the modifiers were read only on the desktop tier, every narrower window
7
+ * — a half-screen browser, a tablet — fell through to the touch branch, which
8
+ * never looks at them, and shift, cmd and ctrl click all just opened the
9
+ * message. A modifier can only come from a real keyboard, so a row honours it at
10
+ * every width; a tap carries none and is unaffected.
11
+ *
12
+ * The press, not the click, is where a modified click is taken. A mailbox row is
13
+ * an `<a href>`, and a modified click on an anchor is a browser gesture: shift
14
+ * opens a new window, cmd a new tab, ctrl raises the context menu. WebKit
15
+ * commits to that gesture from the press, so a `click` handler calling
16
+ * `preventDefault` is already too late there. `claimClick` still handles the
17
+ * click for engines that deliver it that way.
18
+ */
19
+ import { type MouseEvent, useCallback, useRef } from "react";
20
+ import type { SelectionModifiers } from "@/hooks/useSelection";
21
+
22
+ export const modifiersOf = (e: MouseEvent): SelectionModifiers => ({
23
+ shiftKey: e.shiftKey,
24
+ metaKey: e.metaKey,
25
+ ctrlKey: e.ctrlKey,
26
+ });
27
+
28
+ export const isModified = (m: SelectionModifiers): boolean =>
29
+ m.shiftKey || m.metaKey || m.ctrlKey;
30
+
31
+ export interface ModifierSelect {
32
+ /** Takes a modified press for selection before the browser acts on it. */
33
+ onMouseDown: (e: MouseEvent<HTMLElement>) => void;
34
+ /**
35
+ * Suppresses the context menu macOS raises from a ctrl-click the press
36
+ * already spent on selection. A plain right-click keeps its menu.
37
+ */
38
+ onContextMenu: (e: MouseEvent) => void;
39
+ /**
40
+ * True when the click belongs to a modified gesture selection has taken, in
41
+ * which case it is already consumed and the row must not open.
42
+ */
43
+ claimClick: (e: MouseEvent) => boolean;
44
+ }
45
+
46
+ const consume = (e: MouseEvent): void => {
47
+ e.preventDefault();
48
+ e.stopPropagation();
49
+ };
50
+
51
+ export const useModifierSelect = (
52
+ messageId: string,
53
+ onRowSelect?: (messageId: string, modifiers: SelectionModifiers) => boolean,
54
+ ): ModifierSelect => {
55
+ // Set while a modified press has already been handed to selection, so the
56
+ // click the same gesture still delivers cannot open the row on top of it.
57
+ const claimedRef = useRef(false);
58
+
59
+ const onMouseDown = useCallback(
60
+ (e: MouseEvent<HTMLElement>) => {
61
+ claimedRef.current = false;
62
+ // Only the primary button; a right-click keeps its context menu.
63
+ if (e.button !== 0) return;
64
+ const modifiers = modifiersOf(e);
65
+ if (!isModified(modifiers)) return;
66
+ // The browser's own default is only worth taking once selection has
67
+ // actually taken the press. On a row without selection, cmd-click still
68
+ // opens a new tab.
69
+ if (!onRowSelect?.(messageId, modifiers)) return;
70
+ consume(e);
71
+ claimedRef.current = true;
72
+ // Preventing the press also drops the focus it would have moved, so put
73
+ // it back: the roving cursor follows the row the user clicked, and it is
74
+ // the origin a later shift-click ranges from.
75
+ e.currentTarget.focus();
76
+ // Shift-click otherwise drags a native text selection across the rows it
77
+ // spans — the row highlight is the selection the user asked for.
78
+ if (modifiers.shiftKey) window.getSelection()?.removeAllRanges();
79
+ },
80
+ [onRowSelect, messageId],
81
+ );
82
+
83
+ const onContextMenu = useCallback(
84
+ (e: MouseEvent) => {
85
+ if (!e.ctrlKey || !onRowSelect) return;
86
+ e.preventDefault();
87
+ },
88
+ [onRowSelect],
89
+ );
90
+
91
+ const claimClick = useCallback(
92
+ (e: MouseEvent): boolean => {
93
+ if (claimedRef.current) {
94
+ claimedRef.current = false;
95
+ consume(e);
96
+ return true;
97
+ }
98
+ const modifiers = modifiersOf(e);
99
+ if (!isModified(modifiers)) return false;
100
+ if (!onRowSelect?.(messageId, modifiers)) return false;
101
+ consume(e);
102
+ if (modifiers.shiftKey) window.getSelection()?.removeAllRanges();
103
+ return true;
104
+ },
105
+ [onRowSelect, messageId],
106
+ );
107
+
108
+ return { onMouseDown, onContextMenu, claimClick };
109
+ };
@@ -34,6 +34,7 @@ import {
34
34
  FieldLabel,
35
35
  Input,
36
36
  Kbd,
37
+ PasswordInput,
37
38
  Select,
38
39
  ServerFields,
39
40
  securityToApi,
@@ -766,9 +767,8 @@ function StepCredentials({
766
767
  <FieldLabel htmlFor="credentials-password">
767
768
  Password or app password
768
769
  </FieldLabel>
769
- <Input
770
+ <PasswordInput
770
771
  id="credentials-password"
771
- type="password"
772
772
  value={localPassword}
773
773
  onChange={(e) => {
774
774
  setLocalPassword(e.target.value);
@@ -1033,6 +1033,13 @@ function StepTest({
1033
1033
  );
1034
1034
  }
1035
1035
 
1036
+ /**
1037
+ * How long the sync step holds "Go to inbox" shut while it waits for the first
1038
+ * messages. Long enough for a normal IMAP handshake and first batch, short
1039
+ * enough that a stalled or silent sync never becomes a dead end.
1040
+ */
1041
+ const INBOX_GATE_MAX_WAIT_MS = 20_000;
1042
+
1036
1043
  function StepSync({
1037
1044
  email,
1038
1045
  displayName,
@@ -1121,7 +1128,7 @@ function StepSync({
1121
1128
  // Poll sync status every 2s once account is created. Stop polling once the
1122
1129
  // account reaches a terminal phase (complete/error) so we don't hammer the
1123
1130
  // endpoint while the user lingers on this step (pattern from ComposeProvider).
1124
- const { data: syncStatus } = useQuery({
1131
+ const { data: syncStatus, isError: syncStatusUnavailable } = useQuery({
1125
1132
  ...syncOperationsGetSyncStatusOptions({
1126
1133
  path: { accountId: accountId ?? "" },
1127
1134
  }),
@@ -1187,6 +1194,33 @@ function StepSync({
1187
1194
  ? "running"
1188
1195
  : "ok";
1189
1196
 
1197
+ // The inbox has something to show once the first messages have landed, the
1198
+ // INBOX mailbox is drained, or the account's sync reached a terminal phase.
1199
+ // Until then "Go to inbox" leads to an empty list, so it does not offer (#452).
1200
+ const inboxHasArrived =
1201
+ inboxSynced > 0 ||
1202
+ inboxMailbox?.phase === "complete" ||
1203
+ syncPhase === "complete" ||
1204
+ syncErrored;
1205
+
1206
+ // Nothing above is allowed to strand the user: if the first messages have not
1207
+ // landed within the wait, or the status endpoint cannot answer at all, the
1208
+ // button opens regardless. The brief tells the truth about an empty list on
1209
+ // the other side, so an early exit costs a wait, not a lie.
1210
+ const [waitedLongEnough, setWaitedLongEnough] = useState(false);
1211
+ useEffect(() => {
1212
+ if (!accountId) return;
1213
+ const timer = setTimeout(
1214
+ () => setWaitedLongEnough(true),
1215
+ INBOX_GATE_MAX_WAIT_MS,
1216
+ );
1217
+ return () => clearTimeout(timer);
1218
+ }, [accountId]);
1219
+
1220
+ const canGoToInbox =
1221
+ !!accountId &&
1222
+ (inboxHasArrived || syncStatusUnavailable || waitedLongEnough);
1223
+
1190
1224
  if (createError) {
1191
1225
  return (
1192
1226
  <WizardShell
@@ -1243,12 +1277,22 @@ function StepSync({
1243
1277
  <span className="text-2xs text-fg-subtle">
1244
1278
  Sync continues in the background
1245
1279
  </span>
1246
- <Button
1247
- variant="primary"
1248
- onClick={() => accountId && onGoToInbox(accountId)}
1249
- >
1250
- Go to inbox
1251
- </Button>
1280
+ {canGoToInbox ? (
1281
+ <Button
1282
+ variant="primary"
1283
+ onClick={() => accountId && onGoToInbox(accountId)}
1284
+ >
1285
+ Go to inbox
1286
+ </Button>
1287
+ ) : (
1288
+ <Button
1289
+ variant="primary"
1290
+ disabled
1291
+ icon={<Loader2 className="size-3.5 animate-spin" />}
1292
+ >
1293
+ Getting your mail…
1294
+ </Button>
1295
+ )}
1252
1296
  </>
1253
1297
  }
1254
1298
  >