@remit/web-client 0.0.147 → 0.0.149
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/compose/InlineCompose.tsx +6 -1
- package/src/components/mail/ConversationView.tsx +19 -11
- package/src/components/mail/IntelligencePane.render.test.ts +1 -1
- package/src/components/mail/conversation-reply-reach.render.test.ts +223 -0
- package/src/hooks/useIntelligenceData.test.ts +40 -0
- package/src/hooks/useIntelligenceData.ts +24 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.149",
|
|
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
|
-
|
|
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
|
-
//
|
|
326
|
-
//
|
|
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
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
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
|
+
});
|
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
} from "@remit/api-http-client/types.gen.ts";
|
|
7
7
|
import {
|
|
8
8
|
buildAuthenticityIntel,
|
|
9
|
+
buildCategoryIntel,
|
|
9
10
|
buildSenderIntel,
|
|
10
11
|
} from "./useIntelligenceData.js";
|
|
11
12
|
|
|
@@ -49,6 +50,45 @@ function makeAddress(
|
|
|
49
50
|
} as RemitImapAddressResponse;
|
|
50
51
|
}
|
|
51
52
|
|
|
53
|
+
// The generated union has a member for every category this build knows, so a
|
|
54
|
+
// value outside it cannot be written as one — which is the whole point: it
|
|
55
|
+
// arrives as JSON from a server that classifies into more than this client can.
|
|
56
|
+
function threadCategorizedAs(category: string): RemitImapThreadMessageResponse {
|
|
57
|
+
return { ...makeThread(), category } as RemitImapThreadMessageResponse;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
describe("buildCategoryIntel", () => {
|
|
61
|
+
test("carries a category the client knows through unchanged", () => {
|
|
62
|
+
const result = buildCategoryIntel(
|
|
63
|
+
makeThread({ category: "newsletter" }),
|
|
64
|
+
undefined,
|
|
65
|
+
);
|
|
66
|
+
assert.equal(result.value, "newsletter");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("falls back to personal when the row carries no category", () => {
|
|
70
|
+
const result = buildCategoryIntel(makeThread(), undefined);
|
|
71
|
+
assert.equal(result.value, "personal");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("resolves a category the client does not know to uncategorized", () => {
|
|
75
|
+
const result = buildCategoryIntel(
|
|
76
|
+
threadCategorizedAs("invoice"),
|
|
77
|
+
undefined,
|
|
78
|
+
);
|
|
79
|
+
assert.equal(result.value, "uncategorized");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("marks the category overridden when the sender carries a different one", () => {
|
|
83
|
+
const thread = makeThread({ category: "personal" });
|
|
84
|
+
const address = makeAddress({
|
|
85
|
+
flags: { category: { value: "newsletter" } },
|
|
86
|
+
} as Partial<RemitImapAddressResponse>);
|
|
87
|
+
assert.equal(buildCategoryIntel(thread, address).overridden, true);
|
|
88
|
+
assert.equal(buildCategoryIntel(thread, undefined).overridden, false);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
52
92
|
describe("buildSenderIntel", () => {
|
|
53
93
|
describe("counter wiring — counters present", () => {
|
|
54
94
|
test("surfaces inboundCount from address", () => {
|
|
@@ -15,6 +15,7 @@ import type {
|
|
|
15
15
|
SenderIntel,
|
|
16
16
|
SimilarMessageIntel,
|
|
17
17
|
} from "@remit/ui";
|
|
18
|
+
import { isThreadCategory } from "@remit/ui";
|
|
18
19
|
import { useQuery } from "@tanstack/react-query";
|
|
19
20
|
import { useMemo } from "react";
|
|
20
21
|
import { isServerError } from "@/lib/error-classifier";
|
|
@@ -56,6 +57,28 @@ const NO_SIGNAL_SUMMARY =
|
|
|
56
57
|
const UNREADABLE_SENDER_SUMMARY =
|
|
57
58
|
"We couldn't read this sender's address, so we can't confirm who really sent this message.";
|
|
58
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Build category intel from the thread message and address lookup.
|
|
62
|
+
*
|
|
63
|
+
* The row's category is a string the API chose, so it is narrowed rather than
|
|
64
|
+
* asserted: a value this build has no entry for — a category a newer server
|
|
65
|
+
* classifies into — resolves to `uncategorized`, which is the taxonomy's own
|
|
66
|
+
* name for "no classification this client can act on" and renders a readable
|
|
67
|
+
* neutral chip. Asserting instead would leave the chip with no tone at all.
|
|
68
|
+
*/
|
|
69
|
+
export function buildCategoryIntel(
|
|
70
|
+
thread: RemitImapThreadMessageResponse,
|
|
71
|
+
address: RemitImapAddressResponse | undefined,
|
|
72
|
+
): IntelligenceData["category"] {
|
|
73
|
+
const served = thread.category ?? "personal";
|
|
74
|
+
return {
|
|
75
|
+
value: isThreadCategory(served) ? served : "uncategorized",
|
|
76
|
+
overridden:
|
|
77
|
+
address?.flags?.category?.value != null &&
|
|
78
|
+
address.flags.category.value !== thread.category,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
59
82
|
/**
|
|
60
83
|
* Build sender intel from the thread message and address lookup.
|
|
61
84
|
*
|
|
@@ -357,12 +380,7 @@ export function useIntelligenceData(
|
|
|
357
380
|
|
|
358
381
|
const authenticity = buildAuthenticityIntel(thread, similar.length);
|
|
359
382
|
|
|
360
|
-
const category =
|
|
361
|
-
value: thread.category ?? "personal",
|
|
362
|
-
overridden:
|
|
363
|
-
address?.flags?.category?.value != null &&
|
|
364
|
-
address.flags.category.value !== thread.category,
|
|
365
|
-
};
|
|
383
|
+
const category = buildCategoryIntel(thread, address);
|
|
366
384
|
|
|
367
385
|
const flags = buildSenderFlags(address);
|
|
368
386
|
|