@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.
@@ -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 { resolveRailOpen } from "@/lib/intelligence-pref";
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. The address is the
15
- * only place its open state lives, and a navigation drops itotherwise a DKIM
16
- * mismatch on one message leaves the drawer covering every message opened
17
- * after it.
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
- * Both rules are the app's own: the fragment goes through
20
- * `retainOpenPanelsAtTier`, and what is up is `resolveRailOpen`.
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, driven by the address alone: `hash` is the whole state,
81
- * and every navigation writes it through the same helper the panes use.
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 = ({ initialHash = "" }: { initialHash?: string }) => {
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 drawerOpen = resolveRailOpen({
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={() => navigate(undefined)}
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={() => setHash("intelligence")}
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={() => navigate(message.id)}
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={drawerOpen}
160
- onClose={() => setHash("")}
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}` : ""}${hash ? `#${hash}` : ""}`}
157
+ {`/mail/inbox${open ? `/${open.id}` : ""}`}
169
158
  </p>
170
159
  </div>
171
160
  );
172
161
  };
173
162
 
174
- /** A message with no drawer over it: the address names no panel. */
175
- export const Closed: Story = {
176
- render: () => <PhoneReader />,
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 drawer over the message it was opened for here by a DKIM mismatch,
181
- * which opens it without being asked.
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 initialHash="intelligence" />,
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
- * The same drawer, then Back and another message. The fragment is dropped on
189
- * the way out, so the second message is not covered by a panel opened for the
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 DroppedByNavigation: Story = {
193
- render: () => <PhoneReader initialHash="intelligence" />,
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 initialHash="intelligence" />,
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 })) reportFatalError(error);
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 [saveStatus, setSaveStatus] = useState<SaveStatus>("idle");
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
- const createMutation = useMutation(
71
- outboxOperationsCreateOutboxMessageMutation(),
72
- );
73
- const updateMutation = useMutation(
74
- outboxDetailOperationsUpdateOutboxMessageMutation(),
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
- setSaveStatus("saving");
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
- setSaveStatus("saved");
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
- setSaveStatus("saved");
152
+ setSaveState(SAVED);
110
153
  queryClient.invalidateQueries({
111
154
  queryKey: outboxOperationsListOutboxMessagesOptions().queryKey,
112
155
  });
113
156
  return result;
114
157
  },
115
- [createMutation, updateMutation, onDraftCreated, queryClient],
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
- setSaveStatus("error");
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
- setSaveStatus("error");
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 { saveStatus, saveError, saveDraft, saveImmediately, stopAutoSave };
243
+ return { saveState, saveError, saveDraft, saveImmediately, stopAutoSave };
182
244
  };