@remit/web-client 0.0.12 → 0.0.14
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/auth/better-auth-config.ts +4 -1
- package/src/components/compose/ComposeForm.tsx +3 -0
- package/src/components/layout/ComposeFab.tsx +19 -34
- package/src/components/layout/MailTopBar.tsx +64 -0
- package/src/components/mail/BriefPane.tsx +1 -12
- package/src/components/mail/FlaggedPane.tsx +1 -12
- package/src/components/mail/IntelligencePane.tsx +10 -8
- package/src/components/mail/MailListHeader.tsx +9 -5
- package/src/components/mail/MailboxPane.tsx +0 -10
- package/src/components/mail/MessageActionMenu.tsx +18 -25
- package/src/components/mail/MessageDetail.tsx +1 -0
- package/src/components/mail/MessageToolbar.render.test.ts +39 -0
- package/src/components/mail/MessageToolbar.tsx +8 -48
- package/src/components/ui/ErrorBanner.tsx +6 -3
- package/src/components/ui/ErrorBannerProvider.render.test.ts +85 -0
- package/src/components/ui/ErrorBannerProvider.tsx +18 -0
- package/src/components/ui/error-banners.ts +9 -0
- package/src/hooks/useComposeTarget.ts +92 -0
- package/src/hooks/useDeleteMessages.ts +14 -21
- package/src/hooks/useIntelligenceData.ts +14 -3
- package/src/hooks/useLayoutTier.test.ts +26 -0
- package/src/hooks/useMarkAsRead.ts +14 -23
- package/src/hooks/useMessageBodyContent.ts +2 -1
- package/src/hooks/useMoveMessages.ts +14 -21
- package/src/hooks/useStaleAccountSync.test.ts +22 -1
- package/src/hooks/useToggleStar.test.ts +31 -0
- package/src/hooks/useToggleStar.ts +27 -31
- package/src/hooks/useUpdateAddressFlags.ts +3 -1
- package/src/lib/api.ts +3 -1
- package/src/lib/client.ts +3 -0
- package/src/lib/compose-routes.test.ts +44 -0
- package/src/lib/compose-routes.ts +25 -0
- package/src/lib/error-classifier.test.ts +156 -8
- package/src/lib/error-classifier.ts +49 -9
- package/src/lib/network-error.ts +58 -0
- package/src/lib/query-error-handler.test.ts +6 -2
- package/src/lib/query-escalation.integration.test.ts +3 -2
- package/src/lib/route-search-query.test.ts +48 -0
- package/src/lib/sender-address.test.ts +60 -0
- package/src/lib/sender-address.ts +37 -0
- package/src/lib/thread-cache.test.ts +65 -0
- package/src/lib/thread-cache.ts +76 -0
- package/src/routes/mail/outbox.tsx +5 -0
- package/src/routes/mail.tsx +15 -1
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import type { RemitImapAddressResponse } from "@remit/api-http-client/types.gen.ts";
|
|
4
|
+
import {
|
|
5
|
+
pickSenderAddress,
|
|
6
|
+
SENDER_ADDRESS_SEARCH_LIMIT,
|
|
7
|
+
senderAddressSearchQuery,
|
|
8
|
+
} from "./sender-address";
|
|
9
|
+
|
|
10
|
+
const address = (
|
|
11
|
+
addressId: string,
|
|
12
|
+
normalizedEmail: string,
|
|
13
|
+
): RemitImapAddressResponse =>
|
|
14
|
+
({ addressId, normalizedEmail }) as RemitImapAddressResponse;
|
|
15
|
+
|
|
16
|
+
describe("senderAddressSearchQuery", () => {
|
|
17
|
+
it("lowercases the address and asks for a window, not one row", () => {
|
|
18
|
+
assert.deepEqual(senderAddressSearchQuery("Support@NPMJS.com"), {
|
|
19
|
+
q: "support@npmjs.com",
|
|
20
|
+
limit: SENDER_ADDRESS_SEARCH_LIMIT,
|
|
21
|
+
});
|
|
22
|
+
assert.ok(SENDER_ADDRESS_SEARCH_LIMIT > 1);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("is stable for a missing sender, so the cache key stays valid", () => {
|
|
26
|
+
assert.deepEqual(senderAddressSearchQuery(undefined), {
|
|
27
|
+
q: "",
|
|
28
|
+
limit: SENDER_ADDRESS_SEARCH_LIMIT,
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("pickSenderAddress", () => {
|
|
34
|
+
it("picks the exact address, not the first prefix match", () => {
|
|
35
|
+
const items = [
|
|
36
|
+
address("other", "sup@npmjs.com"),
|
|
37
|
+
address("wanted", "support@npmjs.com"),
|
|
38
|
+
];
|
|
39
|
+
assert.equal(
|
|
40
|
+
pickSenderAddress(items, "support@npmjs.com")?.addressId,
|
|
41
|
+
"wanted",
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("matches case-insensitively", () => {
|
|
46
|
+
const items = [address("wanted", "support@npmjs.com")];
|
|
47
|
+
assert.equal(
|
|
48
|
+
pickSenderAddress(items, "Support@NPMJS.com")?.addressId,
|
|
49
|
+
"wanted",
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("resolves to undefined rather than the wrong sender", () => {
|
|
54
|
+
const items = [address("other", "sup@npmjs.com")];
|
|
55
|
+
assert.equal(pickSenderAddress(items, "support@npmjs.com"), undefined);
|
|
56
|
+
assert.equal(pickSenderAddress([], "support@npmjs.com"), undefined);
|
|
57
|
+
assert.equal(pickSenderAddress(undefined, "support@npmjs.com"), undefined);
|
|
58
|
+
assert.equal(pickSenderAddress(items, undefined), undefined);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { RemitImapAddressResponse } from "@remit/api-http-client/types.gen.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `GET /addresses/search` is a prefix search over both the display-name
|
|
5
|
+
* compound and the normalized email, so a query for one sender's address can
|
|
6
|
+
* legitimately return several rows (`sup@x.com` also prefixes `support@x.com`,
|
|
7
|
+
* and any display name starting with the same characters matches too).
|
|
8
|
+
*
|
|
9
|
+
* Asking for a single row and taking `items[0]` is therefore wrong twice over:
|
|
10
|
+
* the row it returns may belong to a different sender, and the row we actually
|
|
11
|
+
* want may be past the cut. Fetch a small window instead and pick the exact
|
|
12
|
+
* address out of it.
|
|
13
|
+
*/
|
|
14
|
+
export const SENDER_ADDRESS_SEARCH_LIMIT = 10;
|
|
15
|
+
|
|
16
|
+
/** Query params for the sender-address lookup. One shape, used by every caller. */
|
|
17
|
+
export const senderAddressSearchQuery = (
|
|
18
|
+
senderEmail: string | undefined,
|
|
19
|
+
): { q: string; limit: number } => ({
|
|
20
|
+
q: senderEmail?.toLowerCase() ?? "",
|
|
21
|
+
limit: SENDER_ADDRESS_SEARCH_LIMIT,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Select the address row for exactly this sender. A prefix match on another
|
|
26
|
+
* sender is not this sender, so it resolves to `undefined` rather than the
|
|
27
|
+
* wrong address — silently flagging the wrong sender is worse than not
|
|
28
|
+
* resolving.
|
|
29
|
+
*/
|
|
30
|
+
export const pickSenderAddress = (
|
|
31
|
+
items: RemitImapAddressResponse[] | undefined,
|
|
32
|
+
senderEmail: string | undefined,
|
|
33
|
+
): RemitImapAddressResponse | undefined => {
|
|
34
|
+
if (!senderEmail) return undefined;
|
|
35
|
+
const normalized = senderEmail.toLowerCase();
|
|
36
|
+
return items?.find((item) => item.normalizedEmail === normalized);
|
|
37
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
4
|
+
import {
|
|
5
|
+
isInfiniteThreadData,
|
|
6
|
+
patchThreadListCache,
|
|
7
|
+
type ThreadListCache,
|
|
8
|
+
} from "./thread-cache";
|
|
9
|
+
|
|
10
|
+
const message = (messageId: string): RemitImapThreadMessageResponse =>
|
|
11
|
+
({ messageId }) as RemitImapThreadMessageResponse;
|
|
12
|
+
|
|
13
|
+
const drop = (id: string) => (items: RemitImapThreadMessageResponse[]) =>
|
|
14
|
+
items.filter((item) => item.messageId !== id);
|
|
15
|
+
|
|
16
|
+
describe("isInfiniteThreadData", () => {
|
|
17
|
+
it("recognises only the paged shape", () => {
|
|
18
|
+
assert.equal(isInfiniteThreadData({ pages: [], pageParams: [] }), true);
|
|
19
|
+
assert.equal(isInfiniteThreadData({ items: [] }), false);
|
|
20
|
+
assert.equal(isInfiniteThreadData(undefined), false);
|
|
21
|
+
assert.equal(isInfiniteThreadData(null), false);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("patchThreadListCache", () => {
|
|
26
|
+
it("patches every page of an infinite query", () => {
|
|
27
|
+
const cache: ThreadListCache = {
|
|
28
|
+
pages: [
|
|
29
|
+
{ items: [message("a"), message("b")] },
|
|
30
|
+
{ items: [message("c")] },
|
|
31
|
+
],
|
|
32
|
+
pageParams: [undefined, "next"],
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const patched = patchThreadListCache(cache, drop("b"));
|
|
36
|
+
|
|
37
|
+
assert.deepEqual(patched, {
|
|
38
|
+
pages: [{ items: [message("a")] }, { items: [message("c")] }],
|
|
39
|
+
pageParams: [undefined, "next"],
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("patches a single-shot page, which shares the query-key prefix", () => {
|
|
44
|
+
// The rescue-candidate search caches this shape under the same prefix the
|
|
45
|
+
// mailbox list uses. An updater that assumed `pages` threw here, failing
|
|
46
|
+
// the whole mutation before it ever reached the server (issues #51, #55).
|
|
47
|
+
const cache: ThreadListCache = {
|
|
48
|
+
items: [message("a"), message("b")],
|
|
49
|
+
continuationToken: "t",
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const patched = patchThreadListCache(cache, drop("a"));
|
|
53
|
+
|
|
54
|
+
assert.deepEqual(patched, {
|
|
55
|
+
items: [message("b")],
|
|
56
|
+
continuationToken: "t",
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("leaves an unrecognised entry alone instead of throwing", () => {
|
|
61
|
+
const cache = { total: 3 } as unknown as ThreadListCache;
|
|
62
|
+
assert.equal(patchThreadListCache(cache, drop("a")), cache);
|
|
63
|
+
assert.equal(patchThreadListCache(undefined, drop("a")), undefined);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Optimistic patching of the thread caches.
|
|
5
|
+
*
|
|
6
|
+
* `queryClient.setQueriesData({ queryKey: prefix }, …)` matches by key *prefix*,
|
|
7
|
+
* so an updater written for the mailbox list also runs against every other
|
|
8
|
+
* cached query sharing that prefix. Those do not all have the same shape: the
|
|
9
|
+
* mailbox list is an infinite query (`{ pages: [{ items }] }`) while
|
|
10
|
+
* single-shot readers of the same endpoint — the rescue-candidate search, for
|
|
11
|
+
* one — cache a plain page (`{ items }`). An updater that assumed the infinite
|
|
12
|
+
* shape threw `pages is undefined` inside `onMutate`, which React Query reports
|
|
13
|
+
* as a failed mutation: the move never fired and the user got an error toast
|
|
14
|
+
* for an action that was never attempted (issues #51, #55).
|
|
15
|
+
*
|
|
16
|
+
* `patchThreadListCache` handles both shapes and leaves anything else it does
|
|
17
|
+
* not recognise untouched.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export interface ThreadItemsPage {
|
|
21
|
+
items: RemitImapThreadMessageResponse[];
|
|
22
|
+
[key: string]: unknown;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface InfiniteThreadData {
|
|
26
|
+
pages: ThreadItemsPage[];
|
|
27
|
+
pageParams: Array<string | undefined>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Either cached shape served by the thread list/search endpoints. */
|
|
31
|
+
export type ThreadListCache = InfiniteThreadData | ThreadItemsPage;
|
|
32
|
+
|
|
33
|
+
export const isInfiniteThreadData = (
|
|
34
|
+
data: unknown,
|
|
35
|
+
): data is InfiniteThreadData =>
|
|
36
|
+
typeof data === "object" &&
|
|
37
|
+
data !== null &&
|
|
38
|
+
Array.isArray((data as { pages?: unknown }).pages);
|
|
39
|
+
|
|
40
|
+
const isThreadItemsPage = (data: unknown): data is ThreadItemsPage =>
|
|
41
|
+
typeof data === "object" &&
|
|
42
|
+
data !== null &&
|
|
43
|
+
Array.isArray((data as { items?: unknown }).items);
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Apply `patchItems` to every list of thread messages inside a cache entry,
|
|
47
|
+
* whichever shape it holds.
|
|
48
|
+
*
|
|
49
|
+
* `old` is `unknown` because that is the truth: a prefix match hands this
|
|
50
|
+
* whatever happens to be cached under that prefix, and the two shapes below are
|
|
51
|
+
* the ones we know about today, not the ones we are guaranteed. Declaring a
|
|
52
|
+
* narrower parameter type is what produced the original bug — the type asserted
|
|
53
|
+
* a shape the runtime never promised, and the first entry that disagreed threw.
|
|
54
|
+
* Anything unrecognised is returned untouched.
|
|
55
|
+
*/
|
|
56
|
+
export const patchThreadListCache = (
|
|
57
|
+
old: unknown,
|
|
58
|
+
patchItems: (
|
|
59
|
+
items: RemitImapThreadMessageResponse[],
|
|
60
|
+
) => RemitImapThreadMessageResponse[],
|
|
61
|
+
): unknown => {
|
|
62
|
+
if (old === undefined) return old;
|
|
63
|
+
if (isInfiniteThreadData(old)) {
|
|
64
|
+
return {
|
|
65
|
+
...old,
|
|
66
|
+
pages: old.pages.map((page) => ({
|
|
67
|
+
...page,
|
|
68
|
+
items: patchItems(page.items),
|
|
69
|
+
})),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
if (isThreadItemsPage(old)) {
|
|
73
|
+
return { ...old, items: patchItems(old.items) };
|
|
74
|
+
}
|
|
75
|
+
return old;
|
|
76
|
+
};
|
|
@@ -3,8 +3,13 @@ import { z } from "zod";
|
|
|
3
3
|
|
|
4
4
|
// Search schema: `selectedOutboxMessageId` is read by `OutboxPane` in the
|
|
5
5
|
// parent `/mail` shell via `useSearch({ strict: false })`.
|
|
6
|
+
//
|
|
7
|
+
// `q` is inherited from the parent /mail route; re-declared here so it survives
|
|
8
|
+
// this route's own search validation and isn't dropped when navigating with a
|
|
9
|
+
// functional search updater.
|
|
6
10
|
const outboxSearchSchema = z.object({
|
|
7
11
|
selectedOutboxMessageId: z.string().optional(),
|
|
12
|
+
q: z.string().optional(),
|
|
8
13
|
});
|
|
9
14
|
|
|
10
15
|
export const Route = createFileRoute("/mail/outbox")({
|
package/src/routes/mail.tsx
CHANGED
|
@@ -15,6 +15,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
|
15
15
|
import { z } from "zod";
|
|
16
16
|
import { AppShellSkeleton } from "@/components/layout/AppShellSkeleton";
|
|
17
17
|
import { ComposeFab } from "@/components/layout/ComposeFab";
|
|
18
|
+
import { MailTopBar } from "@/components/layout/MailTopBar";
|
|
18
19
|
import { BriefPane } from "@/components/mail/BriefPane";
|
|
19
20
|
import { FlaggedPane } from "@/components/mail/FlaggedPane";
|
|
20
21
|
import { MailboxPane } from "@/components/mail/MailboxPane";
|
|
@@ -268,7 +269,16 @@ function MailLayout() {
|
|
|
268
269
|
const navContent = (
|
|
269
270
|
<MailNav accounts={accounts} onMailboxSelect={handleMailboxSelect} />
|
|
270
271
|
);
|
|
271
|
-
|
|
272
|
+
// Single-pane only, where the FAB is the compose entry point. Above it the
|
|
273
|
+
// top bar owns compose, and mounting the FAB there would resolve the same
|
|
274
|
+
// compose target twice for a button CSS keeps hidden.
|
|
275
|
+
const overlayContent = isSinglePane ? (
|
|
276
|
+
<ComposeFab accounts={accounts} />
|
|
277
|
+
) : undefined;
|
|
278
|
+
// Desktop only. Below 1024px the single pane keeps its own header search and
|
|
279
|
+
// the phone takeover; there is no room for a bar spanning panes that do not
|
|
280
|
+
// exist side by side.
|
|
281
|
+
const topBar = isSinglePane ? undefined : <MailTopBar accounts={accounts} />;
|
|
272
282
|
const navSlideOver = {
|
|
273
283
|
navOpen: drawerOpen,
|
|
274
284
|
onOpenNav: () => setDrawerOpen(true),
|
|
@@ -303,6 +313,7 @@ function MailLayout() {
|
|
|
303
313
|
) : (
|
|
304
314
|
<AppShellSlotted
|
|
305
315
|
nav={navContent}
|
|
316
|
+
topBar={topBar}
|
|
306
317
|
list={<BriefPane.List />}
|
|
307
318
|
reading={<BriefPane.Reading />}
|
|
308
319
|
intelligenceOpen={intelligenceOpen}
|
|
@@ -330,6 +341,7 @@ function MailLayout() {
|
|
|
330
341
|
) : (
|
|
331
342
|
<AppShellSlotted
|
|
332
343
|
nav={navContent}
|
|
344
|
+
topBar={topBar}
|
|
333
345
|
list={<FlaggedPane.List />}
|
|
334
346
|
reading={<FlaggedPane.Reading />}
|
|
335
347
|
intelligenceOpen={intelligenceOpen}
|
|
@@ -359,6 +371,7 @@ function MailLayout() {
|
|
|
359
371
|
) : (
|
|
360
372
|
<AppShellSlotted
|
|
361
373
|
nav={navContent}
|
|
374
|
+
topBar={topBar}
|
|
362
375
|
list={<MailboxPane.List />}
|
|
363
376
|
reading={<MailboxPane.Reading />}
|
|
364
377
|
intelligence={<MailboxPane.Intelligence />}
|
|
@@ -386,6 +399,7 @@ function MailLayout() {
|
|
|
386
399
|
) : (
|
|
387
400
|
<AppShellSlotted
|
|
388
401
|
nav={navContent}
|
|
402
|
+
topBar={topBar}
|
|
389
403
|
list={<OutboxPane.List />}
|
|
390
404
|
reading={<OutboxPane.Reading />}
|
|
391
405
|
intelligenceOpen={false}
|