@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 +1 -1
- package/src/components/mail/BriefPane.tsx +34 -5
- package/src/components/mail/FlaggedList.tsx +12 -32
- package/src/components/mail/FlaggedPane.tsx +42 -16
- package/src/components/mail/MailboxPane.tsx +9 -9
- package/src/components/mail/MessageToolbar.render.test.ts +29 -1
- package/src/components/mail/MessageToolbar.tsx +20 -28
- package/src/hooks/useStarredThreads.test.ts +143 -0
- package/src/hooks/useStarredThreads.ts +78 -0
- package/src/routes/mail.tsx +8 -2
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": {
|
|
@@ -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 {
|
|
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={
|
|
193
|
-
intelligenceOpen={
|
|
194
|
-
|
|
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
|
|
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
|
-
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 {
|
|
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) => {
|
|
@@ -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={
|
|
137
|
-
intelligenceOpen={
|
|
138
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1098
|
-
|
|
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={
|
|
1128
|
-
|
|
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
|
-
|
|
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 {
|
|
2
|
-
|
|
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
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
+
}
|
package/src/routes/mail.tsx
CHANGED
|
@@ -298,7 +298,8 @@ function MailLayout() {
|
|
|
298
298
|
/>
|
|
299
299
|
</div>
|
|
300
300
|
) : onBriefRoute ? (
|
|
301
|
-
// Daily brief (/mail/) — no mailboxId param;
|
|
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;
|
|
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}
|