@remit/web-client 0.0.296 → 0.0.298
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 +2 -1
- package/src/components/mail/daily-brief.stories.tsx +250 -0
- package/src/components/mail/drafts.stories.tsx +61 -0
- package/src/components/mail/flagged.stories.tsx +159 -0
- package/src/components/mail/outbox.stories.tsx +59 -0
- package/src/hooks/useIntelligenceData.ts +2 -2
- package/src/mocks/story-frame/AppStory.tsx +47 -0
- package/src/mocks/story-frame/mail-handlers.ts +299 -0
- package/src/mocks/story-frame/mail-world.ts +389 -0
- package/src/mocks/story-frame/touch.ts +21 -0
- package/src/router.tsx +3 -1
- package/src/routes/settings/-accounts.test.ts +18 -0
- package/src/routes/settings/accounts.tsx +8 -1
- package/src/test-support/fixtures.ts +1 -0
|
@@ -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
|
+
};
|