@remit/web-client 0.0.204 → 0.0.206
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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.206",
|
|
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": {
|
|
@@ -441,6 +441,32 @@ export const ComposeForm = ({
|
|
|
441
441
|
* the subject the reader edited.
|
|
442
442
|
*/
|
|
443
443
|
const resumedDraftRef = useRef(outboxMessageId !== undefined);
|
|
444
|
+
/**
|
|
445
|
+
* The mode the fields on screen were last written for. A draft that is
|
|
446
|
+
* resumed holds what its reader saved and must not be overwritten when it
|
|
447
|
+
* mounts — but a switch of mode over the same message asks for different
|
|
448
|
+
* recipients, which is how Reply All reaches the Cc field (#796).
|
|
449
|
+
*
|
|
450
|
+
* Taken at mount rather than on the first run that has a source: the source
|
|
451
|
+
* is fetched fresh after a reload, so a reader who presses Reply All before
|
|
452
|
+
* it lands would otherwise have that press recorded as the mode the draft
|
|
453
|
+
* opened under, and Reply All would stay dead.
|
|
454
|
+
*/
|
|
455
|
+
const seededModeRef = useRef<ComposeMode | undefined>(
|
|
456
|
+
outboxMessageId !== undefined ? mode : undefined,
|
|
457
|
+
);
|
|
458
|
+
/**
|
|
459
|
+
* The reader's own address the recipients were last written against, absent
|
|
460
|
+
* until something has been written. The account resolves from the source's
|
|
461
|
+
* mailbox, so it lands after the source: a seed made before it arrived kept
|
|
462
|
+
* the reader in their own Cc (#819), and is redone once it does.
|
|
463
|
+
*/
|
|
464
|
+
const seededMyEmailRef = useRef<{ myEmail: string | undefined } | undefined>(
|
|
465
|
+
undefined,
|
|
466
|
+
);
|
|
467
|
+
// Read where a mode change must not itself be the trigger.
|
|
468
|
+
const modeRef = useRef(mode);
|
|
469
|
+
modeRef.current = mode;
|
|
444
470
|
/**
|
|
445
471
|
* The identity this form has already taken from the surface that mounted it.
|
|
446
472
|
* A reply learns which account the message reached only once the mailbox it
|
|
@@ -480,6 +506,9 @@ export const ComposeForm = ({
|
|
|
480
506
|
prevOutboxMessageIdRef.current = outboxMessageId;
|
|
481
507
|
if (previous === undefined) return;
|
|
482
508
|
resumedDraftRef.current = outboxMessageId !== undefined;
|
|
509
|
+
seededModeRef.current =
|
|
510
|
+
outboxMessageId !== undefined ? modeRef.current : undefined;
|
|
511
|
+
seededMyEmailRef.current = undefined;
|
|
483
512
|
// A draft brings its own body along in a moment; a new message opens on
|
|
484
513
|
// the signature, the same document a fresh mount would have started on.
|
|
485
514
|
const opening = outboxMessageId
|
|
@@ -593,8 +622,18 @@ export const ComposeForm = ({
|
|
|
593
622
|
}, [draftData, draftLoaded]);
|
|
594
623
|
|
|
595
624
|
useEffect(() => {
|
|
596
|
-
if (resumedDraftRef.current) return;
|
|
597
625
|
if (!sourceMessage) return;
|
|
626
|
+
// A draft the reader came back to holds what they saved, so mounting over
|
|
627
|
+
// it seeds nothing — but a switch of mode over the same message asks for
|
|
628
|
+
// a different answer, and rewrites the fields for it (#796). A rewrite
|
|
629
|
+
// made before the account resolved is redone when its address arrives,
|
|
630
|
+
// which is what takes the reader back out of their own Cc (#819).
|
|
631
|
+
if (resumedDraftRef.current && seededModeRef.current === mode) {
|
|
632
|
+
const seeded = seededMyEmailRef.current;
|
|
633
|
+
if (!seeded || seeded.myEmail === account?.email) return;
|
|
634
|
+
}
|
|
635
|
+
seededModeRef.current = mode;
|
|
636
|
+
seededMyEmailRef.current = { myEmail: account?.email };
|
|
598
637
|
|
|
599
638
|
// The fields are being rewritten for a different answer, so what was typed
|
|
600
639
|
// into one and left there belongs to the answer being left behind. Without
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Issue #796: Reply All does nothing on a reply reopened after a reload.
|
|
3
|
+
*
|
|
4
|
+
* The composer mounts holding a draft id, so `resumedDraftRef` starts true —
|
|
5
|
+
* the guard that keeps a saved draft's recipients from being overwritten when
|
|
6
|
+
* it reopens. But that same guard then swallowed every later run of the
|
|
7
|
+
* seeding effect, so switching the mode to Reply All over the same message
|
|
8
|
+
* rewrote nothing: no addresses in Cc, nothing changed on screen. A dead
|
|
9
|
+
* control — the reader cannot tell whether Reply All is broken, misconfigured,
|
|
10
|
+
* or their own mistake.
|
|
11
|
+
*
|
|
12
|
+
* The guarantees here: a resumed reply still opens untouched, but pressing
|
|
13
|
+
* Reply All puts the source message's other recipients in Cc — including when
|
|
14
|
+
* the press lands before the source message or the account has resolved, which
|
|
15
|
+
* after a reload is the ordinary case rather than the exception.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import assert from "node:assert/strict";
|
|
19
|
+
import { afterEach, describe, it } from "node:test";
|
|
20
|
+
import type {
|
|
21
|
+
RemitImapAccountResponse,
|
|
22
|
+
RemitImapDescribeMessageResponse,
|
|
23
|
+
} from "@remit/api-http-client/types.gen.ts";
|
|
24
|
+
import {
|
|
25
|
+
type AnyRouter,
|
|
26
|
+
createMemoryHistory,
|
|
27
|
+
createRootRoute,
|
|
28
|
+
createRoute,
|
|
29
|
+
createRouter,
|
|
30
|
+
RouterContextProvider,
|
|
31
|
+
} from "@tanstack/react-router";
|
|
32
|
+
import { createElement, useState } from "react";
|
|
33
|
+
import { createDomHarness, type DomHarness } from "../../test-support/dom";
|
|
34
|
+
import { type HttpMock, mockFetch } from "../../test-support/http";
|
|
35
|
+
import { ComposeForm } from "./ComposeForm";
|
|
36
|
+
import { ComposeProvider } from "./ComposeProvider";
|
|
37
|
+
|
|
38
|
+
const ACCOUNT_ID = "acc-1";
|
|
39
|
+
const DRAFT_ID = "ob-796";
|
|
40
|
+
|
|
41
|
+
const account = {
|
|
42
|
+
accountId: ACCOUNT_ID,
|
|
43
|
+
email: "me@example.com",
|
|
44
|
+
smtpEnabled: true,
|
|
45
|
+
} as unknown as RemitImapAccountResponse;
|
|
46
|
+
|
|
47
|
+
/** The reader is among the message's recipients, as they are on their own mail. */
|
|
48
|
+
const sourceMessage = {
|
|
49
|
+
message: { messageId: "msg-1", mailboxId: "mbx-1" },
|
|
50
|
+
envelope: {
|
|
51
|
+
subject: "Lunch",
|
|
52
|
+
messageIdValue: "<m1@example.com>",
|
|
53
|
+
from: [{ normalizedEmail: "them@example.com", displayName: "Them" }],
|
|
54
|
+
replyTo: [],
|
|
55
|
+
to: [
|
|
56
|
+
{ normalizedEmail: "other@example.com", displayName: "Other" },
|
|
57
|
+
{ normalizedEmail: account.email, displayName: "Me" },
|
|
58
|
+
],
|
|
59
|
+
cc: [{ normalizedEmail: "copied@example.com", displayName: "Copied" }],
|
|
60
|
+
},
|
|
61
|
+
references: [],
|
|
62
|
+
bodyParts: [],
|
|
63
|
+
} as unknown as RemitImapDescribeMessageResponse;
|
|
64
|
+
|
|
65
|
+
/** The draft as it was saved under Reply: only To holds the sender. */
|
|
66
|
+
const draft = {
|
|
67
|
+
outboxMessageId: DRAFT_ID,
|
|
68
|
+
accountId: ACCOUNT_ID,
|
|
69
|
+
fromAddress: account.email,
|
|
70
|
+
toAddresses: ["them@example.com"],
|
|
71
|
+
ccAddresses: [],
|
|
72
|
+
bccAddresses: [],
|
|
73
|
+
references: [],
|
|
74
|
+
subject: "Re: Lunch",
|
|
75
|
+
textBody: "Does one o'clock work?",
|
|
76
|
+
status: "draft",
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
let harness: DomHarness | undefined;
|
|
80
|
+
let http: HttpMock | undefined;
|
|
81
|
+
|
|
82
|
+
afterEach(() => {
|
|
83
|
+
harness?.close();
|
|
84
|
+
harness = undefined;
|
|
85
|
+
http?.restore();
|
|
86
|
+
http = undefined;
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
(globalThis as { self?: typeof globalThis }).self ??= globalThis;
|
|
90
|
+
|
|
91
|
+
const rootRoute = createRootRoute();
|
|
92
|
+
const mailboxRoute = createRoute({
|
|
93
|
+
getParentRoute: () => rootRoute,
|
|
94
|
+
path: "/mail/$mailboxId",
|
|
95
|
+
validateSearch: (search: Record<string, unknown>) => search,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const testRouter = (): AnyRouter =>
|
|
99
|
+
createRouter({
|
|
100
|
+
routeTree: rootRoute.addChildren([mailboxRoute]),
|
|
101
|
+
history: createMemoryHistory({ initialEntries: ["/mail/mbx-1"] }),
|
|
102
|
+
}) as unknown as AnyRouter;
|
|
103
|
+
|
|
104
|
+
interface ReopenedProps {
|
|
105
|
+
/** A reload refetches the source, which lands after the form is on screen. */
|
|
106
|
+
sourceAtMount: boolean;
|
|
107
|
+
/** The account is looked up from the source's mailbox, so it lands later still. */
|
|
108
|
+
accountAtMount: boolean;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The composer reopened on its saved draft, with a button standing in for the
|
|
113
|
+
* mode control — which switches Reply over to Reply All without remounting the
|
|
114
|
+
* form or changing the draft segment. The two other buttons stand in for the
|
|
115
|
+
* fetches a reload leaves outstanding, so a test can put the press before them.
|
|
116
|
+
*/
|
|
117
|
+
const Reopened = ({ sourceAtMount, accountAtMount }: ReopenedProps) => {
|
|
118
|
+
const [mode, setMode] = useState<"reply" | "reply-all">("reply");
|
|
119
|
+
const [source, setSource] = useState(
|
|
120
|
+
sourceAtMount ? sourceMessage : undefined,
|
|
121
|
+
);
|
|
122
|
+
const [resolved, setResolved] = useState(
|
|
123
|
+
accountAtMount ? account : undefined,
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
return createElement(
|
|
127
|
+
"div",
|
|
128
|
+
null,
|
|
129
|
+
createElement(
|
|
130
|
+
"button",
|
|
131
|
+
{ type: "button", onClick: () => setMode("reply-all") },
|
|
132
|
+
"Reply All",
|
|
133
|
+
),
|
|
134
|
+
createElement(
|
|
135
|
+
"button",
|
|
136
|
+
{ type: "button", onClick: () => setSource(sourceMessage) },
|
|
137
|
+
"Deliver source",
|
|
138
|
+
),
|
|
139
|
+
createElement(
|
|
140
|
+
"button",
|
|
141
|
+
{ type: "button", onClick: () => setResolved(account) },
|
|
142
|
+
"Resolve account",
|
|
143
|
+
),
|
|
144
|
+
createElement(ComposeForm, {
|
|
145
|
+
mode,
|
|
146
|
+
account: resolved,
|
|
147
|
+
sourceMessage: source,
|
|
148
|
+
outboxMessageId: DRAFT_ID,
|
|
149
|
+
onDraftCreated: () => undefined,
|
|
150
|
+
onClose: () => undefined,
|
|
151
|
+
}),
|
|
152
|
+
);
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const mount = async (props: ReopenedProps): Promise<DomHarness> => {
|
|
156
|
+
http = mockFetch(async (call) => {
|
|
157
|
+
if (call.path.endsWith("/config")) return { accounts: [account] };
|
|
158
|
+
return draft;
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const created = createDomHarness();
|
|
162
|
+
harness = created;
|
|
163
|
+
created.renderApp(
|
|
164
|
+
createElement(RouterContextProvider, {
|
|
165
|
+
router: testRouter(),
|
|
166
|
+
// biome-ignore lint/correctness/noChildrenProp: RouterContextProvider types `children` as a required prop, which createElement's rest-argument form does not satisfy
|
|
167
|
+
children: createElement(
|
|
168
|
+
ComposeProvider,
|
|
169
|
+
null,
|
|
170
|
+
createElement(Reopened, props),
|
|
171
|
+
),
|
|
172
|
+
}),
|
|
173
|
+
);
|
|
174
|
+
await created.flush();
|
|
175
|
+
await created.wait(50);
|
|
176
|
+
return created;
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const press = async (mounted: DomHarness, label: string): Promise<void> => {
|
|
180
|
+
mounted.click(mounted.byText("button", label));
|
|
181
|
+
await mounted.flush();
|
|
182
|
+
await mounted.wait(20);
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* What one address field holds, read off the chips rather than off the form's
|
|
187
|
+
* text: every recipient is one chip, and each carries its address whether or
|
|
188
|
+
* not the envelope gave it a display name.
|
|
189
|
+
*/
|
|
190
|
+
const addressesIn = (mounted: DomHarness, label: string): string[] =>
|
|
191
|
+
mounted
|
|
192
|
+
.queryAll(`[data-address-field="${label}"] [aria-label^="Remove "]`)
|
|
193
|
+
.map((chip) =>
|
|
194
|
+
(chip.getAttribute("aria-label") ?? "").replace("Remove ", ""),
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
describe("Reply All on a resumed reply", () => {
|
|
198
|
+
it("keeps the saved draft's fields while it merely reopens", async () => {
|
|
199
|
+
const mounted = await mount({ sourceAtMount: true, accountAtMount: true });
|
|
200
|
+
|
|
201
|
+
assert.deepEqual(addressesIn(mounted, "To"), ["them@example.com"]);
|
|
202
|
+
assert.deepEqual(
|
|
203
|
+
addressesIn(mounted, "Cc"),
|
|
204
|
+
[],
|
|
205
|
+
"reopening must not rewrite what the reader saved",
|
|
206
|
+
);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("puts the source message's other recipients in Cc", async () => {
|
|
210
|
+
const mounted = await mount({ sourceAtMount: true, accountAtMount: true });
|
|
211
|
+
|
|
212
|
+
await press(mounted, "Reply All");
|
|
213
|
+
|
|
214
|
+
assert.deepEqual(addressesIn(mounted, "Cc"), [
|
|
215
|
+
"other@example.com",
|
|
216
|
+
"copied@example.com",
|
|
217
|
+
]);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// After a reload the source is fetched fresh, so the press ordinarily comes
|
|
221
|
+
// first. Reading the mode off that run recorded "reply-all" as the mode the
|
|
222
|
+
// draft opened under, and Cc stayed empty — the original dead control.
|
|
223
|
+
it("seeds Cc when the press lands before the source message", async () => {
|
|
224
|
+
const mounted = await mount({ sourceAtMount: false, accountAtMount: true });
|
|
225
|
+
|
|
226
|
+
await press(mounted, "Reply All");
|
|
227
|
+
await press(mounted, "Deliver source");
|
|
228
|
+
|
|
229
|
+
assert.deepEqual(addressesIn(mounted, "Cc"), [
|
|
230
|
+
"other@example.com",
|
|
231
|
+
"copied@example.com",
|
|
232
|
+
]);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// The account is looked up from the source's mailbox, so it resolves after
|
|
236
|
+
// the source. Its address is the only thing that keeps the reader out of the
|
|
237
|
+
// Cc of their own reply (#819), so a seed made without it is made again.
|
|
238
|
+
it("takes the reader out of Cc when the account resolves after the press", async () => {
|
|
239
|
+
const mounted = await mount({ sourceAtMount: true, accountAtMount: false });
|
|
240
|
+
|
|
241
|
+
await press(mounted, "Reply All");
|
|
242
|
+
await press(mounted, "Resolve account");
|
|
243
|
+
|
|
244
|
+
assert.deepEqual(addressesIn(mounted, "Cc"), [
|
|
245
|
+
"other@example.com",
|
|
246
|
+
"copied@example.com",
|
|
247
|
+
]);
|
|
248
|
+
});
|
|
249
|
+
});
|
|
@@ -141,8 +141,15 @@ export const MoveToTrigger = ({
|
|
|
141
141
|
close();
|
|
142
142
|
}
|
|
143
143
|
};
|
|
144
|
+
// The popover owns Escape while it is open (#732). The global triage
|
|
145
|
+
// layer listens on window and maps Escape to `back`, which closes the
|
|
146
|
+
// conversation; stopping propagation keeps one press from dismissing
|
|
147
|
+
// both. A second Escape then reaches the layer and closes as usual.
|
|
144
148
|
const handleKey = (event: KeyboardEvent) => {
|
|
145
|
-
if (event.key === "Escape")
|
|
149
|
+
if (event.key === "Escape") {
|
|
150
|
+
event.stopPropagation();
|
|
151
|
+
close();
|
|
152
|
+
}
|
|
146
153
|
};
|
|
147
154
|
document.addEventListener("mousedown", handlePointer);
|
|
148
155
|
document.addEventListener("keydown", handleKey);
|