@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,299 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { after, afterEach, before, describe, it } from "node:test";
|
|
3
|
+
import type {
|
|
4
|
+
RemitImapCalendarResponse,
|
|
5
|
+
RemitImapCalendarSuggestionResponse,
|
|
6
|
+
} from "@remit/api-http-client/types.gen.ts";
|
|
7
|
+
import {
|
|
8
|
+
type AnyRouter,
|
|
9
|
+
createMemoryHistory,
|
|
10
|
+
createRootRoute,
|
|
11
|
+
createRoute,
|
|
12
|
+
createRouter,
|
|
13
|
+
Outlet,
|
|
14
|
+
type RouteComponent,
|
|
15
|
+
RouterProvider,
|
|
16
|
+
} from "@tanstack/react-router";
|
|
17
|
+
import { createElement } from "react";
|
|
18
|
+
import { calendarSearchSchema } from "@/lib/calendar-route";
|
|
19
|
+
import { stringifySearch } from "@/lib/search-params";
|
|
20
|
+
import { Route as SuggestionRoute } from "../../routes/calendar/$view.$date/suggestion.$suggestionId.js";
|
|
21
|
+
import { createDomHarness, type DomHarness } from "../../test-support/dom";
|
|
22
|
+
import {
|
|
23
|
+
type HttpCall,
|
|
24
|
+
type HttpMock,
|
|
25
|
+
httpError,
|
|
26
|
+
mockFetch,
|
|
27
|
+
} from "../../test-support/http";
|
|
28
|
+
|
|
29
|
+
(globalThis as { self?: typeof globalThis }).self ??= globalThis;
|
|
30
|
+
|
|
31
|
+
const WORK = "11111111-1111-4111-8111-111111111111";
|
|
32
|
+
const SUGGESTION = "22222222-2222-4222-8222-222222222222";
|
|
33
|
+
const WEEK = "/calendar/week/2026-06-10";
|
|
34
|
+
const ENTRY = `${WEEK}/suggestion/${SUGGESTION}`;
|
|
35
|
+
|
|
36
|
+
const calendars = [
|
|
37
|
+
{
|
|
38
|
+
calendarId: WORK,
|
|
39
|
+
accountConfigId: "cfg-1",
|
|
40
|
+
urlSegment: "work",
|
|
41
|
+
displayName: "Work",
|
|
42
|
+
color: "Cal1",
|
|
43
|
+
componentSet: "VeventOnly",
|
|
44
|
+
source: "Default",
|
|
45
|
+
timezone: "Europe/Amsterdam",
|
|
46
|
+
syncSequence: 1,
|
|
47
|
+
subscriptionUrl: "",
|
|
48
|
+
subscriptionEnabled: false,
|
|
49
|
+
subscriptionCheckedAt: 0,
|
|
50
|
+
subscriptionFetchedAt: 0,
|
|
51
|
+
subscriptionError: "",
|
|
52
|
+
createdAt: 0,
|
|
53
|
+
updatedAt: 0,
|
|
54
|
+
},
|
|
55
|
+
] satisfies RemitImapCalendarResponse[];
|
|
56
|
+
|
|
57
|
+
const reading = (
|
|
58
|
+
overrides: Partial<RemitImapCalendarSuggestionResponse> = {},
|
|
59
|
+
): RemitImapCalendarSuggestionResponse => ({
|
|
60
|
+
suggestionId: SUGGESTION,
|
|
61
|
+
accountConfigId: "cfg-1",
|
|
62
|
+
messageId: "msg-1",
|
|
63
|
+
bodyPartId: "part-1",
|
|
64
|
+
icalUid: "uid-1",
|
|
65
|
+
sequence: 0,
|
|
66
|
+
method: "Request",
|
|
67
|
+
source: "IcalendarPart",
|
|
68
|
+
state: "Pending",
|
|
69
|
+
summary: "Billing migration kickoff",
|
|
70
|
+
dtStart: "2026-06-11T12:00:00+00:00",
|
|
71
|
+
dtEnd: "2026-06-11T13:00:00+00:00",
|
|
72
|
+
allDay: false,
|
|
73
|
+
location: "Room Noord",
|
|
74
|
+
organizer: "priya@example.test",
|
|
75
|
+
zoneCertainty: "Explicit",
|
|
76
|
+
acceptedCalendarObjectId: "",
|
|
77
|
+
createdAt: 0,
|
|
78
|
+
updatedAt: 0,
|
|
79
|
+
...overrides,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
let harness: DomHarness | undefined;
|
|
83
|
+
let http: HttpMock | undefined;
|
|
84
|
+
const runnerZone = process.env.TZ;
|
|
85
|
+
|
|
86
|
+
before(() => {
|
|
87
|
+
process.env.TZ = "Europe/Amsterdam";
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
after(() => {
|
|
91
|
+
if (runnerZone === undefined) Reflect.deleteProperty(process.env, "TZ");
|
|
92
|
+
else process.env.TZ = runnerZone;
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
afterEach(() => {
|
|
96
|
+
harness?.close();
|
|
97
|
+
harness = undefined;
|
|
98
|
+
http?.restore();
|
|
99
|
+
http = undefined;
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const componentOf = (route: unknown): RouteComponent => {
|
|
103
|
+
const { component } = (route as { options: { component?: RouteComponent } })
|
|
104
|
+
.options;
|
|
105
|
+
if (!component) throw new Error("the route mounts no component");
|
|
106
|
+
return component;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const testRouter = (): AnyRouter => {
|
|
110
|
+
const rootRoute = createRootRoute({ component: Outlet });
|
|
111
|
+
const viewRoute = createRoute({
|
|
112
|
+
getParentRoute: () => rootRoute,
|
|
113
|
+
path: "/calendar/$view/$date",
|
|
114
|
+
validateSearch: calendarSearchSchema,
|
|
115
|
+
component: Outlet,
|
|
116
|
+
});
|
|
117
|
+
const routeTree = rootRoute.addChildren([
|
|
118
|
+
viewRoute.addChildren([
|
|
119
|
+
createRoute({
|
|
120
|
+
getParentRoute: () => viewRoute,
|
|
121
|
+
path: "/",
|
|
122
|
+
component: () => null,
|
|
123
|
+
}),
|
|
124
|
+
createRoute({
|
|
125
|
+
getParentRoute: () => viewRoute,
|
|
126
|
+
path: "suggestion/$suggestionId",
|
|
127
|
+
component: componentOf(SuggestionRoute),
|
|
128
|
+
}),
|
|
129
|
+
]),
|
|
130
|
+
]);
|
|
131
|
+
return createRouter({
|
|
132
|
+
routeTree,
|
|
133
|
+
stringifySearch,
|
|
134
|
+
history: createMemoryHistory({ initialEntries: [ENTRY] }),
|
|
135
|
+
}) as unknown as AnyRouter;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const server = (
|
|
139
|
+
suggestion: RemitImapCalendarSuggestionResponse,
|
|
140
|
+
answer: (call: HttpCall) => Response | undefined = () => undefined,
|
|
141
|
+
) => {
|
|
142
|
+
let pending = [suggestion];
|
|
143
|
+
return (call: HttpCall): unknown => {
|
|
144
|
+
if (call.path.endsWith("/calendars")) return { items: calendars };
|
|
145
|
+
if (call.path.endsWith("/calendar-suggestions")) return { items: pending };
|
|
146
|
+
if (call.path.endsWith("/accept")) {
|
|
147
|
+
const refusal = answer(call);
|
|
148
|
+
if (refusal) return refusal;
|
|
149
|
+
pending = [];
|
|
150
|
+
return { ...suggestion, state: "Accepted" };
|
|
151
|
+
}
|
|
152
|
+
return { items: [] };
|
|
153
|
+
};
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const mount = async (respond: (call: HttpCall) => unknown) => {
|
|
157
|
+
http = mockFetch(respond);
|
|
158
|
+
const router = testRouter();
|
|
159
|
+
await router.load();
|
|
160
|
+
harness = createDomHarness();
|
|
161
|
+
harness.renderApp(createElement(RouterProvider, { router }));
|
|
162
|
+
await harness.waitFor(
|
|
163
|
+
() =>
|
|
164
|
+
(harness?.queryAll<HTMLInputElement>("input") ?? []).some(
|
|
165
|
+
(input) => input.value === "Billing migration kickoff",
|
|
166
|
+
),
|
|
167
|
+
"the editor to open seeded from the suggestion",
|
|
168
|
+
);
|
|
169
|
+
await harness.waitFor(
|
|
170
|
+
() => harness?.query("input[type=radio]:checked") !== null,
|
|
171
|
+
"the default calendar to be picked",
|
|
172
|
+
);
|
|
173
|
+
return router;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const fieldValue = (label: string): string => {
|
|
177
|
+
if (!harness) throw new Error("not mounted");
|
|
178
|
+
return (harness.byLabel(label) as HTMLInputElement).value;
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const acceptedEdits = (): HttpCall[] =>
|
|
182
|
+
http?.to(`/calendar-suggestions/${SUGGESTION}/accept`) ?? [];
|
|
183
|
+
|
|
184
|
+
describe("changing a suggestion before adding it", () => {
|
|
185
|
+
it("opens the editor on what the mail said, on this device's clock", async () => {
|
|
186
|
+
await mount(server(reading()));
|
|
187
|
+
|
|
188
|
+
assert.equal(fieldValue("Title"), "Billing migration kickoff");
|
|
189
|
+
assert.equal(fieldValue("Location"), "Room Noord");
|
|
190
|
+
assert.equal(fieldValue("Date"), "2026-06-11");
|
|
191
|
+
assert.equal(fieldValue("Start time"), "14:00");
|
|
192
|
+
assert.equal(fieldValue("End time"), "15:00");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("adds the corrected event, settles the suggestion and closes the editor", async () => {
|
|
196
|
+
const router = await mount(server(reading()));
|
|
197
|
+
|
|
198
|
+
harness?.type(harness.byLabel("Title"), "Billing migration kickoff, moved");
|
|
199
|
+
harness?.type(harness.byLabel("Start time"), "16:00");
|
|
200
|
+
harness?.type(harness.byLabel("End time"), "17:30");
|
|
201
|
+
await harness?.flush();
|
|
202
|
+
harness?.click(harness.byText("button", "Add"));
|
|
203
|
+
|
|
204
|
+
await harness?.waitFor(
|
|
205
|
+
() => router.state.location.pathname === WEEK,
|
|
206
|
+
"the editor to close once the server settled the suggestion",
|
|
207
|
+
);
|
|
208
|
+
const sent = acceptedEdits();
|
|
209
|
+
assert.equal(sent.length, 1);
|
|
210
|
+
assert.deepEqual(sent[0].body, {
|
|
211
|
+
calendarId: WORK,
|
|
212
|
+
event: {
|
|
213
|
+
summary: "Billing migration kickoff, moved",
|
|
214
|
+
start: "2026-06-11T16:00:00+02:00",
|
|
215
|
+
end: "2026-06-11T17:30:00+02:00",
|
|
216
|
+
allDay: false,
|
|
217
|
+
timeZone: "Europe/Amsterdam",
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
const listings = http?.to("/calendar-suggestions") ?? [];
|
|
221
|
+
assert.ok(
|
|
222
|
+
listings.length > 1,
|
|
223
|
+
"the waiting list is read again once the suggestion is settled",
|
|
224
|
+
);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("places a reading nobody could put on a clock, even when the time is left alone", async () => {
|
|
228
|
+
await mount(
|
|
229
|
+
server(
|
|
230
|
+
reading({
|
|
231
|
+
dtStart: "2026-06-11T09:00:00+00:00",
|
|
232
|
+
dtEnd: "2026-06-11T10:00:00+00:00",
|
|
233
|
+
zoneCertainty: "Ambiguous",
|
|
234
|
+
}),
|
|
235
|
+
),
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
assert.equal(fieldValue("Start time"), "09:00");
|
|
239
|
+
assert.match(harness?.text() ?? "", /time zone nothing could resolve/);
|
|
240
|
+
harness?.click(harness.byText("button", "Add"));
|
|
241
|
+
await harness?.waitFor(
|
|
242
|
+
() => acceptedEdits().length === 1,
|
|
243
|
+
"the correction to be sent",
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
assert.deepEqual(acceptedEdits()[0].body?.event, {
|
|
247
|
+
start: "2026-06-11T09:00:00+02:00",
|
|
248
|
+
end: "2026-06-11T10:00:00+02:00",
|
|
249
|
+
allDay: false,
|
|
250
|
+
timeZone: "Europe/Amsterdam",
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("states a refusal and keeps the editor open with what was typed", async () => {
|
|
255
|
+
const router = await mount(
|
|
256
|
+
server(reading(), () => httpError(400, "Already declined.")),
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
harness?.type(harness.byLabel("Title"), "Kickoff, renamed");
|
|
260
|
+
await harness?.flush();
|
|
261
|
+
harness?.click(harness.byText("button", "Add"));
|
|
262
|
+
|
|
263
|
+
await harness?.waitFor(
|
|
264
|
+
() => harness?.query('[role="alert"]') !== null,
|
|
265
|
+
"the refusal to be stated",
|
|
266
|
+
);
|
|
267
|
+
assert.match(harness?.text() ?? "", /Couldn't add this to your calendar/);
|
|
268
|
+
assert.equal(fieldValue("Title"), "Kickoff, renamed");
|
|
269
|
+
assert.equal(router.state.location.pathname, ENTRY);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("finds a suggestion that is not on the first page of what waits", async () => {
|
|
273
|
+
http = mockFetch((call: HttpCall): unknown => {
|
|
274
|
+
if (call.path.endsWith("/calendars")) return { items: calendars };
|
|
275
|
+
if (call.path.endsWith("/calendar-suggestions"))
|
|
276
|
+
return call.url.includes("continuationToken=page-2")
|
|
277
|
+
? { items: [reading()] }
|
|
278
|
+
: {
|
|
279
|
+
items: [{ ...reading(), suggestionId: "other" }],
|
|
280
|
+
continuationToken: "page-2",
|
|
281
|
+
};
|
|
282
|
+
return { items: [] };
|
|
283
|
+
});
|
|
284
|
+
const router = testRouter();
|
|
285
|
+
await router.load();
|
|
286
|
+
harness = createDomHarness();
|
|
287
|
+
harness.renderApp(createElement(RouterProvider, { router }));
|
|
288
|
+
|
|
289
|
+
await harness.waitFor(
|
|
290
|
+
() =>
|
|
291
|
+
harness
|
|
292
|
+
?.queryAll<HTMLInputElement>("input")
|
|
293
|
+
.some((input) => input.value === "Billing migration kickoff") ===
|
|
294
|
+
true,
|
|
295
|
+
"the editor to open on the suggestion from the second page",
|
|
296
|
+
);
|
|
297
|
+
assert.doesNotMatch(harness.text(), /no longer waiting/);
|
|
298
|
+
});
|
|
299
|
+
});
|
|
@@ -69,16 +69,21 @@ const pending: RemitImapCalendarSuggestionResponse = {
|
|
|
69
69
|
|
|
70
70
|
let harness: DomHarness | undefined;
|
|
71
71
|
let http: HttpMock | undefined;
|
|
72
|
+
let changedFirst: { suggestionId: string; date: string }[] = [];
|
|
72
73
|
|
|
73
74
|
afterEach(() => {
|
|
74
75
|
harness?.close();
|
|
75
76
|
harness = undefined;
|
|
76
77
|
http?.restore();
|
|
77
78
|
http = undefined;
|
|
79
|
+
changedFirst = [];
|
|
78
80
|
});
|
|
79
81
|
|
|
80
|
-
const server = (
|
|
81
|
-
|
|
82
|
+
const server = (
|
|
83
|
+
accept: () => Response | undefined,
|
|
84
|
+
reading: RemitImapCalendarSuggestionResponse = pending,
|
|
85
|
+
) => {
|
|
86
|
+
let waiting = [reading];
|
|
82
87
|
return (call: HttpCall): unknown => {
|
|
83
88
|
if (call.path.endsWith("/calendars")) return { items: calendars };
|
|
84
89
|
if (call.path.endsWith("/calendar-suggestions")) return { items: waiting };
|
|
@@ -95,7 +100,12 @@ const server = (accept: () => Response | undefined) => {
|
|
|
95
100
|
const mount = async (respond: (call: HttpCall) => unknown) => {
|
|
96
101
|
http = mockFetch(respond);
|
|
97
102
|
harness = createDomHarness();
|
|
98
|
-
harness.renderApp(
|
|
103
|
+
harness.renderApp(
|
|
104
|
+
createElement(PendingSuggestions, {
|
|
105
|
+
onChangeFirst: (suggestionId: string, date: string) =>
|
|
106
|
+
changedFirst.push({ suggestionId, date }),
|
|
107
|
+
}),
|
|
108
|
+
);
|
|
99
109
|
await harness.waitFor(
|
|
100
110
|
() => harness?.text().includes("Quarterly review") === true,
|
|
101
111
|
"the waiting reading to be drawn",
|
|
@@ -134,4 +144,59 @@ describe("what the mail is waiting on, beside the calendar", () => {
|
|
|
134
144
|
assert.match(harness?.text() ?? "", /Couldn't add this to your calendar/);
|
|
135
145
|
assert.match(harness?.text() ?? "", /Quarterly review/);
|
|
136
146
|
});
|
|
147
|
+
|
|
148
|
+
it("hands the top reading to the editor on the day it falls", async () => {
|
|
149
|
+
await mount(server(() => undefined));
|
|
150
|
+
harness?.click(harness.byText("button", "Change first"));
|
|
151
|
+
|
|
152
|
+
assert.deepEqual(changedFirst, [
|
|
153
|
+
{ suggestionId: SUGGESTION, date: "2026-09-01" },
|
|
154
|
+
]);
|
|
155
|
+
assert.equal(
|
|
156
|
+
http?.to(`/calendar-suggestions/${SUGGESTION}/accept`).length,
|
|
157
|
+
0,
|
|
158
|
+
);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("sends a reading nobody could put on a clock to the editor instead of adding it", async () => {
|
|
162
|
+
await mount(
|
|
163
|
+
server(() => undefined, { ...pending, zoneCertainty: "Ambiguous" }),
|
|
164
|
+
);
|
|
165
|
+
harness?.click(harness.byText("button", "Add"));
|
|
166
|
+
|
|
167
|
+
assert.deepEqual(
|
|
168
|
+
changedFirst.map((entry) => entry.suggestionId),
|
|
169
|
+
[SUGGESTION],
|
|
170
|
+
);
|
|
171
|
+
assert.equal(
|
|
172
|
+
http?.to(`/calendar-suggestions/${SUGGESTION}/accept`).length,
|
|
173
|
+
0,
|
|
174
|
+
);
|
|
175
|
+
assert.match(harness?.text() ?? "", /Quarterly review/);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("applies a cancellation nobody could put on a clock, and offers no editor for it", async () => {
|
|
179
|
+
await mount(
|
|
180
|
+
server(() => undefined, {
|
|
181
|
+
...pending,
|
|
182
|
+
method: "Cancel",
|
|
183
|
+
zoneCertainty: "Ambiguous",
|
|
184
|
+
}),
|
|
185
|
+
);
|
|
186
|
+
assert.equal(
|
|
187
|
+
harness
|
|
188
|
+
?.queryAll("button")
|
|
189
|
+
.some((button) => button.textContent?.includes("Change first")),
|
|
190
|
+
false,
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
harness?.click(harness.byText("button", "Add"));
|
|
194
|
+
await harness?.waitFor(
|
|
195
|
+
() =>
|
|
196
|
+
(http?.to(`/calendar-suggestions/${SUGGESTION}/accept`).length ?? 0) >
|
|
197
|
+
0,
|
|
198
|
+
"the cancellation to be applied",
|
|
199
|
+
);
|
|
200
|
+
assert.deepEqual(changedFirst, []);
|
|
201
|
+
});
|
|
137
202
|
});
|
|
@@ -31,7 +31,7 @@ import {
|
|
|
31
31
|
unwrapLanguage,
|
|
32
32
|
} from "@remit/ui";
|
|
33
33
|
import type { ComposeBodyMode, ComposeInsertion } from "@remit/ui/rich-text";
|
|
34
|
-
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
34
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
35
35
|
import type { RefObject } from "react";
|
|
36
36
|
import {
|
|
37
37
|
lazy,
|
|
@@ -46,6 +46,7 @@ import {
|
|
|
46
46
|
type DraftForAttachment,
|
|
47
47
|
useComposeAttachments,
|
|
48
48
|
} from "../../hooks/useComposeAttachments";
|
|
49
|
+
import { useInlineImages } from "../../hooks/useInlineImages";
|
|
49
50
|
import { useMessageBodyContent } from "../../hooks/useMessageBodyContent";
|
|
50
51
|
import { useSaveDraft } from "../../hooks/useSaveDraft";
|
|
51
52
|
import { useSignature } from "../../hooks/useSignature.js";
|
|
@@ -462,9 +463,7 @@ export const ComposeForm = ({
|
|
|
462
463
|
* failure to `draft` (#933), so opening a Failed message and pressing Escape
|
|
463
464
|
* would take it out of the Outbox with the reader having changed nothing. The
|
|
464
465
|
* first run after a load records what was loaded; only a payload that differs
|
|
465
|
-
* from it is an edit.
|
|
466
|
-
* touched the document, every later run saves as it always did, retries of a
|
|
467
|
-
* failed write included.
|
|
466
|
+
* from it is an edit.
|
|
468
467
|
*/
|
|
469
468
|
const loadedPayloadRef = useRef<string | undefined>(undefined);
|
|
470
469
|
const captureLoadedPayloadRef = useRef(false);
|
|
@@ -877,11 +876,41 @@ export const ComposeForm = ({
|
|
|
877
876
|
remove: removeAttachment,
|
|
878
877
|
load: loadAttachments,
|
|
879
878
|
reset: resetAttachments,
|
|
879
|
+
owns: ownsAttachment,
|
|
880
|
+
isLoaded: attachmentsLoaded,
|
|
881
|
+
refreshLinks: refreshAttachmentLinks,
|
|
882
|
+
storedContent,
|
|
883
|
+
onRemoved: onAttachmentRemoved,
|
|
880
884
|
blockingReason: attachmentBlockingReason,
|
|
881
885
|
} = useComposeAttachments({
|
|
882
886
|
ensureDraft: ensureDraftForAttachment,
|
|
883
887
|
});
|
|
884
888
|
|
|
889
|
+
const queryClient = useQueryClient();
|
|
890
|
+
const inlineImages = useInlineImages({
|
|
891
|
+
attach,
|
|
892
|
+
owns: ownsAttachment,
|
|
893
|
+
isLoaded: attachmentsLoaded,
|
|
894
|
+
storedContent,
|
|
895
|
+
refreshLinks: () => {
|
|
896
|
+
if (!outboxMessageId) return Promise.resolve();
|
|
897
|
+
return queryClient
|
|
898
|
+
.fetchQuery({
|
|
899
|
+
...outboxDetailOperationsGetOutboxMessageOptions({
|
|
900
|
+
path: { outboxMessageId },
|
|
901
|
+
}),
|
|
902
|
+
staleTime: 0,
|
|
903
|
+
})
|
|
904
|
+
.then((draft) => refreshAttachmentLinks(draft.attachments));
|
|
905
|
+
},
|
|
906
|
+
onRemoved: onAttachmentRemoved,
|
|
907
|
+
draftAttachments:
|
|
908
|
+
draftData && draftData.outboxMessageId === outboxMessageId
|
|
909
|
+
? draftData.attachments
|
|
910
|
+
: [],
|
|
911
|
+
pushError,
|
|
912
|
+
});
|
|
913
|
+
|
|
885
914
|
const attachmentDocumentRef = useRef(outboxMessageId);
|
|
886
915
|
useEffect(() => {
|
|
887
916
|
const previous = attachmentDocumentRef.current;
|
|
@@ -1086,6 +1115,7 @@ export const ComposeForm = ({
|
|
|
1086
1115
|
return;
|
|
1087
1116
|
}
|
|
1088
1117
|
if (loadedPayloadRef.current === fingerprint) return;
|
|
1118
|
+
loadedPayloadRef.current = undefined;
|
|
1089
1119
|
|
|
1090
1120
|
saveDraft(payload);
|
|
1091
1121
|
}, [
|
|
@@ -1283,6 +1313,9 @@ export const ComposeForm = ({
|
|
|
1283
1313
|
return (
|
|
1284
1314
|
<ComposeFormShell
|
|
1285
1315
|
layout={layout}
|
|
1316
|
+
onDropFiles={(files) => {
|
|
1317
|
+
void attach(files);
|
|
1318
|
+
}}
|
|
1286
1319
|
banner={
|
|
1287
1320
|
<>
|
|
1288
1321
|
{selectedAccount && selectedAccountMissingSmtp ? (
|
|
@@ -1381,6 +1414,7 @@ export const ComposeForm = ({
|
|
|
1381
1414
|
spellcheck={spellcheck}
|
|
1382
1415
|
insertion={pendingInsertion}
|
|
1383
1416
|
onInserted={setInsertedVersion}
|
|
1417
|
+
inlineImages={inlineImages}
|
|
1384
1418
|
/>
|
|
1385
1419
|
</Suspense>
|
|
1386
1420
|
</ComposeFormShell>
|