@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
|
@@ -4,20 +4,20 @@ import { ChevronLeft, Info } from "lucide-react";
|
|
|
4
4
|
import { useState } from "react";
|
|
5
5
|
import { expect, userEvent, within } from "storybook/test";
|
|
6
6
|
import { Drawer } from "@/components/layout/Drawer";
|
|
7
|
-
import {
|
|
8
|
-
import { parseOpenPanels, retainOpenPanelsAtTier } from "@/routing";
|
|
7
|
+
import { useIntelligenceDrawer } from "@/hooks/useIntelligenceDrawer";
|
|
9
8
|
|
|
10
9
|
/**
|
|
11
|
-
* The intelligence drawer below the desktop tier (#777).
|
|
10
|
+
* The intelligence drawer below the desktop tier (#777, #778).
|
|
12
11
|
*
|
|
13
12
|
* There is no fourth pane here: the rail is a full-screen drawer over the open
|
|
14
|
-
* message, so it belongs to the thread it was opened for
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
13
|
+
* message, so it belongs to the thread it was opened for and to nothing else.
|
|
14
|
+
* It is modal, so it opens only when the reader asks — a DKIM mismatch shows
|
|
15
|
+
* the banner and leaves the message alone. A drawer that opened itself would
|
|
16
|
+
* put a scrim over the message, and the reader's next tap would land on the
|
|
17
|
+
* scrim rather than on Back.
|
|
18
18
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
19
|
+
* The rule is the app's own: `useIntelligenceDrawer` is the hook the phone and
|
|
20
|
+
* mid-width panes both run on.
|
|
21
21
|
*/
|
|
22
22
|
const meta: Meta = {
|
|
23
23
|
title: "Flows/Reading/Intelligence Drawer",
|
|
@@ -30,8 +30,6 @@ type Story = StoryObj;
|
|
|
30
30
|
const PHONE_WIDTH = 390;
|
|
31
31
|
const PHONE_HEIGHT = 720;
|
|
32
32
|
|
|
33
|
-
const retainOnPhone = retainOpenPanelsAtTier(false);
|
|
34
|
-
|
|
35
33
|
const intelligence: IntelligenceData = {
|
|
36
34
|
sender: {
|
|
37
35
|
name: "Mondial Relay",
|
|
@@ -77,23 +75,14 @@ const messages: readonly Message[] = [
|
|
|
77
75
|
];
|
|
78
76
|
|
|
79
77
|
/**
|
|
80
|
-
* The phone reader
|
|
81
|
-
*
|
|
78
|
+
* The phone reader. What is open is the thread on screen and, beside it, the
|
|
79
|
+
* drawer the reader asked for — held by `useIntelligenceDrawer` against that
|
|
80
|
+
* thread, so opening another one takes the drawer down with it.
|
|
82
81
|
*/
|
|
83
|
-
const PhoneReader = (
|
|
84
|
-
const [hash, setHash] = useState(initialHash);
|
|
82
|
+
const PhoneReader = () => {
|
|
85
83
|
const [openId, setOpenId] = useState<string | undefined>(messages[0]?.id);
|
|
86
84
|
const open = messages.find((message) => message.id === openId);
|
|
87
|
-
const
|
|
88
|
-
panels: parseOpenPanels(hash),
|
|
89
|
-
prefersOpen: true,
|
|
90
|
-
isDesktop: false,
|
|
91
|
-
hasThread: open !== undefined,
|
|
92
|
-
});
|
|
93
|
-
const navigate = (nextId: string | undefined) => {
|
|
94
|
-
setHash(retainOnPhone(hash));
|
|
95
|
-
setOpenId(nextId);
|
|
96
|
-
};
|
|
85
|
+
const drawer = useIntelligenceDrawer(open?.id ?? null);
|
|
97
86
|
|
|
98
87
|
return (
|
|
99
88
|
<div className="flex flex-col gap-2">
|
|
@@ -113,7 +102,7 @@ const PhoneReader = ({ initialHash = "" }: { initialHash?: string }) => {
|
|
|
113
102
|
<button
|
|
114
103
|
type="button"
|
|
115
104
|
aria-label="Back"
|
|
116
|
-
onClick={() =>
|
|
105
|
+
onClick={() => setOpenId(undefined)}
|
|
117
106
|
className="inline-flex size-11 items-center justify-center rounded-md text-fg hover:bg-surface-raised"
|
|
118
107
|
>
|
|
119
108
|
<ChevronLeft className="size-5" />
|
|
@@ -124,7 +113,7 @@ const PhoneReader = ({ initialHash = "" }: { initialHash?: string }) => {
|
|
|
124
113
|
<button
|
|
125
114
|
type="button"
|
|
126
115
|
aria-label="Message details"
|
|
127
|
-
onClick={
|
|
116
|
+
onClick={drawer.toggle}
|
|
128
117
|
className="inline-flex size-11 items-center justify-center rounded-md text-fg hover:bg-surface-raised"
|
|
129
118
|
>
|
|
130
119
|
<Info className="size-5" />
|
|
@@ -141,7 +130,7 @@ const PhoneReader = ({ initialHash = "" }: { initialHash?: string }) => {
|
|
|
141
130
|
<li key={message.id}>
|
|
142
131
|
<button
|
|
143
132
|
type="button"
|
|
144
|
-
onClick={() =>
|
|
133
|
+
onClick={() => setOpenId(message.id)}
|
|
145
134
|
className="flex w-full flex-col items-start gap-0.5 border-b border-line px-4 py-3 text-left hover:bg-surface-raised"
|
|
146
135
|
>
|
|
147
136
|
<span className="text-sm font-semibold text-fg">
|
|
@@ -156,8 +145,8 @@ const PhoneReader = ({ initialHash = "" }: { initialHash?: string }) => {
|
|
|
156
145
|
</ul>
|
|
157
146
|
)}
|
|
158
147
|
<Drawer
|
|
159
|
-
isOpen={
|
|
160
|
-
onClose={
|
|
148
|
+
isOpen={drawer.isOpen}
|
|
149
|
+
onClose={drawer.close}
|
|
161
150
|
ariaLabel="Message details"
|
|
162
151
|
side="right"
|
|
163
152
|
>
|
|
@@ -165,34 +154,58 @@ const PhoneReader = ({ initialHash = "" }: { initialHash?: string }) => {
|
|
|
165
154
|
</Drawer>
|
|
166
155
|
</div>
|
|
167
156
|
<p className="font-mono text-2xs text-fg-subtle">
|
|
168
|
-
{`/mail/inbox${open ? `/${open.id}` : ""}
|
|
157
|
+
{`/mail/inbox${open ? `/${open.id}` : ""}`}
|
|
169
158
|
</p>
|
|
170
159
|
</div>
|
|
171
160
|
);
|
|
172
161
|
};
|
|
173
162
|
|
|
174
|
-
/**
|
|
175
|
-
|
|
176
|
-
|
|
163
|
+
/** The reader's own press on the details control. */
|
|
164
|
+
const openDetails = async (canvasElement: HTMLElement): Promise<void> => {
|
|
165
|
+
await userEvent.click(
|
|
166
|
+
within(canvasElement).getByLabelText("Message details"),
|
|
167
|
+
);
|
|
177
168
|
};
|
|
178
169
|
|
|
179
170
|
/**
|
|
180
|
-
* The
|
|
181
|
-
*
|
|
171
|
+
* The warned message as it opens: the banner's own wording, and nothing over
|
|
172
|
+
* it. A mismatch is what the panel explains, never a reason to cover the
|
|
173
|
+
* message with it — a scrim there takes the reader's next tap, Back included.
|
|
182
174
|
*/
|
|
175
|
+
export const Closed: Story = {
|
|
176
|
+
render: () => <PhoneReader />,
|
|
177
|
+
play: async ({ canvasElement }) => {
|
|
178
|
+
const canvas = within(canvasElement);
|
|
179
|
+
await expect(canvas.queryByRole("dialog")).toBeNull();
|
|
180
|
+
await expect(canvas.getByLabelText("Back")).toBeVisible();
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
/** The drawer over the message it was opened for. */
|
|
183
185
|
export const OpenOverThread: Story = {
|
|
184
|
-
render: () => <PhoneReader
|
|
186
|
+
render: () => <PhoneReader />,
|
|
187
|
+
play: async ({ canvasElement }) => {
|
|
188
|
+
await openDetails(canvasElement);
|
|
189
|
+
await expect(within(canvasElement).getByRole("dialog")).toBeVisible();
|
|
190
|
+
},
|
|
185
191
|
};
|
|
186
192
|
|
|
187
193
|
/**
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
* first. Press Back with the drawer up to walk it.
|
|
194
|
+
* Dismissed, and gone for the next message too: the drawer is held against the
|
|
195
|
+
* thread it was opened for, so nothing carries it forward.
|
|
191
196
|
*/
|
|
192
|
-
export const
|
|
193
|
-
render: () => <PhoneReader
|
|
197
|
+
export const DismissedAndNotCarried: Story = {
|
|
198
|
+
render: () => <PhoneReader />,
|
|
194
199
|
play: async ({ canvasElement }) => {
|
|
195
200
|
const canvas = within(canvasElement);
|
|
201
|
+
await openDetails(canvasElement);
|
|
202
|
+
await expect(canvas.getByRole("dialog")).toBeVisible();
|
|
203
|
+
|
|
204
|
+
// The scrim carries the same name as the header's close button and comes
|
|
205
|
+
// first in the drawer, so the visible control is the second of the two.
|
|
206
|
+
await userEvent.click(canvas.getAllByLabelText("Close menu")[1]);
|
|
207
|
+
await expect(canvas.queryByRole("dialog")).toBeNull();
|
|
208
|
+
|
|
196
209
|
await userEvent.click(canvas.getByLabelText("Back"));
|
|
197
210
|
await userEvent.click(canvas.getByText("Standup moved to 10:15"));
|
|
198
211
|
await expect(canvas.queryByRole("dialog")).toBeNull();
|
|
@@ -204,5 +217,8 @@ export const DroppedByNavigation: Story = {
|
|
|
204
217
|
export const OpenOverThreadDark: Story = {
|
|
205
218
|
name: "Open Over Thread (dark)",
|
|
206
219
|
parameters: { theme: "dark" },
|
|
207
|
-
render: () => <PhoneReader
|
|
220
|
+
render: () => <PhoneReader />,
|
|
221
|
+
play: async ({ canvasElement }) => {
|
|
222
|
+
await openDetails(canvasElement);
|
|
223
|
+
},
|
|
208
224
|
};
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The intelligence drawer is modal, so what matters as much as opening it is
|
|
3
|
+
* that nothing else can. It belongs to the thread it was opened for: leaving
|
|
4
|
+
* that thread puts it away, and coming back to the same message opens it clear
|
|
5
|
+
* rather than under the scrim it was last left with (#778).
|
|
6
|
+
*
|
|
7
|
+
* The reachable case is a phone's system Back, which is a navigation like any
|
|
8
|
+
* other — the thread goes, and the drawer has to go with it for good.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { afterEach, beforeEach, describe, it } from "node:test";
|
|
13
|
+
import { createElement } from "react";
|
|
14
|
+
import { createDomHarness, type DomHarness } from "../test-support/dom";
|
|
15
|
+
import { useIntelligenceDrawer } from "./useIntelligenceDrawer";
|
|
16
|
+
|
|
17
|
+
let dom: DomHarness;
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
dom = createDomHarness();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
dom.close();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
/** Reports what the drawer says, and offers the controls a reader has. */
|
|
28
|
+
const Probe = ({ threadId }: { threadId: string | null }) => {
|
|
29
|
+
const drawer = useIntelligenceDrawer(threadId);
|
|
30
|
+
return createElement(
|
|
31
|
+
"div",
|
|
32
|
+
null,
|
|
33
|
+
createElement(
|
|
34
|
+
"span",
|
|
35
|
+
{ "data-state": "" },
|
|
36
|
+
drawer.isOpen ? "open" : "shut",
|
|
37
|
+
),
|
|
38
|
+
createElement("button", {
|
|
39
|
+
type: "button",
|
|
40
|
+
"data-open": "",
|
|
41
|
+
onClick: drawer.open,
|
|
42
|
+
}),
|
|
43
|
+
createElement("button", {
|
|
44
|
+
type: "button",
|
|
45
|
+
"data-toggle": "",
|
|
46
|
+
onClick: drawer.toggle,
|
|
47
|
+
}),
|
|
48
|
+
);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const state = (): string | undefined =>
|
|
52
|
+
dom.query("[data-state]")?.textContent ?? undefined;
|
|
53
|
+
const press = (handle: string): void => {
|
|
54
|
+
const button = dom.query(`[${handle}]`);
|
|
55
|
+
if (!button) throw new Error(`no ${handle} control`);
|
|
56
|
+
dom.click(button);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
describe("the intelligence drawer", () => {
|
|
60
|
+
it("opens only when it is asked to", () => {
|
|
61
|
+
dom.render(createElement(Probe, { threadId: "thread-a" }));
|
|
62
|
+
assert.equal(state(), "shut");
|
|
63
|
+
|
|
64
|
+
press("data-open");
|
|
65
|
+
assert.equal(state(), "open");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("does not come back with the message it was opened over", () => {
|
|
69
|
+
dom.render(createElement(Probe, { threadId: "thread-a" }));
|
|
70
|
+
press("data-open");
|
|
71
|
+
assert.equal(state(), "open");
|
|
72
|
+
|
|
73
|
+
// System Back: the thread closes under it.
|
|
74
|
+
dom.render(createElement(Probe, { threadId: null }));
|
|
75
|
+
assert.equal(state(), "shut");
|
|
76
|
+
|
|
77
|
+
// And the same message again, which is where the scrim used to be waiting.
|
|
78
|
+
dom.render(createElement(Probe, { threadId: "thread-a" }));
|
|
79
|
+
assert.equal(state(), "shut");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("stays behind when the reader moves to another thread", () => {
|
|
83
|
+
dom.render(createElement(Probe, { threadId: "thread-a" }));
|
|
84
|
+
press("data-open");
|
|
85
|
+
assert.equal(state(), "open");
|
|
86
|
+
|
|
87
|
+
dom.render(createElement(Probe, { threadId: "thread-b" }));
|
|
88
|
+
assert.equal(state(), "shut");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("toggles shut again from the toolbar's control", () => {
|
|
92
|
+
dom.render(createElement(Probe, { threadId: "thread-a" }));
|
|
93
|
+
press("data-toggle");
|
|
94
|
+
assert.equal(state(), "open");
|
|
95
|
+
|
|
96
|
+
press("data-toggle");
|
|
97
|
+
assert.equal(state(), "shut");
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A 401 escalates over `meta.softError` only when someone is waiting on the
|
|
3
|
+
* answer. The self-update poll is the case that made that seam necessary: it is
|
|
4
|
+
* mounted at the app root and answers 401 by design for anyone whose session has
|
|
5
|
+
* lapsed, so escalating it put the full-screen fatal page over every screen in
|
|
6
|
+
* the app at load — including the sign-in the shell was already about to ask
|
|
7
|
+
* for.
|
|
8
|
+
*
|
|
9
|
+
* Held with the real caches wired to `lib/query-error-handler.ts`, because the
|
|
10
|
+
* seam is in what those two handlers pass and a classifier unit test cannot see
|
|
11
|
+
* a caller that never passed anything.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import assert from "node:assert/strict";
|
|
15
|
+
import { afterEach, describe, it } from "node:test";
|
|
16
|
+
import {
|
|
17
|
+
MutationCache,
|
|
18
|
+
QueryCache,
|
|
19
|
+
QueryClient,
|
|
20
|
+
useMutation,
|
|
21
|
+
} from "@tanstack/react-query";
|
|
22
|
+
import { createElement, Fragment, useEffect } from "react";
|
|
23
|
+
import { FatalErrorOverlay } from "../components/ui/FatalErrorOverlay";
|
|
24
|
+
import { ApiError } from "../lib/api";
|
|
25
|
+
import { softErrorMeta } from "../lib/error-classifier";
|
|
26
|
+
import { __resetFatalError } from "../lib/fatal-error";
|
|
27
|
+
import {
|
|
28
|
+
handleMutationCacheError,
|
|
29
|
+
handleQueryCacheError,
|
|
30
|
+
} from "../lib/query-error-handler";
|
|
31
|
+
import { createDomHarness, type DomHarness } from "../test-support/dom";
|
|
32
|
+
import { type HttpMock, httpError, mockFetch } from "../test-support/http";
|
|
33
|
+
import { SelfUpdateProvider } from "./use-system-update";
|
|
34
|
+
|
|
35
|
+
let harness: DomHarness | undefined;
|
|
36
|
+
let http: HttpMock | undefined;
|
|
37
|
+
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
harness?.close();
|
|
40
|
+
harness = undefined;
|
|
41
|
+
http?.restore();
|
|
42
|
+
http = undefined;
|
|
43
|
+
__resetFatalError();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const escalatingClient = (): QueryClient =>
|
|
47
|
+
new QueryClient({
|
|
48
|
+
queryCache: new QueryCache({ onError: handleQueryCacheError }),
|
|
49
|
+
mutationCache: new MutationCache({ onError: handleMutationCacheError }),
|
|
50
|
+
defaultOptions: {
|
|
51
|
+
queries: { retry: false },
|
|
52
|
+
mutations: { retry: false },
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const fatalOverlay = () =>
|
|
57
|
+
harness?.query('[data-testid="fatal-error-overlay"]') ?? null;
|
|
58
|
+
|
|
59
|
+
/** A write nobody pressed a button for, refused for want of a session. */
|
|
60
|
+
const SoftWrite = () => {
|
|
61
|
+
const mutation = useMutation({
|
|
62
|
+
mutationFn: async (): Promise<void> => {
|
|
63
|
+
throw new ApiError("signed out", 401);
|
|
64
|
+
},
|
|
65
|
+
meta: softErrorMeta,
|
|
66
|
+
});
|
|
67
|
+
const { mutate } = mutation;
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
mutate();
|
|
70
|
+
}, [mutate]);
|
|
71
|
+
return null;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
describe("a 401 and who was waiting on it", () => {
|
|
75
|
+
it("leaves the app standing when the root update poll is signed out", async () => {
|
|
76
|
+
http = mockFetch((call) => {
|
|
77
|
+
if (call.path.endsWith("/system/update")) {
|
|
78
|
+
return httpError(401, "session expired");
|
|
79
|
+
}
|
|
80
|
+
return {};
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
harness = createDomHarness({ queryClient: escalatingClient() });
|
|
84
|
+
harness.renderApp(
|
|
85
|
+
createElement(
|
|
86
|
+
Fragment,
|
|
87
|
+
null,
|
|
88
|
+
createElement(FatalErrorOverlay),
|
|
89
|
+
createElement(SelfUpdateProvider, null),
|
|
90
|
+
),
|
|
91
|
+
);
|
|
92
|
+
await harness.flush();
|
|
93
|
+
await harness.wait(50);
|
|
94
|
+
await harness.flush();
|
|
95
|
+
|
|
96
|
+
assert.ok(
|
|
97
|
+
http.to("/system/update").length > 0,
|
|
98
|
+
"the poll was made, so the 401 really was classified",
|
|
99
|
+
);
|
|
100
|
+
assert.equal(
|
|
101
|
+
fatalOverlay(),
|
|
102
|
+
null,
|
|
103
|
+
"a background poll's 401 must not take the whole app down",
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("still escalates a soft write's 401 — no banner signs anyone back in", async () => {
|
|
108
|
+
harness = createDomHarness({ queryClient: escalatingClient() });
|
|
109
|
+
harness.renderApp(
|
|
110
|
+
createElement(
|
|
111
|
+
Fragment,
|
|
112
|
+
null,
|
|
113
|
+
createElement(FatalErrorOverlay),
|
|
114
|
+
createElement(SoftWrite),
|
|
115
|
+
),
|
|
116
|
+
);
|
|
117
|
+
await harness.flush();
|
|
118
|
+
await harness.wait(50);
|
|
119
|
+
await harness.flush();
|
|
120
|
+
|
|
121
|
+
assert.ok(
|
|
122
|
+
fatalOverlay(),
|
|
123
|
+
"a signed-out session must reach the page that signs back in",
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { useCallback, useState } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The intelligence drawer's own state, scoped to the thread it was opened for.
|
|
5
|
+
*
|
|
6
|
+
* The drawer is modal, so it opens only when it is asked for. `intelligenceOpen`
|
|
7
|
+
* is the rail's persisted preference and the DKIM auto-open sets it on every
|
|
8
|
+
* tier, so driving the drawer from it throws a scrim over a message the moment
|
|
9
|
+
* one is selected — and the reader's next tap lands on that scrim instead of the
|
|
10
|
+
* control they aimed at, Back to messages included. Naming the thread is also
|
|
11
|
+
* what closes it again when they move on: a bare flag would still be set when
|
|
12
|
+
* they came back.
|
|
13
|
+
*/
|
|
14
|
+
export interface IntelligenceDrawer {
|
|
15
|
+
/** Up only while the thread it was opened for is the one on screen. */
|
|
16
|
+
isOpen: boolean;
|
|
17
|
+
/** The banner's "Why?" — always an open, never a close. */
|
|
18
|
+
open: () => void;
|
|
19
|
+
close: () => void;
|
|
20
|
+
/** The toolbar's control. */
|
|
21
|
+
toggle: () => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function useIntelligenceDrawer(
|
|
25
|
+
openThreadId: string | null,
|
|
26
|
+
): IntelligenceDrawer {
|
|
27
|
+
const [drawerThreadId, setDrawerThreadId] = useState<string | null>(null);
|
|
28
|
+
// Leaving the thread puts the drawer away for good, rather than leaving it
|
|
29
|
+
// armed for the reader's return. Held state alone would reopen it: press
|
|
30
|
+
// system Back and come to the same message again and the scrim would be
|
|
31
|
+
// waiting, which is the defect this hook exists to end (#778).
|
|
32
|
+
//
|
|
33
|
+
// Adjusted during render, not in an effect: React re-runs the render before
|
|
34
|
+
// committing, so nothing paints with the stale thread. An effect would show
|
|
35
|
+
// one frame of an open drawer over the message first.
|
|
36
|
+
if (drawerThreadId !== null && drawerThreadId !== openThreadId) {
|
|
37
|
+
setDrawerThreadId(null);
|
|
38
|
+
}
|
|
39
|
+
// Derived rather than stored: moving to another thread closes it with no
|
|
40
|
+
// effect to run.
|
|
41
|
+
const isOpen = openThreadId !== null && drawerThreadId === openThreadId;
|
|
42
|
+
|
|
43
|
+
const close = useCallback(() => setDrawerThreadId(null), []);
|
|
44
|
+
const open = useCallback(
|
|
45
|
+
() => setDrawerThreadId(openThreadId),
|
|
46
|
+
[openThreadId],
|
|
47
|
+
);
|
|
48
|
+
const toggle = useCallback(
|
|
49
|
+
() => setDrawerThreadId(isOpen ? null : openThreadId),
|
|
50
|
+
[isOpen, openThreadId],
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
return { isOpen, open, close, toggle };
|
|
54
|
+
}
|
|
@@ -9,6 +9,7 @@ import { useCallback, useEffect, useMemo, useRef } from "react";
|
|
|
9
9
|
import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
|
|
10
10
|
import { formatErrorDetail } from "@/components/ui/error-banners";
|
|
11
11
|
import { runChunkedMutation } from "@/lib/bulk-actions";
|
|
12
|
+
import { softErrorMeta } from "@/lib/error-classifier";
|
|
12
13
|
import {
|
|
13
14
|
cancelThreadListQueries,
|
|
14
15
|
invalidateThreadListQueries,
|
|
@@ -138,8 +139,12 @@ export const useMarkAsRead = ({
|
|
|
138
139
|
const markedAsReadRef = useRef<Set<string>>(new Set());
|
|
139
140
|
const pendingRef = useRef<Set<string>>(new Set());
|
|
140
141
|
|
|
142
|
+
// Nobody asked for this write and nobody is waiting on it — it fires after a
|
|
143
|
+
// dwell on an open message. The rollback and the banner below are the whole
|
|
144
|
+
// of its failure, so a refusal must not take the screen down with it.
|
|
141
145
|
const { mutate: markAsRead } = useMutation({
|
|
142
146
|
...messageBulkOperationsUpdateFlagsMutation(),
|
|
147
|
+
meta: softErrorMeta,
|
|
143
148
|
onMutate: async (variables): Promise<MarkAsReadContext> => {
|
|
144
149
|
const messageIds = new Set(variables.body.messageIds ?? []);
|
|
145
150
|
const isRead = variables.body.isRead ?? true;
|
|
@@ -32,8 +32,13 @@ const messageFor = (error: unknown): string =>
|
|
|
32
32
|
? error.message
|
|
33
33
|
: "Something went wrong";
|
|
34
34
|
|
|
35
|
+
// The user pulled to refresh and is watching the spinner, so this is theirs to
|
|
36
|
+
// be answered: a 401 here escalates over the soft meta rather than resolving
|
|
37
|
+
// into a spinner that stops for no stated reason.
|
|
35
38
|
const escalateIfFatal = (error: unknown): void => {
|
|
36
|
-
if (shouldEscalate(error, { softError: true }))
|
|
39
|
+
if (shouldEscalate(error, { softError: true }, "user")) {
|
|
40
|
+
reportFatalError(error);
|
|
41
|
+
}
|
|
37
42
|
};
|
|
38
43
|
|
|
39
44
|
const maxLastSynced = (
|
|
@@ -3,10 +3,10 @@ import {
|
|
|
3
3
|
outboxOperationsCreateOutboxMessageMutation,
|
|
4
4
|
outboxOperationsListOutboxMessagesOptions,
|
|
5
5
|
} from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
6
|
+
import type { ComposeSaveState } from "@remit/ui";
|
|
6
7
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
7
8
|
import { useCallback, useRef, useState } from "react";
|
|
8
|
-
|
|
9
|
-
export type SaveStatus = "idle" | "saving" | "saved" | "error";
|
|
9
|
+
import { softErrorMeta } from "../lib/error-classifier";
|
|
10
10
|
|
|
11
11
|
export type ImmediateSave =
|
|
12
12
|
| { outcome: "saved"; outboxMessageId: string }
|
|
@@ -29,6 +29,44 @@ interface UseSaveDraftOptions {
|
|
|
29
29
|
onDraftCreated: (id: string) => void;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* A draft with no To address yet has nothing the create endpoint will accept —
|
|
34
|
+
* `CreateOutboxMessageInput.toAddresses` carries `@minItems(1)`, so the request
|
|
35
|
+
* comes back 400. Cc and Bcc do not stand in for it; the constraint names
|
|
36
|
+
* `toAddresses` and nothing else. Forward opens in exactly that state, with a
|
|
37
|
+
* subject and a quote and no address, and it is a normal place to be while
|
|
38
|
+
* writing rather than a failure to report. The update endpoint has no such
|
|
39
|
+
* constraint, so only a draft that does not exist yet is held back.
|
|
40
|
+
*
|
|
41
|
+
* The send guard in `outbox-queue.ts` counts Cc and Bcc, and is right to: a
|
|
42
|
+
* Bcc-only envelope is real mail. It answers a different question — whether
|
|
43
|
+
* this message has anywhere to go — from this one, which is only whether the
|
|
44
|
+
* create schema will take it.
|
|
45
|
+
*/
|
|
46
|
+
const nothingToCreateYet = (
|
|
47
|
+
targetId: string | undefined,
|
|
48
|
+
data: DraftData,
|
|
49
|
+
): boolean => targetId === undefined && data.toAddresses.length === 0;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Held back, and naming what is actually missing. "A recipient" was a lie to
|
|
53
|
+
* anyone who had filled in Cc: they had one, and were being told to add what
|
|
54
|
+
* they could see on screen.
|
|
55
|
+
*
|
|
56
|
+
* Module scope, not a literal built in the render: this is set from inside the
|
|
57
|
+
* autosave effect, and a fresh object each time would be a new state on every
|
|
58
|
+
* render with the effect re-running on each of them.
|
|
59
|
+
*/
|
|
60
|
+
const NOT_SAVED_WITHOUT_A_TO_ADDRESS: ComposeSaveState = {
|
|
61
|
+
status: "unsaved",
|
|
62
|
+
reason: "Not saved — add a To address to keep this draft.",
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const IDLE: ComposeSaveState = { status: "idle" };
|
|
66
|
+
const SAVING: ComposeSaveState = { status: "saving" };
|
|
67
|
+
const SAVED: ComposeSaveState = { status: "saved" };
|
|
68
|
+
const SAVE_FAILED: ComposeSaveState = { status: "error" };
|
|
69
|
+
|
|
32
70
|
const settled = (promise: Promise<unknown>): Promise<void> =>
|
|
33
71
|
promise.then(
|
|
34
72
|
() => undefined,
|
|
@@ -39,7 +77,7 @@ export const useSaveDraft = ({
|
|
|
39
77
|
outboxMessageId,
|
|
40
78
|
onDraftCreated,
|
|
41
79
|
}: UseSaveDraftOptions) => {
|
|
42
|
-
const [
|
|
80
|
+
const [saveState, setSaveState] = useState<ComposeSaveState>(IDLE);
|
|
43
81
|
const [saveError, setSaveError] = useState<unknown>(null);
|
|
44
82
|
const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
45
83
|
const closedIdsRef = useRef<Set<string>>(new Set());
|
|
@@ -67,16 +105,21 @@ export const useSaveDraft = ({
|
|
|
67
105
|
if (leavingADocument && timerRef.current) clearTimeout(timerRef.current);
|
|
68
106
|
}
|
|
69
107
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
108
|
+
// A write that fails belongs in the composer's banner beside the message it
|
|
109
|
+
// could not save, never on the full-screen page that unmounts the composer
|
|
110
|
+
// and the message with it. A 5xx still escalates.
|
|
111
|
+
const createMutation = useMutation({
|
|
112
|
+
...outboxOperationsCreateOutboxMessageMutation(),
|
|
113
|
+
meta: softErrorMeta,
|
|
114
|
+
});
|
|
115
|
+
const updateMutation = useMutation({
|
|
116
|
+
...outboxDetailOperationsUpdateOutboxMessageMutation(),
|
|
117
|
+
meta: softErrorMeta,
|
|
118
|
+
});
|
|
76
119
|
|
|
77
120
|
const executeSave = useCallback(
|
|
78
121
|
async (data: DraftData) => {
|
|
79
|
-
|
|
122
|
+
setSaveState(SAVING);
|
|
80
123
|
setSaveError(null);
|
|
81
124
|
|
|
82
125
|
const targetId = targetIdRef.current;
|
|
@@ -94,7 +137,7 @@ export const useSaveDraft = ({
|
|
|
94
137
|
references: data.references,
|
|
95
138
|
},
|
|
96
139
|
});
|
|
97
|
-
|
|
140
|
+
setSaveState(SAVED);
|
|
98
141
|
return result;
|
|
99
142
|
}
|
|
100
143
|
|
|
@@ -106,13 +149,18 @@ export const useSaveDraft = ({
|
|
|
106
149
|
});
|
|
107
150
|
targetIdRef.current = result.outboxMessageId;
|
|
108
151
|
onDraftCreated(result.outboxMessageId);
|
|
109
|
-
|
|
152
|
+
setSaveState(SAVED);
|
|
110
153
|
queryClient.invalidateQueries({
|
|
111
154
|
queryKey: outboxOperationsListOutboxMessagesOptions().queryKey,
|
|
112
155
|
});
|
|
113
156
|
return result;
|
|
114
157
|
},
|
|
115
|
-
[
|
|
158
|
+
[
|
|
159
|
+
createMutation.mutateAsync,
|
|
160
|
+
updateMutation.mutateAsync,
|
|
161
|
+
onDraftCreated,
|
|
162
|
+
queryClient,
|
|
163
|
+
],
|
|
116
164
|
);
|
|
117
165
|
|
|
118
166
|
// One entry takes one write at a time. Overlapping writes settle in whatever
|
|
@@ -131,6 +179,20 @@ export const useSaveDraft = ({
|
|
|
131
179
|
const saveDraft = useCallback(
|
|
132
180
|
(data: DraftData) => {
|
|
133
181
|
if (timerRef.current) clearTimeout(timerRef.current);
|
|
182
|
+
// Said now rather than two seconds from now: the composer is holding
|
|
183
|
+
// text nothing is going to persist, and the moment it starts holding it
|
|
184
|
+
// is the moment the user has to be able to see that.
|
|
185
|
+
if (nothingToCreateYet(targetIdRef.current, data)) {
|
|
186
|
+
setSaveState(NOT_SAVED_WITHOUT_A_TO_ADDRESS);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
// The sentence goes the moment its reason does, rather than standing
|
|
190
|
+
// for the two seconds until the write it is no longer true about
|
|
191
|
+
// lands. Only that sentence is cleared: a "Draft saved" from the
|
|
192
|
+
// previous write is still the truth about this document.
|
|
193
|
+
setSaveState((current) =>
|
|
194
|
+
current.status === "unsaved" ? IDLE : current,
|
|
195
|
+
);
|
|
134
196
|
timerRef.current = setTimeout(() => {
|
|
135
197
|
const targetId = targetIdRef.current;
|
|
136
198
|
if (targetId && closedIdsRef.current.has(targetId)) return;
|
|
@@ -139,7 +201,7 @@ export const useSaveDraft = ({
|
|
|
139
201
|
// through the global MutationCache.onError sink.
|
|
140
202
|
enqueueSave(data).catch((error: unknown) => {
|
|
141
203
|
setSaveError(error);
|
|
142
|
-
|
|
204
|
+
setSaveState(SAVE_FAILED);
|
|
143
205
|
});
|
|
144
206
|
}, 2000);
|
|
145
207
|
},
|
|
@@ -161,7 +223,7 @@ export const useSaveDraft = ({
|
|
|
161
223
|
}),
|
|
162
224
|
)
|
|
163
225
|
.catch((error: unknown): ImmediateSave => {
|
|
164
|
-
|
|
226
|
+
setSaveState(SAVE_FAILED);
|
|
165
227
|
return { outcome: "failed", error };
|
|
166
228
|
});
|
|
167
229
|
},
|
|
@@ -178,5 +240,5 @@ export const useSaveDraft = ({
|
|
|
178
240
|
if (closedOutboxMessageId) closedIdsRef.current.add(closedOutboxMessageId);
|
|
179
241
|
}, []);
|
|
180
242
|
|
|
181
|
-
return {
|
|
243
|
+
return { saveState, saveError, saveDraft, saveImmediately, stopAutoSave };
|
|
182
244
|
};
|