@remit/web-client 0.0.16 → 0.0.17
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.
|
|
3
|
+
"version": "0.0.17",
|
|
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": {
|
|
@@ -1,28 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* FlaggedList — a FLAT, cross-account inbox of starred mail.
|
|
3
3
|
*
|
|
4
|
-
* Reads
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* Reads the starred listing through `useStarredThreads` — GET /threads with
|
|
5
|
+
* `starred=true`, served by the `byStarred` index — which returns every starred
|
|
6
|
+
* thread in the config across all non-muted mailboxes, paged. `FlaggedPane`
|
|
7
|
+
* resolves the open thread from that same hook, so every row rendered here can
|
|
8
|
+
* be opened. Starredness is decided server-side from `hasStars`; the client
|
|
9
|
+
* neither re-filters nor caps the set, so a starred thread outside the newest
|
|
10
|
+
* inbox page still appears. Rendered as one continuous list (no category
|
|
9
11
|
* sections). The shared `MailViewChrome` owns the `MailHeader` + filter
|
|
10
12
|
* expando; the kit `MessageListPane` (flat, no `briefFilters`) owns the loading
|
|
11
13
|
* / empty / error chrome and keyboard hints, with a consumer-supplied
|
|
12
14
|
* `listBody` so the real rows render at every width.
|
|
13
15
|
*/
|
|
14
|
-
import { unifiedThreadOperationsListAllThreadsQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
15
|
-
import { unifiedThreadOperationsListAllThreads } from "@remit/api-http-client/sdk.gen.ts";
|
|
16
16
|
import {
|
|
17
17
|
ComfortableRow,
|
|
18
18
|
flaggedFilterConfig,
|
|
19
19
|
MessageListPane,
|
|
20
20
|
type ThreadRowData,
|
|
21
21
|
} from "@remit/ui";
|
|
22
|
-
import { useInfiniteQuery } from "@tanstack/react-query";
|
|
23
22
|
import { useCallback, useMemo, useState } from "react";
|
|
24
23
|
import { formatErrorMessage } from "@/components/ui/ErrorState";
|
|
25
24
|
import { useIsDesktop } from "@/hooks/useMediaQuery";
|
|
25
|
+
import { useStarredThreads } from "@/hooks/useStarredThreads";
|
|
26
26
|
import {
|
|
27
27
|
matchesBriefSearch,
|
|
28
28
|
matchesSearchTokens,
|
|
@@ -72,7 +72,7 @@ export function FlaggedList({
|
|
|
72
72
|
}, []);
|
|
73
73
|
|
|
74
74
|
const {
|
|
75
|
-
|
|
75
|
+
threads,
|
|
76
76
|
isLoading,
|
|
77
77
|
isError,
|
|
78
78
|
error,
|
|
@@ -80,25 +80,7 @@ export function FlaggedList({
|
|
|
80
80
|
fetchNextPage,
|
|
81
81
|
hasNextPage,
|
|
82
82
|
isFetchingNextPage,
|
|
83
|
-
} =
|
|
84
|
-
queryKey: unifiedThreadOperationsListAllThreadsQueryKey({
|
|
85
|
-
query: { starred: true, order: "desc" },
|
|
86
|
-
}),
|
|
87
|
-
queryFn: async ({ pageParam }) => {
|
|
88
|
-
const { data } = await unifiedThreadOperationsListAllThreads({
|
|
89
|
-
query: {
|
|
90
|
-
starred: true,
|
|
91
|
-
order: "desc",
|
|
92
|
-
continuationToken: pageParam,
|
|
93
|
-
},
|
|
94
|
-
throwOnError: true,
|
|
95
|
-
});
|
|
96
|
-
return data;
|
|
97
|
-
},
|
|
98
|
-
initialPageParam: undefined as string | undefined,
|
|
99
|
-
getNextPageParam: (lastPage) => lastPage.continuationToken,
|
|
100
|
-
staleTime: 60_000,
|
|
101
|
-
});
|
|
83
|
+
} = useStarredThreads();
|
|
102
84
|
|
|
103
85
|
const { freeText: sq, tokens: queryTokens } = parseSearchTokens(
|
|
104
86
|
searchQuery.trim().toLowerCase(),
|
|
@@ -109,9 +91,7 @@ export function FlaggedList({
|
|
|
109
91
|
const predicates = Array.from(activeFilters)
|
|
110
92
|
.map((id) => FILTER_PREDICATES[id])
|
|
111
93
|
.filter((p): p is (t: ThreadRowData) => boolean => p != null);
|
|
112
|
-
return dedupeByThread(
|
|
113
|
-
(threadsData?.pages ?? []).flatMap((page) => page.items ?? []),
|
|
114
|
-
)
|
|
94
|
+
return dedupeByThread(threads)
|
|
115
95
|
.map(toThreadRowData)
|
|
116
96
|
.filter(
|
|
117
97
|
(t) =>
|
|
@@ -120,7 +100,7 @@ export function FlaggedList({
|
|
|
120
100
|
(!sq || matchesBriefSearch(t, sq)) &&
|
|
121
101
|
matchesSearchTokens(t, queryTokens),
|
|
122
102
|
);
|
|
123
|
-
}, [
|
|
103
|
+
}, [threads, selectedCategory, activeFilters, sq, queryTokens]);
|
|
124
104
|
|
|
125
105
|
const preset = useMemo(() => flaggedFilterConfig(), []);
|
|
126
106
|
|
|
@@ -2,9 +2,14 @@
|
|
|
2
2
|
* FlaggedPane — compound component for the Flagged virtual mailbox
|
|
3
3
|
* (/mail/flagged route).
|
|
4
4
|
*
|
|
5
|
-
* Mirrors BriefPane: it
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* Mirrors BriefPane in shape: it resolves the open thread and owns the list /
|
|
6
|
+
* reading / phone slots. The list itself is a FLAT inbox of starred mail (see
|
|
7
|
+
* `FlaggedList`), not the sectioned brief.
|
|
8
|
+
*
|
|
9
|
+
* The selection resolves from the starred listing, the same query that produced
|
|
10
|
+
* the rows. The unified listing is INBOX-scoped, so resolving against it left
|
|
11
|
+
* every starred thread filed elsewhere — Sent, an archive folder, anything past
|
|
12
|
+
* the inbox window — visible in the list but impossible to open (issue #70).
|
|
8
13
|
*
|
|
9
14
|
* <FlaggedPane selectedMessageId={...}>
|
|
10
15
|
* <AppShellSlotted
|
|
@@ -15,10 +20,8 @@
|
|
|
15
20
|
*
|
|
16
21
|
* On phone, use `<FlaggedPane.Phone />` instead.
|
|
17
22
|
*/
|
|
18
|
-
import { unifiedThreadOperationsListAllThreadsOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
19
23
|
import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
20
24
|
import { ReadingPaneEmpty, useAppShellLayout } from "@remit/ui";
|
|
21
|
-
import { useQuery } from "@tanstack/react-query";
|
|
22
25
|
import { useNavigate } from "@tanstack/react-router";
|
|
23
26
|
import {
|
|
24
27
|
createContext,
|
|
@@ -32,6 +35,7 @@ import { ConversationView } from "@/components/mail/ConversationView";
|
|
|
32
35
|
import { FlaggedList } from "@/components/mail/FlaggedList";
|
|
33
36
|
import { IntelligencePane } from "@/components/mail/IntelligencePane";
|
|
34
37
|
import { MessageToolbar } from "@/components/mail/MessageToolbar";
|
|
38
|
+
import { useStarredThreads } from "@/hooks/useStarredThreads";
|
|
35
39
|
import { useMailContext } from "@/lib/mail-context";
|
|
36
40
|
|
|
37
41
|
/* ------------------------------------------------------------------ */
|
|
@@ -68,15 +72,12 @@ function FlaggedPaneProvider({
|
|
|
68
72
|
}: FlaggedPaneProps) {
|
|
69
73
|
const navigate = useNavigate();
|
|
70
74
|
|
|
71
|
-
const {
|
|
72
|
-
...unifiedThreadOperationsListAllThreadsOptions(),
|
|
73
|
-
staleTime: 60_000,
|
|
74
|
-
});
|
|
75
|
+
const { threads } = useStarredThreads();
|
|
75
76
|
|
|
76
77
|
const selectedThread = useMemo(() => {
|
|
77
78
|
if (!selectedMessageId) return undefined;
|
|
78
|
-
return
|
|
79
|
-
}, [
|
|
79
|
+
return threads.find((t) => t.messageId === selectedMessageId);
|
|
80
|
+
}, [threads, selectedMessageId]);
|
|
80
81
|
|
|
81
82
|
const handleSelectMessage = useCallback(
|
|
82
83
|
(id: string) => {
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useStarredThreads — regression cover for issue #70.
|
|
3
|
+
*
|
|
4
|
+
* The Starred pane rendered its rows from `GET /threads?starred=true` but
|
|
5
|
+
* resolved the open thread from the unfiltered (INBOX-scoped) listing. Rows for
|
|
6
|
+
* starred mail filed outside INBOX therefore had nothing to resolve to and
|
|
7
|
+
* opened no reading pane.
|
|
8
|
+
*
|
|
9
|
+
* Strategy follows useRescueCandidates.test.ts: pre-seed a QueryClient under the
|
|
10
|
+
* key the hook generates and render it synchronously, so the queryFn never
|
|
11
|
+
* fires. The last two cases are the ones that fail if the wrong listing comes
|
|
12
|
+
* back — the unfiltered key is seeded alongside and must not be read.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import assert from "node:assert/strict";
|
|
16
|
+
import { describe, test } from "node:test";
|
|
17
|
+
import { unifiedThreadOperationsListAllThreadsQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
18
|
+
import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
19
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
20
|
+
import { createElement } from "react";
|
|
21
|
+
import { renderToString } from "react-dom/server";
|
|
22
|
+
import {
|
|
23
|
+
starredThreadsQueryKey,
|
|
24
|
+
useStarredThreads,
|
|
25
|
+
} from "./useStarredThreads.js";
|
|
26
|
+
|
|
27
|
+
interface Page {
|
|
28
|
+
items: RemitImapThreadMessageResponse[];
|
|
29
|
+
continuationToken?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function makeThread(
|
|
33
|
+
messageId: string,
|
|
34
|
+
overrides: Partial<RemitImapThreadMessageResponse> = {},
|
|
35
|
+
): RemitImapThreadMessageResponse {
|
|
36
|
+
return {
|
|
37
|
+
messageId,
|
|
38
|
+
threadMessageId: `tm-${messageId}`,
|
|
39
|
+
threadId: `t-${messageId}`,
|
|
40
|
+
accountConfigId: "acc",
|
|
41
|
+
mailboxId: "mb-inbox",
|
|
42
|
+
sentDate: 1_000_000,
|
|
43
|
+
isRead: false,
|
|
44
|
+
hasAttachment: false,
|
|
45
|
+
hasStars: true,
|
|
46
|
+
isDeleted: false,
|
|
47
|
+
createdAt: 1_000_000,
|
|
48
|
+
updatedAt: 1_000_000,
|
|
49
|
+
fromName: "Anna de Vries",
|
|
50
|
+
fromEmail: "anna@example.nl",
|
|
51
|
+
subject: `subject ${messageId}`,
|
|
52
|
+
snippet: "…",
|
|
53
|
+
senderTrust: "vip",
|
|
54
|
+
...overrides,
|
|
55
|
+
} as RemitImapThreadMessageResponse;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Renders the hook against a cache holding `starredPages` under the starred key
|
|
60
|
+
* and `unfilteredItems` under the unfiltered one, so a hook reading the wrong
|
|
61
|
+
* listing returns the wrong threads instead of returning nothing.
|
|
62
|
+
*/
|
|
63
|
+
function renderHook(
|
|
64
|
+
starredPages: Page[],
|
|
65
|
+
unfilteredItems: RemitImapThreadMessageResponse[] = [],
|
|
66
|
+
): RemitImapThreadMessageResponse[] {
|
|
67
|
+
const client = new QueryClient({
|
|
68
|
+
defaultOptions: { queries: { retry: false } },
|
|
69
|
+
});
|
|
70
|
+
client.setQueryData(starredThreadsQueryKey(), {
|
|
71
|
+
pages: starredPages,
|
|
72
|
+
pageParams: starredPages.map((_, index) =>
|
|
73
|
+
index === 0 ? undefined : `page-${index}`,
|
|
74
|
+
),
|
|
75
|
+
});
|
|
76
|
+
client.setQueryData(unifiedThreadOperationsListAllThreadsQueryKey(), {
|
|
77
|
+
items: unfilteredItems,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
let captured: RemitImapThreadMessageResponse[] = [];
|
|
81
|
+
|
|
82
|
+
function Capture() {
|
|
83
|
+
captured = useStarredThreads().threads;
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
renderToString(
|
|
88
|
+
createElement(
|
|
89
|
+
QueryClientProvider,
|
|
90
|
+
{ client },
|
|
91
|
+
createElement(Capture),
|
|
92
|
+
) as never,
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
return captured;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
describe("starredThreadsQueryKey", () => {
|
|
99
|
+
test("asks for the starred listing, newest first", () => {
|
|
100
|
+
const [params] = starredThreadsQueryKey() as [
|
|
101
|
+
{ query?: { starred?: boolean; order?: string } },
|
|
102
|
+
];
|
|
103
|
+
assert.equal(params.query?.starred, true);
|
|
104
|
+
assert.equal(params.query?.order, "desc");
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe("useStarredThreads", () => {
|
|
109
|
+
test("returns no threads when the listing is empty", () => {
|
|
110
|
+
assert.deepEqual(renderHook([{ items: [] }]), []);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("flattens every loaded page, in page order", () => {
|
|
114
|
+
const threads = renderHook([
|
|
115
|
+
{ items: [makeThread("m1"), makeThread("m2")], continuationToken: "p2" },
|
|
116
|
+
{ items: [makeThread("m3")] },
|
|
117
|
+
]);
|
|
118
|
+
assert.deepEqual(
|
|
119
|
+
threads.map((thread) => thread.messageId),
|
|
120
|
+
["m1", "m2", "m3"],
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("returns a starred thread that lives outside INBOX", () => {
|
|
125
|
+
// The case issue #70 could not open: starred, filed in Sent, and absent
|
|
126
|
+
// from the unfiltered listing the pane used to resolve against.
|
|
127
|
+
const sent = makeThread("m-sent", { mailboxId: "mb-sent" });
|
|
128
|
+
const threads = renderHook([{ items: [sent] }], [makeThread("m-inbox")]);
|
|
129
|
+
assert.deepEqual(
|
|
130
|
+
threads.map((thread) => thread.messageId),
|
|
131
|
+
["m-sent"],
|
|
132
|
+
);
|
|
133
|
+
assert.equal(
|
|
134
|
+
threads.find((thread) => thread.messageId === "m-sent")?.mailboxId,
|
|
135
|
+
"mb-sent",
|
|
136
|
+
);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("does not read the unfiltered INBOX listing", () => {
|
|
140
|
+
const threads = renderHook([{ items: [] }], [makeThread("m-inbox")]);
|
|
141
|
+
assert.deepEqual(threads, []);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The starred listing — `GET /threads?starred=true`, served by the `byStarred`
|
|
3
|
+
* index — as one hook, so the Starred pane's list and its selection resolve
|
|
4
|
+
* from the same query.
|
|
5
|
+
*
|
|
6
|
+
* The two are the same cache entry, not two requests: they share a query key,
|
|
7
|
+
* so `threads` here is exactly the set of rows the list rendered. Resolving a
|
|
8
|
+
* selection from any other listing reintroduces issue #70 — the INBOX-scoped
|
|
9
|
+
* unified listing cannot see a starred thread filed elsewhere, so its row is
|
|
10
|
+
* clickable but resolves to nothing and no reading pane opens.
|
|
11
|
+
*/
|
|
12
|
+
import { unifiedThreadOperationsListAllThreadsQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
13
|
+
import { unifiedThreadOperationsListAllThreads } from "@remit/api-http-client/sdk.gen.ts";
|
|
14
|
+
import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
15
|
+
import { useInfiniteQuery } from "@tanstack/react-query";
|
|
16
|
+
import { useMemo } from "react";
|
|
17
|
+
|
|
18
|
+
export const starredThreadsQueryKey = () =>
|
|
19
|
+
unifiedThreadOperationsListAllThreadsQueryKey({
|
|
20
|
+
query: { starred: true, order: "desc" },
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
interface StarredThreads {
|
|
24
|
+
/** Every starred thread across the pages loaded so far, newest first. */
|
|
25
|
+
threads: RemitImapThreadMessageResponse[];
|
|
26
|
+
isLoading: boolean;
|
|
27
|
+
isError: boolean;
|
|
28
|
+
error: unknown;
|
|
29
|
+
refetch: () => void;
|
|
30
|
+
fetchNextPage: () => void;
|
|
31
|
+
hasNextPage: boolean;
|
|
32
|
+
isFetchingNextPage: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function useStarredThreads(): StarredThreads {
|
|
36
|
+
const {
|
|
37
|
+
data,
|
|
38
|
+
isLoading,
|
|
39
|
+
isError,
|
|
40
|
+
error,
|
|
41
|
+
refetch,
|
|
42
|
+
fetchNextPage,
|
|
43
|
+
hasNextPage,
|
|
44
|
+
isFetchingNextPage,
|
|
45
|
+
} = useInfiniteQuery({
|
|
46
|
+
queryKey: starredThreadsQueryKey(),
|
|
47
|
+
queryFn: async ({ pageParam }) => {
|
|
48
|
+
const { data: page } = await unifiedThreadOperationsListAllThreads({
|
|
49
|
+
query: {
|
|
50
|
+
starred: true,
|
|
51
|
+
order: "desc",
|
|
52
|
+
continuationToken: pageParam,
|
|
53
|
+
},
|
|
54
|
+
throwOnError: true,
|
|
55
|
+
});
|
|
56
|
+
return page;
|
|
57
|
+
},
|
|
58
|
+
initialPageParam: undefined as string | undefined,
|
|
59
|
+
getNextPageParam: (lastPage) => lastPage.continuationToken,
|
|
60
|
+
staleTime: 60_000,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const threads = useMemo(
|
|
64
|
+
() => (data?.pages ?? []).flatMap((page) => page.items ?? []),
|
|
65
|
+
[data],
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
threads,
|
|
70
|
+
isLoading,
|
|
71
|
+
isError,
|
|
72
|
+
error,
|
|
73
|
+
refetch,
|
|
74
|
+
fetchNextPage,
|
|
75
|
+
hasNextPage,
|
|
76
|
+
isFetchingNextPage,
|
|
77
|
+
};
|
|
78
|
+
}
|