@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.16",
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 GET /threads with `starred=true`, which is served by the `byStarred`
5
- * index and returns every starred thread in the config across all non-muted
6
- * mailboxes, paged. Starredness is decided server-side from `hasStars`; the
7
- * client neither re-filters nor caps the set, so a starred thread outside the
8
- * newest inbox page still appears. Rendered as one continuous list (no category
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
- data: threadsData,
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
- } = useInfiniteQuery({
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
- }, [threadsData, selectedCategory, activeFilters, sq, queryTokens]);
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 reads the unified cross-account thread list to resolve
6
- * the open thread and owns the list / reading / phone slots. The list itself is
7
- * a FLAT inbox of starred mail (see `FlaggedList`), not the sectioned brief.
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 { data: threadsData } = useQuery({
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 threadsData?.items.find((t) => t.messageId === selectedMessageId);
79
- }, [threadsData, selectedMessageId]);
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
+ }