@remit/web-client 0.0.295 → 0.0.297

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/web-client",
3
- "version": "0.0.295",
3
+ "version": "0.0.297",
4
4
  "type": "module",
5
5
  "description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
6
6
  "exports": {
@@ -109,6 +109,7 @@
109
109
  "dictionary-nl": "2.0.0",
110
110
  "esbuild": "*",
111
111
  "jsdom": "^29.1.1",
112
+ "msw": "^2",
112
113
  "react": "^19",
113
114
  "react-dom": "^19.2.4",
114
115
  "tailwindcss": "^4",
@@ -323,11 +323,6 @@ function WiredPanel({
323
323
  updateFlags({ muted: { value: next } });
324
324
  }, [data?.flags?.muted, updateFlags]);
325
325
 
326
- const handleToggleUnsubscribe = useCallback(() => {
327
- const next = !(data?.flags?.unsubscribed === true);
328
- updateFlags({ unsubscribed: { value: next } });
329
- }, [data?.flags?.unsubscribed, updateFlags]);
330
-
331
326
  const handleReclassifySelect = useCallback(
332
327
  (category: CategoryOverride) => {
333
328
  setReclassifyOpen(false);
@@ -349,7 +344,6 @@ function WiredPanel({
349
344
  const actions: IntelligenceQuickActions = {
350
345
  onToggleVip: canUpdateFlags ? handleToggleVip : undefined,
351
346
  onToggleMute: canUpdateFlags ? handleToggleMute : undefined,
352
- onToggleUnsubscribe: canUpdateFlags ? handleToggleUnsubscribe : undefined,
353
347
  onReclassify: canUpdateFlags ? () => setReclassifyOpen(true) : undefined,
354
348
  onNotSpam: spamAction === "notSpam" ? handleNotSpam : undefined,
355
349
  onReportSpam: spamAction === "reportSpam" ? handleReportSpam : undefined,
@@ -0,0 +1,250 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { expect, userEvent, waitFor, within } from "storybook/test";
3
+ import { AppStory } from "@/mocks/story-frame/AppStory";
4
+ import { mailHandlers } from "@/mocks/story-frame/mail-handlers";
5
+ import { mailWorld, newsletterBacklog } from "@/mocks/story-frame/mail-world";
6
+ import { longPress } from "@/mocks/story-frame/touch";
7
+
8
+ const world = mailWorld();
9
+
10
+ const meta = {
11
+ title: "Playground/Shipped/Mail/Daily brief",
12
+ component: AppStory,
13
+ args: { url: "/mail/brief" },
14
+ parameters: {
15
+ layout: "fullscreen",
16
+ msw: { handlers: mailHandlers(world) },
17
+ },
18
+ } satisfies Meta<typeof AppStory>;
19
+ export default meta;
20
+
21
+ type Story = StoryObj<typeof meta>;
22
+
23
+ const phone = { viewport: { value: "mobile", isRotated: false } };
24
+
25
+ const rows = (canvasElement: HTMLElement): HTMLElement[] =>
26
+ Array.from(canvasElement.querySelectorAll<HTMLElement>("[data-list-row]"));
27
+
28
+ const ticked = (canvasElement: HTMLElement): number =>
29
+ rows(canvasElement).filter((row) =>
30
+ row.querySelector('[aria-label="Deselect message"]'),
31
+ ).length;
32
+
33
+ const openFilters = async (
34
+ canvasElement: HTMLElement,
35
+ label: "Expand filters" | "Search filters" = "Expand filters",
36
+ ) => {
37
+ const canvas = within(canvasElement);
38
+ await userEvent.click(await canvas.findByLabelText(label));
39
+ };
40
+
41
+ const pick = async (
42
+ canvasElement: HTMLElement,
43
+ group: "Accounts" | "Categories" | "Attributes",
44
+ name: string,
45
+ ) => {
46
+ const fieldset = await within(canvasElement).findByLabelText(group);
47
+ await userEvent.click(within(fieldset).getByRole("button", { name }));
48
+ };
49
+
50
+ export const Default: Story = {
51
+ play: async ({ canvasElement }) => {
52
+ const canvas = within(canvasElement);
53
+ await expect(await canvas.findByText("Q3 planning")).toBeVisible();
54
+ await expect(canvas.getByText("Autumn sale starts now")).toBeVisible();
55
+ },
56
+ };
57
+
58
+ export const Phone: Story = {
59
+ globals: phone,
60
+ play: async ({ canvasElement }) => {
61
+ const canvas = within(canvasElement);
62
+ await expect(await canvas.findByText("Q3 planning")).toBeVisible();
63
+ },
64
+ };
65
+
66
+ export const CaughtUp: Story = {
67
+ parameters: {
68
+ msw: { handlers: mailHandlers(mailWorld({ threads: [] })) },
69
+ },
70
+ play: async ({ canvasElement }) => {
71
+ const canvas = within(canvasElement);
72
+ await expect(await canvas.findByText("You're caught up")).toBeVisible();
73
+ },
74
+ };
75
+
76
+ export const FilteredToCategory: Story = {
77
+ play: async ({ canvasElement }) => {
78
+ const canvas = within(canvasElement);
79
+ await canvas.findByText("Q3 planning");
80
+ await openFilters(canvasElement);
81
+ await pick(canvasElement, "Categories", "Newsletter");
82
+ await waitFor(() =>
83
+ expect(canvas.queryByText("Q3 planning")).not.toBeInTheDocument(),
84
+ );
85
+ await expect(
86
+ await canvas.findByText("This week in type systems"),
87
+ ).toBeVisible();
88
+ },
89
+ };
90
+
91
+ export const WorkOnly: Story = {
92
+ play: async ({ canvasElement }) => {
93
+ const canvas = within(canvasElement);
94
+ await canvas.findByText("Dinner on Saturday?");
95
+ await openFilters(canvasElement);
96
+ await pick(canvasElement, "Accounts", "alice.tan");
97
+ await waitFor(() =>
98
+ expect(canvas.queryByText("Dinner on Saturday?")).not.toBeInTheDocument(),
99
+ );
100
+ await expect(await canvas.findByText("Q3 planning")).toBeVisible();
101
+ },
102
+ };
103
+
104
+ export const SelectAllThenNarrow: Story = {
105
+ parameters: {
106
+ msw: {
107
+ handlers: mailHandlers(
108
+ mailWorld({
109
+ threads: [...world.threads, ...newsletterBacklog(14)],
110
+ }),
111
+ ),
112
+ },
113
+ },
114
+ play: async ({ canvasElement }) => {
115
+ const canvas = within(canvasElement);
116
+ await canvas.findByText("Q3 planning");
117
+ await expect(
118
+ await canvas.findByRole("button", { name: /^Show (all|\d+ more)/ }),
119
+ ).toBeVisible();
120
+ await openFilters(canvasElement);
121
+
122
+ const firstRow = rows(canvasElement)[0];
123
+ firstRow?.focus();
124
+ await expect(document.activeElement).toBe(firstRow);
125
+ await userEvent.keyboard("{Meta>}a{/Meta}");
126
+ await waitFor(() =>
127
+ expect(
128
+ canvas.getByText(/(loaded selected|messages? selected)$/),
129
+ ).toHaveTextContent(`All ${rows(canvasElement).length} loaded selected`),
130
+ );
131
+ const selectedEverywhere = ticked(canvasElement);
132
+
133
+ await expect(selectedEverywhere).toBe(rows(canvasElement).length);
134
+
135
+ await pick(canvasElement, "Categories", "Newsletter");
136
+ await expect(
137
+ await canvas.findByText("Issue 1: notes from the field"),
138
+ ).toBeVisible();
139
+ await waitFor(() => expect(ticked(canvasElement)).toBe(0));
140
+ await expect(
141
+ canvas.queryByText(/(loaded selected|messages? selected)$/),
142
+ ).toBeNull();
143
+ },
144
+ };
145
+
146
+ export const ShiftArrowRange: Story = {
147
+ play: async ({ canvasElement }) => {
148
+ const canvas = within(canvasElement);
149
+ await canvas.findByText("Q3 planning");
150
+ rows(canvasElement)[0]?.focus();
151
+ await userEvent.keyboard("j");
152
+ await userEvent.keyboard("{Shift>}{ArrowDown}{ArrowDown}{/Shift}");
153
+ await waitFor(() =>
154
+ expect(canvas.getByText("2 messages selected")).toBeInTheDocument(),
155
+ );
156
+ },
157
+ };
158
+
159
+ export const OpenWithJ: Story = {
160
+ play: async ({ canvasElement }) => {
161
+ const canvas = within(canvasElement);
162
+ await canvas.findByText("Q3 planning");
163
+ rows(canvasElement)[0]?.focus();
164
+ await userEvent.keyboard("j");
165
+ await waitFor(() =>
166
+ expect(
167
+ canvas.getAllByText("Here is the countersigned copy for your records."),
168
+ ).toHaveLength(2),
169
+ );
170
+ },
171
+ };
172
+
173
+ export const MultiSelect: Story = {
174
+ play: async ({ canvasElement }) => {
175
+ const canvas = within(canvasElement);
176
+ await canvas.findByText("Q3 planning");
177
+ const [first, , third] = rows(canvasElement);
178
+ for (const row of [first, third]) {
179
+ const toggle = row?.querySelector<HTMLElement>(
180
+ '[aria-label="Select message"]',
181
+ );
182
+ if (toggle) await userEvent.click(toggle);
183
+ }
184
+ await expect(await canvas.findByText("2 messages selected")).toBeVisible();
185
+ },
186
+ };
187
+
188
+ export const MultiSelectPhone: Story = {
189
+ globals: phone,
190
+ play: async ({ canvasElement }) => {
191
+ const canvas = within(canvasElement);
192
+ await canvas.findByText("Q3 planning");
193
+ const first = rows(canvasElement)[0];
194
+ if (first) await longPress(first);
195
+ await expect(await canvas.findByText("1 message selected")).toBeVisible();
196
+ },
197
+ };
198
+
199
+ export const Search: Story = {
200
+ args: { url: "/mail/brief?q=lease" },
201
+ play: async ({ canvasElement }) => {
202
+ const canvas = within(canvasElement);
203
+ await expect(
204
+ await canvas.findByText("Signed lease attached"),
205
+ ).toBeVisible();
206
+ await expect(canvas.queryByText("Dinner on Saturday?")).toBeNull();
207
+ },
208
+ };
209
+
210
+ export const SearchWithChips: Story = {
211
+ args: { url: "/mail/brief?q=your" },
212
+ play: async ({ canvasElement }) => {
213
+ const canvas = within(canvasElement);
214
+ await canvas.findByText("Signed lease attached");
215
+ await openFilters(canvasElement);
216
+ await pick(canvasElement, "Attributes", "Unread");
217
+ await waitFor(() =>
218
+ expect(canvas.getByLabelText("Search mail")).toHaveValue(
219
+ "your is:unread",
220
+ ),
221
+ );
222
+ },
223
+ };
224
+
225
+ export const SearchOfChipsOnly: Story = {
226
+ args: { url: "/mail/brief?q=is:unread" },
227
+ play: async ({ canvasElement }) => {
228
+ const canvas = within(canvasElement);
229
+ const offer = await canvas.findByRole("button", {
230
+ name: "Make this a filter",
231
+ });
232
+ await userEvent.click(offer);
233
+ await expect(
234
+ await canvas.findByText(/isn't a filter condition/, { selector: "p" }),
235
+ ).toBeVisible();
236
+ },
237
+ };
238
+
239
+ export const SearchHitOutsideTheBrief: Story = {
240
+ args: { url: "/mail/brief?q=outline" },
241
+ play: async ({ canvasElement }) => {
242
+ const canvas = within(canvasElement);
243
+ await userEvent.click(await canvas.findByText("Conference talk outline"));
244
+ await waitFor(() =>
245
+ expect(
246
+ canvas.getAllByText("Here is the rough structure for the keynote."),
247
+ ).toHaveLength(2),
248
+ );
249
+ },
250
+ };
@@ -0,0 +1,61 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { expect, within } from "storybook/test";
3
+ import { AppStory } from "@/mocks/story-frame/AppStory";
4
+ import { mailHandlers } from "@/mocks/story-frame/mail-handlers";
5
+ import {
6
+ mailboxIdFor,
7
+ mailWorld,
8
+ PERSONAL,
9
+ } from "@/mocks/story-frame/mail-world";
10
+
11
+ const world = mailWorld();
12
+ const draftsMailboxId = mailboxIdFor(PERSONAL, "Drafts");
13
+
14
+ const meta = {
15
+ title: "Playground/Shipped/Mail/Drafts",
16
+ component: AppStory,
17
+ args: { url: `/mail/${draftsMailboxId}` },
18
+ parameters: {
19
+ layout: "fullscreen",
20
+ msw: { handlers: mailHandlers(world) },
21
+ },
22
+ } satisfies Meta<typeof AppStory>;
23
+ export default meta;
24
+
25
+ type Story = StoryObj<typeof meta>;
26
+
27
+ export const Segmented: Story = {
28
+ play: async ({ canvasElement }) => {
29
+ const canvas = within(canvasElement);
30
+ await expect(await canvas.findByText("Not yet sent (Remit)")).toBeVisible();
31
+ await expect(canvas.getByText("On the server")).toBeVisible();
32
+ await expect(canvas.getByText("Re: Q3 planning")).toBeVisible();
33
+ await expect(canvas.getByText("Re: Conference talk outline")).toBeVisible();
34
+ },
35
+ };
36
+
37
+ export const Phone: Story = {
38
+ globals: { viewport: { value: "mobile", isRotated: false } },
39
+ play: async ({ canvasElement }) => {
40
+ const canvas = within(canvasElement);
41
+ await expect(await canvas.findByText("Not yet sent (Remit)")).toBeVisible();
42
+ },
43
+ };
44
+
45
+ export const Empty: Story = {
46
+ parameters: {
47
+ msw: {
48
+ handlers: mailHandlers({
49
+ ...world,
50
+ threads: world.threads.filter(
51
+ (row) => row.mailboxId !== draftsMailboxId,
52
+ ),
53
+ outbox: world.outbox.filter((message) => message.status !== "draft"),
54
+ }),
55
+ },
56
+ },
57
+ play: async ({ canvasElement }) => {
58
+ const canvas = within(canvasElement);
59
+ await expect(await canvas.findByText("No drafts")).toBeVisible();
60
+ },
61
+ };
@@ -0,0 +1,159 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { expect, userEvent, waitFor, within } from "storybook/test";
3
+ import { AppStory } from "@/mocks/story-frame/AppStory";
4
+ import { mailHandlers } from "@/mocks/story-frame/mail-handlers";
5
+ import { mailWorld } from "@/mocks/story-frame/mail-world";
6
+
7
+ const world = mailWorld();
8
+
9
+ const meta = {
10
+ title: "Playground/Shipped/Mail/Flagged",
11
+ component: AppStory,
12
+ args: { url: "/mail/flagged" },
13
+ parameters: {
14
+ layout: "fullscreen",
15
+ msw: { handlers: mailHandlers(world) },
16
+ },
17
+ } satisfies Meta<typeof AppStory>;
18
+ export default meta;
19
+
20
+ type Story = StoryObj<typeof meta>;
21
+
22
+ const filterToCategory = async (canvasElement: HTMLElement, name: string) => {
23
+ const canvas = within(canvasElement);
24
+ await userEvent.click(await canvas.findByLabelText("Expand filters"));
25
+ const categories = await canvas.findByLabelText("Categories");
26
+ await userEvent.click(within(categories).getByRole("button", { name }));
27
+ };
28
+
29
+ const listHeader = (canvasElement: HTMLElement): HTMLElement => {
30
+ const title = within(canvasElement)
31
+ .getAllByText("Starred")
32
+ .find((element) => element.closest("header"));
33
+ const header = title?.closest("header");
34
+ if (!header) throw new Error("The Starred list header is not on screen");
35
+ return header;
36
+ };
37
+
38
+ export const Default: Story = {
39
+ play: async ({ canvasElement }) => {
40
+ const canvas = within(canvasElement);
41
+ await expect(await canvas.findByText("Q3 planning")).toBeVisible();
42
+ await expect(canvas.getByText("Conference talk outline")).toBeVisible();
43
+ await expect(canvas.getByText("Invoice #1042")).toBeVisible();
44
+ await expect(canvas.queryByText("Dinner on Saturday?")).toBeNull();
45
+ },
46
+ };
47
+
48
+ export const Phone: Story = {
49
+ globals: { viewport: { value: "mobile", isRotated: false } },
50
+ play: async ({ canvasElement }) => {
51
+ const canvas = within(canvasElement);
52
+ await expect(await canvas.findByText("Q3 planning")).toBeVisible();
53
+ },
54
+ };
55
+
56
+ export const CountUnavailable: Story = {
57
+ parameters: {
58
+ msw: { handlers: mailHandlers(world, { withholdCounts: true }) },
59
+ },
60
+ play: async ({ canvasElement }) => {
61
+ const canvas = within(canvasElement);
62
+ await canvas.findByText("Q3 planning");
63
+ await expect(listHeader(canvasElement).textContent).not.toMatch(/\d/);
64
+ },
65
+ };
66
+
67
+ export const FilteredWithResults: Story = {
68
+ play: async ({ canvasElement }) => {
69
+ const canvas = within(canvasElement);
70
+ await canvas.findByText("Invoice #1042");
71
+ await filterToCategory(canvasElement, "Personal");
72
+ await waitFor(() =>
73
+ expect(canvas.queryByText("Invoice #1042")).not.toBeInTheDocument(),
74
+ );
75
+ await expect(await canvas.findByText("Q3 planning")).toBeVisible();
76
+ await expect(canvas.getByText("Conference talk outline")).toBeVisible();
77
+ },
78
+ };
79
+
80
+ export const FilteredEmpty: Story = {
81
+ play: async ({ canvasElement }) => {
82
+ const canvas = within(canvasElement);
83
+ await canvas.findByText("Q3 planning");
84
+ await filterToCategory(canvasElement, "Newsletter");
85
+ await expect(
86
+ await canvas.findByText("Every message in this folder was checked."),
87
+ ).toBeVisible();
88
+ await expect(canvas.queryByText("No messages in Starred")).toBeNull();
89
+ },
90
+ };
91
+
92
+ export const FilteredSearchEmpty: Story = {
93
+ play: async ({ canvasElement }) => {
94
+ const canvas = within(canvasElement);
95
+ await canvas.findByText("Q3 planning");
96
+ await filterToCategory(canvasElement, "Newsletter");
97
+ await canvas.findByText("Every message in this folder was checked.");
98
+ await userEvent.type(
99
+ canvas.getByLabelText("Search mail"),
100
+ "planning{Enter}",
101
+ );
102
+ await expect(
103
+ await canvas.findByText("No results for “planning” in Newsletter mail"),
104
+ ).toBeVisible();
105
+ await expect(
106
+ canvas.getByText("Every message in this folder was checked."),
107
+ ).toBeVisible();
108
+ },
109
+ };
110
+
111
+ export const FilteredWithMorePages: Story = {
112
+ parameters: {
113
+ msw: { handlers: mailHandlers(world, { pageSize: 2 }) },
114
+ },
115
+ play: async ({ canvasElement }) => {
116
+ const canvas = within(canvasElement);
117
+ await canvas.findByText("Q3 planning");
118
+ await filterToCategory(canvasElement, "Personal");
119
+ await expect(
120
+ await canvas.findByRole("button", { name: "Load more" }),
121
+ ).toBeEnabled();
122
+ },
123
+ };
124
+
125
+ export const FilteredFetchingMore: Story = {
126
+ parameters: {
127
+ msw: {
128
+ handlers: mailHandlers(world, { pageSize: 2, holdLaterPages: true }),
129
+ },
130
+ },
131
+ play: async ({ canvasElement }) => {
132
+ const canvas = within(canvasElement);
133
+ await canvas.findByText("Q3 planning");
134
+ await filterToCategory(canvasElement, "Personal");
135
+ await userEvent.click(
136
+ await canvas.findByRole("button", { name: "Load more" }),
137
+ );
138
+ await expect(
139
+ await canvas.findByRole("button", { name: "Loading…" }),
140
+ ).toBeDisabled();
141
+ },
142
+ };
143
+
144
+ export const Empty: Story = {
145
+ parameters: {
146
+ msw: {
147
+ handlers: mailHandlers({
148
+ ...world,
149
+ threads: world.threads.filter((row) => !row.hasStars),
150
+ }),
151
+ },
152
+ },
153
+ play: async ({ canvasElement }) => {
154
+ const canvas = within(canvasElement);
155
+ await expect(
156
+ await canvas.findByText("No messages in Starred"),
157
+ ).toBeVisible();
158
+ },
159
+ };
@@ -0,0 +1,59 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { expect, within } from "storybook/test";
3
+ import { AppStory } from "@/mocks/story-frame/AppStory";
4
+ import { mailHandlers } from "@/mocks/story-frame/mail-handlers";
5
+ import { mailWorld } from "@/mocks/story-frame/mail-world";
6
+
7
+ const meta = {
8
+ title: "Playground/Shipped/Mail/Outbox",
9
+ component: AppStory,
10
+ args: { url: "/mail/outbox" },
11
+ parameters: {
12
+ layout: "fullscreen",
13
+ msw: { handlers: mailHandlers(mailWorld()) },
14
+ },
15
+ } satisfies Meta<typeof AppStory>;
16
+ export default meta;
17
+
18
+ type Story = StoryObj<typeof meta>;
19
+
20
+ export const AllStatuses: Story = {
21
+ play: async ({ canvasElement }) => {
22
+ const canvas = within(canvasElement);
23
+ await expect(await canvas.findByText("Design review notes")).toBeVisible();
24
+ for (const subject of [
25
+ "Re: Q3 planning",
26
+ "Weekly update",
27
+ "Signed lease",
28
+ "Re: contract",
29
+ ]) {
30
+ await expect(canvas.getByText(subject)).toBeVisible();
31
+ }
32
+ for (const error of [
33
+ "Sent, but not filed: this account has no Sent folder",
34
+ "SMTP connection timed out",
35
+ "No SMTP server configured for this account",
36
+ ]) {
37
+ await expect(canvas.getByText(error, { exact: false })).toBeVisible();
38
+ }
39
+ await expect(canvas.queryByText("Invoice #1042")).toBeNull();
40
+ },
41
+ };
42
+
43
+ export const Phone: Story = {
44
+ globals: { viewport: { value: "mobile", isRotated: false } },
45
+ play: async ({ canvasElement }) => {
46
+ const canvas = within(canvasElement);
47
+ await expect(await canvas.findByText("Design review notes")).toBeVisible();
48
+ },
49
+ };
50
+
51
+ export const Empty: Story = {
52
+ parameters: {
53
+ msw: { handlers: mailHandlers(mailWorld({ outbox: [] })) },
54
+ },
55
+ play: async ({ canvasElement }) => {
56
+ const canvas = within(canvasElement);
57
+ await expect(await canvas.findByText("No outbox messages")).toBeVisible();
58
+ },
59
+ };
@@ -118,7 +118,6 @@ function buildSenderFlags(
118
118
  vip: address.flags.vip?.value === true,
119
119
  muted: address.flags.muted?.value === true,
120
120
  blocked: address.flags.blocked?.value === true,
121
- unsubscribed: address.flags.unsubscribed?.value === true,
122
121
  };
123
122
  }
124
123
 
@@ -55,7 +55,7 @@ function applyFlagPatch(
55
55
  }
56
56
 
57
57
  /**
58
- * Optimistic PATCH for per-sender flags (VIP / Mute / Block / Unsubscribe).
58
+ * Optimistic PATCH for per-sender flags (VIP / Mute / Block).
59
59
  * Mirrors the `useToggleTrusted` pattern: patch the cached
60
60
  * address-search response so the toggle flips instantly, roll back on error,
61
61
  * and invalidate on settle to reconcile with the server.
@@ -0,0 +1,47 @@
1
+ import {
2
+ MutationCache,
3
+ QueryCache,
4
+ QueryClient,
5
+ QueryClientProvider,
6
+ } from "@tanstack/react-query";
7
+ import { createMemoryHistory, RouterProvider } from "@tanstack/react-router";
8
+ import { useState } from "react";
9
+ import {
10
+ handleMutationCacheError,
11
+ handleQueryCacheError,
12
+ } from "@/lib/query-error-handler";
13
+ import { noopTelemetry } from "@/lib/telemetry";
14
+ import { createAppRouter } from "@/router";
15
+ import "@/lib/i18n";
16
+ import "@/lib/client";
17
+
18
+ export interface AppStoryProps {
19
+ url: string;
20
+ }
21
+
22
+ const createStoryQueryClient = (): QueryClient =>
23
+ new QueryClient({
24
+ queryCache: new QueryCache({ onError: handleQueryCacheError }),
25
+ mutationCache: new MutationCache({ onError: handleMutationCacheError }),
26
+ defaultOptions: {
27
+ queries: { staleTime: Number.POSITIVE_INFINITY, retry: false },
28
+ mutations: { retry: false },
29
+ },
30
+ });
31
+
32
+ export function AppStory({ url }: AppStoryProps) {
33
+ const [queryClient] = useState(createStoryQueryClient);
34
+ const [router] = useState(() =>
35
+ createAppRouter(
36
+ queryClient,
37
+ noopTelemetry,
38
+ createMemoryHistory({ initialEntries: [url] }),
39
+ ),
40
+ );
41
+
42
+ return (
43
+ <QueryClientProvider client={queryClient}>
44
+ <RouterProvider router={router} />
45
+ </QueryClientProvider>
46
+ );
47
+ }
@@ -0,0 +1,299 @@
1
+ import type {
2
+ FilterOperationsListFiltersResponse,
3
+ LabelOperationsListLabelsResponse,
4
+ MailboxOperationsListMailboxesResponse,
5
+ MeOperationsListQuarantineResponse,
6
+ MeOperationsListVipSuggestionsResponse,
7
+ MessageBulkOperationsUpdateFlagsResponse,
8
+ MessageCalendarSuggestionOperationsListMessageCalendarSuggestionsResponse,
9
+ OutboxOperationsListOutboxMessagesResponse,
10
+ RemitImapConfigDescriptionResponse,
11
+ RemitImapDescribeMessageResponse,
12
+ RemitImapThreadMessageResponse,
13
+ RemitImapThreadSearchResponse,
14
+ SemanticSearchOperationsSemanticSearchResponse,
15
+ SyncOperationsGetSyncStatusResponse,
16
+ SyncOperationsTriggerSyncResponse,
17
+ SystemOperationsGetSystemUpdateResponse,
18
+ ThreadDetailOperationsListThreadMessagesResponse,
19
+ ThreadOperationsListThreadsResponse,
20
+ } from "@remit/api-http-client/types.gen.ts";
21
+ import { type HttpHandler, HttpResponse, http } from "msw";
22
+ import { makeConfig } from "@/test-support/fixtures";
23
+ import type { MailWorld } from "./mail-world";
24
+
25
+ const API = "/api";
26
+
27
+ export interface MailHandlerOptions {
28
+ withholdCounts?: boolean;
29
+ pageSize?: number;
30
+ holdLaterPages?: boolean;
31
+ }
32
+
33
+ const contentPath = (row: RemitImapThreadMessageResponse): string =>
34
+ `/content/accounts/${row.accountConfigId}/${row.accountId}/messages/${row.messageId}/parts/1`;
35
+
36
+ const describe = (
37
+ row: RemitImapThreadMessageResponse,
38
+ ): RemitImapDescribeMessageResponse => ({
39
+ message: {
40
+ messageId: row.messageId,
41
+ mailboxId: row.mailboxId,
42
+ uid: 1,
43
+ rfc822Size: 2048,
44
+ internalDate: row.sentDate,
45
+ status: row.status,
46
+ syncStatus: row.syncStatus,
47
+ abandonedMutation: row.abandonedMutation,
48
+ },
49
+ envelope: {
50
+ messageId: row.messageId,
51
+ date: row.sentDate,
52
+ subject: row.subject,
53
+ from: [
54
+ {
55
+ addressId: `addr-${row.messageId}`,
56
+ displayName: row.fromName,
57
+ normalizedEmail: row.fromEmail ?? "",
58
+ addressRole: "from",
59
+ addressOrder: 0,
60
+ },
61
+ ],
62
+ to: [],
63
+ cc: [],
64
+ bcc: [],
65
+ replyTo: [],
66
+ category: row.category,
67
+ senderTrust: row.senderTrust,
68
+ },
69
+ flags: row.isRead ? ["\\Seen"] : [],
70
+ bodyParts: [
71
+ {
72
+ bodyPartId: `part-${row.messageId}`,
73
+ mediaType: "TEXT",
74
+ mediaSubtype: "plain",
75
+ sizeOctets: (row.snippet ?? "").length,
76
+ isMultipart: false,
77
+ contentUrl: contentPath(row),
78
+ },
79
+ ],
80
+ references: [],
81
+ });
82
+
83
+ const matchesText = (
84
+ row: RemitImapThreadMessageResponse,
85
+ needle: string,
86
+ fields: (keyof RemitImapThreadMessageResponse)[],
87
+ ): boolean => {
88
+ const lowered = needle.toLowerCase();
89
+ return fields.some((field) =>
90
+ String(row[field] ?? "")
91
+ .toLowerCase()
92
+ .includes(lowered),
93
+ );
94
+ };
95
+
96
+ const narrow = (
97
+ rows: RemitImapThreadMessageResponse[],
98
+ params: URLSearchParams,
99
+ ): RemitImapThreadMessageResponse[] => {
100
+ const categories = params.getAll("category");
101
+ const query = params.get("query");
102
+ const from = params.get("from");
103
+ const subject = params.get("subject");
104
+ const accountId = params.get("accountId");
105
+ const mailboxId = params.get("mailboxId");
106
+ return rows.filter((row) => {
107
+ if (params.get("starred") === "true" && !row.hasStars) return false;
108
+ if (params.get("unread") === "true" && row.isRead) return false;
109
+ if (params.get("attachments") === "true" && !row.hasAttachment)
110
+ return false;
111
+ if (categories.length > 0 && !categories.includes(row.category))
112
+ return false;
113
+ if (accountId && row.accountId !== accountId) return false;
114
+ if (mailboxId && row.mailboxId !== mailboxId) return false;
115
+ if (query && !matchesText(row, query, ["subject", "fromName", "snippet"]))
116
+ return false;
117
+ if (from && !matchesText(row, from, ["fromName", "fromEmail"]))
118
+ return false;
119
+ if (subject && !matchesText(row, subject, ["subject"])) return false;
120
+ return true;
121
+ });
122
+ };
123
+
124
+ const newestFirst = (
125
+ rows: RemitImapThreadMessageResponse[],
126
+ ): RemitImapThreadMessageResponse[] =>
127
+ [...rows].sort((a, b) => b.sentDate - a.sentDate);
128
+
129
+ const listing = (
130
+ rows: RemitImapThreadMessageResponse[],
131
+ params: URLSearchParams,
132
+ options: MailHandlerOptions,
133
+ ): RemitImapThreadSearchResponse => {
134
+ const offset = Number(params.get("continuationToken") ?? 0);
135
+ const size = options.pageSize ?? Number(params.get("limit") ?? rows.length);
136
+ const next = offset + size;
137
+ const counted = params.get("count") === "true" && !options.withholdCounts;
138
+ return {
139
+ items:
140
+ params.get("results") === "false"
141
+ ? []
142
+ : newestFirst(rows).slice(offset, next),
143
+ continuationToken:
144
+ options.pageSize !== undefined && next < rows.length
145
+ ? String(next)
146
+ : undefined,
147
+ count: counted ? new Set(rows.map((row) => row.threadId)).size : undefined,
148
+ };
149
+ };
150
+
151
+ const pending = (): Promise<never> => new Promise<never>(() => undefined);
152
+
153
+ export const mailHandlers = (
154
+ world: MailWorld,
155
+ options: MailHandlerOptions = {},
156
+ ): HttpHandler[] => {
157
+ const byMessageId = new Map(world.threads.map((row) => [row.messageId, row]));
158
+ const inboxIds = new Set(
159
+ world.accounts.flatMap((account) =>
160
+ account.folderAppointments
161
+ .filter((appointment) => appointment.role === "Inbox")
162
+ .map((appointment) => appointment.mailboxId),
163
+ ),
164
+ );
165
+
166
+ return [
167
+ http.get(`${API}/config`, () =>
168
+ HttpResponse.json<RemitImapConfigDescriptionResponse>(
169
+ makeConfig(world.accounts),
170
+ ),
171
+ ),
172
+ http.get(`${API}/accounts/:accountId/mailboxes`, ({ params }) =>
173
+ HttpResponse.json<MailboxOperationsListMailboxesResponse>({
174
+ items: world.mailboxes.filter(
175
+ (mailbox) => mailbox.accountId === params.accountId,
176
+ ),
177
+ }),
178
+ ),
179
+ http.get(`${API}/accounts/:accountId/sync/status`, ({ params }) =>
180
+ HttpResponse.json<SyncOperationsGetSyncStatusResponse>({
181
+ accountId: String(params.accountId),
182
+ syncPhase: "complete",
183
+ mailboxes: [],
184
+ }),
185
+ ),
186
+ http.get(`${API}/accounts/:accountId/labels`, () =>
187
+ HttpResponse.json<LabelOperationsListLabelsResponse>({ items: [] }),
188
+ ),
189
+ http.get(`${API}/accounts/:accountId/filters`, () =>
190
+ HttpResponse.json<FilterOperationsListFiltersResponse>({ items: [] }),
191
+ ),
192
+ http.post(`${API}/accounts/:accountId/sync`, () =>
193
+ HttpResponse.json<SyncOperationsTriggerSyncResponse>({
194
+ triggered: false,
195
+ message: "Already up to date",
196
+ }),
197
+ ),
198
+ http.get(`${API}/messages/:messageId/calendar-suggestions`, () =>
199
+ HttpResponse.json<MessageCalendarSuggestionOperationsListMessageCalendarSuggestionsResponse>(
200
+ { items: [] },
201
+ ),
202
+ ),
203
+ http.get(`${API}/messages/:messageId`, ({ params }) => {
204
+ const row = byMessageId.get(String(params.messageId));
205
+ if (!row)
206
+ return HttpResponse.json(
207
+ { status: 404, message: "Message not found" },
208
+ { status: 404 },
209
+ );
210
+ return HttpResponse.json<RemitImapDescribeMessageResponse>(describe(row));
211
+ }),
212
+ http.post(`${API}/messages/flags`, () =>
213
+ HttpResponse.json<MessageBulkOperationsUpdateFlagsResponse>({
214
+ successCount: 1,
215
+ failureCount: 0,
216
+ }),
217
+ ),
218
+ http.get(
219
+ "/content/accounts/:accountConfigId/:accountId/messages/:messageId/parts/*",
220
+ ({ params }) =>
221
+ HttpResponse.text(
222
+ byMessageId.get(String(params.messageId))?.snippet ?? "",
223
+ ),
224
+ ),
225
+ http.get(`${API}/threads/:threadId/messages`, ({ params }) =>
226
+ HttpResponse.json<ThreadDetailOperationsListThreadMessagesResponse>({
227
+ items: world.threads.filter((row) => row.threadId === params.threadId),
228
+ }),
229
+ ),
230
+ http.get(`${API}/threads`, ({ request }) => {
231
+ const params = new URL(request.url).searchParams;
232
+ const searching = params.has("query");
233
+ const scope = world.threads.filter(
234
+ (row) =>
235
+ searching ||
236
+ params.get("starred") === "true" ||
237
+ inboxIds.has(row.mailboxId),
238
+ );
239
+ if (options.holdLaterPages && params.has("continuationToken"))
240
+ return pending();
241
+ return HttpResponse.json(listing(narrow(scope, params), params, options));
242
+ }),
243
+ http.get(
244
+ `${API}/mailboxes/:mailboxId/threads/search`,
245
+ ({ request, params }) =>
246
+ HttpResponse.json(
247
+ listing(
248
+ narrow(
249
+ world.threads.filter((row) => row.mailboxId === params.mailboxId),
250
+ new URL(request.url).searchParams,
251
+ ),
252
+ new URL(request.url).searchParams,
253
+ options,
254
+ ),
255
+ ),
256
+ ),
257
+ http.get(`${API}/mailboxes/:mailboxId/threads`, ({ params }) =>
258
+ HttpResponse.json<ThreadOperationsListThreadsResponse>({
259
+ items: newestFirst(
260
+ world.threads.filter((row) => row.mailboxId === params.mailboxId),
261
+ ),
262
+ }),
263
+ ),
264
+ http.get(`${API}/outbox`, () =>
265
+ HttpResponse.json<OutboxOperationsListOutboxMessagesResponse>({
266
+ items: world.outbox,
267
+ }),
268
+ ),
269
+ http.get(`${API}/search/semantic`, () =>
270
+ HttpResponse.json<SemanticSearchOperationsSemanticSearchResponse>({
271
+ items: [],
272
+ }),
273
+ ),
274
+ http.get(`${API}/me/quarantine`, () =>
275
+ HttpResponse.json<MeOperationsListQuarantineResponse>({ entries: [] }),
276
+ ),
277
+ http.get(`${API}/me/vip-suggestions`, () =>
278
+ HttpResponse.json<MeOperationsListVipSuggestionsResponse>({
279
+ suggestions: [],
280
+ }),
281
+ ),
282
+ http.get(`${API}/system/update`, () =>
283
+ HttpResponse.json<SystemOperationsGetSystemUpdateResponse>({
284
+ currentVersion: "0.0.0-storybook",
285
+ check: { status: "disabled" },
286
+ run: null,
287
+ }),
288
+ ),
289
+ http.all(`${API}/*`, ({ request }) =>
290
+ HttpResponse.json(
291
+ {
292
+ status: 501,
293
+ message: `No story handler for ${request.method} ${new URL(request.url).pathname}`,
294
+ },
295
+ { status: 501 },
296
+ ),
297
+ ),
298
+ ];
299
+ };
@@ -0,0 +1,389 @@
1
+ import type {
2
+ RemitImapAccountResponse,
3
+ RemitImapCanonicalMailboxRole,
4
+ RemitImapMailboxResponse,
5
+ RemitImapMessageCategory,
6
+ RemitImapOutboxMessageResponse,
7
+ RemitImapOutboxMessageStatus,
8
+ RemitImapThreadMessageResponse,
9
+ } from "@remit/api-http-client/types.gen.ts";
10
+ import {
11
+ makeAccount,
12
+ makeMailbox,
13
+ makeThreadMessage,
14
+ } from "@/test-support/fixtures";
15
+
16
+ export interface MailWorld {
17
+ accounts: RemitImapAccountResponse[];
18
+ mailboxes: RemitImapMailboxResponse[];
19
+ threads: RemitImapThreadMessageResponse[];
20
+ outbox: RemitImapOutboxMessageResponse[];
21
+ }
22
+
23
+ const MINUTE = 60_000;
24
+ const HOUR = 60 * MINUTE;
25
+ const DAY = 24 * HOUR;
26
+ const NOW = Date.UTC(2026, 8, 24, 13, 0);
27
+
28
+ const ROLES = [
29
+ "Inbox",
30
+ "Drafts",
31
+ "Sent",
32
+ "Archive",
33
+ "Junk",
34
+ "Trash",
35
+ ] as const satisfies readonly RemitImapCanonicalMailboxRole[];
36
+
37
+ const FOLDER_PATHS: Record<(typeof ROLES)[number], string> = {
38
+ Inbox: "INBOX",
39
+ Drafts: "Drafts",
40
+ Sent: "Sent",
41
+ Archive: "Archive",
42
+ Junk: "Junk",
43
+ Trash: "Trash",
44
+ };
45
+
46
+ export const mailboxIdFor = (
47
+ accountId: string,
48
+ role: RemitImapCanonicalMailboxRole,
49
+ ): string => `mbx-${accountId}-${role.toLowerCase()}`;
50
+
51
+ const accountWithFolders = (
52
+ accountId: string,
53
+ email: string,
54
+ displayName: string,
55
+ ): RemitImapAccountResponse =>
56
+ makeAccount({
57
+ accountId,
58
+ email,
59
+ username: email,
60
+ smtpUsername: email,
61
+ displayName,
62
+ lastSyncAt: NOW,
63
+ folderAppointments: ROLES.map((role) => ({
64
+ role,
65
+ source: "Appointed",
66
+ mailboxId: mailboxIdFor(accountId, role),
67
+ })),
68
+ });
69
+
70
+ const foldersOf = (accountId: string): RemitImapMailboxResponse[] =>
71
+ ROLES.map((role) =>
72
+ makeMailbox({
73
+ accountId,
74
+ mailboxId: mailboxIdFor(accountId, role),
75
+ fullPath: FOLDER_PATHS[role],
76
+ specialUse: role === "Inbox" ? undefined : [role],
77
+ }),
78
+ );
79
+
80
+ export const PERSONAL = "acc-personal";
81
+ export const WORK = "acc-work";
82
+
83
+ const accounts = [
84
+ accountWithFolders(PERSONAL, "alice@example.com", "Personal"),
85
+ accountWithFolders(WORK, "alice.tan@acme.example", "Work"),
86
+ ];
87
+
88
+ interface ThreadSeed {
89
+ id: string;
90
+ accountId: string;
91
+ role?: RemitImapCanonicalMailboxRole;
92
+ fromName: string;
93
+ fromEmail: string;
94
+ subject: string;
95
+ snippet: string;
96
+ category: RemitImapMessageCategory;
97
+ ago: number;
98
+ isRead?: boolean;
99
+ starred?: boolean;
100
+ hasAttachment?: boolean;
101
+ }
102
+
103
+ const thread = (seed: ThreadSeed): RemitImapThreadMessageResponse =>
104
+ makeThreadMessage({
105
+ messageId: `msg-${seed.id}`,
106
+ threadId: `thread-${seed.id}`,
107
+ threadMessageId: `tm-${seed.id}`,
108
+ accountId: seed.accountId,
109
+ mailboxId: mailboxIdFor(seed.accountId, seed.role ?? "Inbox"),
110
+ fromName: seed.fromName,
111
+ fromEmail: seed.fromEmail,
112
+ subject: seed.subject,
113
+ snippet: seed.snippet,
114
+ category: seed.category,
115
+ sentDate: NOW - seed.ago,
116
+ isRead: seed.isRead ?? false,
117
+ hasAttachment: seed.hasAttachment ?? false,
118
+ star: seed.starred ? "yellow" : "none",
119
+ hasStars: seed.starred ?? false,
120
+ syncStatus: "synced",
121
+ });
122
+
123
+ const inboxThreads: RemitImapThreadMessageResponse[] = [
124
+ thread({
125
+ id: "q3-planning",
126
+ accountId: WORK,
127
+ fromName: "Ada Lovelace",
128
+ fromEmail: "ada@acme.example",
129
+ subject: "Q3 planning",
130
+ snippet: "Can we move the review to Thursday? I have the numbers ready.",
131
+ category: "personal",
132
+ ago: 20 * MINUTE,
133
+ starred: true,
134
+ }),
135
+ thread({
136
+ id: "lease",
137
+ accountId: PERSONAL,
138
+ fromName: "Amara Okafor",
139
+ fromEmail: "amara@example.org",
140
+ subject: "Signed lease attached",
141
+ snippet: "Here is the countersigned copy for your records.",
142
+ category: "personal",
143
+ ago: 2 * HOUR,
144
+ hasAttachment: true,
145
+ starred: true,
146
+ }),
147
+ thread({
148
+ id: "dinner",
149
+ accountId: PERSONAL,
150
+ fromName: "Grace Hopper",
151
+ fromEmail: "grace@example.org",
152
+ subject: "Dinner on Saturday?",
153
+ snippet: "We are thinking the new place on the corner, around seven.",
154
+ category: "personal",
155
+ ago: 5 * HOUR,
156
+ isRead: true,
157
+ }),
158
+ thread({
159
+ id: "invoice",
160
+ accountId: WORK,
161
+ fromName: "Billing",
162
+ fromEmail: "billing@saas.example",
163
+ subject: "Invoice #1042",
164
+ snippet: "Your invoice for September is ready.",
165
+ category: "transactional",
166
+ ago: 7 * HOUR,
167
+ isRead: true,
168
+ starred: true,
169
+ }),
170
+ thread({
171
+ id: "shipping",
172
+ accountId: PERSONAL,
173
+ fromName: "Parcel Service",
174
+ fromEmail: "track@parcel.example",
175
+ subject: "Your order has shipped",
176
+ snippet: "Estimated delivery: tomorrow before noon.",
177
+ category: "transactional",
178
+ ago: 9 * HOUR,
179
+ }),
180
+ thread({
181
+ id: "weekly-digest",
182
+ accountId: PERSONAL,
183
+ fromName: "The Weekly",
184
+ fromEmail: "digest@weekly.example",
185
+ subject: "This week in type systems",
186
+ snippet: "Variance, soundness and a long read on gradual typing.",
187
+ category: "newsletter",
188
+ ago: DAY,
189
+ }),
190
+ thread({
191
+ id: "sale",
192
+ accountId: PERSONAL,
193
+ fromName: "Outdoor Store",
194
+ fromEmail: "news@outdoor.example",
195
+ subject: "Autumn sale starts now",
196
+ snippet: "Up to 40% off jackets and boots this weekend only.",
197
+ category: "marketing",
198
+ ago: DAY + 3 * HOUR,
199
+ }),
200
+ thread({
201
+ id: "mention",
202
+ accountId: WORK,
203
+ fromName: "Chat",
204
+ fromEmail: "notifications@chat.example",
205
+ subject: "Linus mentioned you in #design",
206
+ snippet: "Linus: can you take a look at the new header?",
207
+ category: "social",
208
+ ago: DAY + 5 * HOUR,
209
+ }),
210
+ thread({
211
+ id: "build",
212
+ accountId: WORK,
213
+ fromName: "CI",
214
+ fromEmail: "ci@builds.example",
215
+ subject: "Build passed on main",
216
+ snippet: "All 212 checks passed in 6m 12s.",
217
+ category: "automated",
218
+ ago: 2 * DAY,
219
+ isRead: true,
220
+ }),
221
+ thread({
222
+ id: "talk-outline",
223
+ accountId: PERSONAL,
224
+ role: "Archive",
225
+ fromName: "Ken Thompson",
226
+ fromEmail: "ken@example.org",
227
+ subject: "Conference talk outline",
228
+ snippet: "Here is the rough structure for the keynote.",
229
+ category: "personal",
230
+ ago: 3 * DAY,
231
+ isRead: true,
232
+ starred: true,
233
+ }),
234
+ ];
235
+
236
+ const serverDrafts: RemitImapThreadMessageResponse[] = [
237
+ thread({
238
+ id: "draft-talk",
239
+ accountId: PERSONAL,
240
+ role: "Drafts",
241
+ fromName: "Alice",
242
+ fromEmail: "alice@example.com",
243
+ subject: "Re: Conference talk outline",
244
+ snippet: "Thanks Ken, I will send slides by Friday.",
245
+ category: "personal",
246
+ ago: 4 * HOUR,
247
+ isRead: true,
248
+ }),
249
+ ];
250
+
251
+ interface OutboxSeed {
252
+ id: string;
253
+ accountId: string;
254
+ to: string;
255
+ subject: string;
256
+ body: string;
257
+ status: RemitImapOutboxMessageStatus;
258
+ ago: number;
259
+ lastError?: string;
260
+ }
261
+
262
+ export const outboxMessage = (
263
+ seed: OutboxSeed,
264
+ ): RemitImapOutboxMessageResponse => {
265
+ const from = accounts.find((account) => account.accountId === seed.accountId);
266
+ return {
267
+ outboxMessageId: `out-${seed.id}`,
268
+ accountId: seed.accountId,
269
+ fromAddress: from?.email ?? "alice@example.com",
270
+ fromName: "Alice",
271
+ toAddresses: [seed.to],
272
+ ccAddresses: [],
273
+ bccAddresses: [],
274
+ subject: seed.subject,
275
+ references: [],
276
+ textBody: seed.body,
277
+ status: seed.status,
278
+ lastError: seed.lastError,
279
+ sentAt: seed.status === "sent" ? NOW - seed.ago : undefined,
280
+ createdAt: NOW - seed.ago,
281
+ updatedAt: NOW - seed.ago,
282
+ attachments: [],
283
+ };
284
+ };
285
+
286
+ const remitDrafts: RemitImapOutboxMessageResponse[] = [
287
+ outboxMessage({
288
+ id: "draft-q3",
289
+ accountId: PERSONAL,
290
+ to: "ada@acme.example",
291
+ subject: "Re: Q3 planning",
292
+ body: "Thanks, Thursday works for me.",
293
+ status: "draft",
294
+ ago: 10 * MINUTE,
295
+ }),
296
+ outboxMessage({
297
+ id: "draft-untitled",
298
+ accountId: PERSONAL,
299
+ to: "team@example.com",
300
+ subject: "",
301
+ body: "",
302
+ status: "draft",
303
+ ago: HOUR,
304
+ }),
305
+ ];
306
+
307
+ export const outboxInEveryStatus: RemitImapOutboxMessageResponse[] = [
308
+ outboxMessage({
309
+ id: "queued",
310
+ accountId: WORK,
311
+ to: "ada@acme.example",
312
+ subject: "Re: Q3 planning",
313
+ body: "Thursday it is.",
314
+ status: "queued",
315
+ ago: 2 * MINUTE,
316
+ }),
317
+ outboxMessage({
318
+ id: "sending",
319
+ accountId: WORK,
320
+ to: "team@acme.example",
321
+ subject: "Weekly update",
322
+ body: "Highlights from this week.",
323
+ status: "sending",
324
+ ago: 3 * MINUTE,
325
+ }),
326
+ outboxMessage({
327
+ id: "sent",
328
+ accountId: WORK,
329
+ to: "grace@example.org",
330
+ subject: "Invoice #1042",
331
+ body: "Paid, thanks.",
332
+ status: "sent",
333
+ ago: 16 * MINUTE,
334
+ }),
335
+ outboxMessage({
336
+ id: "unfiled",
337
+ accountId: PERSONAL,
338
+ to: "amara@example.org",
339
+ subject: "Signed lease",
340
+ body: "Signed and attached.",
341
+ status: "unfiled",
342
+ ago: 22 * MINUTE,
343
+ lastError: "Sent, but not filed: this account has no Sent folder",
344
+ }),
345
+ outboxMessage({
346
+ id: "failed",
347
+ accountId: WORK,
348
+ to: "linus@example.org",
349
+ subject: "Design review notes",
350
+ body: "Notes from today.",
351
+ status: "failed",
352
+ ago: 34 * MINUTE,
353
+ lastError: "SMTP connection timed out",
354
+ }),
355
+ outboxMessage({
356
+ id: "blocked",
357
+ accountId: PERSONAL,
358
+ to: "ken@example.org",
359
+ subject: "Re: contract",
360
+ body: "Looks good to me.",
361
+ status: "blocked",
362
+ ago: DAY,
363
+ lastError: "No SMTP server configured for this account",
364
+ }),
365
+ ];
366
+
367
+ export const newsletterBacklog = (
368
+ count: number,
369
+ ): RemitImapThreadMessageResponse[] =>
370
+ Array.from({ length: count }, (_, index) =>
371
+ thread({
372
+ id: `digest-${index}`,
373
+ accountId: PERSONAL,
374
+ fromName: "The Weekly",
375
+ fromEmail: "digest@weekly.example",
376
+ subject: `Issue ${index + 1}: notes from the field`,
377
+ snippet: "Three long reads and a short one.",
378
+ category: "newsletter",
379
+ ago: 2 * DAY + index * HOUR,
380
+ }),
381
+ );
382
+
383
+ export const mailWorld = (overrides: Partial<MailWorld> = {}): MailWorld => ({
384
+ accounts,
385
+ mailboxes: accounts.flatMap((account) => foldersOf(account.accountId)),
386
+ threads: [...inboxThreads, ...serverDrafts],
387
+ outbox: [...remitDrafts, ...outboxInEveryStatus],
388
+ ...overrides,
389
+ });
@@ -0,0 +1,21 @@
1
+ const HOLD_MS = 700;
2
+
3
+ const touch = (target: HTMLElement, type: string) => {
4
+ const { left, top } = target.getBoundingClientRect();
5
+ target.dispatchEvent(
6
+ new PointerEvent(type, {
7
+ bubbles: true,
8
+ pointerType: "touch",
9
+ pointerId: 1,
10
+ isPrimary: true,
11
+ clientX: left + 20,
12
+ clientY: top + 10,
13
+ }),
14
+ );
15
+ };
16
+
17
+ export const longPress = async (target: HTMLElement): Promise<void> => {
18
+ touch(target, "pointerdown");
19
+ await new Promise((resolve) => setTimeout(resolve, HOLD_MS));
20
+ touch(target, "pointerup");
21
+ };
package/src/router.tsx CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { QueryClient } from "@tanstack/react-query";
2
- import { createRouter } from "@tanstack/react-router";
2
+ import { createRouter, type RouterHistory } from "@tanstack/react-router";
3
3
  import { AppShellSkeleton } from "./components/layout/AppShellSkeleton";
4
4
  import { recordRoute } from "./lib/route-breadcrumbs";
5
5
  import { stringifySearch } from "./lib/search-params";
@@ -13,10 +13,12 @@ export interface RouterContext {
13
13
  export const createAppRouter = (
14
14
  queryClient: QueryClient,
15
15
  telemetry: Telemetry,
16
+ history?: RouterHistory,
16
17
  ) => {
17
18
  const router = createRouter({
18
19
  routeTree,
19
20
  context: { queryClient },
21
+ history,
20
22
  // A query param that carries a list writes one param per value
21
23
  // (`lib/search-params.ts`), which is what the router's own reader already
22
24
  // expects and what a hand-written link says.