@remit/web-client 0.0.312 → 0.0.314
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 +1 -1
- package/src/components/calendar/CalendarComposePane.tsx +15 -2
- package/src/components/calendar/CalendarWaiting.tsx +3 -0
- package/src/components/calendar/ChangeCalendarSuggestion.stories.tsx +182 -0
- package/src/components/calendar/ChangeCalendarSuggestion.tsx +159 -0
- package/src/components/calendar/PendingSuggestions.tsx +35 -5
- package/src/components/calendar/calendar-story-server.tsx +5 -0
- package/src/components/calendar/change-suggestion-first.render.test.ts +299 -0
- package/src/components/calendar/pending-suggestions.render.test.ts +68 -3
- package/src/components/compose/ComposeForm.tsx +38 -4
- package/src/components/compose/compose-inline-image.render.test.ts +542 -0
- package/src/hooks/calendar/draft.ts +1 -1
- package/src/hooks/calendar/index.ts +7 -0
- package/src/hooks/calendar/suggestion-draft.ts +77 -0
- package/src/hooks/calendar/useCalendarSuggestions.ts +59 -1
- package/src/hooks/intelligence-calendar.render.test.ts +135 -0
- package/src/hooks/useComposeAttachments.ts +6 -1
- package/src/hooks/useInlineImages.ts +93 -0
- package/src/hooks/useIntelligenceCalendar.ts +36 -5
- package/src/lib/calendar-suggestion.test.ts +10 -1
- package/src/lib/calendar-suggestion.ts +12 -2
- package/src/lib/compose-attachment-controller.test.ts +110 -0
- package/src/lib/compose-attachment-controller.ts +120 -16
- package/src/routeTree.gen.ts +23 -0
- package/src/routes/calendar/$view.$date/suggestion.$suggestionId.tsx +22 -0
- package/src/routes/calendar/$view.$date.tsx +9 -3
- package/src/routes/calendar/-calendar-tree.test.ts +18 -0
- package/src/routing/calendar.ts +36 -1
- package/src/routing/index.ts +2 -0
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { afterEach, before, describe, it, mock } from "node:test";
|
|
3
|
+
import type {
|
|
4
|
+
RemitImapAccountResponse,
|
|
5
|
+
RemitImapDescribeMessageResponse,
|
|
6
|
+
} from "@remit/api-http-client/types.gen.ts";
|
|
7
|
+
import {
|
|
8
|
+
type AnyRouter,
|
|
9
|
+
createMemoryHistory,
|
|
10
|
+
createRootRoute,
|
|
11
|
+
createRoute,
|
|
12
|
+
createRouter,
|
|
13
|
+
RouterContextProvider,
|
|
14
|
+
} from "@tanstack/react-router";
|
|
15
|
+
import { createElement } from "react";
|
|
16
|
+
import { createDomHarness, type DomHarness } from "../../test-support/dom";
|
|
17
|
+
import {
|
|
18
|
+
type HttpCall,
|
|
19
|
+
type HttpMock,
|
|
20
|
+
mockFetch,
|
|
21
|
+
} from "../../test-support/http";
|
|
22
|
+
import { ComposeForm } from "./ComposeForm";
|
|
23
|
+
import { ComposeProvider } from "./ComposeProvider";
|
|
24
|
+
|
|
25
|
+
const ACCOUNT_ID = "acc-1";
|
|
26
|
+
const OUTBOX_MESSAGE_ID = "ob-1307";
|
|
27
|
+
const AUTOSAVE_DEBOUNCE_MS = 2000;
|
|
28
|
+
|
|
29
|
+
const account = {
|
|
30
|
+
accountId: ACCOUNT_ID,
|
|
31
|
+
email: "me@example.com",
|
|
32
|
+
smtpEnabled: true,
|
|
33
|
+
} as unknown as RemitImapAccountResponse;
|
|
34
|
+
|
|
35
|
+
const sourceMessage = {
|
|
36
|
+
message: { messageId: "msg-1" },
|
|
37
|
+
envelope: {
|
|
38
|
+
subject: "The chart",
|
|
39
|
+
messageIdValue: "<m1@example.com>",
|
|
40
|
+
from: [{ normalizedEmail: "them@example.com", displayName: "Them" }],
|
|
41
|
+
replyTo: [],
|
|
42
|
+
to: [],
|
|
43
|
+
cc: [],
|
|
44
|
+
},
|
|
45
|
+
references: [],
|
|
46
|
+
bodyParts: [],
|
|
47
|
+
} as unknown as RemitImapDescribeMessageResponse;
|
|
48
|
+
|
|
49
|
+
(globalThis as { self?: typeof globalThis }).self ??= globalThis;
|
|
50
|
+
|
|
51
|
+
class JsdomClipboardEvent extends Event {
|
|
52
|
+
readonly clipboardData: DataTransfer;
|
|
53
|
+
|
|
54
|
+
constructor(type: string, clipboardData: DataTransfer) {
|
|
55
|
+
super(type, { bubbles: true, cancelable: true });
|
|
56
|
+
this.clipboardData = clipboardData;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const clipboardGlobal = globalThis as { ClipboardEvent?: unknown };
|
|
61
|
+
clipboardGlobal.ClipboardEvent ??= JsdomClipboardEvent;
|
|
62
|
+
const ClipboardEventClass = clipboardGlobal.ClipboardEvent as new (
|
|
63
|
+
type: string,
|
|
64
|
+
clipboardData: DataTransfer,
|
|
65
|
+
) => Event;
|
|
66
|
+
|
|
67
|
+
const transferOf = (files: File[], text = ""): DataTransfer =>
|
|
68
|
+
({
|
|
69
|
+
types: ["Files"],
|
|
70
|
+
files,
|
|
71
|
+
getData: (type: string) => (type === "text/plain" ? text : ""),
|
|
72
|
+
}) as unknown as DataTransfer;
|
|
73
|
+
|
|
74
|
+
before(() => {
|
|
75
|
+
Object.defineProperty(Range.prototype, "getBoundingClientRect", {
|
|
76
|
+
value: () => ({
|
|
77
|
+
top: 0,
|
|
78
|
+
bottom: 0,
|
|
79
|
+
left: 0,
|
|
80
|
+
right: 0,
|
|
81
|
+
width: 0,
|
|
82
|
+
height: 0,
|
|
83
|
+
x: 0,
|
|
84
|
+
y: 0,
|
|
85
|
+
}),
|
|
86
|
+
configurable: true,
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const rootRoute = createRootRoute();
|
|
91
|
+
const mailboxRoute = createRoute({
|
|
92
|
+
getParentRoute: () => rootRoute,
|
|
93
|
+
path: "/mail/$mailboxId",
|
|
94
|
+
validateSearch: (search: Record<string, unknown>) => search,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const testRouter = (): AnyRouter =>
|
|
98
|
+
createRouter({
|
|
99
|
+
routeTree: rootRoute.addChildren([mailboxRoute]),
|
|
100
|
+
history: createMemoryHistory({ initialEntries: ["/mail/mbx-1"] }),
|
|
101
|
+
}) as unknown as AnyRouter;
|
|
102
|
+
|
|
103
|
+
let harness: DomHarness | undefined;
|
|
104
|
+
let http: HttpMock | undefined;
|
|
105
|
+
|
|
106
|
+
interface ServerAttachment {
|
|
107
|
+
outboxAttachmentId: string;
|
|
108
|
+
filename: string;
|
|
109
|
+
contentType: string;
|
|
110
|
+
sizeBytes: number;
|
|
111
|
+
state: "Pending" | "Stored";
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
interface Server {
|
|
115
|
+
htmlBody: string;
|
|
116
|
+
attachments: ServerAttachment[];
|
|
117
|
+
refuseMints: boolean;
|
|
118
|
+
minted: number;
|
|
119
|
+
failPuts: number;
|
|
120
|
+
linkGeneration: number;
|
|
121
|
+
expireBefore: number;
|
|
122
|
+
contentStatus: number;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
let server: Server;
|
|
126
|
+
|
|
127
|
+
const freshServer = (): Server => ({
|
|
128
|
+
htmlBody: "<p>Here it is</p>",
|
|
129
|
+
attachments: [],
|
|
130
|
+
refuseMints: false,
|
|
131
|
+
minted: 0,
|
|
132
|
+
failPuts: 0,
|
|
133
|
+
linkGeneration: 0,
|
|
134
|
+
expireBefore: 0,
|
|
135
|
+
contentStatus: 200,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
afterEach(() => {
|
|
139
|
+
harness?.close();
|
|
140
|
+
harness = undefined;
|
|
141
|
+
http?.restore();
|
|
142
|
+
http = undefined;
|
|
143
|
+
mock.restoreAll();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const CONTENT_HOST = "https://content.test";
|
|
147
|
+
|
|
148
|
+
const toResponse = (attachment: ServerAttachment) => ({
|
|
149
|
+
...attachment,
|
|
150
|
+
outboxMessageId: OUTBOX_MESSAGE_ID,
|
|
151
|
+
contentId: `${attachment.outboxAttachmentId}@remit`,
|
|
152
|
+
contentUrl: `${CONTENT_HOST}/${attachment.outboxAttachmentId}?g=${server.linkGeneration}`,
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const outboxEntry = () => ({
|
|
156
|
+
outboxMessageId: OUTBOX_MESSAGE_ID,
|
|
157
|
+
accountId: ACCOUNT_ID,
|
|
158
|
+
fromAddress: account.email,
|
|
159
|
+
toAddresses: ["them@example.com"],
|
|
160
|
+
attachments: server.attachments.map(toResponse),
|
|
161
|
+
ccAddresses: [],
|
|
162
|
+
bccAddresses: [],
|
|
163
|
+
references: [],
|
|
164
|
+
subject: "Re: The chart",
|
|
165
|
+
textBody: "Here it is",
|
|
166
|
+
htmlBody: server.htmlBody,
|
|
167
|
+
status: "draft",
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const refusal = (filename: string): Response =>
|
|
171
|
+
new Response(
|
|
172
|
+
JSON.stringify({
|
|
173
|
+
code: "attachment_rejected",
|
|
174
|
+
reason: "FileTooLarge",
|
|
175
|
+
message: `"${filename}" is 30.0 MB, over the 25.0 MB a message can carry.`,
|
|
176
|
+
limitBytes: 26_214_400,
|
|
177
|
+
usedBytes: 0,
|
|
178
|
+
}),
|
|
179
|
+
{ status: 413, headers: { "content-type": "application/json" } },
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
const answer = (call: HttpCall): unknown => {
|
|
183
|
+
if (call.url.startsWith(CONTENT_HOST)) {
|
|
184
|
+
const generation = Number(new URL(call.url).searchParams.get("g"));
|
|
185
|
+
if (generation < server.expireBefore) {
|
|
186
|
+
return new Response("", {
|
|
187
|
+
status: 403,
|
|
188
|
+
headers: { "x-remit-403-reason": "expired" },
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
if (server.contentStatus !== 200) {
|
|
192
|
+
return new Response("", { status: server.contentStatus });
|
|
193
|
+
}
|
|
194
|
+
return new Response(new Uint8Array([0x89, 0x50, 0x4e, 0x47]), {
|
|
195
|
+
status: 200,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
if (call.url.startsWith("https://upload.test")) {
|
|
199
|
+
if (server.failPuts === 0) return {};
|
|
200
|
+
server.failPuts -= 1;
|
|
201
|
+
return new Response("", { status: 500 });
|
|
202
|
+
}
|
|
203
|
+
if (call.path.endsWith("/config")) return { accounts: [account] };
|
|
204
|
+
if (call.method === "POST" && call.path.endsWith("/attachments")) {
|
|
205
|
+
const filename = String(call.body?.filename);
|
|
206
|
+
if (server.refuseMints) return refusal(filename);
|
|
207
|
+
server.minted += 1;
|
|
208
|
+
const attachment: ServerAttachment = {
|
|
209
|
+
outboxAttachmentId: `att-${server.minted}`,
|
|
210
|
+
filename,
|
|
211
|
+
contentType: String(call.body?.contentType),
|
|
212
|
+
sizeBytes: Number(call.body?.sizeBytes),
|
|
213
|
+
state: "Pending",
|
|
214
|
+
};
|
|
215
|
+
server.attachments.push(attachment);
|
|
216
|
+
return {
|
|
217
|
+
...toResponse(attachment),
|
|
218
|
+
uploadUrl: `https://upload.test/${attachment.outboxAttachmentId}`,
|
|
219
|
+
uploadExpiresAt: 4_000_000_000,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
if (call.path.endsWith("/complete")) {
|
|
223
|
+
const outboxAttachmentId = call.path.split("/").at(-2);
|
|
224
|
+
const attachment = server.attachments.find(
|
|
225
|
+
(candidate) => candidate.outboxAttachmentId === outboxAttachmentId,
|
|
226
|
+
);
|
|
227
|
+
if (!attachment)
|
|
228
|
+
throw new Error(`completed ${outboxAttachmentId} unminted`);
|
|
229
|
+
attachment.state = "Stored";
|
|
230
|
+
return toResponse(attachment);
|
|
231
|
+
}
|
|
232
|
+
if (
|
|
233
|
+
call.method === "GET" &&
|
|
234
|
+
call.path.endsWith(`/outbox/${OUTBOX_MESSAGE_ID}`)
|
|
235
|
+
) {
|
|
236
|
+
server.linkGeneration += 1;
|
|
237
|
+
}
|
|
238
|
+
if (call.method === "PATCH") {
|
|
239
|
+
if (typeof call.body?.htmlBody === "string") {
|
|
240
|
+
server.htmlBody = call.body.htmlBody;
|
|
241
|
+
}
|
|
242
|
+
const kept = call.body?.attachmentIds;
|
|
243
|
+
if (Array.isArray(kept)) {
|
|
244
|
+
server.attachments = server.attachments.filter((attachment) =>
|
|
245
|
+
kept.includes(attachment.outboxAttachmentId),
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return outboxEntry();
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
let objectUrls = 0;
|
|
253
|
+
|
|
254
|
+
const mount = async (): Promise<DomHarness> => {
|
|
255
|
+
if (!http) {
|
|
256
|
+
server = freshServer();
|
|
257
|
+
objectUrls = 0;
|
|
258
|
+
mock.method(URL, "createObjectURL", () => {
|
|
259
|
+
objectUrls += 1;
|
|
260
|
+
return `blob:object-${objectUrls}`;
|
|
261
|
+
});
|
|
262
|
+
mock.method(URL, "revokeObjectURL", () => {});
|
|
263
|
+
http = mockFetch(answer);
|
|
264
|
+
}
|
|
265
|
+
const dom = createDomHarness();
|
|
266
|
+
harness = dom;
|
|
267
|
+
dom.renderApp(
|
|
268
|
+
createElement(RouterContextProvider, {
|
|
269
|
+
router: testRouter(),
|
|
270
|
+
// biome-ignore lint/correctness/noChildrenProp: RouterContextProvider types `children` as a required prop, which createElement's rest-argument form does not satisfy
|
|
271
|
+
children: createElement(
|
|
272
|
+
ComposeProvider,
|
|
273
|
+
null,
|
|
274
|
+
createElement(ComposeForm, {
|
|
275
|
+
mode: "reply",
|
|
276
|
+
account,
|
|
277
|
+
sourceMessage,
|
|
278
|
+
outboxMessageId: OUTBOX_MESSAGE_ID,
|
|
279
|
+
onDraftCreated: () => {},
|
|
280
|
+
onClose: () => {},
|
|
281
|
+
}),
|
|
282
|
+
),
|
|
283
|
+
}),
|
|
284
|
+
);
|
|
285
|
+
await dom.flush();
|
|
286
|
+
await dom.waitFor(
|
|
287
|
+
() =>
|
|
288
|
+
dom
|
|
289
|
+
.query("[data-testid=compose-body]")
|
|
290
|
+
?.textContent?.includes("Here it is") === true,
|
|
291
|
+
"the draft to load into the writing surface",
|
|
292
|
+
30_000,
|
|
293
|
+
);
|
|
294
|
+
return dom;
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
const bodyOf = (dom: DomHarness): HTMLElement => {
|
|
298
|
+
const body = dom.query("[data-testid=compose-body]");
|
|
299
|
+
if (!body) throw new Error("the compose body is not mounted");
|
|
300
|
+
return body;
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
const attachedRows = (dom: DomHarness): [string, string | null][] =>
|
|
304
|
+
dom
|
|
305
|
+
.queryAll("[data-testid=compose-attachment]")
|
|
306
|
+
.map((row) => [
|
|
307
|
+
row.querySelector("bdi")?.textContent ?? "",
|
|
308
|
+
row.getAttribute("data-state"),
|
|
309
|
+
]);
|
|
310
|
+
|
|
311
|
+
const rowIn = (dom: DomHarness, state: string): boolean =>
|
|
312
|
+
attachedRows(dom).some(([, seen]) => seen === state);
|
|
313
|
+
|
|
314
|
+
const png = (name: string): File =>
|
|
315
|
+
new File(["\u0089PNG"], name, { type: "image/png" });
|
|
316
|
+
|
|
317
|
+
const paste = (dom: DomHarness, files: File[]): void => {
|
|
318
|
+
dom.dispatch(
|
|
319
|
+
bodyOf(dom),
|
|
320
|
+
new ClipboardEventClass("paste", transferOf(files)),
|
|
321
|
+
);
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
const pasteAndStore = async (dom: DomHarness): Promise<void> => {
|
|
325
|
+
paste(dom, [png("chart.png")]);
|
|
326
|
+
await dom.waitFor(() => rowIn(dom, "attached"), "the pasted image to store");
|
|
327
|
+
await dom.wait(AUTOSAVE_DEBOUNCE_MS + 200);
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
describe("a file pasted or dropped into the composer (#1307)", () => {
|
|
331
|
+
it("puts a pasted image in the body, referred to by the content-id it was stored under", async () => {
|
|
332
|
+
const dom = await mount();
|
|
333
|
+
|
|
334
|
+
await pasteAndStore(dom);
|
|
335
|
+
|
|
336
|
+
assert.deepEqual(attachedRows(dom), [["chart.png", "attached"]]);
|
|
337
|
+
assert.equal(
|
|
338
|
+
bodyOf(dom).querySelector("img")?.getAttribute("src"),
|
|
339
|
+
"blob:object-1",
|
|
340
|
+
"the composer does not show the picture it was handed",
|
|
341
|
+
);
|
|
342
|
+
assert.ok(
|
|
343
|
+
server.htmlBody.includes('src="cid:att-1@remit"'),
|
|
344
|
+
`the saved body does not refer to the stored image: ${server.htmlBody}`,
|
|
345
|
+
);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it("shows the stored picture again when the draft is reopened", async () => {
|
|
349
|
+
await pasteAndStore(await mount());
|
|
350
|
+
harness?.close();
|
|
351
|
+
|
|
352
|
+
const reopened = await mount();
|
|
353
|
+
await reopened.waitFor(
|
|
354
|
+
() =>
|
|
355
|
+
bodyOf(reopened).querySelector("img")?.getAttribute("src") ===
|
|
356
|
+
"blob:object-2",
|
|
357
|
+
"the reopened draft to resolve its picture",
|
|
358
|
+
);
|
|
359
|
+
|
|
360
|
+
assert.ok(
|
|
361
|
+
http?.calls.some((call) => call.url.startsWith(`${CONTENT_HOST}/att-1?`)),
|
|
362
|
+
"the picture was not read back from where the file is stored",
|
|
363
|
+
);
|
|
364
|
+
assert.deepEqual(attachedRows(reopened), [["chart.png", "attached"]]);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it("never saves a picture whose file was removed, even after an undo", async () => {
|
|
368
|
+
await pasteAndStore(await mount());
|
|
369
|
+
harness?.close();
|
|
370
|
+
|
|
371
|
+
const reopened = await mount();
|
|
372
|
+
await reopened.waitFor(
|
|
373
|
+
() =>
|
|
374
|
+
bodyOf(reopened).querySelector("img")?.getAttribute("src") ===
|
|
375
|
+
"blob:object-2",
|
|
376
|
+
"the reopened draft to resolve its picture",
|
|
377
|
+
);
|
|
378
|
+
reopened.click(reopened.byLabel("Remove chart.png"));
|
|
379
|
+
await reopened.waitFor(
|
|
380
|
+
() => bodyOf(reopened).querySelector("img") === null,
|
|
381
|
+
"the removed file's picture to leave the body",
|
|
382
|
+
);
|
|
383
|
+
reopened.dispatch(
|
|
384
|
+
bodyOf(reopened),
|
|
385
|
+
new reopened.window.KeyboardEvent("keydown", {
|
|
386
|
+
key: "z",
|
|
387
|
+
code: "KeyZ",
|
|
388
|
+
ctrlKey: true,
|
|
389
|
+
bubbles: true,
|
|
390
|
+
cancelable: true,
|
|
391
|
+
}),
|
|
392
|
+
);
|
|
393
|
+
await reopened.wait(AUTOSAVE_DEBOUNCE_MS + 200);
|
|
394
|
+
|
|
395
|
+
assert.equal(
|
|
396
|
+
server.htmlBody.includes("cid:att-1@remit"),
|
|
397
|
+
false,
|
|
398
|
+
server.htmlBody,
|
|
399
|
+
);
|
|
400
|
+
assert.equal(
|
|
401
|
+
bodyOf(reopened).querySelector("img"),
|
|
402
|
+
null,
|
|
403
|
+
"a picture is on screen for a file the draft no longer carries",
|
|
404
|
+
);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it("renews an expired picture link and shows the picture", async () => {
|
|
408
|
+
await pasteAndStore(await mount());
|
|
409
|
+
harness?.close();
|
|
410
|
+
server.expireBefore = server.linkGeneration + 2;
|
|
411
|
+
|
|
412
|
+
const reopened = await mount();
|
|
413
|
+
await reopened.waitFor(
|
|
414
|
+
() =>
|
|
415
|
+
bodyOf(reopened).querySelector("img")?.getAttribute("src") ===
|
|
416
|
+
"blob:object-2",
|
|
417
|
+
"the renewed link to resolve the picture",
|
|
418
|
+
);
|
|
419
|
+
|
|
420
|
+
const reads = (http?.calls ?? []).filter((call) =>
|
|
421
|
+
call.url.startsWith(`${CONTENT_HOST}/att-1?`),
|
|
422
|
+
);
|
|
423
|
+
assert.equal(
|
|
424
|
+
reads.length,
|
|
425
|
+
2,
|
|
426
|
+
"the expired link was not read, then renewed",
|
|
427
|
+
);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it("says the session ended when the picture is refused for authentication", async () => {
|
|
431
|
+
await pasteAndStore(await mount());
|
|
432
|
+
harness?.close();
|
|
433
|
+
server.contentStatus = 401;
|
|
434
|
+
|
|
435
|
+
const reopened = await mount();
|
|
436
|
+
await reopened.waitFor(
|
|
437
|
+
() => reopened.text().includes("Your session expired"),
|
|
438
|
+
"the signed-out notice",
|
|
439
|
+
);
|
|
440
|
+
|
|
441
|
+
assert.match(reopened.text(), /Sign in again/);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
it("keeps a retried pasted image inline once its upload goes through", async () => {
|
|
445
|
+
const dom = await mount();
|
|
446
|
+
server.failPuts = 1;
|
|
447
|
+
|
|
448
|
+
paste(dom, [png("chart.png")]);
|
|
449
|
+
await dom.waitFor(() => rowIn(dom, "failed"), "the upload to fail");
|
|
450
|
+
await dom.waitFor(
|
|
451
|
+
() => bodyOf(dom).querySelector("img") === null,
|
|
452
|
+
"the failed picture to leave the body",
|
|
453
|
+
);
|
|
454
|
+
dom.click(dom.byText("button", "Try again"));
|
|
455
|
+
await dom.waitFor(() => rowIn(dom, "attached"), "the retry to store");
|
|
456
|
+
await dom.wait(AUTOSAVE_DEBOUNCE_MS + 200);
|
|
457
|
+
|
|
458
|
+
assert.ok(
|
|
459
|
+
bodyOf(dom).querySelector("img"),
|
|
460
|
+
"the retried picture is not in the body",
|
|
461
|
+
);
|
|
462
|
+
assert.ok(
|
|
463
|
+
server.htmlBody.includes('src="cid:att-2@remit"'),
|
|
464
|
+
`the saved body does not refer to the retried image: ${server.htmlBody}`,
|
|
465
|
+
);
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
it("takes a refused image back out of the body and says why on its row", async () => {
|
|
469
|
+
const dom = await mount();
|
|
470
|
+
server.refuseMints = true;
|
|
471
|
+
|
|
472
|
+
paste(dom, [png("huge.png")]);
|
|
473
|
+
await dom.waitFor(() => rowIn(dom, "failed"), "the refusal to land");
|
|
474
|
+
await dom.waitFor(
|
|
475
|
+
() => bodyOf(dom).querySelector("img") === null,
|
|
476
|
+
"the refused picture to leave the body",
|
|
477
|
+
);
|
|
478
|
+
await dom.wait(AUTOSAVE_DEBOUNCE_MS + 200);
|
|
479
|
+
|
|
480
|
+
assert.match(
|
|
481
|
+
dom.query("[data-testid=compose-attachment-error]")?.textContent ?? "",
|
|
482
|
+
/huge\.png/,
|
|
483
|
+
);
|
|
484
|
+
assert.equal(server.htmlBody.includes("<img"), false, server.htmlBody);
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
it("takes the picture out of the body when its file is removed", async () => {
|
|
488
|
+
const dom = await mount();
|
|
489
|
+
await pasteAndStore(dom);
|
|
490
|
+
|
|
491
|
+
dom.click(dom.byLabel("Remove chart.png"));
|
|
492
|
+
await dom.waitFor(
|
|
493
|
+
() => bodyOf(dom).querySelector("img") === null,
|
|
494
|
+
"the removed file's picture to leave the body",
|
|
495
|
+
);
|
|
496
|
+
await dom.wait(AUTOSAVE_DEBOUNCE_MS + 200);
|
|
497
|
+
|
|
498
|
+
assert.deepEqual(attachedRows(dom), []);
|
|
499
|
+
assert.equal(server.htmlBody.includes("cid:"), false, server.htmlBody);
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
it("attaches a pasted file that is not a picture, leaving the body alone", async () => {
|
|
503
|
+
const dom = await mount();
|
|
504
|
+
|
|
505
|
+
paste(dom, [
|
|
506
|
+
new File(["%PDF"], "minutes.pdf", { type: "application/pdf" }),
|
|
507
|
+
]);
|
|
508
|
+
await dom.waitFor(
|
|
509
|
+
() => rowIn(dom, "attached"),
|
|
510
|
+
"the pasted file to attach",
|
|
511
|
+
);
|
|
512
|
+
|
|
513
|
+
assert.deepEqual(attachedRows(dom), [["minutes.pdf", "attached"]]);
|
|
514
|
+
assert.equal(bodyOf(dom).querySelector("img"), null);
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
it("attaches a dropped file the way the picker does, leaving the body alone", async () => {
|
|
518
|
+
const dom = await mount();
|
|
519
|
+
const area = dom.query("[data-testid=compose-body-area]");
|
|
520
|
+
if (!area) throw new Error("the compose body area is not mounted");
|
|
521
|
+
|
|
522
|
+
const drop = new Event("drop", { bubbles: true, cancelable: true });
|
|
523
|
+
Object.defineProperty(drop, "dataTransfer", {
|
|
524
|
+
value: transferOf([
|
|
525
|
+
new File(["%PDF"], "minutes.pdf", { type: "application/pdf" }),
|
|
526
|
+
]),
|
|
527
|
+
});
|
|
528
|
+
dom.dispatch(area, drop);
|
|
529
|
+
await dom.waitFor(
|
|
530
|
+
() => rowIn(dom, "attached"),
|
|
531
|
+
"the dropped file to attach",
|
|
532
|
+
);
|
|
533
|
+
|
|
534
|
+
assert.deepEqual(attachedRows(dom), [["minutes.pdf", "attached"]]);
|
|
535
|
+
assert.equal(drop.defaultPrevented, true, "the browser opened the file");
|
|
536
|
+
assert.equal(
|
|
537
|
+
bodyOf(dom).querySelector("img"),
|
|
538
|
+
null,
|
|
539
|
+
"a dropped file went into the body",
|
|
540
|
+
);
|
|
541
|
+
});
|
|
542
|
+
});
|
|
@@ -85,7 +85,7 @@ export function draftFromEvent(
|
|
|
85
85
|
* Stamping any other zone's offset onto those digits moves the event by the
|
|
86
86
|
* difference between the two — silently, and only for whoever is travelling.
|
|
87
87
|
*/
|
|
88
|
-
function timesFor(
|
|
88
|
+
export function timesFor(
|
|
89
89
|
draft: EventDraft,
|
|
90
90
|
clock: string,
|
|
91
91
|
): { start: string; end: string } {
|
|
@@ -63,6 +63,11 @@ export {
|
|
|
63
63
|
useCalendarSelection,
|
|
64
64
|
useDrawnEvents,
|
|
65
65
|
} from "./selection";
|
|
66
|
+
export {
|
|
67
|
+
type AcceptEditedInput,
|
|
68
|
+
acceptEditedInputFromDrafts,
|
|
69
|
+
draftFromSuggestion,
|
|
70
|
+
} from "./suggestion-draft";
|
|
66
71
|
export {
|
|
67
72
|
type CalendarCollectionOutcome,
|
|
68
73
|
type CalendarCollectionWrites,
|
|
@@ -89,9 +94,11 @@ export { type CalendarJumpSearch, useCalendarJump } from "./useCalendarJump";
|
|
|
89
94
|
export {
|
|
90
95
|
type CalendarSuggestionAnswers,
|
|
91
96
|
type CalendarSuggestionsResult,
|
|
97
|
+
type PendingCalendarSuggestionResult,
|
|
92
98
|
type SuggestionAnswer,
|
|
93
99
|
useCalendarSuggestionAnswers,
|
|
94
100
|
useMessageCalendarSuggestions,
|
|
101
|
+
usePendingCalendarSuggestion,
|
|
95
102
|
usePendingCalendarSuggestions,
|
|
96
103
|
} from "./useCalendarSuggestions";
|
|
97
104
|
export { type CalendarsResult, useCalendars } from "./useCalendars";
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
RemitImapAcceptCalendarSuggestionInput,
|
|
3
|
+
RemitImapCalendarSuggestionResponse,
|
|
4
|
+
} from "@remit/api-http-client/types.gen.ts";
|
|
5
|
+
import type { EventDraft } from "@remit/ui";
|
|
6
|
+
import {
|
|
7
|
+
type DraftRefusal,
|
|
8
|
+
type DraftZones,
|
|
9
|
+
emptyDraft,
|
|
10
|
+
patchFromDrafts,
|
|
11
|
+
timesFor,
|
|
12
|
+
} from "./draft";
|
|
13
|
+
import { addDays, isoOnClock } from "./window";
|
|
14
|
+
|
|
15
|
+
export type AcceptEditedInput =
|
|
16
|
+
| { ok: true; input: RemitImapAcceptCalendarSuggestionInput }
|
|
17
|
+
| DraftRefusal;
|
|
18
|
+
|
|
19
|
+
type Suggestion = Pick<
|
|
20
|
+
RemitImapCalendarSuggestionResponse,
|
|
21
|
+
"summary" | "location" | "dtStart" | "dtEnd" | "allDay" | "zoneCertainty"
|
|
22
|
+
>;
|
|
23
|
+
|
|
24
|
+
export function draftFromSuggestion(
|
|
25
|
+
suggestion: Suggestion,
|
|
26
|
+
calendarId: string,
|
|
27
|
+
clock: string,
|
|
28
|
+
): EventDraft {
|
|
29
|
+
const base = {
|
|
30
|
+
title: suggestion.summary,
|
|
31
|
+
location: suggestion.location,
|
|
32
|
+
};
|
|
33
|
+
if (suggestion.allDay) {
|
|
34
|
+
const date = suggestion.dtStart.slice(0, 10);
|
|
35
|
+
const end = suggestion.dtEnd.slice(0, 10);
|
|
36
|
+
return {
|
|
37
|
+
...emptyDraft(date, calendarId),
|
|
38
|
+
...base,
|
|
39
|
+
allDay: true,
|
|
40
|
+
startTime: "",
|
|
41
|
+
endDate: end > date ? addDays(end, -1) : date,
|
|
42
|
+
endTime: "",
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
const wall = (iso: string): string =>
|
|
46
|
+
suggestion.zoneCertainty === "Ambiguous" ? iso : isoOnClock(iso, clock);
|
|
47
|
+
const start = wall(suggestion.dtStart);
|
|
48
|
+
const end = wall(suggestion.dtEnd);
|
|
49
|
+
return {
|
|
50
|
+
...emptyDraft(start.slice(0, 10), calendarId),
|
|
51
|
+
...base,
|
|
52
|
+
startTime: start.slice(11, 16),
|
|
53
|
+
endDate: end.slice(0, 10),
|
|
54
|
+
endTime: end.slice(11, 16),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function acceptEditedInputFromDrafts(
|
|
59
|
+
suggestion: Suggestion,
|
|
60
|
+
seed: EventDraft,
|
|
61
|
+
draft: EventDraft,
|
|
62
|
+
zones: DraftZones,
|
|
63
|
+
): AcceptEditedInput {
|
|
64
|
+
const built = patchFromDrafts(seed, draft, zones);
|
|
65
|
+
if (!built.ok) return built;
|
|
66
|
+
const placed =
|
|
67
|
+
suggestion.zoneCertainty === "Ambiguous" || built.patch.start !== undefined;
|
|
68
|
+
const event = placed
|
|
69
|
+
? {
|
|
70
|
+
...built.patch,
|
|
71
|
+
...timesFor(draft, zones.clock),
|
|
72
|
+
allDay: draft.allDay,
|
|
73
|
+
timeZone: zones.anchor,
|
|
74
|
+
}
|
|
75
|
+
: built.patch;
|
|
76
|
+
return { ok: true, input: { calendarId: draft.calendarId, event } };
|
|
77
|
+
}
|