@remit/web-client 0.0.147 → 0.0.148

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.147",
3
+ "version": "0.0.148",
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": {
@@ -21,7 +21,12 @@ export const InlineCompose = ({
21
21
  // Capped against the viewport as well as in pixels: on a short one — a phone
22
22
  // in landscape, or one with the keyboard up — a flat 400px is taller than
23
23
  // the pane, and the surface's own header scrolls off before its verbs do.
24
- <div className="border-t border-line bg-canvas max-h-[min(400px,60dvh)] flex flex-col">
24
+ //
25
+ // A desktop pane has room a phone does not, so the surface takes a height
26
+ // there rather than a ceiling. Left to size itself, the recipient rows and
27
+ // the verbs come first and the writing area gets its 120px floor — a couple
28
+ // of lines to answer a message in.
29
+ <div className="border-t border-line bg-canvas max-h-[min(400px,60dvh)] flex flex-col lg:h-[min(560px,55dvh)] lg:max-h-none">
25
30
  <ComposeForm
26
31
  mode={mode}
27
32
  account={account}
@@ -322,8 +322,8 @@ export const ConversationView = ({
322
322
  onComposeClose?.();
323
323
  }, [onComposeClose]);
324
324
 
325
- // Single-pane compose opens below the message, which on a long one is several
326
- // screens down: without this, replying to it looks like nothing happening.
325
+ // Compose opens below the message, which on a long one is several screens
326
+ // down: without this, replying to it looks like nothing happening.
327
327
  //
328
328
  // Aligned on its bottom edge, where the send and discard verbs are, and held
329
329
  // there while it changes height. It mounts before the message it quotes has
@@ -483,15 +483,23 @@ export const ConversationView = ({
483
483
  onOpenIntelligence={onOpenIntelligence}
484
484
  />
485
485
  )}
486
- <div className="flex-1 overflow-auto">{renderMessages(false)}</div>
487
- {composeMode !== null && (
488
- <InlineCompose
489
- mode={composeMode}
490
- account={activeAccount}
491
- sourceMessage={latestMessageData}
492
- onClose={handleCloseCompose}
493
- />
494
- )}
486
+ {/* The reply is part of the conversation, so it scrolls with it. Held
487
+ below the pane instead, it took whatever height the message left
488
+ over — nothing on a long one — and no amount of scrolling could
489
+ reach it. */}
490
+ <div className="min-h-0 flex-1 overflow-auto">
491
+ {renderMessages(false)}
492
+ {composeMode !== null && (
493
+ <div ref={composeRef}>
494
+ <InlineCompose
495
+ mode={composeMode}
496
+ account={activeAccount}
497
+ sourceMessage={latestMessageData}
498
+ onClose={handleCloseCompose}
499
+ />
500
+ </div>
501
+ )}
502
+ </div>
495
503
  </article>
496
504
  );
497
505
  };
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Reading a message and answering it, on a desktop pane.
3
+ *
4
+ * The inline reply was held below the conversation rather than inside it. It
5
+ * took whatever height the message left over, which on a normal-length one is
6
+ * nothing: the recipient rows and the verbs stayed, the writing area went down
7
+ * to a couple of lines, and scrolling the message could not reach it because
8
+ * the thing to reach was already on screen and squeezed.
9
+ *
10
+ * The reply belongs to the conversation and scrolls with it, and the chevron
11
+ * beside the sender is the control that puts a message away — reclaiming the
12
+ * space is what a reader reaches for it to do.
13
+ */
14
+
15
+ import assert from "node:assert/strict";
16
+ import { afterEach, describe, it } from "node:test";
17
+ import type {
18
+ RemitImapDescribeMessageResponse,
19
+ RemitImapThreadMessageResponse,
20
+ } from "@remit/api-http-client/types.gen.ts";
21
+ import {
22
+ type AnyRouter,
23
+ createMemoryHistory,
24
+ createRootRoute,
25
+ createRoute,
26
+ createRouter,
27
+ Outlet,
28
+ RouterProvider,
29
+ } from "@tanstack/react-router";
30
+ import { createElement } from "react";
31
+ import { ComposeProvider } from "@/components/compose/ComposeProvider";
32
+ import { createDomHarness, type DomHarness } from "@/test-support/dom";
33
+ import { makeAccount, makeThreadMessage } from "@/test-support/fixtures";
34
+ import { type HttpMock, mockFetch } from "@/test-support/http";
35
+ import { ConversationView } from "./ConversationView";
36
+
37
+ const ACCOUNT_ID = "acc-1";
38
+ const THREAD_ID = "thread-1";
39
+ const MAILBOX_ID = "mbx-inbox";
40
+ const MESSAGE_ID = "msg-1";
41
+
42
+ const account = makeAccount({ accountId: ACCOUNT_ID });
43
+
44
+ const threadMessage: RemitImapThreadMessageResponse = makeThreadMessage({
45
+ messageId: MESSAGE_ID,
46
+ threadId: THREAD_ID,
47
+ mailboxId: MAILBOX_ID,
48
+ accountId: ACCOUNT_ID,
49
+ subject: "Lunch Thursday?",
50
+ fromName: "Ada Lovelace",
51
+ fromEmail: "ada@example.com",
52
+ // Already read: marking one read on open is a mutation this test has no
53
+ // business driving.
54
+ isRead: true,
55
+ });
56
+
57
+ const describeMessage: RemitImapDescribeMessageResponse = {
58
+ message: {
59
+ messageId: MESSAGE_ID,
60
+ mailboxId: MAILBOX_ID,
61
+ uid: 1,
62
+ rfc822Size: 512,
63
+ internalDate: 1_767_225_600_000,
64
+ },
65
+ envelope: {
66
+ messageId: MESSAGE_ID,
67
+ date: 1_767_225_600_000,
68
+ subject: "Lunch Thursday?",
69
+ messageIdValue: "<ada-1@example.com>",
70
+ from: [
71
+ {
72
+ addressId: "addr-ada",
73
+ displayName: "Ada Lovelace",
74
+ normalizedEmail: "ada@example.com",
75
+ addressRole: "from",
76
+ addressOrder: 0,
77
+ },
78
+ ],
79
+ to: [
80
+ {
81
+ addressId: "addr-me",
82
+ normalizedEmail: "alice@example.com",
83
+ addressRole: "to",
84
+ addressOrder: 0,
85
+ },
86
+ ],
87
+ cc: [],
88
+ bcc: [],
89
+ replyTo: [],
90
+ category: "uncategorized",
91
+ senderTrust: "unknown",
92
+ },
93
+ flags: ["\\Seen"],
94
+ bodyParts: [],
95
+ references: [],
96
+ };
97
+
98
+ let harness: DomHarness | undefined;
99
+ let http: HttpMock | undefined;
100
+
101
+ afterEach(() => {
102
+ harness?.close();
103
+ harness = undefined;
104
+ http?.restore();
105
+ http = undefined;
106
+ });
107
+
108
+ // The router reads `self` at construction; the shared jsdom globals stop at
109
+ // `window`.
110
+ (globalThis as { self?: typeof globalThis }).self ??= globalThis;
111
+
112
+ // The conversation is what the mailbox route renders, and the per-message
113
+ // action menu reads the route's own search — so it is mounted on a route
114
+ // rather than beside one.
115
+ const testRouter = (): AnyRouter => {
116
+ // The compose provider sits at the root the way `__root.tsx` mounts it.
117
+ const rootRoute = createRootRoute({
118
+ component: () =>
119
+ createElement(ComposeProvider, null, createElement(Outlet)),
120
+ });
121
+ const routeTree = rootRoute.addChildren([
122
+ createRoute({
123
+ getParentRoute: () => rootRoute,
124
+ path: "/mail/$mailboxId",
125
+ validateSearch: (search: Record<string, unknown>) => search,
126
+ component: () =>
127
+ createElement(ConversationView, {
128
+ threadId: THREAD_ID,
129
+ mailboxId: MAILBOX_ID,
130
+ subject: "Lunch Thursday?",
131
+ }),
132
+ }),
133
+ ]);
134
+ return createRouter({
135
+ routeTree,
136
+ history: createMemoryHistory({ initialEntries: [`/mail/${MAILBOX_ID}`] }),
137
+ }) as unknown as AnyRouter;
138
+ };
139
+
140
+ const mount = async (): Promise<DomHarness> => {
141
+ http = mockFetch((call) => {
142
+ if (call.path.endsWith("/config")) return { accounts: [account] };
143
+ if (call.path.endsWith(`/threads/${THREAD_ID}/messages`)) {
144
+ return { items: [threadMessage] };
145
+ }
146
+ if (call.path.endsWith(`/messages/${MESSAGE_ID}`)) return describeMessage;
147
+ return { items: [] };
148
+ });
149
+
150
+ const router = testRouter();
151
+ await router.load();
152
+ harness = createDomHarness();
153
+ harness.renderApp(createElement(RouterProvider, { router }));
154
+ await harness.flush();
155
+ await harness.wait(20);
156
+ await harness.flush();
157
+ return harness;
158
+ };
159
+
160
+ /** The r shortcut the reading pane binds — how a reader opens the reply. */
161
+ const pressReply = async (mounted: DomHarness): Promise<void> => {
162
+ mounted.dispatch(
163
+ mounted.window,
164
+ new mounted.window.KeyboardEvent("keydown", { key: "r", bubbles: true }),
165
+ );
166
+ await mounted.flush();
167
+ await mounted.wait(20);
168
+ await mounted.flush();
169
+ };
170
+
171
+ /**
172
+ * Which region of the pane a node belongs to — the pane's own child that holds
173
+ * it. Two nodes in the same region move together; two in different ones are
174
+ * separate bands of the pane, each with whatever height the other leaves.
175
+ */
176
+ const paneRegionHolding = (pane: Element, node: Node): Element | null =>
177
+ [...pane.children].find((child) => child.contains(node)) ?? null;
178
+
179
+ describe("answering the message that is open", () => {
180
+ it("puts the reply in the same scrolling region as the message", async () => {
181
+ const mounted = await mount();
182
+
183
+ await pressReply(mounted);
184
+
185
+ const pane = mounted.query("article");
186
+ assert.ok(pane, "the conversation pane is mounted");
187
+
188
+ const compose = mounted.query('[data-testid="compose-body-area"]');
189
+ assert.ok(compose, "the reply opened");
190
+
191
+ const message = mounted.query('[data-testid="message-date"]');
192
+ assert.ok(message, "the message is on screen");
193
+
194
+ // Compared as a boolean: an assertion over two DOM nodes serializes the
195
+ // whole jsdom graph into its failure message.
196
+ assert.ok(
197
+ paneRegionHolding(pane, compose) === paneRegionHolding(pane, message),
198
+ "the reply scrolls with the message instead of sitting in a band below it, where a long message leaves it no height",
199
+ );
200
+ });
201
+
202
+ it("collapses the message from the chevron beside the sender", async () => {
203
+ const mounted = await mount();
204
+
205
+ assert.ok(
206
+ mounted.text().includes("ada@example.com"),
207
+ "the message opens expanded",
208
+ );
209
+
210
+ const collapse = mounted.byLabel("Collapse message");
211
+ mounted.click(collapse);
212
+ await mounted.flush();
213
+
214
+ assert.ok(
215
+ mounted.query('[aria-label="Collapse message"]') === null,
216
+ "the chevron collapsed the message it belongs to",
217
+ );
218
+ assert.ok(
219
+ mounted.query('[role="button"][aria-expanded="false"]'),
220
+ "the message is back to a collapsed row",
221
+ );
222
+ });
223
+ });