@remit/web-client 0.0.311 → 0.0.313
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/compose/ComposeForm.tsx +76 -13
- package/src/components/compose/ComposeProvider.tsx +35 -1
- package/src/components/compose/ConversationCompose.tsx +19 -0
- package/src/components/compose/compose-inline-image.render.test.ts +542 -0
- package/src/components/compose/compose.stories.tsx +13 -0
- package/src/components/compose/reply-with-times.ts +22 -0
- package/src/components/mail/BriefPane.tsx +7 -0
- package/src/components/mail/FlaggedPane.tsx +7 -0
- package/src/components/mail/IntelligenceDrawer.tsx +4 -0
- package/src/components/mail/IntelligencePane.tsx +7 -1
- package/src/components/mail/MailboxPane.tsx +7 -0
- package/src/components/mail/offer-times-reply.render.test.ts +529 -0
- package/src/hooks/intelligence-calendar.render.test.ts +0 -19
- package/src/hooks/useComposeAttachments.ts +6 -1
- package/src/hooks/useInlineImages.ts +93 -0
- package/src/hooks/useIntelligenceCalendar.ts +8 -19
- package/src/lib/compose-attachment-controller.test.ts +110 -0
- package/src/lib/compose-attachment-controller.ts +120 -16
- package/src/lib/mail-search.ts +17 -0
- package/src/routes/mail/$mailboxId/$threadId/$messageId/$mode.{-$outboxMessageId}.tsx +2 -1
- package/src/routes/mail/brief/$threadId/$messageId/$mode.{-$outboxMessageId}.tsx +2 -1
- package/src/routes/mail/flagged/$threadId/$messageId/$mode.{-$outboxMessageId}.tsx +2 -1
- package/src/routing/compose.ts +2 -1
- package/src/routing/outbox-draft.ts +2 -1
- package/src/routing/reply.ts +34 -8
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { RemitImapOutboxAttachmentResponse } from "@remit/api-http-client/types.gen.ts";
|
|
2
|
+
import type { InlineImagePlacement, InlineImages } from "@remit/ui/rich-text";
|
|
3
|
+
import { useMemo, useRef } from "react";
|
|
4
|
+
import { useAuthProvider } from "@/auth/provider";
|
|
5
|
+
import {
|
|
6
|
+
attachmentFailureContent,
|
|
7
|
+
extractAttachmentFailureDetail,
|
|
8
|
+
extractAttachmentFailureReason,
|
|
9
|
+
fetchAttachment,
|
|
10
|
+
} from "@/lib/attachment-download";
|
|
11
|
+
import type { PushErrorInput } from "../components/ui/error-banners";
|
|
12
|
+
import type { StoredContent } from "../lib/compose-attachment-controller";
|
|
13
|
+
|
|
14
|
+
interface UseInlineImagesOptions {
|
|
15
|
+
attach: (files: File[], placement?: InlineImagePlacement) => Promise<void>;
|
|
16
|
+
owns: (contentId: string) => boolean;
|
|
17
|
+
isLoaded: () => boolean;
|
|
18
|
+
storedContent: (contentId: string) => StoredContent | null;
|
|
19
|
+
refreshLinks: () => Promise<void>;
|
|
20
|
+
onRemoved: (listener: (contentId: string) => void) => () => void;
|
|
21
|
+
draftAttachments: readonly RemitImapOutboxAttachmentResponse[];
|
|
22
|
+
pushError: (input: PushErrorInput) => string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const useInlineImages = (
|
|
26
|
+
options: UseInlineImagesOptions,
|
|
27
|
+
): InlineImages => {
|
|
28
|
+
const { getToken } = useAuthProvider();
|
|
29
|
+
const latest = useRef({ ...options, getToken });
|
|
30
|
+
latest.current = { ...options, getToken };
|
|
31
|
+
|
|
32
|
+
return useMemo<InlineImages>(() => {
|
|
33
|
+
const beforeLoad = (contentId: string): StoredContent | null => {
|
|
34
|
+
const current = latest.current;
|
|
35
|
+
if (current.isLoaded()) return null;
|
|
36
|
+
return (
|
|
37
|
+
current.draftAttachments.find(
|
|
38
|
+
(attachment) => attachment.contentId === contentId,
|
|
39
|
+
) ?? null
|
|
40
|
+
);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const stored = (contentId: string): StoredContent | null =>
|
|
44
|
+
latest.current.storedContent(contentId) ?? beforeLoad(contentId);
|
|
45
|
+
|
|
46
|
+
const fetchStored = (content: StoredContent): Promise<string> =>
|
|
47
|
+
fetchAttachment(content.contentUrl, latest.current.getToken).then(
|
|
48
|
+
(blob) =>
|
|
49
|
+
URL.createObjectURL(new Blob([blob], { type: content.contentType })),
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
const fetchRenewed = (contentId: string): Promise<string> =>
|
|
53
|
+
latest.current.refreshLinks().then(() => {
|
|
54
|
+
const renewed = stored(contentId);
|
|
55
|
+
if (!renewed) {
|
|
56
|
+
throw new Error("the file is no longer on this draft");
|
|
57
|
+
}
|
|
58
|
+
return fetchStored(renewed);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
attach: (files, placement) => {
|
|
63
|
+
void latest.current.attach(files, placement);
|
|
64
|
+
},
|
|
65
|
+
owns: (contentId) =>
|
|
66
|
+
latest.current.owns(contentId) || beforeLoad(contentId) !== null,
|
|
67
|
+
onRemoved: (listener) => latest.current.onRemoved(listener),
|
|
68
|
+
resolve: (contentId) => {
|
|
69
|
+
const content = stored(contentId);
|
|
70
|
+
if (!content) return Promise.resolve(null);
|
|
71
|
+
return fetchStored(content)
|
|
72
|
+
.catch((error: unknown) => {
|
|
73
|
+
if (extractAttachmentFailureReason(error) !== "link-expired") {
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
return fetchRenewed(contentId);
|
|
77
|
+
})
|
|
78
|
+
.catch((error: unknown) => {
|
|
79
|
+
const reason = extractAttachmentFailureReason(error);
|
|
80
|
+
latest.current.pushError({
|
|
81
|
+
...attachmentFailureContent(
|
|
82
|
+
reason,
|
|
83
|
+
content.filename,
|
|
84
|
+
extractAttachmentFailureDetail(error),
|
|
85
|
+
),
|
|
86
|
+
error: reason === "generic" ? error : undefined,
|
|
87
|
+
});
|
|
88
|
+
return null;
|
|
89
|
+
});
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}, []);
|
|
93
|
+
};
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
calendarUnavailable,
|
|
23
23
|
calendarWriteGate,
|
|
24
24
|
} from "@/components/calendar/CalendarUnavailable";
|
|
25
|
+
import type { ReplyWithText } from "@/components/compose/reply-with-times";
|
|
25
26
|
import {
|
|
26
27
|
calendarWindowOfDays,
|
|
27
28
|
deviceTimeZone,
|
|
@@ -57,7 +58,6 @@ interface Working {
|
|
|
57
58
|
tab: IntelligenceTabId | undefined;
|
|
58
59
|
offering: boolean;
|
|
59
60
|
picked: string[];
|
|
60
|
-
copy: "idle" | "copied" | "failed";
|
|
61
61
|
answering: string;
|
|
62
62
|
/** The card the last refusal was about, and what it said. */
|
|
63
63
|
failure: { suggestionId: string; text: string };
|
|
@@ -68,7 +68,6 @@ const fresh = (messageId: string): Working => ({
|
|
|
68
68
|
tab: undefined,
|
|
69
69
|
offering: false,
|
|
70
70
|
picked: [],
|
|
71
|
-
copy: "idle",
|
|
72
71
|
answering: "",
|
|
73
72
|
failure: { suggestionId: "", text: "" },
|
|
74
73
|
});
|
|
@@ -92,6 +91,7 @@ export interface IntelligenceCalendar {
|
|
|
92
91
|
|
|
93
92
|
export function useIntelligenceCalendar(
|
|
94
93
|
thread: RemitImapThreadMessageResponse,
|
|
94
|
+
replyWithText?: ReplyWithText,
|
|
95
95
|
): IntelligenceCalendar {
|
|
96
96
|
const messageId = thread.messageId;
|
|
97
97
|
const { suggestions, isLoading, error } =
|
|
@@ -238,7 +238,8 @@ export function useIntelligenceCalendar(
|
|
|
238
238
|
: "Couldn't read the invitations in this message. Reopen it to try again.";
|
|
239
239
|
|
|
240
240
|
const cancellation = invitation?.method === "Cancel";
|
|
241
|
-
const
|
|
241
|
+
const pickedText =
|
|
242
|
+
date === "" ? "" : slotsAsText(date, slots, working.picked);
|
|
242
243
|
|
|
243
244
|
const actions: IntelligenceCalendarActions = {
|
|
244
245
|
onAddInvite: () =>
|
|
@@ -272,24 +273,14 @@ export function useIntelligenceCalendar(
|
|
|
272
273
|
onOfferOtherTimes: () => update({ offering: true }),
|
|
273
274
|
onToggleSlot: (slot) =>
|
|
274
275
|
update({
|
|
275
|
-
copy: "idle",
|
|
276
276
|
picked: working.picked.includes(slot.startTime)
|
|
277
277
|
? working.picked.filter((start) => start !== slot.startTime)
|
|
278
278
|
: [...working.picked, slot.startTime],
|
|
279
279
|
}),
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
if (!clipboard) {
|
|
285
|
-
update({ copy: "failed" });
|
|
286
|
-
return;
|
|
287
|
-
}
|
|
288
|
-
void Promise.resolve()
|
|
289
|
-
.then(() => clipboard.writeText(copyText))
|
|
290
|
-
.then(() => update({ copy: "copied" }))
|
|
291
|
-
.catch(() => update({ copy: "failed" }));
|
|
292
|
-
},
|
|
280
|
+
onReplyWithSlots: replyWithText
|
|
281
|
+
? () =>
|
|
282
|
+
replyWithText({ threadId: thread.threadId, messageId }, pickedText)
|
|
283
|
+
: undefined,
|
|
293
284
|
onAddSuggestion: (suggestionId) =>
|
|
294
285
|
answer(suggestionId, "add this to your calendar", () =>
|
|
295
286
|
answers.accept(suggestionId, defaultCalendarId),
|
|
@@ -334,8 +325,6 @@ export function useIntelligenceCalendar(
|
|
|
334
325
|
proposals: [],
|
|
335
326
|
slots,
|
|
336
327
|
picked: working.picked,
|
|
337
|
-
copy: working.copy,
|
|
338
|
-
copyText,
|
|
339
328
|
}
|
|
340
329
|
: undefined,
|
|
341
330
|
suggestions: deck,
|
|
@@ -55,6 +55,7 @@ const createServer = () => {
|
|
|
55
55
|
},
|
|
56
56
|
complete: async (_draft, outboxAttachmentId) => {
|
|
57
57
|
completed.push(outboxAttachmentId);
|
|
58
|
+
return { contentUrl: `https://content.test/${outboxAttachmentId}` };
|
|
58
59
|
},
|
|
59
60
|
keep: async (_draft, attachmentIds) => {
|
|
60
61
|
keeps.push(attachmentIds);
|
|
@@ -66,12 +67,20 @@ const createServer = () => {
|
|
|
66
67
|
},
|
|
67
68
|
};
|
|
68
69
|
|
|
70
|
+
const refuseMint = (filename: string, error: unknown) => {
|
|
71
|
+
const answer = mints.get(filename);
|
|
72
|
+
if (!answer) throw new Error(`no reservation asked for ${filename}`);
|
|
73
|
+
answer.reject(error);
|
|
74
|
+
};
|
|
75
|
+
|
|
69
76
|
const answerMint = (filename: string, outboxAttachmentId: string) => {
|
|
70
77
|
const answer = mints.get(filename);
|
|
71
78
|
if (!answer) throw new Error(`no reservation asked for ${filename}`);
|
|
72
79
|
answer.resolve({
|
|
73
80
|
outboxAttachmentId,
|
|
74
81
|
filename,
|
|
82
|
+
contentType: "application/octet-stream",
|
|
83
|
+
contentId: `${outboxAttachmentId}@test`,
|
|
75
84
|
uploadUrl: `https://upload.test/${outboxAttachmentId}`,
|
|
76
85
|
});
|
|
77
86
|
};
|
|
@@ -79,6 +88,7 @@ const createServer = () => {
|
|
|
79
88
|
return {
|
|
80
89
|
transport,
|
|
81
90
|
answerMint,
|
|
91
|
+
refuseMint,
|
|
82
92
|
onDraft,
|
|
83
93
|
put,
|
|
84
94
|
completed,
|
|
@@ -128,6 +138,106 @@ describe("ComposeAttachmentController", () => {
|
|
|
128
138
|
assert.equal(controller.blockingReason(), undefined);
|
|
129
139
|
});
|
|
130
140
|
|
|
141
|
+
it("hands the content-id to whoever attached the file once it is stored", async () => {
|
|
142
|
+
const { server, controller } = setup();
|
|
143
|
+
const stored: [string, string][] = [];
|
|
144
|
+
const shot = file("shot.png");
|
|
145
|
+
|
|
146
|
+
const attaching = controller.attach([shot], {
|
|
147
|
+
stored: (attached, contentId) => {
|
|
148
|
+
stored.push([attached.name, contentId]);
|
|
149
|
+
},
|
|
150
|
+
failed: () => {
|
|
151
|
+
throw new Error("a stored file was reported failed");
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
await settle();
|
|
155
|
+
assert.deepEqual(stored, [], "named before the bytes were stored");
|
|
156
|
+
server.answerMint("shot.png", "att-shot");
|
|
157
|
+
await attaching;
|
|
158
|
+
|
|
159
|
+
assert.deepEqual(stored, [["shot.png", "att-shot@test"]]);
|
|
160
|
+
assert.deepEqual(controller.storedContent("att-shot@test"), {
|
|
161
|
+
filename: "shot.png",
|
|
162
|
+
contentType: "application/octet-stream",
|
|
163
|
+
contentUrl: "https://content.test/att-shot",
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("tells whoever attached a refused file that it did not land, and keeps the refusal on its row", async () => {
|
|
168
|
+
const { server, controller } = setup();
|
|
169
|
+
const failed: string[] = [];
|
|
170
|
+
|
|
171
|
+
const attaching = controller.attach([file("huge.png")], {
|
|
172
|
+
stored: () => {
|
|
173
|
+
throw new Error("a refused file was reported stored");
|
|
174
|
+
},
|
|
175
|
+
failed: (attached) => {
|
|
176
|
+
failed.push(attached.name);
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
await settle();
|
|
180
|
+
server.refuseMint("huge.png", {
|
|
181
|
+
code: "attachment_rejected",
|
|
182
|
+
reason: "FileTooLarge",
|
|
183
|
+
message: '"huge.png" is 30.0 MB, over the 25.0 MB a message can carry.',
|
|
184
|
+
});
|
|
185
|
+
await attaching;
|
|
186
|
+
|
|
187
|
+
assert.deepEqual(failed, ["huge.png"]);
|
|
188
|
+
assert.deepEqual(rowFor(controller, "huge.png")?.state, {
|
|
189
|
+
status: "failed",
|
|
190
|
+
reason: '"huge.png" is 30.0 MB, over the 25.0 MB a message can carry.',
|
|
191
|
+
retryable: false,
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("names the content-id of a file once the server has dropped it", async () => {
|
|
196
|
+
const { server, controller } = setup();
|
|
197
|
+
const removed: string[] = [];
|
|
198
|
+
controller.onRemoved((contentId) => removed.push(contentId));
|
|
199
|
+
|
|
200
|
+
const attaching = controller.attach([file("shot.png")]);
|
|
201
|
+
await settle();
|
|
202
|
+
server.answerMint("shot.png", "att-shot");
|
|
203
|
+
await attaching;
|
|
204
|
+
await controller.remove(controller.getSnapshot()[0].key);
|
|
205
|
+
|
|
206
|
+
assert.deepEqual(removed, ["att-shot@test"]);
|
|
207
|
+
assert.equal(controller.owns("att-shot@test"), false);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("keeps a file attached while the draft it joins was still loading", async () => {
|
|
211
|
+
const { server, controller } = setup();
|
|
212
|
+
|
|
213
|
+
const attaching = controller.attach([file("minutes.pdf")]);
|
|
214
|
+
await settle();
|
|
215
|
+
controller.load(DRAFT, [
|
|
216
|
+
{
|
|
217
|
+
outboxAttachmentId: "att-earlier",
|
|
218
|
+
outboxMessageId: DRAFT,
|
|
219
|
+
filename: "agenda.pdf",
|
|
220
|
+
contentType: "application/pdf",
|
|
221
|
+
sizeBytes: 5,
|
|
222
|
+
state: "Stored",
|
|
223
|
+
contentId: "att-earlier@remit",
|
|
224
|
+
contentUrl: "https://content.test/att-earlier",
|
|
225
|
+
},
|
|
226
|
+
]);
|
|
227
|
+
server.answerMint("minutes.pdf", "att-minutes");
|
|
228
|
+
await attaching;
|
|
229
|
+
|
|
230
|
+
assert.deepEqual(
|
|
231
|
+
controller
|
|
232
|
+
.getSnapshot()
|
|
233
|
+
.map((item) => [item.filename, item.state.status]),
|
|
234
|
+
[
|
|
235
|
+
["agenda.pdf", "attached"],
|
|
236
|
+
["minutes.pdf", "attached"],
|
|
237
|
+
],
|
|
238
|
+
);
|
|
239
|
+
});
|
|
240
|
+
|
|
131
241
|
it("takes a file removed while its reservation is out off the draft, and never uploads it", async () => {
|
|
132
242
|
const { server, controller } = setup();
|
|
133
243
|
|
|
@@ -12,9 +12,26 @@ export type DraftForAttachment =
|
|
|
12
12
|
export interface MintedAttachment {
|
|
13
13
|
outboxAttachmentId: string;
|
|
14
14
|
filename: string;
|
|
15
|
+
contentType: string;
|
|
16
|
+
contentId: string;
|
|
15
17
|
uploadUrl: string;
|
|
16
18
|
}
|
|
17
19
|
|
|
20
|
+
export interface CompletedAttachment {
|
|
21
|
+
contentUrl: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface AttachmentPlacement {
|
|
25
|
+
stored: (file: File, contentId: string) => void;
|
|
26
|
+
failed: (file: File) => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface StoredContent {
|
|
30
|
+
filename: string;
|
|
31
|
+
contentType: string;
|
|
32
|
+
contentUrl: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
18
35
|
/** The four requests a file on a draft is made of. Each throws on failure. */
|
|
19
36
|
export interface AttachmentTransport {
|
|
20
37
|
mint: (outboxMessageId: string, file: File) => Promise<MintedAttachment>;
|
|
@@ -22,7 +39,7 @@ export interface AttachmentTransport {
|
|
|
22
39
|
complete: (
|
|
23
40
|
outboxMessageId: string,
|
|
24
41
|
outboxAttachmentId: string,
|
|
25
|
-
) => Promise<
|
|
42
|
+
) => Promise<CompletedAttachment>;
|
|
26
43
|
keep: (outboxMessageId: string, attachmentIds: string[]) => Promise<void>;
|
|
27
44
|
}
|
|
28
45
|
|
|
@@ -34,6 +51,10 @@ export interface ComposeAttachmentControllerDeps {
|
|
|
34
51
|
interface Entry extends ComposeAttachmentItem {
|
|
35
52
|
serverId: string | null;
|
|
36
53
|
file: File | null;
|
|
54
|
+
placement: AttachmentPlacement | null;
|
|
55
|
+
contentId: string | null;
|
|
56
|
+
contentType: string;
|
|
57
|
+
contentUrl: string | null;
|
|
37
58
|
}
|
|
38
59
|
|
|
39
60
|
interface AttachmentRejection {
|
|
@@ -91,6 +112,10 @@ const fromServer = (attachment: RemitImapOutboxAttachmentResponse): Entry => ({
|
|
|
91
112
|
key: attachment.outboxAttachmentId,
|
|
92
113
|
serverId: attachment.outboxAttachmentId,
|
|
93
114
|
file: null,
|
|
115
|
+
placement: null,
|
|
116
|
+
contentId: attachment.contentId,
|
|
117
|
+
contentType: attachment.contentType,
|
|
118
|
+
contentUrl: attachment.contentUrl,
|
|
94
119
|
filename: attachment.filename,
|
|
95
120
|
sizeBytes: attachment.sizeBytes,
|
|
96
121
|
state:
|
|
@@ -124,6 +149,8 @@ export class ComposeAttachmentController {
|
|
|
124
149
|
private readonly inFlight = new Map<string, AbortController>();
|
|
125
150
|
private readonly mintedAfterRemoval = new Map<string, string>();
|
|
126
151
|
private readonly removing = new Map<string, string>();
|
|
152
|
+
private readonly removedListeners = new Set<(contentId: string) => void>();
|
|
153
|
+
private loaded = false;
|
|
127
154
|
|
|
128
155
|
constructor(private readonly deps: ComposeAttachmentControllerDeps) {}
|
|
129
156
|
|
|
@@ -134,6 +161,47 @@ export class ComposeAttachmentController {
|
|
|
134
161
|
|
|
135
162
|
getSnapshot = (): ComposeAttachmentItem[] => this.snapshot;
|
|
136
163
|
|
|
164
|
+
owns = (contentId: string): boolean =>
|
|
165
|
+
this.entries.some((entry) => entry.contentId === contentId);
|
|
166
|
+
|
|
167
|
+
storedContent = (contentId: string): StoredContent | null => {
|
|
168
|
+
const entry = this.entries.find(
|
|
169
|
+
(candidate) =>
|
|
170
|
+
candidate.contentId === contentId && candidate.contentUrl !== null,
|
|
171
|
+
);
|
|
172
|
+
if (!entry?.contentUrl) return null;
|
|
173
|
+
return {
|
|
174
|
+
filename: entry.filename,
|
|
175
|
+
contentType: entry.contentType,
|
|
176
|
+
contentUrl: entry.contentUrl,
|
|
177
|
+
};
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
isLoaded = (): boolean => this.loaded;
|
|
181
|
+
|
|
182
|
+
refreshLinks = (
|
|
183
|
+
attachments: readonly RemitImapOutboxAttachmentResponse[],
|
|
184
|
+
): void => {
|
|
185
|
+
const links = new Map(
|
|
186
|
+
attachments.map((attachment) => [
|
|
187
|
+
attachment.outboxAttachmentId,
|
|
188
|
+
attachment.contentUrl,
|
|
189
|
+
]),
|
|
190
|
+
);
|
|
191
|
+
this.set(
|
|
192
|
+
this.entries.map((entry) => {
|
|
193
|
+
const contentUrl =
|
|
194
|
+
entry.serverId === null ? undefined : links.get(entry.serverId);
|
|
195
|
+
return contentUrl === undefined ? entry : { ...entry, contentUrl };
|
|
196
|
+
}),
|
|
197
|
+
);
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
onRemoved = (listener: (contentId: string) => void): (() => void) => {
|
|
201
|
+
this.removedListeners.add(listener);
|
|
202
|
+
return () => this.removedListeners.delete(listener);
|
|
203
|
+
};
|
|
204
|
+
|
|
137
205
|
blockingReason = (): string | undefined => {
|
|
138
206
|
const [removing] = this.removing.values();
|
|
139
207
|
if (removing !== undefined) return `Removing "${removing}"…`;
|
|
@@ -210,6 +278,8 @@ export class ComposeAttachmentController {
|
|
|
210
278
|
this.patch(generation, key, {
|
|
211
279
|
serverId: minted.outboxAttachmentId,
|
|
212
280
|
filename: minted.filename,
|
|
281
|
+
contentId: minted.contentId,
|
|
282
|
+
contentType: minted.contentType,
|
|
213
283
|
});
|
|
214
284
|
}
|
|
215
285
|
settle();
|
|
@@ -232,19 +302,29 @@ export class ComposeAttachmentController {
|
|
|
232
302
|
|
|
233
303
|
const completed = await transport
|
|
234
304
|
.complete(outboxMessageId, minted.outboxAttachmentId)
|
|
235
|
-
.
|
|
236
|
-
(
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
305
|
+
.catch((error: unknown) => {
|
|
306
|
+
this.patch(generation, key, {
|
|
307
|
+
state: refusalOf(minted.filename, error),
|
|
308
|
+
});
|
|
309
|
+
return null;
|
|
310
|
+
});
|
|
311
|
+
if (completed === null || signal.aborted) return;
|
|
312
|
+
this.patch(generation, key, {
|
|
313
|
+
state: ATTACHED,
|
|
314
|
+
contentUrl: completed.contentUrl,
|
|
315
|
+
});
|
|
316
|
+
if (generation !== this.generation) return;
|
|
317
|
+
this.entries
|
|
318
|
+
.find((entry) => entry.key === key)
|
|
319
|
+
?.placement?.stored(file, minted.contentId);
|
|
246
320
|
};
|
|
247
321
|
|
|
322
|
+
private settleFailure(generation: number, key: string, file: File): void {
|
|
323
|
+
if (generation !== this.generation) return;
|
|
324
|
+
const entry = this.entries.find((candidate) => candidate.key === key);
|
|
325
|
+
if (entry?.state.status === "failed") entry.placement?.failed(file);
|
|
326
|
+
}
|
|
327
|
+
|
|
248
328
|
private run = async (
|
|
249
329
|
generation: number,
|
|
250
330
|
key: string,
|
|
@@ -254,6 +334,7 @@ export class ComposeAttachmentController {
|
|
|
254
334
|
): Promise<void> => {
|
|
255
335
|
await this.upload(generation, key, file, outboxMessageId, signal);
|
|
256
336
|
if (this.inFlight.get(key)?.signal === signal) this.inFlight.delete(key);
|
|
337
|
+
this.settleFailure(generation, key, file);
|
|
257
338
|
};
|
|
258
339
|
|
|
259
340
|
/**
|
|
@@ -282,7 +363,10 @@ export class ComposeAttachmentController {
|
|
|
282
363
|
);
|
|
283
364
|
};
|
|
284
365
|
|
|
285
|
-
attach = async (
|
|
366
|
+
attach = async (
|
|
367
|
+
files: File[],
|
|
368
|
+
placement?: AttachmentPlacement,
|
|
369
|
+
): Promise<void> => {
|
|
286
370
|
const generation = this.generation;
|
|
287
371
|
const added = files.map((file) => {
|
|
288
372
|
this.nextKey += 1;
|
|
@@ -290,6 +374,10 @@ export class ComposeAttachmentController {
|
|
|
290
374
|
key: `local-${this.nextKey}`,
|
|
291
375
|
serverId: null,
|
|
292
376
|
file,
|
|
377
|
+
placement: placement ?? null,
|
|
378
|
+
contentId: null,
|
|
379
|
+
contentType: file.type || "application/octet-stream",
|
|
380
|
+
contentUrl: null,
|
|
293
381
|
filename: file.name,
|
|
294
382
|
sizeBytes: file.size,
|
|
295
383
|
state: UPLOADING,
|
|
@@ -302,11 +390,12 @@ export class ComposeAttachmentController {
|
|
|
302
390
|
|
|
303
391
|
const draft = await this.deps.ensureDraft();
|
|
304
392
|
if (draft.outcome === "refused") {
|
|
305
|
-
for (const { entry } of added) {
|
|
393
|
+
for (const { entry, file } of added) {
|
|
306
394
|
this.inFlight.delete(entry.key);
|
|
307
395
|
this.patch(generation, entry.key, {
|
|
308
396
|
state: failed(draft.reason, true),
|
|
309
397
|
});
|
|
398
|
+
this.settleFailure(generation, entry.key, file);
|
|
310
399
|
}
|
|
311
400
|
return;
|
|
312
401
|
}
|
|
@@ -379,7 +468,12 @@ export class ComposeAttachmentController {
|
|
|
379
468
|
this.set(this.entries);
|
|
380
469
|
const serverId = entry.serverId ?? this.mintedAfterRemoval.get(key) ?? null;
|
|
381
470
|
this.mintedAfterRemoval.delete(key);
|
|
382
|
-
if (
|
|
471
|
+
if (generation !== this.generation) return;
|
|
472
|
+
if (failure === null) {
|
|
473
|
+
if (entry.contentId === null) return;
|
|
474
|
+
for (const listener of this.removedListeners) listener(entry.contentId);
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
383
477
|
if (serverId === null) return;
|
|
384
478
|
this.set([
|
|
385
479
|
...this.entries,
|
|
@@ -400,7 +494,16 @@ export class ComposeAttachmentController {
|
|
|
400
494
|
attachments: readonly RemitImapOutboxAttachmentResponse[],
|
|
401
495
|
): void => {
|
|
402
496
|
this.draftId = outboxMessageId;
|
|
403
|
-
this.
|
|
497
|
+
this.loaded = true;
|
|
498
|
+
const listed = new Set(
|
|
499
|
+
attachments.map((attachment) => attachment.outboxAttachmentId),
|
|
500
|
+
);
|
|
501
|
+
const attachedHere = this.entries.filter(
|
|
502
|
+
(entry) =>
|
|
503
|
+
entry.file !== null &&
|
|
504
|
+
(entry.serverId === null || !listed.has(entry.serverId)),
|
|
505
|
+
);
|
|
506
|
+
this.set([...attachments.map(fromServer), ...attachedHere]);
|
|
404
507
|
};
|
|
405
508
|
|
|
406
509
|
reset = (outboxMessageId: string | undefined): void => {
|
|
@@ -410,6 +513,7 @@ export class ComposeAttachmentController {
|
|
|
410
513
|
this.mintedAfterRemoval.clear();
|
|
411
514
|
this.removing.clear();
|
|
412
515
|
this.draftId = outboxMessageId;
|
|
516
|
+
this.loaded = false;
|
|
413
517
|
this.set([]);
|
|
414
518
|
};
|
|
415
519
|
}
|
package/src/lib/mail-search.ts
CHANGED
|
@@ -50,3 +50,20 @@ export const mailListSearchSchemas = {
|
|
|
50
50
|
"/mail/outbox": outboxSearchSchema,
|
|
51
51
|
"/mail/$mailboxId": mailboxSearchSchema,
|
|
52
52
|
} as const;
|
|
53
|
+
|
|
54
|
+
export const composeSeedSearch = z.object({ body: z.string().optional() });
|
|
55
|
+
|
|
56
|
+
export type ComposeSeed = Required<z.infer<typeof composeSeedSearch>>;
|
|
57
|
+
|
|
58
|
+
export const withoutComposeSeed = (
|
|
59
|
+
prev: Record<string, unknown>,
|
|
60
|
+
): Record<string, unknown> => {
|
|
61
|
+
const { body: _seed, ...rest } = prev;
|
|
62
|
+
return rest;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export const replySearchSchemas = {
|
|
66
|
+
brief: briefSearchSchema.extend(composeSeedSearch.shape),
|
|
67
|
+
flagged: flaggedSearchSchema.extend(composeSeedSearch.shape),
|
|
68
|
+
mailbox: mailboxSearchSchema.extend(composeSeedSearch.shape),
|
|
69
|
+
} as const;
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
* The conversation reads it off the address and opens it at its head.
|
|
9
9
|
*/
|
|
10
10
|
import { createFileRoute } from "@tanstack/react-router";
|
|
11
|
+
import { replySearchSchemas } from "@/lib/mail-search";
|
|
11
12
|
|
|
12
13
|
export const Route = createFileRoute(
|
|
13
14
|
"/mail/$mailboxId/$threadId/$messageId/$mode/{-$outboxMessageId}",
|
|
14
|
-
)({});
|
|
15
|
+
)({ validateSearch: replySearchSchemas.mailbox });
|
|
@@ -20,7 +20,8 @@
|
|
|
20
20
|
* the pane reads the reply off the address, the way the shell reads compose.
|
|
21
21
|
*/
|
|
22
22
|
import { createFileRoute } from "@tanstack/react-router";
|
|
23
|
+
import { replySearchSchemas } from "@/lib/mail-search";
|
|
23
24
|
|
|
24
25
|
export const Route = createFileRoute(
|
|
25
26
|
"/mail/brief/$threadId/$messageId/$mode/{-$outboxMessageId}",
|
|
26
|
-
)({});
|
|
27
|
+
)({ validateSearch: replySearchSchemas.brief });
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
* The conversation reads it off the address and opens it at its head.
|
|
9
9
|
*/
|
|
10
10
|
import { createFileRoute } from "@tanstack/react-router";
|
|
11
|
+
import { replySearchSchemas } from "@/lib/mail-search";
|
|
11
12
|
|
|
12
13
|
export const Route = createFileRoute(
|
|
13
14
|
"/mail/flagged/$threadId/$messageId/$mode/{-$outboxMessageId}",
|
|
14
|
-
)({});
|
|
15
|
+
)({ validateSearch: replySearchSchemas.flagged });
|
package/src/routing/compose.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useNavigate, useParams } from "@tanstack/react-router";
|
|
2
2
|
import { useCallback } from "react";
|
|
3
|
+
import { withoutComposeSeed } from "@/lib/mail-search";
|
|
3
4
|
import { useBrowsedList, useNavigateToBrowsedList } from "./browsed-list";
|
|
4
5
|
import { useRetainOpenPanels } from "./fragment";
|
|
5
6
|
|
|
@@ -108,7 +109,7 @@ function useComposeNavigate(): (
|
|
|
108
109
|
) => {
|
|
109
110
|
navigate({
|
|
110
111
|
...composeTarget(list, mailboxId, outboxMessageId),
|
|
111
|
-
search:
|
|
112
|
+
search: withoutComposeSeed,
|
|
112
113
|
// Opening or closing the surface is going somewhere, so the panes the
|
|
113
114
|
// reader keeps up travel and the overlays they were reading over do
|
|
114
115
|
// not. Recording the draft is not going anywhere — the address is
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useNavigate, useParams } from "@tanstack/react-router";
|
|
2
2
|
import { useCallback } from "react";
|
|
3
|
+
import { withoutComposeSeed } from "@/lib/mail-search";
|
|
3
4
|
import { useNavigateToBrowsedList } from "./browsed-list";
|
|
4
5
|
import { useRetainOpenPanels } from "./fragment";
|
|
5
6
|
|
|
@@ -29,7 +30,7 @@ export function useOpenOutboxDraft(): (outboxMessageId: string) => void {
|
|
|
29
30
|
navigate({
|
|
30
31
|
to: "/mail/outbox/draft/$outboxMessageId",
|
|
31
32
|
params: { outboxMessageId },
|
|
32
|
-
search:
|
|
33
|
+
search: withoutComposeSeed,
|
|
33
34
|
hash: retainPanels,
|
|
34
35
|
});
|
|
35
36
|
},
|