@remit/web-client 0.0.15 → 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.15",
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": {
@@ -14,7 +14,11 @@
14
14
  */
15
15
  import { unifiedThreadOperationsListAllThreadsOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
16
16
  import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
17
- import { ReadingPaneEmpty, type SearchResult } from "@remit/ui";
17
+ import {
18
+ ReadingPaneEmpty,
19
+ type SearchResult,
20
+ useAppShellLayout,
21
+ } from "@remit/ui";
18
22
  import { useQuery } from "@tanstack/react-query";
19
23
  import { useNavigate, useSearch } from "@tanstack/react-router";
20
24
  import {
@@ -184,14 +188,20 @@ function BriefList() {
184
188
  */
185
189
  function BriefReading() {
186
190
  const { conversation } = useBriefPane();
187
- const { onToggleIntelligence } = useMailContext();
191
+ const { intelligenceOpen, onToggleIntelligence } = useMailContext();
192
+ // The rail's own width gate, not the shell tier: between 1024 and 1280 the
193
+ // reading pane is mounted but the rail is not, so "enabled" would promise an
194
+ // open that cannot happen.
195
+ const railFits = useAppShellLayout()?.showIntelligencePane ?? false;
196
+ const hasThread = Boolean(conversation);
197
+ const canToggleIntelligence = railFits && hasThread;
188
198
 
189
199
  return (
190
200
  <section className="flex h-full w-full min-w-0 flex-col bg-canvas">
191
201
  <MessageToolbar
192
- hasThread={Boolean(conversation)}
193
- intelligenceOpen={false}
194
- showIntelligenceToggle={false}
202
+ hasThread={hasThread}
203
+ intelligenceOpen={canToggleIntelligence && intelligenceOpen}
204
+ canToggleIntelligence={canToggleIntelligence}
195
205
  onToggleIntelligence={onToggleIntelligence}
196
206
  />
197
207
  <div className="min-h-0 flex-1 overflow-hidden">
@@ -211,6 +221,24 @@ function BriefReading() {
211
221
  );
212
222
  }
213
223
 
224
+ /**
225
+ * Intelligence pane: IntelligencePane for the thread open in the brief.
226
+ * Mount in the `intelligence` slot of `AppShellSlotted`. Only rendered ≥ 1280px.
227
+ */
228
+ function BriefIntelligence() {
229
+ const { selectedThread } = useBriefPane();
230
+ const { onToggleIntelligence } = useMailContext();
231
+
232
+ return (
233
+ <IntelligencePane
234
+ onClose={onToggleIntelligence}
235
+ thread={selectedThread}
236
+ mailboxId={selectedThread?.mailboxId}
237
+ accountId={selectedThread?.accountId}
238
+ />
239
+ );
240
+ }
241
+
214
242
  /**
215
243
  * Phone view: ConversationView when thread is open, or the DailyBrief list.
216
244
  */
@@ -272,6 +300,7 @@ function BriefPhone() {
272
300
  const BriefPane = Object.assign(BriefPaneProvider, {
273
301
  List: BriefList,
274
302
  Reading: BriefReading,
303
+ Intelligence: BriefIntelligence,
275
304
  Phone: BriefPhone,
276
305
  });
277
306
 
@@ -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
- import { ReadingPaneEmpty } from "@remit/ui";
21
- import { useQuery } from "@tanstack/react-query";
24
+ import { ReadingPaneEmpty, useAppShellLayout } from "@remit/ui";
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) => {
@@ -128,14 +129,20 @@ function FlaggedListSlot() {
128
129
  */
129
130
  function FlaggedReading() {
130
131
  const { selectedThread } = useFlaggedPane();
131
- const { onToggleIntelligence } = useMailContext();
132
+ const { intelligenceOpen, onToggleIntelligence } = useMailContext();
133
+ // The rail's own width gate, not the shell tier: between 1024 and 1280 the
134
+ // reading pane is mounted but the rail is not, so "enabled" would promise an
135
+ // open that cannot happen.
136
+ const railFits = useAppShellLayout()?.showIntelligencePane ?? false;
137
+ const hasThread = Boolean(selectedThread);
138
+ const canToggleIntelligence = railFits && hasThread;
132
139
 
133
140
  return (
134
141
  <section className="flex h-full w-full min-w-0 flex-col bg-canvas">
135
142
  <MessageToolbar
136
- hasThread={Boolean(selectedThread)}
137
- intelligenceOpen={false}
138
- showIntelligenceToggle={false}
143
+ hasThread={hasThread}
144
+ intelligenceOpen={canToggleIntelligence && intelligenceOpen}
145
+ canToggleIntelligence={canToggleIntelligence}
139
146
  onToggleIntelligence={onToggleIntelligence}
140
147
  />
141
148
  <div className="min-h-0 flex-1 overflow-hidden">
@@ -154,6 +161,24 @@ function FlaggedReading() {
154
161
  );
155
162
  }
156
163
 
164
+ /**
165
+ * Intelligence pane: IntelligencePane for the open thread.
166
+ * Mount in the `intelligence` slot of `AppShellSlotted`. Only rendered ≥ 1280px.
167
+ */
168
+ function FlaggedIntelligence() {
169
+ const { selectedThread } = useFlaggedPane();
170
+ const { onToggleIntelligence } = useMailContext();
171
+
172
+ return (
173
+ <IntelligencePane
174
+ onClose={onToggleIntelligence}
175
+ thread={selectedThread}
176
+ mailboxId={selectedThread?.mailboxId}
177
+ accountId={selectedThread?.accountId}
178
+ />
179
+ );
180
+ }
181
+
157
182
  /** Phone view: ConversationView when a thread is open, else the flat list. */
158
183
  function FlaggedPhone() {
159
184
  const { selectedThread, onCloseThread } = useFlaggedPane();
@@ -200,6 +225,7 @@ function FlaggedPhone() {
200
225
  const FlaggedPane = Object.assign(FlaggedPaneProvider, {
201
226
  List: FlaggedListSlot,
202
227
  Reading: FlaggedReading,
228
+ Intelligence: FlaggedIntelligence,
203
229
  Phone: FlaggedPhone,
204
230
  });
205
231
 
@@ -32,6 +32,7 @@ import {
32
32
  ReadingPaneEmpty,
33
33
  type RescueCandidate,
34
34
  type SearchResult,
35
+ useAppShellLayout,
35
36
  } from "@remit/ui";
36
37
  import {
37
38
  keepPreviousData,
@@ -179,7 +180,6 @@ interface MailboxPaneContextValue {
179
180
  onSelectFilterCategory: (id: string) => void;
180
181
  onToggleFilterAttribute: (id: string) => void;
181
182
  onClearFilters: () => void;
182
- showIntelligence: boolean;
183
183
  intelligenceOpen: boolean;
184
184
  onToggleIntelligence: () => void;
185
185
  // List actions
@@ -782,13 +782,11 @@ function MailboxPaneProvider({
782
782
  }
783
783
  }, [normalizedSearchQuery, mailboxType, telemetry]);
784
784
 
785
- const hasThread = Boolean(selectedThread);
786
785
  const hasRemitDraftOpen =
787
786
  isDraftsMailbox &&
788
787
  composeState.isOpen &&
789
788
  !!composeState.outboxMessageId &&
790
789
  !selectedThread;
791
- const showIntelligence = isDesktop && intelligenceOpen && hasThread;
792
790
 
793
791
  useTriageKeyboard({
794
792
  // A modal owns the keyboard outright. Suspending the layer is what keeps a
@@ -868,7 +866,6 @@ function MailboxPaneProvider({
868
866
  onSelectFilterCategory,
869
867
  onToggleFilterAttribute,
870
868
  onClearFilters,
871
- showIntelligence,
872
869
  intelligenceOpen,
873
870
  onToggleIntelligence,
874
871
  onDeleteMessages: handleDeleteMessages,
@@ -1081,7 +1078,7 @@ function MailboxReading() {
1081
1078
  selectedThread,
1082
1079
  conversation,
1083
1080
  hasRemitDraftOpen,
1084
- showIntelligence,
1081
+ intelligenceOpen,
1085
1082
  onToggleIntelligence,
1086
1083
  toolbarComposeRequest,
1087
1084
  onToolbarReply,
@@ -1094,9 +1091,12 @@ function MailboxReading() {
1094
1091
  onToolbarMove,
1095
1092
  composeState,
1096
1093
  } = useMailboxPane();
1097
- const tier = useLayoutTier();
1098
- const isDesktop = tier === "desktop";
1094
+ // The rail's own width gate, not the shell tier: between 1024 and 1280 the
1095
+ // reading pane is mounted but the rail is not, so "enabled" would promise an
1096
+ // open that cannot happen.
1097
+ const railFits = useAppShellLayout()?.showIntelligencePane ?? false;
1099
1098
  const hasThread = Boolean(conversation);
1099
+ const canToggleIntelligence = railFits && hasThread;
1100
1100
 
1101
1101
  const detailPane =
1102
1102
  composeState.isOpen && !conversation ? (
@@ -1124,8 +1124,8 @@ function MailboxReading() {
1124
1124
  <section className="flex h-full w-full min-w-0 flex-col bg-canvas">
1125
1125
  <MessageToolbar
1126
1126
  hasThread={hasThread}
1127
- intelligenceOpen={showIntelligence}
1128
- showIntelligenceToggle={isDesktop && hasThread}
1127
+ intelligenceOpen={canToggleIntelligence && intelligenceOpen}
1128
+ canToggleIntelligence={canToggleIntelligence}
1129
1129
  onToggleIntelligence={onToggleIntelligence}
1130
1130
  onReply={hasThread ? onToolbarReply : undefined}
1131
1131
  onReplyAll={hasThread ? onToolbarReplyAll : undefined}
@@ -69,7 +69,7 @@ describe("MessageToolbar carries message context only (#49)", () => {
69
69
  createElement(MessageToolbar, {
70
70
  hasThread,
71
71
  intelligenceOpen: false,
72
- showIntelligenceToggle: true,
72
+ canToggleIntelligence: true,
73
73
  onToggleIntelligence: () => undefined,
74
74
  }) as never,
75
75
  );
@@ -94,3 +94,31 @@ describe("MessageToolbar carries message context only (#49)", () => {
94
94
  assert.match(html, /intelligence sidebar/);
95
95
  });
96
96
  });
97
+
98
+ /**
99
+ * The toggle holds its slot whatever the view and the selection are (#52); it
100
+ * reports "cannot act" by being disabled, not by leaving the bar.
101
+ */
102
+ describe("MessageToolbar keeps the intelligence toggle in place (#52)", () => {
103
+ const render = (canToggleIntelligence: boolean): string =>
104
+ renderToString(
105
+ createElement(MessageToolbar, {
106
+ hasThread: false,
107
+ intelligenceOpen: false,
108
+ canToggleIntelligence,
109
+ onToggleIntelligence: () => undefined,
110
+ }) as never,
111
+ );
112
+
113
+ it("renders the toggle when it cannot open a rail", () => {
114
+ assert.match(render(false), /aria-label="Show intelligence sidebar"/);
115
+ });
116
+
117
+ it("disables it rather than dropping it from the bar", () => {
118
+ assert.match(render(false), /\sdisabled(=""|\s|>)/);
119
+ });
120
+
121
+ it("leaves it pressable once a rail can open", () => {
122
+ assert.equal(/\sdisabled(=""|\s|>)/.test(render(true)), false);
123
+ });
124
+ });
@@ -1,5 +1,8 @@
1
- import { Button, type MailAction, MailActionToolbar } from "@remit/ui";
2
- import { Info } from "lucide-react";
1
+ import {
2
+ IntelligenceToggle,
3
+ type MailAction,
4
+ MailActionToolbar,
5
+ } from "@remit/ui";
3
6
  import { useState } from "react";
4
7
  import { tooltipForAction } from "@/lib/keymap";
5
8
  import { MoveToTrigger } from "./MoveToTrigger";
@@ -13,19 +16,21 @@ import { MoveToTrigger } from "./MoveToTrigger";
13
16
  * Search, compose, bug report and the account menu are not message context;
14
17
  * they live in the app top bar above every pane (`MailTopBar`, #49).
15
18
  *
16
- * Buttons are always pressable (never `disabled`): with no thread open a press
17
- * is a no-op that surfaces a one-line inline explanation rather than greying
18
- * out (`doc/rules/ux.md`).
19
+ * The control set is fixed: every button occupies the same slot on every view
20
+ * and in every selection state (#52). The mail verbs stay pressable with no
21
+ * thread open — a press is a no-op that surfaces a one-line inline explanation
22
+ * (`doc/rules/ux.md`). The intelligence toggle greys out instead, because it
23
+ * has nothing to explain: it opens a rail, and there is no rail to open.
19
24
  */
20
25
  export interface MessageToolbarProps {
21
26
  hasThread: boolean;
22
27
  intelligenceOpen: boolean;
23
28
  /**
24
- * Whether the intelligence toggle is shown at all. The rail is contextual
25
- * to an open message, so the toggle only appears once a thread is selected
26
- * (matches the remit-ui AppShell reference) it never opens an empty rail.
29
+ * Whether pressing the intelligence toggle would open a rail: the view has
30
+ * one, the width allows it, and a thread is selected. The button renders
31
+ * either way disabled when false, never absent (#52).
27
32
  */
28
- showIntelligenceToggle: boolean;
33
+ canToggleIntelligence: boolean;
29
34
  onToggleIntelligence: () => void;
30
35
 
31
36
  /* ---- wired action callbacks (omit to keep the no-op-explain behaviour) ---- */
@@ -60,7 +65,7 @@ const OPEN_FIRST = "Open a message first";
60
65
  export const MessageToolbar = ({
61
66
  hasThread,
62
67
  intelligenceOpen,
63
- showIntelligenceToggle,
68
+ canToggleIntelligence,
64
69
  onToggleIntelligence,
65
70
  onReply,
66
71
  onReplyAll,
@@ -103,24 +108,11 @@ export const MessageToolbar = ({
103
108
  ) : undefined
104
109
  }
105
110
  >
106
- {showIntelligenceToggle && (
107
- <Button
108
- variant="ghost"
109
- size="sm"
110
- icon={<Info className="size-4" />}
111
- title="Intelligence"
112
- aria-label={
113
- intelligenceOpen
114
- ? "Hide intelligence sidebar"
115
- : "Show intelligence sidebar"
116
- }
117
- aria-pressed={intelligenceOpen}
118
- onClick={onToggleIntelligence}
119
- className={
120
- intelligenceOpen ? "bg-accent-2-soft text-accent-2" : undefined
121
- }
122
- />
123
- )}
111
+ <IntelligenceToggle
112
+ open={intelligenceOpen}
113
+ enabled={canToggleIntelligence}
114
+ onToggle={onToggleIntelligence}
115
+ />
124
116
  </MailActionToolbar>
125
117
  );
126
118
  };
@@ -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
+ }
@@ -298,7 +298,8 @@ function MailLayout() {
298
298
  />
299
299
  </div>
300
300
  ) : onBriefRoute ? (
301
- // Daily brief (/mail/) — no mailboxId param; 2-pane layout (no intelligence).
301
+ // Daily brief (/mail/) — no mailboxId param; same 3-pane layout as a
302
+ // mailbox: an open message has an intelligence rail here too (#52).
302
303
  <BriefPane selectedMessageId={mobileSelectedMessageId}>
303
304
  {isSinglePane ? (
304
305
  <AppShellSlotted
@@ -316,7 +317,9 @@ function MailLayout() {
316
317
  topBar={topBar}
317
318
  list={<BriefPane.List />}
318
319
  reading={<BriefPane.Reading />}
320
+ intelligence={<BriefPane.Intelligence />}
319
321
  intelligenceOpen={intelligenceOpen}
322
+ hasThread={Boolean(mobileSelectedMessageId)}
320
323
  overlay={overlayContent}
321
324
  skeleton={<AppShellSkeleton />}
322
325
  isLoading={isLoading || hasNoAccounts}
@@ -326,7 +329,7 @@ function MailLayout() {
326
329
  </BriefPane>
327
330
  ) : onFlaggedRoute ? (
328
331
  // Flagged virtual mailbox (/mail/flagged) — flat starred list across
329
- // accounts; 2-pane layout (no intelligence), like the brief.
332
+ // accounts; same slots as the brief, intelligence rail included.
330
333
  <FlaggedPane selectedMessageId={mobileSelectedMessageId}>
331
334
  {isSinglePane ? (
332
335
  <AppShellSlotted
@@ -344,7 +347,9 @@ function MailLayout() {
344
347
  topBar={topBar}
345
348
  list={<FlaggedPane.List />}
346
349
  reading={<FlaggedPane.Reading />}
350
+ intelligence={<FlaggedPane.Intelligence />}
347
351
  intelligenceOpen={intelligenceOpen}
352
+ hasThread={Boolean(mobileSelectedMessageId)}
348
353
  overlay={overlayContent}
349
354
  skeleton={<AppShellSkeleton />}
350
355
  isLoading={isLoading || hasNoAccounts}
@@ -376,6 +381,7 @@ function MailLayout() {
376
381
  reading={<MailboxPane.Reading />}
377
382
  intelligence={<MailboxPane.Intelligence />}
378
383
  intelligenceOpen={intelligenceOpen}
384
+ hasThread={Boolean(mobileSelectedMessageId)}
379
385
  overlay={overlayContent}
380
386
  skeleton={<AppShellSkeleton />}
381
387
  isLoading={isLoading || hasNoAccounts}