@remit/web-client 0.0.173 → 0.0.175
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 +23 -6
- package/src/components/compose/forward-autosave-is-not-fatal.render.test.ts +357 -0
- package/src/components/mail/BriefPane.tsx +7 -6
- package/src/components/mail/FlaggedPane.tsx +7 -6
- package/src/components/mail/MailboxPane.tsx +36 -53
- package/src/components/mail/intelligence-drawer.stories.tsx +59 -43
- package/src/hooks/intelligence-drawer.render.test.ts +99 -0
- package/src/hooks/signed-out-poll-is-not-fatal.render.test.ts +126 -0
- package/src/hooks/useIntelligenceDrawer.ts +54 -0
- package/src/hooks/useMarkAsRead.ts +5 -0
- package/src/hooks/useRefreshControl.ts +6 -1
- package/src/hooks/useSaveDraft.ts +78 -16
- package/src/hooks/useStaleAccountSync.test.ts +15 -0
- package/src/hooks/useStaleAccountSync.ts +5 -1
- package/src/lib/error-classifier.test.ts +46 -0
- package/src/lib/error-classifier.ts +55 -1
- package/src/lib/query-error-handler.ts +9 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.175",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
|
|
6
6
|
"exports": {
|
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
import { useMessageBodyContent } from "../../hooks/useMessageBodyContent";
|
|
41
41
|
import { useSaveDraft } from "../../hooks/useSaveDraft";
|
|
42
42
|
import { useSignature } from "../../hooks/useSignature.js";
|
|
43
|
+
import { softErrorMeta } from "../../lib/error-classifier";
|
|
43
44
|
import { accountIsMissingSmtp } from "../settings/account-form-helpers.js";
|
|
44
45
|
import { useErrorBanners } from "../ui/ErrorBannerProvider.js";
|
|
45
46
|
import {
|
|
@@ -212,6 +213,17 @@ type SendReadiness =
|
|
|
212
213
|
| { status: "blocked"; reason: string }
|
|
213
214
|
| { status: "ready"; accountId: string };
|
|
214
215
|
|
|
216
|
+
/**
|
|
217
|
+
* Naming To, not "a recipient". Sending goes through the draft, and a draft is
|
|
218
|
+
* created against `CreateOutboxMessageInput`, whose `@minItems(1)` is on
|
|
219
|
+
* `toAddresses` alone — so a message addressed only in Cc has a recipient and
|
|
220
|
+
* still cannot be sent from here, and being told to add one it can already see
|
|
221
|
+
* leaves it with nothing to do. The server's own send guard counts Cc and Bcc,
|
|
222
|
+
* because a Bcc-only envelope is real mail; it is answering whether the message
|
|
223
|
+
* has anywhere to go, which is not the question this one asks.
|
|
224
|
+
*/
|
|
225
|
+
const NO_TO_ADDRESS_MESSAGE = "Add a To address before sending.";
|
|
226
|
+
|
|
215
227
|
const isFormEmpty = (
|
|
216
228
|
toAddresses: AddressEntry[],
|
|
217
229
|
ccAddresses: AddressEntry[],
|
|
@@ -536,7 +548,7 @@ export const ComposeForm = ({
|
|
|
536
548
|
[onDraftCreated],
|
|
537
549
|
);
|
|
538
550
|
|
|
539
|
-
const {
|
|
551
|
+
const { saveState, saveError, saveDraft, saveImmediately, stopAutoSave } =
|
|
540
552
|
useSaveDraft({
|
|
541
553
|
outboxMessageId,
|
|
542
554
|
onDraftCreated: adoptCreatedDraft,
|
|
@@ -555,12 +567,17 @@ export const ComposeForm = ({
|
|
|
555
567
|
});
|
|
556
568
|
}, [saveError, pushError]);
|
|
557
569
|
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
570
|
+
// A refused send is reported below, next to the message it did not send, and
|
|
571
|
+
// the composer stays up so the user can fix the address and press it again.
|
|
572
|
+
// A 5xx still escalates.
|
|
573
|
+
const sendMutation = useMutation({
|
|
574
|
+
...outboxDetailOperationsSendOutboxMessageMutation(),
|
|
575
|
+
meta: softErrorMeta,
|
|
576
|
+
});
|
|
561
577
|
|
|
562
578
|
const deleteMutation = useMutation({
|
|
563
579
|
...outboxDetailOperationsDeleteOutboxMessageMutation(),
|
|
580
|
+
meta: softErrorMeta,
|
|
564
581
|
onError: (error) => {
|
|
565
582
|
// Discard closes the dialog optimistically. A soft 4xx (409/404 the
|
|
566
583
|
// draft is already gone) must not pass silently as success — surface a
|
|
@@ -621,7 +638,7 @@ export const ComposeForm = ({
|
|
|
621
638
|
return { status: "blocked", reason: SMTP_MISSING_MESSAGE };
|
|
622
639
|
}
|
|
623
640
|
if (toAddresses.length === 0) {
|
|
624
|
-
return { status: "blocked", reason:
|
|
641
|
+
return { status: "blocked", reason: NO_TO_ADDRESS_MESSAGE };
|
|
625
642
|
}
|
|
626
643
|
return { status: "ready", accountId: selectedAccountId };
|
|
627
644
|
}, [
|
|
@@ -864,7 +881,7 @@ export const ComposeForm = ({
|
|
|
864
881
|
onSend={attemptSend}
|
|
865
882
|
onBlocked={reportBlocked}
|
|
866
883
|
onDiscard={handleDiscard}
|
|
867
|
-
|
|
884
|
+
save={saveState}
|
|
868
885
|
/>
|
|
869
886
|
}
|
|
870
887
|
>
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Forward opens with a subject and a quote and deliberately no recipient. The
|
|
3
|
+
* autosave gate only skips a form that is blank everywhere, so the seeded
|
|
4
|
+
* subject used to fire a create — and a create carries `@minItems(1)` on
|
|
5
|
+
* `toAddresses`, so the server refused it 400. That refusal then went to the
|
|
6
|
+
* full-screen fatal page, which unmounts the composer and takes the message
|
|
7
|
+
* with it: the one failure mode a composer must never have.
|
|
8
|
+
*
|
|
9
|
+
* Both halves are held here. The invalid request is not made at all while the
|
|
10
|
+
* draft has no recipient to create it with — and the composer says so, because
|
|
11
|
+
* a message being held unsaved in silence is the same loss with the noise taken
|
|
12
|
+
* out. A write that does fail lands in a banner beside the message rather than
|
|
13
|
+
* over it. A 5xx and a 401 keep their own rules.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import assert from "node:assert/strict";
|
|
17
|
+
import { afterEach, describe, it } from "node:test";
|
|
18
|
+
import type {
|
|
19
|
+
RemitImapAccountResponse,
|
|
20
|
+
RemitImapDescribeMessageResponse,
|
|
21
|
+
} from "@remit/api-http-client/types.gen.ts";
|
|
22
|
+
import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
|
|
23
|
+
import {
|
|
24
|
+
type AnyRouter,
|
|
25
|
+
createMemoryHistory,
|
|
26
|
+
createRootRoute,
|
|
27
|
+
createRoute,
|
|
28
|
+
createRouter,
|
|
29
|
+
RouterContextProvider,
|
|
30
|
+
} from "@tanstack/react-router";
|
|
31
|
+
import { createElement, Fragment, useState } from "react";
|
|
32
|
+
import { __resetFatalError } from "../../lib/fatal-error";
|
|
33
|
+
import {
|
|
34
|
+
handleMutationCacheError,
|
|
35
|
+
handleQueryCacheError,
|
|
36
|
+
} from "../../lib/query-error-handler";
|
|
37
|
+
import { createDomHarness, type DomHarness } from "../../test-support/dom";
|
|
38
|
+
import { type HttpMock, httpError, mockFetch } from "../../test-support/http";
|
|
39
|
+
import { FatalErrorOverlay } from "../ui/FatalErrorOverlay";
|
|
40
|
+
import { ComposeForm } from "./ComposeForm";
|
|
41
|
+
import { ComposeProvider } from "./ComposeProvider";
|
|
42
|
+
|
|
43
|
+
const ACCOUNT_ID = "acc-1";
|
|
44
|
+
const OUTBOX_MESSAGE_ID = "ob-fwd";
|
|
45
|
+
const AUTOSAVE_DEBOUNCE_MS = 2000;
|
|
46
|
+
|
|
47
|
+
const account = {
|
|
48
|
+
accountId: ACCOUNT_ID,
|
|
49
|
+
email: "me@example.com",
|
|
50
|
+
smtpEnabled: true,
|
|
51
|
+
} as unknown as RemitImapAccountResponse;
|
|
52
|
+
|
|
53
|
+
const sourceMessage = {
|
|
54
|
+
message: { messageId: "msg-1" },
|
|
55
|
+
envelope: {
|
|
56
|
+
subject: "Lunch",
|
|
57
|
+
messageIdValue: "<m1@example.com>",
|
|
58
|
+
from: [{ normalizedEmail: "them@example.com", displayName: "Them" }],
|
|
59
|
+
replyTo: [],
|
|
60
|
+
to: [],
|
|
61
|
+
cc: [],
|
|
62
|
+
},
|
|
63
|
+
references: [],
|
|
64
|
+
bodyParts: [],
|
|
65
|
+
} as unknown as RemitImapDescribeMessageResponse;
|
|
66
|
+
|
|
67
|
+
const outboxEntry = () => ({
|
|
68
|
+
outboxMessageId: OUTBOX_MESSAGE_ID,
|
|
69
|
+
accountId: ACCOUNT_ID,
|
|
70
|
+
fromAddress: account.email,
|
|
71
|
+
toAddresses: ["them@example.com"],
|
|
72
|
+
ccAddresses: [],
|
|
73
|
+
bccAddresses: [],
|
|
74
|
+
references: [],
|
|
75
|
+
subject: "Fwd: Lunch",
|
|
76
|
+
textBody: "here you go",
|
|
77
|
+
status: "draft",
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
let harness: DomHarness | undefined;
|
|
81
|
+
let http: HttpMock | undefined;
|
|
82
|
+
|
|
83
|
+
afterEach(() => {
|
|
84
|
+
harness?.close();
|
|
85
|
+
harness = undefined;
|
|
86
|
+
http?.restore();
|
|
87
|
+
http = undefined;
|
|
88
|
+
__resetFatalError();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const creates = () =>
|
|
92
|
+
(http?.calls ?? []).filter(
|
|
93
|
+
(call) => call.method === "POST" && call.path.endsWith("/outbox"),
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
const patches = () =>
|
|
97
|
+
(http?.calls ?? []).filter((call) => call.method === "PATCH");
|
|
98
|
+
|
|
99
|
+
const sends = () =>
|
|
100
|
+
(http?.calls ?? []).filter((call) => call.path.endsWith("/send"));
|
|
101
|
+
|
|
102
|
+
const fatalOverlay = () =>
|
|
103
|
+
harness?.query('[data-testid="fatal-error-overlay"]') ?? null;
|
|
104
|
+
|
|
105
|
+
const bannerAlerts = () =>
|
|
106
|
+
harness?.queryAll('[aria-label="Notifications"] [role="alert"]') ?? [];
|
|
107
|
+
|
|
108
|
+
// The draft the composer is on is its owner's to hand it — here, the test's.
|
|
109
|
+
const Opened = ({ outboxMessageId }: { outboxMessageId?: string }) => {
|
|
110
|
+
const [draftId, setDraftId] = useState(outboxMessageId);
|
|
111
|
+
|
|
112
|
+
return createElement(ComposeForm, {
|
|
113
|
+
mode: "forward",
|
|
114
|
+
account,
|
|
115
|
+
sourceMessage,
|
|
116
|
+
outboxMessageId: draftId,
|
|
117
|
+
onDraftCreated: setDraftId,
|
|
118
|
+
onClose: () => {},
|
|
119
|
+
});
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
(globalThis as { self?: typeof globalThis }).self ??= globalThis;
|
|
123
|
+
|
|
124
|
+
const rootRoute = createRootRoute();
|
|
125
|
+
const mailboxRoute = createRoute({
|
|
126
|
+
getParentRoute: () => rootRoute,
|
|
127
|
+
path: "/mail/$mailboxId",
|
|
128
|
+
validateSearch: (search: Record<string, unknown>) => search,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const testRouter = (): AnyRouter =>
|
|
132
|
+
createRouter({
|
|
133
|
+
routeTree: rootRoute.addChildren([mailboxRoute]),
|
|
134
|
+
history: createMemoryHistory({ initialEntries: ["/mail/mbx-1"] }),
|
|
135
|
+
}) as unknown as AnyRouter;
|
|
136
|
+
|
|
137
|
+
interface MountOptions {
|
|
138
|
+
/** The draft compose opens on, when the user is resuming one. */
|
|
139
|
+
outboxMessageId?: string;
|
|
140
|
+
/** The status every PATCH answers with, when it is to fail. */
|
|
141
|
+
patchStatus?: number;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The stub holds the constraint the API holds: a create with no recipient is
|
|
146
|
+
* refused, in the words the backend's schema validation uses.
|
|
147
|
+
*/
|
|
148
|
+
const mount = async (options: MountOptions = {}): Promise<void> => {
|
|
149
|
+
http = mockFetch(async (call) => {
|
|
150
|
+
if (call.path.endsWith("/config")) return { accounts: [account] };
|
|
151
|
+
|
|
152
|
+
if (call.method === "POST" && call.path.endsWith("/outbox")) {
|
|
153
|
+
const to = call.body?.toAddresses;
|
|
154
|
+
if (!Array.isArray(to) || to.length === 0) {
|
|
155
|
+
return httpError(
|
|
156
|
+
400,
|
|
157
|
+
"body/requestBody/toAddresses must NOT have fewer than 1 items",
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
return outboxEntry();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (call.method === "PATCH") {
|
|
164
|
+
if (options.patchStatus) {
|
|
165
|
+
return httpError(options.patchStatus, "the draft moved on");
|
|
166
|
+
}
|
|
167
|
+
return outboxEntry();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return outboxEntry();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const queryClient = new QueryClient({
|
|
174
|
+
queryCache: new QueryCache({ onError: handleQueryCacheError }),
|
|
175
|
+
mutationCache: new MutationCache({ onError: handleMutationCacheError }),
|
|
176
|
+
defaultOptions: {
|
|
177
|
+
queries: { retry: false },
|
|
178
|
+
mutations: { retry: false },
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
harness = createDomHarness({ queryClient });
|
|
183
|
+
harness.renderApp(
|
|
184
|
+
createElement(
|
|
185
|
+
Fragment,
|
|
186
|
+
null,
|
|
187
|
+
createElement(FatalErrorOverlay),
|
|
188
|
+
createElement(RouterContextProvider, {
|
|
189
|
+
router: testRouter(),
|
|
190
|
+
// biome-ignore lint/correctness/noChildrenProp: RouterContextProvider types `children` as a required prop, which createElement's rest-argument form does not satisfy
|
|
191
|
+
children: createElement(
|
|
192
|
+
ComposeProvider,
|
|
193
|
+
null,
|
|
194
|
+
createElement(Opened, { outboxMessageId: options.outboxMessageId }),
|
|
195
|
+
),
|
|
196
|
+
}),
|
|
197
|
+
),
|
|
198
|
+
);
|
|
199
|
+
await harness.flush();
|
|
200
|
+
await harness.wait(50);
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const subjectField = (): HTMLInputElement => {
|
|
204
|
+
const field = harness?.query<HTMLInputElement>("[data-subject-field]");
|
|
205
|
+
if (!field) throw new Error("the compose subject field is not mounted");
|
|
206
|
+
return field;
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const recipientField = (): HTMLInputElement => {
|
|
210
|
+
const field = harness?.query<HTMLInputElement>("#address-field-To");
|
|
211
|
+
if (!field) throw new Error("the compose recipient field is not mounted");
|
|
212
|
+
return field;
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const addRecipient = async (email: string): Promise<void> => {
|
|
216
|
+
harness?.type(recipientField(), email);
|
|
217
|
+
harness?.dispatch(
|
|
218
|
+
recipientField(),
|
|
219
|
+
new (
|
|
220
|
+
harness.window as unknown as { KeyboardEvent: typeof KeyboardEvent }
|
|
221
|
+
).KeyboardEvent("keydown", { key: "Enter", bubbles: true }),
|
|
222
|
+
);
|
|
223
|
+
await harness?.flush();
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
describe("a forwarded message and the draft it cannot create yet", () => {
|
|
227
|
+
it("attempts no create while the forward has no recipient", async () => {
|
|
228
|
+
await mount();
|
|
229
|
+
|
|
230
|
+
assert.equal(
|
|
231
|
+
subjectField().value,
|
|
232
|
+
"Fwd: Lunch",
|
|
233
|
+
"the forward seeded its subject, so the form is not blank",
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
await harness?.wait(AUTOSAVE_DEBOUNCE_MS + 300);
|
|
237
|
+
|
|
238
|
+
assert.equal(creates().length, 0, "no create was attempted");
|
|
239
|
+
assert.equal(fatalOverlay(), null, "the composer stayed on screen");
|
|
240
|
+
assert.equal(bannerAlerts().length, 0, "nothing was raised as a failure");
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("says the draft is not being saved, and what would make it save", async () => {
|
|
244
|
+
await mount();
|
|
245
|
+
|
|
246
|
+
harness?.type(subjectField(), "Fwd: Lunch and the three paragraphs after");
|
|
247
|
+
await harness?.flush();
|
|
248
|
+
|
|
249
|
+
const indicator = harness?.byText("output", "Not saved");
|
|
250
|
+
assert.match(
|
|
251
|
+
indicator?.textContent ?? "",
|
|
252
|
+
/add a To address/i,
|
|
253
|
+
"the composer names the field the create schema actually requires",
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
await addRecipient("them@example.com");
|
|
257
|
+
await harness?.wait(AUTOSAVE_DEBOUNCE_MS + 300);
|
|
258
|
+
|
|
259
|
+
assert.equal(creates().length, 1, "the held content was written");
|
|
260
|
+
assert.equal(
|
|
261
|
+
creates()[0]?.body?.subject,
|
|
262
|
+
"Fwd: Lunch and the three paragraphs after",
|
|
263
|
+
"including everything typed while it was held",
|
|
264
|
+
);
|
|
265
|
+
assert.match(harness?.text() ?? "", /Draft saved/);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("creates the draft, with everything the forward seeded, once a recipient is there", async () => {
|
|
269
|
+
await mount();
|
|
270
|
+
await harness?.wait(AUTOSAVE_DEBOUNCE_MS + 300);
|
|
271
|
+
|
|
272
|
+
await addRecipient("them@example.com");
|
|
273
|
+
await harness?.wait(AUTOSAVE_DEBOUNCE_MS + 300);
|
|
274
|
+
|
|
275
|
+
const created = creates();
|
|
276
|
+
assert.equal(created.length, 1, "the draft was created once");
|
|
277
|
+
assert.deepEqual(created[0]?.body?.toAddresses, ["them@example.com"]);
|
|
278
|
+
assert.equal(created[0]?.body?.subject, "Fwd: Lunch");
|
|
279
|
+
assert.equal(fatalOverlay(), null, "the composer stayed on screen");
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("refuses to send a forward that has no recipient, and says why", async () => {
|
|
283
|
+
await mount();
|
|
284
|
+
|
|
285
|
+
const send = harness?.byText("button", "Send");
|
|
286
|
+
if (!send) throw new Error("the send button is not mounted");
|
|
287
|
+
harness?.click(send);
|
|
288
|
+
await harness?.flush();
|
|
289
|
+
await harness?.wait(100);
|
|
290
|
+
|
|
291
|
+
assert.match(harness?.text() ?? "", /Add a To address before sending/);
|
|
292
|
+
assert.equal(creates().length, 0, "nothing was created");
|
|
293
|
+
assert.equal(sends().length, 0, "nothing was sent");
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("drops the held sentence the moment the To address arrives", async () => {
|
|
297
|
+
await mount();
|
|
298
|
+
|
|
299
|
+
harness?.type(subjectField(), "Fwd: Lunch and the three paragraphs after");
|
|
300
|
+
await harness?.flush();
|
|
301
|
+
assert.match(harness?.text() ?? "", /Not saved/);
|
|
302
|
+
|
|
303
|
+
await addRecipient("them@example.com");
|
|
304
|
+
|
|
305
|
+
// Before the debounce, not after it: the sentence stopped being true the
|
|
306
|
+
// moment the address landed, and standing for another two seconds tells
|
|
307
|
+
// the user their draft is being dropped when it is on its way.
|
|
308
|
+
assert.doesNotMatch(
|
|
309
|
+
harness?.text() ?? "",
|
|
310
|
+
/Not saved/,
|
|
311
|
+
"a reason that no longer holds is off screen at once",
|
|
312
|
+
);
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
describe("a failed autosave and the message it is holding", () => {
|
|
317
|
+
it("banners a refused write and leaves the composer and its text alone", async () => {
|
|
318
|
+
await mount({ outboxMessageId: OUTBOX_MESSAGE_ID, patchStatus: 409 });
|
|
319
|
+
|
|
320
|
+
harness?.type(subjectField(), "Fwd: Lunch on Thursday");
|
|
321
|
+
await harness?.wait(AUTOSAVE_DEBOUNCE_MS + 300);
|
|
322
|
+
|
|
323
|
+
assert.ok(patches().length > 0, "the write was attempted");
|
|
324
|
+
assert.equal(
|
|
325
|
+
fatalOverlay(),
|
|
326
|
+
null,
|
|
327
|
+
"a refused autosave must not take the composer down",
|
|
328
|
+
);
|
|
329
|
+
assert.match(harness?.text() ?? "", /Couldn't save draft/);
|
|
330
|
+
assert.equal(
|
|
331
|
+
subjectField().value,
|
|
332
|
+
"Fwd: Lunch on Thursday",
|
|
333
|
+
"what was written is still on screen",
|
|
334
|
+
);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("escalates a 401 — a dismissible banner is no way back in", async () => {
|
|
338
|
+
await mount({ outboxMessageId: OUTBOX_MESSAGE_ID, patchStatus: 401 });
|
|
339
|
+
|
|
340
|
+
harness?.type(subjectField(), "Fwd: Lunch on Thursday");
|
|
341
|
+
await harness?.wait(AUTOSAVE_DEBOUNCE_MS + 300);
|
|
342
|
+
|
|
343
|
+
assert.ok(
|
|
344
|
+
fatalOverlay(),
|
|
345
|
+
"a signed-out session must reach the page that signs back in",
|
|
346
|
+
);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it("still escalates a 5xx from the same write", async () => {
|
|
350
|
+
await mount({ outboxMessageId: OUTBOX_MESSAGE_ID, patchStatus: 500 });
|
|
351
|
+
|
|
352
|
+
harness?.type(subjectField(), "Fwd: Lunch on Thursday");
|
|
353
|
+
await harness?.wait(AUTOSAVE_DEBOUNCE_MS + 300);
|
|
354
|
+
|
|
355
|
+
assert.ok(fatalOverlay(), "our API answering 'I'm broken' is never soft");
|
|
356
|
+
});
|
|
357
|
+
});
|
|
@@ -31,6 +31,7 @@ import { IntelligencePane } from "@/components/mail/IntelligencePane";
|
|
|
31
31
|
import { MessageToolbar } from "@/components/mail/MessageToolbar";
|
|
32
32
|
import type { OpenMessageOptions } from "@/components/mail/ThreadListInteraction";
|
|
33
33
|
import { useDeleteMessages } from "@/hooks/useDeleteMessages";
|
|
34
|
+
import { useIntelligenceDrawer } from "@/hooks/useIntelligenceDrawer";
|
|
34
35
|
import { useToggleReadFor } from "@/hooks/useMarkAsRead";
|
|
35
36
|
import { type ThreadActions, useThreadActions } from "@/hooks/useThreadActions";
|
|
36
37
|
import { useThreadRow } from "@/hooks/useThreadRow";
|
|
@@ -408,7 +409,7 @@ function BriefPhone() {
|
|
|
408
409
|
previousThread,
|
|
409
410
|
handleDeselectIfRemoved,
|
|
410
411
|
} = useBriefPane();
|
|
411
|
-
const
|
|
412
|
+
const drawer = useIntelligenceDrawer(conversation?.threadId ?? null);
|
|
412
413
|
|
|
413
414
|
if (conversation) {
|
|
414
415
|
return (
|
|
@@ -420,21 +421,21 @@ function BriefPhone() {
|
|
|
420
421
|
selectedMessageId={conversation.messageId}
|
|
421
422
|
authenticity={conversation.authenticity}
|
|
422
423
|
onBack={onCloseThread}
|
|
423
|
-
onOpenIntelligence={
|
|
424
|
+
onOpenIntelligence={drawer.toggle}
|
|
424
425
|
onSwipeNext={nextThread ? () => onOpenThread(nextThread) : undefined}
|
|
425
426
|
onSwipePrevious={
|
|
426
427
|
previousThread ? () => onOpenThread(previousThread) : undefined
|
|
427
428
|
}
|
|
428
|
-
mobileIntelligenceOpen={
|
|
429
|
+
mobileIntelligenceOpen={drawer.isOpen}
|
|
429
430
|
/>
|
|
430
431
|
<Drawer
|
|
431
|
-
isOpen={
|
|
432
|
-
onClose={
|
|
432
|
+
isOpen={drawer.isOpen}
|
|
433
|
+
onClose={drawer.close}
|
|
433
434
|
ariaLabel="Message details"
|
|
434
435
|
side="right"
|
|
435
436
|
>
|
|
436
437
|
<IntelligencePane
|
|
437
|
-
onClose={
|
|
438
|
+
onClose={drawer.close}
|
|
438
439
|
thread={selectedThread}
|
|
439
440
|
mailboxId={selectedThread?.mailboxId}
|
|
440
441
|
accountId={selectedThread?.accountId}
|
|
@@ -41,6 +41,7 @@ import { IntelligencePane } from "@/components/mail/IntelligencePane";
|
|
|
41
41
|
import { MessageToolbar } from "@/components/mail/MessageToolbar";
|
|
42
42
|
import type { OpenMessageOptions } from "@/components/mail/ThreadListInteraction";
|
|
43
43
|
import { useDeleteMessages } from "@/hooks/useDeleteMessages";
|
|
44
|
+
import { useIntelligenceDrawer } from "@/hooks/useIntelligenceDrawer";
|
|
44
45
|
import { useToggleReadFor } from "@/hooks/useMarkAsRead";
|
|
45
46
|
import { useStarredThreads } from "@/hooks/useStarredThreads";
|
|
46
47
|
import { type ThreadActions, useThreadActions } from "@/hooks/useThreadActions";
|
|
@@ -409,7 +410,7 @@ function FlaggedPhone() {
|
|
|
409
410
|
previousThread,
|
|
410
411
|
handleDeselectIfRemoved,
|
|
411
412
|
} = useFlaggedPane();
|
|
412
|
-
const
|
|
413
|
+
const drawer = useIntelligenceDrawer(conversation?.threadId ?? null);
|
|
413
414
|
|
|
414
415
|
if (conversation) {
|
|
415
416
|
return (
|
|
@@ -421,21 +422,21 @@ function FlaggedPhone() {
|
|
|
421
422
|
selectedMessageId={conversation.messageId}
|
|
422
423
|
authenticity={conversation.authenticity}
|
|
423
424
|
onBack={onCloseThread}
|
|
424
|
-
onOpenIntelligence={
|
|
425
|
+
onOpenIntelligence={drawer.toggle}
|
|
425
426
|
onSwipeNext={nextThread ? () => onOpenThread(nextThread) : undefined}
|
|
426
427
|
onSwipePrevious={
|
|
427
428
|
previousThread ? () => onOpenThread(previousThread) : undefined
|
|
428
429
|
}
|
|
429
|
-
mobileIntelligenceOpen={
|
|
430
|
+
mobileIntelligenceOpen={drawer.isOpen}
|
|
430
431
|
/>
|
|
431
432
|
<Drawer
|
|
432
|
-
isOpen={
|
|
433
|
-
onClose={
|
|
433
|
+
isOpen={drawer.isOpen}
|
|
434
|
+
onClose={drawer.close}
|
|
434
435
|
ariaLabel="Message details"
|
|
435
436
|
side="right"
|
|
436
437
|
>
|
|
437
438
|
<IntelligencePane
|
|
438
|
-
onClose={
|
|
439
|
+
onClose={drawer.close}
|
|
439
440
|
thread={selectedThread}
|
|
440
441
|
mailboxId={selectedThread?.mailboxId}
|
|
441
442
|
accountId={selectedThread?.accountId}
|
|
@@ -72,6 +72,7 @@ import {
|
|
|
72
72
|
} from "@/hooks/useDeleteMessages";
|
|
73
73
|
import type { EscalationSearchQuery } from "@/hooks/useEscalatedActions";
|
|
74
74
|
import { useIntelligenceData } from "@/hooks/useIntelligenceData";
|
|
75
|
+
import { useIntelligenceDrawer } from "@/hooks/useIntelligenceDrawer";
|
|
75
76
|
import { useLayoutTier } from "@/hooks/useLayoutTier";
|
|
76
77
|
import { useMailboxAccount } from "@/hooks/useMailboxAccount";
|
|
77
78
|
import { useToggleReadFor } from "@/hooks/useMarkAsRead";
|
|
@@ -507,23 +508,6 @@ function MailboxPaneProvider({
|
|
|
507
508
|
const focusedThread =
|
|
508
509
|
threads.find((t) => t.messageId === triageFocusedId) ?? selectedThread;
|
|
509
510
|
|
|
510
|
-
// Auto-open intelligence pane on DKIM mismatch.
|
|
511
|
-
const autoOpenedForRef = useRef<string | null>(null);
|
|
512
|
-
useEffect(() => {
|
|
513
|
-
const id = selectedThread?.messageId ?? null;
|
|
514
|
-
if (!id) return;
|
|
515
|
-
if (autoOpenedForRef.current === id) return;
|
|
516
|
-
if (selectedThread?.authenticity?.dkimMismatch) {
|
|
517
|
-
autoOpenedForRef.current = id;
|
|
518
|
-
if (!intelligenceOpen) onToggleIntelligence();
|
|
519
|
-
}
|
|
520
|
-
}, [
|
|
521
|
-
selectedThread?.messageId,
|
|
522
|
-
selectedThread?.authenticity?.dkimMismatch,
|
|
523
|
-
intelligenceOpen,
|
|
524
|
-
onToggleIntelligence,
|
|
525
|
-
]);
|
|
526
|
-
|
|
527
511
|
// The mailbox's own unseen total. A count over the loaded pages undercounts
|
|
528
512
|
// every mailbox larger than one page and creeps upward as the user scrolls,
|
|
529
513
|
// so there is no fallback: until the mailbox resolves there is no number.
|
|
@@ -1085,41 +1069,41 @@ function MailboxReading() {
|
|
|
1085
1069
|
const railFits = useAppShellLayout()?.showIntelligencePane ?? false;
|
|
1086
1070
|
const hasThread = Boolean(conversation);
|
|
1087
1071
|
|
|
1088
|
-
// The
|
|
1089
|
-
//
|
|
1090
|
-
//
|
|
1091
|
-
//
|
|
1092
|
-
//
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
!
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
);
|
|
1111
|
-
|
|
1112
|
-
const
|
|
1113
|
-
|
|
1114
|
-
: openIntelligenceDrawer;
|
|
1072
|
+
// The rail opens itself on a DKIM mismatch. It lives here, behind the rail's
|
|
1073
|
+
// own width gate, because raising it writes `#intelligence` into the address
|
|
1074
|
+
// — and a panel the address names with no renderer behind it is a panel that
|
|
1075
|
+
// opens nothing (`docs/architecture/url-state.md`, R6). Below this width the
|
|
1076
|
+
// banner is the announcement and its "Why?" is the way in.
|
|
1077
|
+
const autoOpenedForRef = useRef<string | null>(null);
|
|
1078
|
+
useEffect(() => {
|
|
1079
|
+
if (!railFits) return;
|
|
1080
|
+
const id = selectedThread?.messageId ?? null;
|
|
1081
|
+
if (!id) return;
|
|
1082
|
+
if (autoOpenedForRef.current === id) return;
|
|
1083
|
+
if (!selectedThread?.authenticity?.dkimMismatch) return;
|
|
1084
|
+
autoOpenedForRef.current = id;
|
|
1085
|
+
if (!intelligenceOpen) onToggleIntelligence();
|
|
1086
|
+
}, [
|
|
1087
|
+
railFits,
|
|
1088
|
+
selectedThread?.messageId,
|
|
1089
|
+
selectedThread?.authenticity?.dkimMismatch,
|
|
1090
|
+
intelligenceOpen,
|
|
1091
|
+
onToggleIntelligence,
|
|
1092
|
+
]);
|
|
1093
|
+
|
|
1094
|
+
const drawer = useIntelligenceDrawer(conversation?.threadId ?? null);
|
|
1095
|
+
const drawerOpen = !railFits && drawer.isOpen;
|
|
1096
|
+
const closeIntelligenceDrawer = drawer.close;
|
|
1097
|
+
const openIntelligence = railFits ? onToggleIntelligence : drawer.open;
|
|
1115
1098
|
// The toolbar's control, which toggles whichever surface this width has.
|
|
1099
|
+
const { toggle: toggleDrawer } = drawer;
|
|
1116
1100
|
const toggleIntelligence = useCallback(() => {
|
|
1117
1101
|
if (railFits) {
|
|
1118
1102
|
onToggleIntelligence();
|
|
1119
1103
|
return;
|
|
1120
1104
|
}
|
|
1121
|
-
|
|
1122
|
-
}, [railFits, onToggleIntelligence,
|
|
1105
|
+
toggleDrawer();
|
|
1106
|
+
}, [railFits, onToggleIntelligence, toggleDrawer]);
|
|
1123
1107
|
const intelligenceShowing =
|
|
1124
1108
|
hasThread && (railFits ? intelligenceOpen : drawerOpen);
|
|
1125
1109
|
|
|
@@ -1219,13 +1203,12 @@ function MailboxPhone() {
|
|
|
1219
1203
|
selectedThread,
|
|
1220
1204
|
conversation,
|
|
1221
1205
|
onOpenThread,
|
|
1222
|
-
intelligenceOpen,
|
|
1223
|
-
onToggleIntelligence,
|
|
1224
1206
|
onBack,
|
|
1225
1207
|
nextThread,
|
|
1226
1208
|
previousThread,
|
|
1227
1209
|
handleDeselectIfRemoved,
|
|
1228
1210
|
} = useMailboxPane();
|
|
1211
|
+
const drawer = useIntelligenceDrawer(conversation?.threadId ?? null);
|
|
1229
1212
|
|
|
1230
1213
|
if (conversation) {
|
|
1231
1214
|
return (
|
|
@@ -1237,21 +1220,21 @@ function MailboxPhone() {
|
|
|
1237
1220
|
selectedMessageId={conversation.messageId}
|
|
1238
1221
|
authenticity={conversation.authenticity}
|
|
1239
1222
|
onBack={onBack}
|
|
1240
|
-
onOpenIntelligence={
|
|
1223
|
+
onOpenIntelligence={drawer.toggle}
|
|
1241
1224
|
onSwipeNext={nextThread ? () => onOpenThread(nextThread) : undefined}
|
|
1242
1225
|
onSwipePrevious={
|
|
1243
1226
|
previousThread ? () => onOpenThread(previousThread) : undefined
|
|
1244
1227
|
}
|
|
1245
|
-
mobileIntelligenceOpen={
|
|
1228
|
+
mobileIntelligenceOpen={drawer.isOpen}
|
|
1246
1229
|
/>
|
|
1247
1230
|
<Drawer
|
|
1248
|
-
isOpen={
|
|
1249
|
-
onClose={
|
|
1231
|
+
isOpen={drawer.isOpen}
|
|
1232
|
+
onClose={drawer.close}
|
|
1250
1233
|
ariaLabel="Message details"
|
|
1251
1234
|
side="right"
|
|
1252
1235
|
>
|
|
1253
1236
|
<IntelligencePane
|
|
1254
|
-
onClose={
|
|
1237
|
+
onClose={drawer.close}
|
|
1255
1238
|
thread={selectedThread}
|
|
1256
1239
|
mailboxId={mailboxId}
|
|
1257
1240
|
accountId={mailboxAccountId}
|