@remit/web-client 0.0.207 → 0.0.209
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/mail/BriefPane.tsx +5 -1
- package/src/components/mail/FlaggedPane.tsx +5 -1
- package/src/components/mail/MailboxPane.tsx +6 -1
- package/src/components/mail/MoveToTrigger.render.test.ts +90 -8
- package/src/components/mail/MoveToTrigger.tsx +43 -10
- package/src/hooks/useThreadActions.star.test.ts +200 -0
- package/src/hooks/useThreadActions.ts +29 -3
- package/src/hooks/useThreadRow.ts +24 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.209",
|
|
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": {
|
|
@@ -173,6 +173,7 @@ function BriefPaneProvider({ thread, children }: BriefPaneProps) {
|
|
|
173
173
|
|
|
174
174
|
const actions = useThreadActions({
|
|
175
175
|
thread: selectedThread,
|
|
176
|
+
isOpen: true,
|
|
176
177
|
onAfterOptimisticRemove: handleDeselectIfRemoved,
|
|
177
178
|
});
|
|
178
179
|
|
|
@@ -197,7 +198,10 @@ function BriefPaneProvider({ thread, children }: BriefPaneProps) {
|
|
|
197
198
|
[briefThreads, focusedThreadId],
|
|
198
199
|
);
|
|
199
200
|
const triageTarget = focusedThread ?? selectedThread;
|
|
200
|
-
const triageActions = useThreadActions({
|
|
201
|
+
const triageActions = useThreadActions({
|
|
202
|
+
thread: triageTarget,
|
|
203
|
+
isOpen: triageTarget?.messageId === selectedMessageId,
|
|
204
|
+
});
|
|
201
205
|
|
|
202
206
|
// The toolbar answers the conversation on screen; the keyboard answers the
|
|
203
207
|
// row the cursor is on, which may be one the address has not opened yet.
|
|
@@ -179,6 +179,7 @@ function FlaggedPaneProvider({ thread, children }: FlaggedPaneProps) {
|
|
|
179
179
|
|
|
180
180
|
const actions = useThreadActions({
|
|
181
181
|
thread: selectedThread,
|
|
182
|
+
isOpen: true,
|
|
182
183
|
onAfterOptimisticRemove: handleDeselectIfRemoved,
|
|
183
184
|
});
|
|
184
185
|
|
|
@@ -203,7 +204,10 @@ function FlaggedPaneProvider({ thread, children }: FlaggedPaneProps) {
|
|
|
203
204
|
[threads, focusedThreadId],
|
|
204
205
|
);
|
|
205
206
|
const triageTarget = focusedThread ?? selectedThread;
|
|
206
|
-
const triageActions = useThreadActions({
|
|
207
|
+
const triageActions = useThreadActions({
|
|
208
|
+
thread: triageTarget,
|
|
209
|
+
isOpen: triageTarget?.messageId === selectedMessageId,
|
|
210
|
+
});
|
|
207
211
|
|
|
208
212
|
// The toolbar answers the conversation on screen; the keyboard answers the
|
|
209
213
|
// row the cursor is on, which may be one the address has not opened yet.
|
|
@@ -227,6 +227,8 @@ interface MailboxPaneContextValue {
|
|
|
227
227
|
onReply: ((mode: ReplyMode) => void) | undefined;
|
|
228
228
|
onToolbarDelete: () => void;
|
|
229
229
|
onToolbarStar: () => void;
|
|
230
|
+
/** Whether the open message is starred, as the conversation reports it. */
|
|
231
|
+
isStarred: boolean | undefined;
|
|
230
232
|
onToolbarMove: (destMailboxId: string) => void;
|
|
231
233
|
// Phone actions
|
|
232
234
|
onBack: () => void;
|
|
@@ -515,6 +517,7 @@ function MailboxPaneProvider({
|
|
|
515
517
|
|
|
516
518
|
const toolbarActions = useThreadActions({
|
|
517
519
|
thread: selectedThread,
|
|
520
|
+
isOpen: true,
|
|
518
521
|
mailboxId,
|
|
519
522
|
accountId: mailboxAccountId,
|
|
520
523
|
onAfterOptimisticRemove: handleDeselectIfRemoved,
|
|
@@ -806,6 +809,7 @@ function MailboxPaneProvider({
|
|
|
806
809
|
onReply: replyToOpenThread,
|
|
807
810
|
onToolbarDelete: toolbarActions.deleteThread,
|
|
808
811
|
onToolbarStar: toolbarActions.toggleStar,
|
|
812
|
+
isStarred: toolbarActions.isStarred,
|
|
809
813
|
onToolbarMove: toolbarActions.moveThread,
|
|
810
814
|
onBack: goBack,
|
|
811
815
|
nextThread: adjacentThread(nextMessageId),
|
|
@@ -1080,6 +1084,7 @@ function MailboxReading() {
|
|
|
1080
1084
|
onReply,
|
|
1081
1085
|
onToolbarDelete,
|
|
1082
1086
|
onToolbarStar,
|
|
1087
|
+
isStarred,
|
|
1083
1088
|
onToolbarMove,
|
|
1084
1089
|
handleDeselectIfRemoved,
|
|
1085
1090
|
intelligenceRef,
|
|
@@ -1137,7 +1142,7 @@ function MailboxReading() {
|
|
|
1137
1142
|
onForward={onReply ? () => onReply("forward") : undefined}
|
|
1138
1143
|
onDelete={hasThread ? onToolbarDelete : undefined}
|
|
1139
1144
|
onToggleStar={hasThread ? onToolbarStar : undefined}
|
|
1140
|
-
isStarred={
|
|
1145
|
+
isStarred={isStarred}
|
|
1141
1146
|
moveContext={
|
|
1142
1147
|
hasThread && mailboxAccountId
|
|
1143
1148
|
? {
|
|
@@ -65,8 +65,40 @@ const FOLDERS = [
|
|
|
65
65
|
makeMailbox({ mailboxId: "mbx-receipts", fullPath: "Receipts" }),
|
|
66
66
|
];
|
|
67
67
|
|
|
68
|
+
// The desktop picker renders through a portal into document.body, so its
|
|
69
|
+
// content sits outside the render container these helpers normally scope to.
|
|
70
|
+
const pickerRoot = (dom: DomHarness): ParentNode => {
|
|
71
|
+
const trigger = dom.byLabel("Move to folder");
|
|
72
|
+
return trigger.getAttribute("aria-haspopup") === "dialog"
|
|
73
|
+
? dom.container
|
|
74
|
+
: dom.document.body;
|
|
75
|
+
};
|
|
76
|
+
|
|
68
77
|
const rowText = (dom: DomHarness): string[] =>
|
|
69
|
-
dom.
|
|
78
|
+
[...pickerRoot(dom).querySelectorAll("[role=treeitem]")].map(
|
|
79
|
+
(row) => row.textContent ?? "",
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const pickerText = (dom: DomHarness): string =>
|
|
83
|
+
pickerRoot(dom).textContent ?? "";
|
|
84
|
+
|
|
85
|
+
const pickerByLabel = (dom: DomHarness, label: string): HTMLElement => {
|
|
86
|
+
const found = pickerRoot(dom).querySelector(`[aria-label="${label}"]`);
|
|
87
|
+
if (!found) throw new Error(`no picker element labelled "${label}"`);
|
|
88
|
+
return found as HTMLElement;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const pickerByText = (
|
|
92
|
+
dom: DomHarness,
|
|
93
|
+
selector: string,
|
|
94
|
+
text: string,
|
|
95
|
+
): HTMLElement => {
|
|
96
|
+
const found = [...pickerRoot(dom).querySelectorAll(selector)].find((node) =>
|
|
97
|
+
(node.textContent ?? "").includes(text),
|
|
98
|
+
);
|
|
99
|
+
if (!found) throw new Error(`no picker ${selector} containing "${text}"`);
|
|
100
|
+
return found as HTMLElement;
|
|
101
|
+
};
|
|
70
102
|
|
|
71
103
|
describe("MoveToTrigger", () => {
|
|
72
104
|
it("reports itself as collapsed until it is opened", () => {
|
|
@@ -103,7 +135,7 @@ describe("MoveToTrigger", () => {
|
|
|
103
135
|
false,
|
|
104
136
|
);
|
|
105
137
|
|
|
106
|
-
dom.click(dom
|
|
138
|
+
dom.click(pickerByLabel(dom, "Move to Work"));
|
|
107
139
|
assert.ok(rowText(dom).some((label) => label.includes("Clients")));
|
|
108
140
|
});
|
|
109
141
|
|
|
@@ -117,10 +149,10 @@ describe("MoveToTrigger", () => {
|
|
|
117
149
|
|
|
118
150
|
// Picking a folder also opens it, so the move is a separate press —
|
|
119
151
|
// otherwise the first tap would fire before anything nested was reachable.
|
|
120
|
-
dom.click(dom
|
|
152
|
+
dom.click(pickerByLabel(dom, "Move to Work"));
|
|
121
153
|
assert.deepEqual(moved, []);
|
|
122
154
|
|
|
123
|
-
dom.click(dom
|
|
155
|
+
dom.click(pickerByText(dom, "button", "Move to Work"));
|
|
124
156
|
assert.deepEqual(moved, ["mbx-work"]);
|
|
125
157
|
assert.equal(
|
|
126
158
|
dom.byLabel("Move to folder").getAttribute("aria-expanded"),
|
|
@@ -131,10 +163,10 @@ describe("MoveToTrigger", () => {
|
|
|
131
163
|
it("names the destination on the button that runs the move", () => {
|
|
132
164
|
const dom = mount({ mailboxes: FOLDERS });
|
|
133
165
|
dom.click(dom.byLabel("Move to folder"));
|
|
134
|
-
dom.click(dom
|
|
135
|
-
dom.click(dom
|
|
166
|
+
dom.click(pickerByLabel(dom, "Move to Work"));
|
|
167
|
+
dom.click(pickerByLabel(dom, "Move to Clients"));
|
|
136
168
|
|
|
137
|
-
assert.match(dom
|
|
169
|
+
assert.match(pickerText(dom), /Move to Clients/);
|
|
138
170
|
});
|
|
139
171
|
|
|
140
172
|
it("closes on Escape and on a click outside it", () => {
|
|
@@ -159,6 +191,56 @@ describe("MoveToTrigger", () => {
|
|
|
159
191
|
assert.equal(isOpen(), false);
|
|
160
192
|
});
|
|
161
193
|
|
|
194
|
+
it("renders the desktop picker outside its own subtree (#601)", () => {
|
|
195
|
+
// Regression: an in-place absolute popover is clipped by the reading
|
|
196
|
+
// pane's overflow-hidden shell and painted underneath the thread list.
|
|
197
|
+
const dom = mount({ mailboxes: FOLDERS });
|
|
198
|
+
dom.click(dom.byLabel("Move to folder"));
|
|
199
|
+
|
|
200
|
+
const trigger = dom.byLabel("Move to folder");
|
|
201
|
+
const tree = pickerRoot(dom).querySelector("[role=tree]");
|
|
202
|
+
assert.ok(tree, "the picker is open");
|
|
203
|
+
assert.ok(
|
|
204
|
+
!trigger.parentElement?.contains(tree),
|
|
205
|
+
"the picker escapes the trigger's subtree via a portal",
|
|
206
|
+
);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("stays open when the press lands inside the portalled panel (#601)", () => {
|
|
210
|
+
// The panel is not in the trigger's subtree any more, so the outside-press
|
|
211
|
+
// guard has to recognise it by its own ref — otherwise the first press on
|
|
212
|
+
// a folder closes the picker before it can be picked.
|
|
213
|
+
const dom = mount({ mailboxes: FOLDERS });
|
|
214
|
+
dom.click(dom.byLabel("Move to folder"));
|
|
215
|
+
|
|
216
|
+
dom.dispatch(
|
|
217
|
+
pickerByLabel(dom, "Move to Work"),
|
|
218
|
+
new dom.window.MouseEvent("mousedown", { bubbles: true }),
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
assert.equal(
|
|
222
|
+
dom.byLabel("Move to folder").getAttribute("aria-expanded"),
|
|
223
|
+
"true",
|
|
224
|
+
);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("takes the keyboard into the picker and hands it back on close", () => {
|
|
228
|
+
// Regression: with the panel on the body, Tab from the trigger walked on
|
|
229
|
+
// to the next toolbar button instead of entering the picker.
|
|
230
|
+
const dom = mount({ mailboxes: FOLDERS });
|
|
231
|
+
dom.click(dom.byLabel("Move to folder"));
|
|
232
|
+
|
|
233
|
+
const filter = pickerRoot(dom).querySelector('input[type="search"]');
|
|
234
|
+
assert.ok(filter, "the picker has a filter field");
|
|
235
|
+
assert.equal(dom.document.activeElement, filter);
|
|
236
|
+
|
|
237
|
+
dom.dispatch(
|
|
238
|
+
dom.window.document,
|
|
239
|
+
new dom.window.KeyboardEvent("keydown", { key: "Escape" }),
|
|
240
|
+
);
|
|
241
|
+
assert.equal(dom.document.activeElement, dom.byLabel("Move to folder"));
|
|
242
|
+
});
|
|
243
|
+
|
|
162
244
|
it("refuses to open, and says why, when the selection spans accounts", () => {
|
|
163
245
|
const hint = "Select messages from one account to move them";
|
|
164
246
|
const dom = mount({ mailboxes: FOLDERS, disabledHint: hint });
|
|
@@ -212,6 +294,6 @@ describe("MoveToTrigger", () => {
|
|
|
212
294
|
dom.click(dom.byLabel("Move to folder"));
|
|
213
295
|
// No cached mailboxes and no network in the test harness: the picker
|
|
214
296
|
// shows its loading state rather than an empty list of destinations.
|
|
215
|
-
assert.match(dom
|
|
297
|
+
assert.match(pickerText(dom), /Loading folders/);
|
|
216
298
|
});
|
|
217
299
|
});
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { mailboxOperationsListMailboxesOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
Button,
|
|
4
|
+
cn,
|
|
5
|
+
type FolderTreeNode,
|
|
6
|
+
FolderTreePicker,
|
|
7
|
+
PopoverMenuPortal,
|
|
8
|
+
} from "@remit/ui";
|
|
3
9
|
import { useQuery } from "@tanstack/react-query";
|
|
4
10
|
import { FolderInput } from "lucide-react";
|
|
5
11
|
import {
|
|
@@ -75,6 +81,8 @@ export const MoveToTrigger = ({
|
|
|
75
81
|
const [pickedId, setPickedId] = useState<string>();
|
|
76
82
|
const isDesktop = useIsDesktop();
|
|
77
83
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
84
|
+
const panelRef = useRef<HTMLDivElement>(null);
|
|
85
|
+
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
78
86
|
const triggerLabel = label ?? "Move to folder";
|
|
79
87
|
const popoverId = useId();
|
|
80
88
|
const { t } = useTranslation("mail", { useSuspense: false });
|
|
@@ -119,6 +127,13 @@ export const MoveToTrigger = ({
|
|
|
119
127
|
const close = useCallback(() => {
|
|
120
128
|
setIsOpen(false);
|
|
121
129
|
setPickedId(undefined);
|
|
130
|
+
// The panel is about to unmount. Where the keyboard was inside it, hand
|
|
131
|
+
// focus back to the trigger rather than dropping it on the body; where it
|
|
132
|
+
// was elsewhere — a press that landed outside — leave it where it is.
|
|
133
|
+
const active = document.activeElement;
|
|
134
|
+
if (active && panelRef.current?.contains(active)) {
|
|
135
|
+
triggerRef.current?.focus();
|
|
136
|
+
}
|
|
122
137
|
}, []);
|
|
123
138
|
|
|
124
139
|
// Tapping a folder both picks it and opens it, so the move waits for a
|
|
@@ -134,12 +149,13 @@ export const MoveToTrigger = ({
|
|
|
134
149
|
useEffect(() => {
|
|
135
150
|
if (!isOpen || !isDesktop) return;
|
|
136
151
|
const handlePointer = (event: MouseEvent) => {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
152
|
+
const target = event.target as Node;
|
|
153
|
+
if (containerRef.current?.contains(target)) return;
|
|
154
|
+
// The picker renders through a portal into document.body, so it
|
|
155
|
+
// sits outside the trigger's subtree — count presses on it as
|
|
156
|
+
// inside, or picking a folder would close the picker first.
|
|
157
|
+
if (panelRef.current?.contains(target)) return;
|
|
158
|
+
close();
|
|
143
159
|
};
|
|
144
160
|
// The popover owns Escape while it is open (#732). The global triage
|
|
145
161
|
// layer listens on window and maps Escape to `back`, which closes the
|
|
@@ -159,11 +175,15 @@ export const MoveToTrigger = ({
|
|
|
159
175
|
};
|
|
160
176
|
}, [isOpen, isDesktop, close]);
|
|
161
177
|
|
|
178
|
+
const anchorRect = () =>
|
|
179
|
+
containerRef.current?.getBoundingClientRect() ?? null;
|
|
180
|
+
|
|
162
181
|
const isTriggerDisabled = disabled || !!disabledHint;
|
|
163
182
|
|
|
164
183
|
const TriggerButton = (
|
|
165
184
|
<button
|
|
166
185
|
type="button"
|
|
186
|
+
ref={triggerRef}
|
|
167
187
|
onClick={(event) => {
|
|
168
188
|
event.stopPropagation();
|
|
169
189
|
if (isTriggerDisabled) return;
|
|
@@ -204,6 +224,10 @@ export const MoveToTrigger = ({
|
|
|
204
224
|
onSelect={setPickedId}
|
|
205
225
|
onCreateFolder={createFolderIn}
|
|
206
226
|
onCancel={close}
|
|
227
|
+
// The desktop panel is portalled onto the body, so Tab from the
|
|
228
|
+
// trigger would otherwise walk on to the next toolbar button. The
|
|
229
|
+
// drawer runs its own focus and must not raise a phone keyboard.
|
|
230
|
+
autoFocusFilter={isDesktop}
|
|
207
231
|
labels={{
|
|
208
232
|
filterPlaceholder: t("move_picker_filter_placeholder", {
|
|
209
233
|
defaultValue: "Filter folders…",
|
|
@@ -260,18 +284,27 @@ export const MoveToTrigger = ({
|
|
|
260
284
|
return (
|
|
261
285
|
<div ref={containerRef} className="relative inline-block">
|
|
262
286
|
{TriggerButton}
|
|
263
|
-
{
|
|
287
|
+
{/* Portalled + fixed-positioned: an in-place absolute popover is
|
|
288
|
+
clipped by the reading pane's overflow-hidden shell and ends up
|
|
289
|
+
painted underneath the thread list (#601). */}
|
|
290
|
+
<PopoverMenuPortal
|
|
291
|
+
open={isOpen}
|
|
292
|
+
align="end"
|
|
293
|
+
panelRef={panelRef}
|
|
294
|
+
getAnchor={anchorRect}
|
|
295
|
+
>
|
|
264
296
|
<div
|
|
265
297
|
id={popoverId}
|
|
298
|
+
ref={panelRef}
|
|
266
299
|
className={cn(
|
|
267
|
-
"
|
|
300
|
+
"w-72 max-h-96 flex flex-col",
|
|
268
301
|
"bg-surface border border-line rounded-md shadow-lg",
|
|
269
302
|
)}
|
|
270
303
|
>
|
|
271
304
|
{pickerBody}
|
|
272
305
|
{confirmBar}
|
|
273
306
|
</div>
|
|
274
|
-
|
|
307
|
+
</PopoverMenuPortal>
|
|
275
308
|
</div>
|
|
276
309
|
);
|
|
277
310
|
};
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression cover for issue #602 — the reading pane's star did not toggle and
|
|
3
|
+
* never showed the open message as starred.
|
|
4
|
+
*
|
|
5
|
+
* Both symptoms were one cause: the control answered from the list row that
|
|
6
|
+
* represented the conversation, not from the conversation. A row is a copy that
|
|
7
|
+
* stops being refreshed the moment the mail no longer matches the browsed
|
|
8
|
+
* predicate, so the star read unstarred however many times it had been set, and
|
|
9
|
+
* every press asked the server to star a message that already was.
|
|
10
|
+
*
|
|
11
|
+
* Each case mounts the real toolbar over a mocked server and lets the
|
|
12
|
+
* conversation's own listing disagree with the stale list row.
|
|
13
|
+
*/
|
|
14
|
+
import assert from "node:assert/strict";
|
|
15
|
+
import { afterEach, describe, it } from "node:test";
|
|
16
|
+
import { threadDetailOperationsListThreadMessagesQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
17
|
+
import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
18
|
+
import { createElement } from "react";
|
|
19
|
+
import { MessageToolbar } from "@/components/mail/MessageToolbar";
|
|
20
|
+
import { useThreadActions } from "@/hooks/useThreadActions";
|
|
21
|
+
import { createDomHarness, type DomHarness } from "../test-support/dom";
|
|
22
|
+
import { makeConfig, makeThreadMessage } from "../test-support/fixtures";
|
|
23
|
+
import { type HttpMock, mockFetch } from "../test-support/http";
|
|
24
|
+
|
|
25
|
+
const MESSAGE_ID = "msg-1";
|
|
26
|
+
const THREAD_ID = "thread-1";
|
|
27
|
+
|
|
28
|
+
let harness: DomHarness | undefined;
|
|
29
|
+
let http: HttpMock | undefined;
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
harness?.close();
|
|
33
|
+
harness = undefined;
|
|
34
|
+
http?.restore();
|
|
35
|
+
http = undefined;
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
interface ServerState {
|
|
39
|
+
starred: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const conversationKey = threadDetailOperationsListThreadMessagesQueryKey({
|
|
43
|
+
path: { threadId: THREAD_ID },
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const conversationRow = (starred: boolean) =>
|
|
47
|
+
makeThreadMessage({
|
|
48
|
+
messageId: MESSAGE_ID,
|
|
49
|
+
threadId: THREAD_ID,
|
|
50
|
+
hasStars: starred,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The message as the conversation endpoint serves it, and the list row as the
|
|
55
|
+
* mailbox listing served it — which is deliberately allowed to disagree.
|
|
56
|
+
*/
|
|
57
|
+
const mountToolbar = async (options: {
|
|
58
|
+
server: ServerState;
|
|
59
|
+
row: Partial<RemitImapThreadMessageResponse>;
|
|
60
|
+
/** Defaults to the reading pane's case: this thread is the one on screen. */
|
|
61
|
+
isOpen?: boolean;
|
|
62
|
+
}) => {
|
|
63
|
+
const { server, isOpen = true } = options;
|
|
64
|
+
http = mockFetch((call) => {
|
|
65
|
+
if (call.method === "PATCH") {
|
|
66
|
+
const body = call.body as { isStarred?: boolean } | undefined;
|
|
67
|
+
server.starred = body?.isStarred === true;
|
|
68
|
+
return {
|
|
69
|
+
messageId: MESSAGE_ID,
|
|
70
|
+
isRead: true,
|
|
71
|
+
isStarred: server.starred,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
if (call.path.includes("/config")) return makeConfig([]);
|
|
75
|
+
if (call.path.endsWith(`/threads/${THREAD_ID}/messages`)) {
|
|
76
|
+
return { items: [conversationRow(server.starred)] };
|
|
77
|
+
}
|
|
78
|
+
return { items: [] };
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const thread = makeThreadMessage({
|
|
82
|
+
messageId: MESSAGE_ID,
|
|
83
|
+
threadId: THREAD_ID,
|
|
84
|
+
...options.row,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const Harness = () => {
|
|
88
|
+
const actions = useThreadActions({ thread, isOpen });
|
|
89
|
+
return createElement(MessageToolbar, {
|
|
90
|
+
hasThread: true,
|
|
91
|
+
intelligenceOpen: false,
|
|
92
|
+
canToggleIntelligence: false,
|
|
93
|
+
onToggleIntelligence: () => undefined,
|
|
94
|
+
onToggleStar: actions.toggleStar,
|
|
95
|
+
isStarred: actions.isStarred,
|
|
96
|
+
});
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
harness = createDomHarness();
|
|
100
|
+
// The conversation as the server has it, in the cache before the first
|
|
101
|
+
// render — the pane answers from this, not from the list row.
|
|
102
|
+
harness.queryClient.setQueryData(conversationKey, {
|
|
103
|
+
items: [conversationRow(server.starred)],
|
|
104
|
+
});
|
|
105
|
+
harness.renderApp(createElement(Harness));
|
|
106
|
+
await harness.flush();
|
|
107
|
+
await harness.wait(20);
|
|
108
|
+
await harness.flush();
|
|
109
|
+
return harness;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const starButton = (dom: DomHarness) => dom.byLabel("Star");
|
|
113
|
+
|
|
114
|
+
const rendersStarred = (dom: DomHarness): boolean =>
|
|
115
|
+
starButton(dom).getAttribute("aria-pressed") === "true";
|
|
116
|
+
|
|
117
|
+
const conversationRequests = (): number =>
|
|
118
|
+
http?.to(`/threads/${THREAD_ID}/messages`).length ?? 0;
|
|
119
|
+
|
|
120
|
+
const starRequests = (): Array<boolean | undefined> =>
|
|
121
|
+
(http?.calls ?? [])
|
|
122
|
+
.filter((call) => call.method === "PATCH")
|
|
123
|
+
.map(
|
|
124
|
+
(call) => (call.body as { isStarred?: boolean } | undefined)?.isStarred,
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
describe("the reading pane's star follows the open message (#602)", () => {
|
|
128
|
+
it("shows the message starred when the conversation says so, though its list row does not", async () => {
|
|
129
|
+
const dom = await mountToolbar({
|
|
130
|
+
server: { starred: true },
|
|
131
|
+
// What the mailbox listing last said about this message, from before
|
|
132
|
+
// it was starred — the state the pane used to answer from (#602).
|
|
133
|
+
row: { hasStars: false },
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
assert.equal(
|
|
137
|
+
rendersStarred(dom),
|
|
138
|
+
true,
|
|
139
|
+
"the star answers from the conversation, not the list row",
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("asks for the star to come off when a lit star is pressed", async () => {
|
|
144
|
+
const server: ServerState = { starred: true };
|
|
145
|
+
const dom = await mountToolbar({ server, row: { hasStars: false } });
|
|
146
|
+
|
|
147
|
+
dom.click(starButton(dom));
|
|
148
|
+
await dom.flush();
|
|
149
|
+
assert.deepEqual(
|
|
150
|
+
starRequests(),
|
|
151
|
+
[false],
|
|
152
|
+
"pressing a lit star unstars the message",
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("stars, then unstars, on consecutive presses", async () => {
|
|
157
|
+
const server: ServerState = { starred: false };
|
|
158
|
+
const dom = await mountToolbar({ server, row: { hasStars: false } });
|
|
159
|
+
assert.equal(rendersStarred(dom), false);
|
|
160
|
+
|
|
161
|
+
dom.click(starButton(dom));
|
|
162
|
+
await dom.flush();
|
|
163
|
+
assert.equal(server.starred, true);
|
|
164
|
+
assert.deepEqual(starRequests(), [true]);
|
|
165
|
+
|
|
166
|
+
// Nothing is put into the cache by hand here: the settle-invalidate has
|
|
167
|
+
// to re-read the conversation, which the mock now serves starred. Seeding
|
|
168
|
+
// it would make the second press pass even if the refresh never happened.
|
|
169
|
+
await dom.wait(20);
|
|
170
|
+
await dom.flush();
|
|
171
|
+
assert.equal(
|
|
172
|
+
rendersStarred(dom),
|
|
173
|
+
true,
|
|
174
|
+
"the star lights from the refreshed conversation",
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
dom.click(starButton(dom));
|
|
178
|
+
await dom.flush();
|
|
179
|
+
assert.equal(server.starred, false, "the second press unstars");
|
|
180
|
+
assert.deepEqual(starRequests(), [true, false]);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("asks for no conversation for a thread the pane has not opened", async () => {
|
|
184
|
+
// The triage cursor moves a row at a time. A conversation pulled for the
|
|
185
|
+
// row under the cursor would be a request per keystroke, so a target that
|
|
186
|
+
// is not the open thread answers from its own listing row.
|
|
187
|
+
const dom = await mountToolbar({
|
|
188
|
+
server: { starred: false },
|
|
189
|
+
row: { hasStars: true },
|
|
190
|
+
isOpen: false,
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
assert.equal(conversationRequests(), 0);
|
|
194
|
+
assert.equal(
|
|
195
|
+
rendersStarred(dom),
|
|
196
|
+
true,
|
|
197
|
+
"the cursor target answers from the row it came from",
|
|
198
|
+
);
|
|
199
|
+
});
|
|
200
|
+
});
|
|
@@ -15,10 +15,22 @@ import { useDeleteMessages } from "@/hooks/useDeleteMessages";
|
|
|
15
15
|
import { useMailboxAccount } from "@/hooks/useMailboxAccount";
|
|
16
16
|
import { useMoveMessages } from "@/hooks/useMoveMessages";
|
|
17
17
|
import { useThreadMessageIds } from "@/hooks/useThreadMessageIds";
|
|
18
|
+
import { useThreadConversation } from "@/hooks/useThreadRow";
|
|
18
19
|
import { useToggleStar } from "@/hooks/useToggleStar";
|
|
19
20
|
|
|
20
21
|
interface UseThreadActionsOptions {
|
|
21
22
|
thread: RemitImapThreadMessageResponse | undefined;
|
|
23
|
+
/**
|
|
24
|
+
* Whether the pane has this thread open, so its conversation is on screen
|
|
25
|
+
* and already fetched. The star then answers from that conversation: a list
|
|
26
|
+
* row is a copy that survives its listing, and once the mail no longer
|
|
27
|
+
* matches the browsed predicate the row stops being refreshed, so a star
|
|
28
|
+
* read off it is whatever was true when it was last listed (#602).
|
|
29
|
+
*
|
|
30
|
+
* A cursor target is not open — it answers from its own live listing row,
|
|
31
|
+
* because pulling a conversation for it would be a request per keystroke.
|
|
32
|
+
*/
|
|
33
|
+
isOpen?: boolean;
|
|
22
34
|
/** Mailbox whose listings the mutations patch. Defaults to the thread's own. */
|
|
23
35
|
mailboxId?: string;
|
|
24
36
|
/** Account the move picker offers folders from. Defaults to the thread's own. */
|
|
@@ -37,6 +49,7 @@ export interface ThreadActions {
|
|
|
37
49
|
|
|
38
50
|
export const useThreadActions = ({
|
|
39
51
|
thread,
|
|
52
|
+
isOpen = false,
|
|
40
53
|
mailboxId,
|
|
41
54
|
accountId,
|
|
42
55
|
onAfterOptimisticRemove,
|
|
@@ -67,9 +80,22 @@ export const useThreadActions = ({
|
|
|
67
80
|
onAfterOptimisticRemove,
|
|
68
81
|
});
|
|
69
82
|
|
|
83
|
+
// The conversation the pane has open, off the entry `ConversationView` and
|
|
84
|
+
// `useThreadRow` already hold, so the toolbar and the message card answer
|
|
85
|
+
// from the same row for no extra request.
|
|
86
|
+
const conversation = useThreadConversation(
|
|
87
|
+
isOpen ? thread?.threadId : undefined,
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
const openMessage = conversation.find(
|
|
91
|
+
(message) => message.messageId === thread?.messageId,
|
|
92
|
+
);
|
|
93
|
+
const isStarred = openMessage?.hasStars ?? thread?.hasStars;
|
|
94
|
+
|
|
70
95
|
const { toggleStar: toggleStarFor } = useToggleStar({
|
|
71
96
|
threadId: thread?.threadId ?? "",
|
|
72
97
|
mailboxId: resolvedMailboxId ?? "",
|
|
98
|
+
messages: conversation,
|
|
73
99
|
});
|
|
74
100
|
|
|
75
101
|
const deleteThread = useCallback(() => {
|
|
@@ -87,13 +113,13 @@ export const useThreadActions = ({
|
|
|
87
113
|
|
|
88
114
|
const toggleStar = useCallback(() => {
|
|
89
115
|
if (!thread) return;
|
|
90
|
-
toggleStarFor(thread.messageId,
|
|
91
|
-
}, [thread, toggleStarFor]);
|
|
116
|
+
toggleStarFor(thread.messageId, isStarred ?? false);
|
|
117
|
+
}, [thread, isStarred, toggleStarFor]);
|
|
92
118
|
|
|
93
119
|
return {
|
|
94
120
|
mailboxId: resolvedMailboxId,
|
|
95
121
|
accountId: resolvedAccountId,
|
|
96
|
-
isStarred
|
|
122
|
+
isStarred,
|
|
97
123
|
deleteThread,
|
|
98
124
|
moveThread,
|
|
99
125
|
toggleStar,
|
|
@@ -2,6 +2,28 @@ import { threadDetailOperationsListThreadMessagesOptions } from "@remit/api-http
|
|
|
2
2
|
import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
3
3
|
import { useQuery } from "@tanstack/react-query";
|
|
4
4
|
|
|
5
|
+
const NO_MESSAGES: RemitImapThreadMessageResponse[] = [];
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The messages a conversation holds, on the key the reading pane already uses.
|
|
9
|
+
*
|
|
10
|
+
* The single owner of `GET /threads/{threadId}/messages` for the client's
|
|
11
|
+
* hooks: everything that needs the conversation resolves off this one cache
|
|
12
|
+
* entry rather than a round trip of its own. A `threadId` of `undefined` asks
|
|
13
|
+
* for nothing.
|
|
14
|
+
*/
|
|
15
|
+
export const useThreadConversation = (
|
|
16
|
+
threadId: string | undefined,
|
|
17
|
+
): RemitImapThreadMessageResponse[] => {
|
|
18
|
+
const { data } = useQuery({
|
|
19
|
+
...threadDetailOperationsListThreadMessagesOptions({
|
|
20
|
+
path: { threadId: threadId ?? "" },
|
|
21
|
+
}),
|
|
22
|
+
enabled: Boolean(threadId),
|
|
23
|
+
});
|
|
24
|
+
return data?.items ?? NO_MESSAGES;
|
|
25
|
+
};
|
|
26
|
+
|
|
5
27
|
/**
|
|
6
28
|
* A thread's own row for the message the reader pointed at.
|
|
7
29
|
*
|
|
@@ -9,8 +31,7 @@ import { useQuery } from "@tanstack/react-query";
|
|
|
9
31
|
* cross-folder search hit, or an address pasted into a fresh tab.
|
|
10
32
|
* `GET /threads/{threadId}/messages` returns the same row shape a listing does,
|
|
11
33
|
* `mailboxId` included, so the thread id is the whole address even though the
|
|
12
|
-
* mail is filed in some folder.
|
|
13
|
-
* this resolves off the same cache entry rather than a round trip of its own.
|
|
34
|
+
* mail is filed in some folder.
|
|
14
35
|
*
|
|
15
36
|
* A thread spans folders — the reader's own reply sits in Sent — so `messageId`
|
|
16
37
|
* picks the row they pointed at. With none, the newest message answers.
|
|
@@ -19,14 +40,7 @@ export const useThreadRow = (
|
|
|
19
40
|
threadId: string | undefined,
|
|
20
41
|
messageId: string | undefined,
|
|
21
42
|
): RemitImapThreadMessageResponse | undefined => {
|
|
22
|
-
const
|
|
23
|
-
...threadDetailOperationsListThreadMessagesOptions({
|
|
24
|
-
path: { threadId: threadId ?? "" },
|
|
25
|
-
}),
|
|
26
|
-
enabled: Boolean(threadId),
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
const items = data?.items ?? [];
|
|
43
|
+
const items = useThreadConversation(threadId);
|
|
30
44
|
const pointedAt = messageId
|
|
31
45
|
? items.find((item) => item.messageId === messageId)
|
|
32
46
|
: undefined;
|