@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.170",
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
- if (!selectedThread || !selectedMessageId) return undefined;
226
- return (mode: ReplyMode) =>
227
- openReply({
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="Couldn't load this conversation"
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
- if (!selectedThread || !selectedMessageId) return undefined;
232
- return (mode: ReplyMode) =>
233
- openReply({
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
- if (!selectedThread || !selectedMessageId) return undefined;
554
- return (mode: ReplyMode) =>
555
- openReply({
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
+ });
@@ -32,6 +32,7 @@ export {
32
32
  type ReplySurface,
33
33
  type ReplyTarget,
34
34
  replyModes,
35
+ replyToThread,
35
36
  useAdoptReplyDraft,
36
37
  useCloseReply,
37
38
  useIsReplying,
@@ -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
  *