@remit/web-client 0.0.170 → 0.0.171
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 -9
- package/src/components/mail/ConversationView.tsx +9 -1
- package/src/components/mail/FlaggedPane.tsx +5 -9
- package/src/components/mail/MailboxPane.tsx +5 -9
- package/src/components/mail/MessageToolbar.tsx +16 -5
- package/src/components/mail/reply-verbs-reach.render.test.ts +235 -0
- package/src/routing/index.ts +1 -0
- package/src/routing/reply.ts +27 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.171",
|
|
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": {
|
|
@@ -45,6 +45,7 @@ import {
|
|
|
45
45
|
type OpenThreadPath,
|
|
46
46
|
type OpenThreadTarget,
|
|
47
47
|
type ReplyMode,
|
|
48
|
+
replyToThread,
|
|
48
49
|
useIsComposing,
|
|
49
50
|
useIsReplying,
|
|
50
51
|
useOpenReply,
|
|
@@ -221,15 +222,10 @@ function BriefPaneProvider({ thread, children }: BriefPaneProps) {
|
|
|
221
222
|
const isComposing = useIsComposing();
|
|
222
223
|
const isReplying = useIsReplying();
|
|
223
224
|
const openReply = useOpenReply();
|
|
224
|
-
const replyToOpenThread = useMemo(
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
threadId: selectedThread.threadId,
|
|
229
|
-
messageId: selectedMessageId,
|
|
230
|
-
mode,
|
|
231
|
-
});
|
|
232
|
-
}, [openReply, selectedThread, selectedMessageId]);
|
|
225
|
+
const replyToOpenThread = useMemo(
|
|
226
|
+
() => replyToThread(openReply, threadId, selectedMessageId),
|
|
227
|
+
[openReply, threadId, selectedMessageId],
|
|
228
|
+
);
|
|
233
229
|
|
|
234
230
|
const replyToFocusedThread = useMemo(() => {
|
|
235
231
|
if (!triageTarget) return undefined;
|
|
@@ -303,11 +303,19 @@ export const ConversationView = ({
|
|
|
303
303
|
return <LoadingSkeleton />;
|
|
304
304
|
}
|
|
305
305
|
|
|
306
|
+
// A reply addressed over a conversation that never arrived says why the
|
|
307
|
+
// composer is not there, rather than repeating the read failure unchanged —
|
|
308
|
+
// otherwise pressing Reply on a failed thread changes the address and nothing
|
|
309
|
+
// on screen, which is the shape of a broken button.
|
|
306
310
|
if (isError) {
|
|
307
311
|
return (
|
|
308
312
|
<div className="flex h-full items-center justify-center">
|
|
309
313
|
<ErrorState
|
|
310
|
-
title=
|
|
314
|
+
title={
|
|
315
|
+
reply
|
|
316
|
+
? "Couldn't load this conversation, so there is nothing to answer"
|
|
317
|
+
: "Couldn't load this conversation"
|
|
318
|
+
}
|
|
311
319
|
error={error}
|
|
312
320
|
onRetry={() => refetch()}
|
|
313
321
|
/>
|
|
@@ -56,6 +56,7 @@ import {
|
|
|
56
56
|
type OpenThreadPath,
|
|
57
57
|
type OpenThreadTarget,
|
|
58
58
|
type ReplyMode,
|
|
59
|
+
replyToThread,
|
|
59
60
|
useIsComposing,
|
|
60
61
|
useIsReplying,
|
|
61
62
|
useOpenReply,
|
|
@@ -227,15 +228,10 @@ function FlaggedPaneProvider({ thread, children }: FlaggedPaneProps) {
|
|
|
227
228
|
const isComposing = useIsComposing();
|
|
228
229
|
const isReplying = useIsReplying();
|
|
229
230
|
const openReply = useOpenReply();
|
|
230
|
-
const replyToOpenThread = useMemo(
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
threadId: selectedThread.threadId,
|
|
235
|
-
messageId: selectedMessageId,
|
|
236
|
-
mode,
|
|
237
|
-
});
|
|
238
|
-
}, [openReply, selectedThread, selectedMessageId]);
|
|
231
|
+
const replyToOpenThread = useMemo(
|
|
232
|
+
() => replyToThread(openReply, threadId, selectedMessageId),
|
|
233
|
+
[openReply, threadId, selectedMessageId],
|
|
234
|
+
);
|
|
239
235
|
|
|
240
236
|
const replyToFocusedThread = useMemo(() => {
|
|
241
237
|
if (!triageTarget) return undefined;
|
|
@@ -116,6 +116,7 @@ import {
|
|
|
116
116
|
type OpenThreadPath,
|
|
117
117
|
type OpenThreadTarget,
|
|
118
118
|
type ReplyMode,
|
|
119
|
+
replyToThread,
|
|
119
120
|
useIsComposing,
|
|
120
121
|
useIsReplying,
|
|
121
122
|
useOpenCompose,
|
|
@@ -549,15 +550,10 @@ function MailboxPaneProvider({
|
|
|
549
550
|
// row the cursor is on, which may be one the address has not opened yet. Both
|
|
550
551
|
// are one navigation, because the mode and the message it answers are
|
|
551
552
|
// segments of the same address.
|
|
552
|
-
const replyToOpenThread = useMemo(
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
threadId: selectedThread.threadId,
|
|
557
|
-
messageId: selectedMessageId,
|
|
558
|
-
mode,
|
|
559
|
-
});
|
|
560
|
-
}, [openReply, selectedThread, selectedMessageId]);
|
|
553
|
+
const replyToOpenThread = useMemo(
|
|
554
|
+
() => replyToThread(openReply, threadId, selectedMessageId),
|
|
555
|
+
[openReply, threadId, selectedMessageId],
|
|
556
|
+
);
|
|
561
557
|
|
|
562
558
|
const replyToFocusedThread = useMemo(() => {
|
|
563
559
|
if (!focusedThread) return undefined;
|
|
@@ -63,6 +63,14 @@ export interface MessageToolbarProps {
|
|
|
63
63
|
|
|
64
64
|
const OPEN_FIRST = "Open a message first";
|
|
65
65
|
|
|
66
|
+
/**
|
|
67
|
+
* A thread is open and the verb still has nothing to act on — the conversation
|
|
68
|
+
* has not said which turn answers for it yet. A press has to say so: the
|
|
69
|
+
* shared toolbar only explains itself when there is no thread at all, so an
|
|
70
|
+
* unwired handler under an open one would be swallowed in silence (#803).
|
|
71
|
+
*/
|
|
72
|
+
const NOT_LOADED_YET = "This conversation hasn't loaded yet";
|
|
73
|
+
|
|
66
74
|
export const MessageToolbar = ({
|
|
67
75
|
hasThread,
|
|
68
76
|
intelligenceOpen,
|
|
@@ -80,6 +88,9 @@ export const MessageToolbar = ({
|
|
|
80
88
|
const [hint, setHint] = useState<string | null>(null);
|
|
81
89
|
const canDeleteResolved = canDelete ?? hasThread;
|
|
82
90
|
const explain = (message: string) => () => setHint(message);
|
|
91
|
+
// Every verb the bar renders answers a press, wired or not.
|
|
92
|
+
const wired = (handler: (() => void) | undefined) =>
|
|
93
|
+
handler ?? explain(NOT_LOADED_YET);
|
|
83
94
|
|
|
84
95
|
return (
|
|
85
96
|
<MailActionToolbar
|
|
@@ -92,11 +103,11 @@ export const MessageToolbar = ({
|
|
|
92
103
|
forwardTitle={`Forward ${tooltipForAction("forward")}`}
|
|
93
104
|
deleteTitle={`Move to Trash ${tooltipForAction("delete")}`}
|
|
94
105
|
flagTitle={`Star ${tooltipForAction("toggleStar")}`}
|
|
95
|
-
onReply={onReply}
|
|
96
|
-
onReplyAll={onReplyAll}
|
|
97
|
-
onForward={onForward}
|
|
98
|
-
onDelete={canDeleteResolved ? onDelete : explain(OPEN_FIRST)}
|
|
99
|
-
onToggleStar={onToggleStar}
|
|
106
|
+
onReply={wired(onReply)}
|
|
107
|
+
onReplyAll={wired(onReplyAll)}
|
|
108
|
+
onForward={wired(onForward)}
|
|
109
|
+
onDelete={canDeleteResolved ? wired(onDelete) : explain(OPEN_FIRST)}
|
|
110
|
+
onToggleStar={wired(onToggleStar)}
|
|
100
111
|
onMove={explain(OPEN_FIRST)}
|
|
101
112
|
moveSlot={
|
|
102
113
|
moveContext ? (
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reply, Reply All and Forward answer the thread the address names (#803).
|
|
3
|
+
*
|
|
4
|
+
* The thread and the turn being answered are both path segments, so the verbs
|
|
5
|
+
* have everything they need the moment the address is read. They used to wait
|
|
6
|
+
* for the listing row instead — a reload or a bookmarked thread left all three
|
|
7
|
+
* pressable and silent for a full round trip, and for good when the request
|
|
8
|
+
* failed.
|
|
9
|
+
*
|
|
10
|
+
* The toolbar is mounted the way the route mounts it: the brief's provider over
|
|
11
|
+
* the address, with the reading pane below it. Each case is what one address
|
|
12
|
+
* does with the thread request unresolved, so nothing here can pass on a row
|
|
13
|
+
* that arrived.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import assert from "node:assert/strict";
|
|
17
|
+
import { afterEach, describe, it } from "node:test";
|
|
18
|
+
import {
|
|
19
|
+
type AnyRouter,
|
|
20
|
+
createMemoryHistory,
|
|
21
|
+
createRootRoute,
|
|
22
|
+
createRoute,
|
|
23
|
+
createRouter,
|
|
24
|
+
Outlet,
|
|
25
|
+
RouterProvider,
|
|
26
|
+
} from "@tanstack/react-router";
|
|
27
|
+
import { createElement } from "react";
|
|
28
|
+
import { ComposeProvider } from "@/components/compose/ComposeProvider";
|
|
29
|
+
import { useOpenThreadPath } from "@/routing";
|
|
30
|
+
import { createDomHarness, type DomHarness } from "@/test-support/dom";
|
|
31
|
+
import { makeAccount } from "@/test-support/fixtures";
|
|
32
|
+
import { type HttpMock, mockFetch } from "@/test-support/http";
|
|
33
|
+
import { BriefPane } from "./BriefPane";
|
|
34
|
+
import { MessageToolbar } from "./MessageToolbar";
|
|
35
|
+
|
|
36
|
+
const ACCOUNT_ID = "acc-1";
|
|
37
|
+
const THREAD_ID = "thread-1";
|
|
38
|
+
const MESSAGE_ID = "msg-1";
|
|
39
|
+
const MESSAGE_PATH = `/mail/brief/${THREAD_ID}/${MESSAGE_ID}`;
|
|
40
|
+
|
|
41
|
+
const account = makeAccount({ accountId: ACCOUNT_ID });
|
|
42
|
+
|
|
43
|
+
let harness: DomHarness | undefined;
|
|
44
|
+
let http: HttpMock | undefined;
|
|
45
|
+
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
harness?.close();
|
|
48
|
+
harness = undefined;
|
|
49
|
+
http?.restore();
|
|
50
|
+
http = undefined;
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// The router reads `self` at construction; the shared jsdom globals stop at
|
|
54
|
+
// `window`.
|
|
55
|
+
(globalThis as { self?: typeof globalThis }).self ??= globalThis;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The brief's real shape: the list is a layout route, the thread and the
|
|
59
|
+
* message are the segments under it, and the mode is the segment under those.
|
|
60
|
+
*/
|
|
61
|
+
const testRouter = (href: string): AnyRouter => {
|
|
62
|
+
const rootRoute = createRootRoute({
|
|
63
|
+
component: () =>
|
|
64
|
+
createElement(ComposeProvider, null, createElement(Outlet)),
|
|
65
|
+
});
|
|
66
|
+
const mailRoute = createRoute({
|
|
67
|
+
getParentRoute: () => rootRoute,
|
|
68
|
+
path: "/mail",
|
|
69
|
+
validateSearch: (search: Record<string, unknown>) => search,
|
|
70
|
+
component: Outlet,
|
|
71
|
+
});
|
|
72
|
+
// Present so `useBrowsedList` has the route its `from` names, the way the
|
|
73
|
+
// generated tree does.
|
|
74
|
+
const mailboxRoute = createRoute({
|
|
75
|
+
getParentRoute: () => mailRoute,
|
|
76
|
+
path: "/$mailboxId",
|
|
77
|
+
component: Outlet,
|
|
78
|
+
});
|
|
79
|
+
const briefRoute = createRoute({
|
|
80
|
+
getParentRoute: () => mailRoute,
|
|
81
|
+
path: "/brief",
|
|
82
|
+
component: BriefLayout,
|
|
83
|
+
});
|
|
84
|
+
const threadRoute = createRoute({
|
|
85
|
+
getParentRoute: () => briefRoute,
|
|
86
|
+
path: "$threadId",
|
|
87
|
+
component: Outlet,
|
|
88
|
+
});
|
|
89
|
+
const messageRoute = createRoute({
|
|
90
|
+
getParentRoute: () => threadRoute,
|
|
91
|
+
path: "$messageId",
|
|
92
|
+
component: () => createElement(BriefPane.Reading),
|
|
93
|
+
});
|
|
94
|
+
const replyRoute = createRoute({
|
|
95
|
+
getParentRoute: () => messageRoute,
|
|
96
|
+
path: "$mode/{-$outboxMessageId}",
|
|
97
|
+
});
|
|
98
|
+
const routeTree = rootRoute.addChildren([
|
|
99
|
+
mailRoute.addChildren([
|
|
100
|
+
mailboxRoute,
|
|
101
|
+
briefRoute.addChildren([
|
|
102
|
+
threadRoute.addChildren([messageRoute.addChildren([replyRoute])]),
|
|
103
|
+
]),
|
|
104
|
+
]),
|
|
105
|
+
]);
|
|
106
|
+
return createRouter({
|
|
107
|
+
routeTree,
|
|
108
|
+
history: createMemoryHistory({ initialEntries: [href] }),
|
|
109
|
+
}) as unknown as AnyRouter;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/** How `routes/mail/brief.tsx` mounts it: the provider over the reading slot. */
|
|
113
|
+
function BriefLayout() {
|
|
114
|
+
return createElement(BriefPane, {
|
|
115
|
+
thread: useOpenThreadPath(),
|
|
116
|
+
// biome-ignore lint/correctness/noChildrenProp: no JSX in a `.ts` test, and createElement's variadic children do not satisfy a required prop
|
|
117
|
+
children: createElement(Outlet),
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
type ThreadRequest = "hangs" | "fails";
|
|
122
|
+
|
|
123
|
+
const mountAt = async (
|
|
124
|
+
href: string,
|
|
125
|
+
threadRequest: ThreadRequest,
|
|
126
|
+
): Promise<[DomHarness, AnyRouter]> => {
|
|
127
|
+
http = mockFetch((call) => {
|
|
128
|
+
if (call.path.endsWith("/config")) return { accounts: [account] };
|
|
129
|
+
if (call.path.endsWith(`/threads/${THREAD_ID}/messages`)) {
|
|
130
|
+
// A request in flight, and one that came back with nothing — the two
|
|
131
|
+
// states in which no row exists to reply from.
|
|
132
|
+
if (threadRequest === "fails") {
|
|
133
|
+
return new Response("upstream unavailable", { status: 502 });
|
|
134
|
+
}
|
|
135
|
+
return new Promise(() => undefined);
|
|
136
|
+
}
|
|
137
|
+
return { items: [] };
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const router = testRouter(href);
|
|
141
|
+
await router.load();
|
|
142
|
+
harness = createDomHarness();
|
|
143
|
+
harness.renderApp(createElement(RouterProvider, { router }));
|
|
144
|
+
await harness.flush();
|
|
145
|
+
await harness.wait(20);
|
|
146
|
+
await harness.flush();
|
|
147
|
+
return [harness, router];
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const press = async (mounted: DomHarness, label: string): Promise<void> => {
|
|
151
|
+
mounted.click(mounted.byLabel(label));
|
|
152
|
+
await mounted.flush();
|
|
153
|
+
await mounted.wait(20);
|
|
154
|
+
await mounted.flush();
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
describe("the reply verbs answer the thread the address names (#803)", () => {
|
|
158
|
+
const cases: Array<[label: string, segment: string]> = [
|
|
159
|
+
["Reply", "reply"],
|
|
160
|
+
["Reply all", "reply-all"],
|
|
161
|
+
["Forward", "forward"],
|
|
162
|
+
];
|
|
163
|
+
|
|
164
|
+
for (const [label, segment] of cases) {
|
|
165
|
+
it(`${label} opens its mode while the thread request is still in flight`, async () => {
|
|
166
|
+
const [mounted, router] = await mountAt(MESSAGE_PATH, "hangs");
|
|
167
|
+
|
|
168
|
+
await press(mounted, label);
|
|
169
|
+
|
|
170
|
+
assert.equal(
|
|
171
|
+
router.state.location.pathname,
|
|
172
|
+
`${MESSAGE_PATH}/${segment}`,
|
|
173
|
+
`${label} answered the message in the address rather than waiting for a row`,
|
|
174
|
+
);
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// The failure case is permanent: no row is ever coming, so a verb that waits
|
|
179
|
+
// for one is dead for as long as the reader stays on the address.
|
|
180
|
+
it("answers a message whose thread request came back an error", async () => {
|
|
181
|
+
const [mounted, router] = await mountAt(MESSAGE_PATH, "fails");
|
|
182
|
+
|
|
183
|
+
await press(mounted, "Reply");
|
|
184
|
+
|
|
185
|
+
assert.equal(router.state.location.pathname, `${MESSAGE_PATH}/reply`);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("says the conversation never arrived where the composer would be", async () => {
|
|
189
|
+
const [mounted] = await mountAt(`${MESSAGE_PATH}/reply`, "fails");
|
|
190
|
+
|
|
191
|
+
assert.match(
|
|
192
|
+
mounted.text(),
|
|
193
|
+
/nothing to answer/,
|
|
194
|
+
"the reply address over a failed conversation explains itself rather than repeating the read error",
|
|
195
|
+
);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* The backstop for the one thing the address can be silent about: a bare thread
|
|
201
|
+
* address leaves which turn answers to the thread itself. The shared toolbar
|
|
202
|
+
* only explains a press when there is no thread at all, so an unwired verb
|
|
203
|
+
* under an open one has to be answered here.
|
|
204
|
+
*/
|
|
205
|
+
describe("no toolbar verb is pressable and silent (#803)", () => {
|
|
206
|
+
const mountToolbar = (): DomHarness => {
|
|
207
|
+
const mounted = createDomHarness();
|
|
208
|
+
harness = mounted;
|
|
209
|
+
mounted.renderApp(
|
|
210
|
+
createElement(MessageToolbar, {
|
|
211
|
+
hasThread: true,
|
|
212
|
+
intelligenceOpen: false,
|
|
213
|
+
canToggleIntelligence: false,
|
|
214
|
+
onToggleIntelligence: () => undefined,
|
|
215
|
+
}),
|
|
216
|
+
);
|
|
217
|
+
return mounted;
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
for (const label of ["Reply", "Reply all", "Forward"]) {
|
|
221
|
+
it(`${label} explains itself with a thread open and no handler wired`, async () => {
|
|
222
|
+
const mounted = mountToolbar();
|
|
223
|
+
|
|
224
|
+
mounted.click(mounted.byLabel(label));
|
|
225
|
+
await mounted.flush();
|
|
226
|
+
|
|
227
|
+
const status = mounted.query('[role="status"]');
|
|
228
|
+
assert.ok(
|
|
229
|
+
status,
|
|
230
|
+
`${label} said nothing at all — a press must never be swallowed`,
|
|
231
|
+
);
|
|
232
|
+
assert.match(status.textContent ?? "", /hasn't loaded yet/);
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
});
|
package/src/routing/index.ts
CHANGED
package/src/routing/reply.ts
CHANGED
|
@@ -201,6 +201,33 @@ export function useOpenReply(): (target: ReplyTarget) => void {
|
|
|
201
201
|
);
|
|
202
202
|
}
|
|
203
203
|
|
|
204
|
+
/**
|
|
205
|
+
* Answer the conversation a list has open, from the segments naming it.
|
|
206
|
+
*
|
|
207
|
+
* The thread and the turn being answered are both in the path, so this is the
|
|
208
|
+
* address answering for itself: a listing row still in flight cannot make the
|
|
209
|
+
* verbs wait, and a thread request that fails outright cannot take them away.
|
|
210
|
+
* Reading the thread id back off a fetched row was the same fact in two places.
|
|
211
|
+
*
|
|
212
|
+
* A plain function rather than a hook, because every list already holds both
|
|
213
|
+
* values and the router state behind them. Reaching for them again here would
|
|
214
|
+
* subscribe each pane to the router a second time, and a pane re-rendering on
|
|
215
|
+
* every address change is felt by everything it wraps.
|
|
216
|
+
*
|
|
217
|
+
* `messageId` is the one thing the address can be silent about — a bare thread
|
|
218
|
+
* address leaves which turn answers for the conversation to the thread, so the
|
|
219
|
+
* caller passes the newest one once it knows. Absent until then, which is the
|
|
220
|
+
* toolbar's to explain.
|
|
221
|
+
*/
|
|
222
|
+
export function replyToThread(
|
|
223
|
+
openReply: (target: ReplyTarget) => void,
|
|
224
|
+
threadId: string | undefined,
|
|
225
|
+
messageId: string | undefined,
|
|
226
|
+
): ((mode: ReplyMode) => void) | undefined {
|
|
227
|
+
if (!threadId || !messageId) return undefined;
|
|
228
|
+
return (mode: ReplyMode) => openReply({ threadId, messageId, mode });
|
|
229
|
+
}
|
|
230
|
+
|
|
204
231
|
/**
|
|
205
232
|
* Record the draft the reply just created, in the address.
|
|
206
233
|
*
|