@remit/web-client 0.0.81 → 0.0.83
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/MailboxPane.tsx +108 -29
- package/src/components/mail/MessageList.tsx +25 -14
- package/src/components/settings/FilterEditor.render.test.ts +1 -0
- package/src/components/settings/FiltersList.render.test.ts +1 -0
- package/src/components/settings/settings-filter.stories.tsx +1 -0
- package/src/hooks/useEscalatedActions.ts +8 -1
- package/src/hooks/useFilters.test.ts +1 -0
- package/src/lib/inbox-filters.test.ts +104 -76
- package/src/lib/inbox-filters.ts +115 -36
- package/src/lib/organize/filter-edit-model.test.ts +1 -0
- package/src/lib/search-pending.test.ts +39 -1
- package/src/lib/search-pending.ts +25 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.83",
|
|
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": {
|
|
@@ -28,13 +28,13 @@ import {
|
|
|
28
28
|
import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
29
29
|
import {
|
|
30
30
|
inboxFilterConfig,
|
|
31
|
+
type MessageListFilter,
|
|
31
32
|
ReadingPaneEmpty,
|
|
32
33
|
type RescueCandidate,
|
|
33
34
|
type SearchResult,
|
|
34
35
|
useAppShellLayout,
|
|
35
36
|
} from "@remit/ui";
|
|
36
37
|
import {
|
|
37
|
-
keepPreviousData,
|
|
38
38
|
useInfiniteQuery,
|
|
39
39
|
useMutation,
|
|
40
40
|
useQueryClient,
|
|
@@ -100,13 +100,20 @@ import {
|
|
|
100
100
|
type ConversationTarget,
|
|
101
101
|
} from "@/lib/conversation-target";
|
|
102
102
|
import { dedupeThreadMessages } from "@/lib/dedupe-thread-messages";
|
|
103
|
-
import {
|
|
103
|
+
import {
|
|
104
|
+
filterReach,
|
|
105
|
+
hasInboxFilter,
|
|
106
|
+
type InboxFilterCriteria,
|
|
107
|
+
inboxFilterParams,
|
|
108
|
+
sameInboxFilter,
|
|
109
|
+
} from "@/lib/inbox-filters";
|
|
104
110
|
import { readIntelligencePref } from "@/lib/intelligence-pref";
|
|
105
111
|
import { useMailContext } from "@/lib/mail-context";
|
|
106
112
|
import { isRescueCandidate } from "@/lib/rescue-candidates";
|
|
107
113
|
import { recordRescueSentToJunk } from "@/lib/rescue-telemetry";
|
|
108
114
|
import {
|
|
109
115
|
isSearchPending as computeIsSearchPending,
|
|
116
|
+
resolveOpenThread,
|
|
110
117
|
resolveSelectedThread,
|
|
111
118
|
} from "@/lib/search-pending";
|
|
112
119
|
import { normalizeSearchQuery } from "@/lib/search-query";
|
|
@@ -137,19 +144,25 @@ interface MailboxPaneContextValue {
|
|
|
137
144
|
unreadCount: number;
|
|
138
145
|
isDraftsMailbox: boolean;
|
|
139
146
|
// Rescue-from-Spam: true on the account's Junk/Spam folder, with the
|
|
140
|
-
// suspected-safe messages
|
|
141
|
-
// + flow above the spam list.
|
|
147
|
+
// suspected-safe messages `useRescueCandidates` fetched. Drives the rescue
|
|
148
|
+
// banner + flow above the spam list.
|
|
142
149
|
isSpamFolder: boolean;
|
|
143
150
|
rescueCandidates: RescueCandidate[];
|
|
144
|
-
// Inbox filter (category + Unread/Flagged/Attachment)
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
151
|
+
// Inbox filter (category + Unread/Flagged/Attachment). The chips are search
|
|
152
|
+
// parameters: the server returns the filtered page, so `threads` is the
|
|
153
|
+
// answer to the active predicate over the whole mailbox rather than a
|
|
154
|
+
// narrowed copy of the loaded window (#306).
|
|
148
155
|
filterCategory: string;
|
|
149
156
|
filterAttributes: ReadonlySet<string>;
|
|
150
157
|
onSelectFilterCategory: (id: string) => void;
|
|
151
158
|
onToggleFilterAttribute: (id: string) => void;
|
|
152
159
|
onClearFilters: () => void;
|
|
160
|
+
/**
|
|
161
|
+
* The active category filter as the empty state renders it — its label, the
|
|
162
|
+
* way out of it, and how much of the mailbox the request reached. Undefined
|
|
163
|
+
* when no category is selected.
|
|
164
|
+
*/
|
|
165
|
+
listFilter: MessageListFilter | undefined;
|
|
153
166
|
intelligenceOpen: boolean;
|
|
154
167
|
onToggleIntelligence: () => void;
|
|
155
168
|
/**
|
|
@@ -196,6 +209,18 @@ interface MailboxPaneContextValue {
|
|
|
196
209
|
previousMessageId: string | undefined;
|
|
197
210
|
}
|
|
198
211
|
|
|
212
|
+
/** The server's own default page size (`DEFAULT_THREADS_PAGE_SIZE`), sent so the
|
|
213
|
+
* filtered path pages like the unfiltered one. */
|
|
214
|
+
const THREADS_PAGE_SIZE = 50;
|
|
215
|
+
|
|
216
|
+
/** Chip id → the label the empty state names the filter by. `all` is absent:
|
|
217
|
+
* it is how the category is cleared, not a category. */
|
|
218
|
+
const CATEGORY_LABELS = new Map(
|
|
219
|
+
inboxFilterConfig()
|
|
220
|
+
.categories.filter((category) => category.id !== "all")
|
|
221
|
+
.map((category) => [category.id, category.label]),
|
|
222
|
+
);
|
|
223
|
+
|
|
199
224
|
const MailboxPaneCtx = createContext<MailboxPaneContextValue | null>(null);
|
|
200
225
|
|
|
201
226
|
function useMailboxPane(): MailboxPaneContextValue {
|
|
@@ -246,8 +271,31 @@ function MailboxPaneProvider({
|
|
|
246
271
|
tokenContext,
|
|
247
272
|
);
|
|
248
273
|
const fromToken = searchTokens.find((t) => t.type === "from");
|
|
274
|
+
|
|
275
|
+
const [filterCategory, setFilterCategory] = useState("all");
|
|
276
|
+
const [filterAttributes, setFilterAttributes] = useState<ReadonlySet<string>>(
|
|
277
|
+
new Set(),
|
|
278
|
+
);
|
|
279
|
+
const filterCriteria: InboxFilterCriteria = useMemo(
|
|
280
|
+
() => ({ category: filterCategory, attributes: filterAttributes }),
|
|
281
|
+
[filterCategory, filterAttributes],
|
|
282
|
+
);
|
|
283
|
+
const filterParams = useMemo(
|
|
284
|
+
() => inboxFilterParams(filterCriteria),
|
|
285
|
+
[filterCriteria],
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
// The chips are query parameters, not a browser-side pass over the loaded
|
|
289
|
+
// pages: a category whose mail sits below the newest page is why the filter
|
|
290
|
+
// showed an empty inbox at all (#306). `listThreads` takes no filters, so any
|
|
291
|
+
// active chip routes the listing through `searchThreads` — one predicate, one
|
|
292
|
+
// query key, so the key and the branch below cannot diverge.
|
|
293
|
+
const hasServerFilter = hasSearchQuery || hasInboxFilter(filterCriteria);
|
|
249
294
|
const searchThreadsQuery = {
|
|
250
295
|
order: "desc" as const,
|
|
296
|
+
// Explicit: an unspecified limit clamps to THREAD_SEARCH_MAX_LIMIT (500),
|
|
297
|
+
// so switching paths without it multiplies the page size by ten.
|
|
298
|
+
limit: THREADS_PAGE_SIZE,
|
|
251
299
|
...(freeText ? { query: freeText } : {}),
|
|
252
300
|
...(fromToken ? { from: fromToken.value } : {}),
|
|
253
301
|
...(searchTokens.some((t) => t.type === "hasAttachment")
|
|
@@ -256,9 +304,10 @@ function MailboxPaneProvider({
|
|
|
256
304
|
...(searchTokens.some((t) => t.type === "isUnread")
|
|
257
305
|
? { unread: true }
|
|
258
306
|
: {}),
|
|
307
|
+
...filterParams,
|
|
259
308
|
};
|
|
260
309
|
|
|
261
|
-
const queryKey =
|
|
310
|
+
const queryKey = hasServerFilter
|
|
262
311
|
? threadOperationsSearchThreadsQueryKey({
|
|
263
312
|
path: { mailboxId },
|
|
264
313
|
query: searchThreadsQuery,
|
|
@@ -280,7 +329,7 @@ function MailboxPaneProvider({
|
|
|
280
329
|
} = useInfiniteQuery({
|
|
281
330
|
queryKey,
|
|
282
331
|
queryFn: async ({ pageParam }) => {
|
|
283
|
-
if (
|
|
332
|
+
if (hasServerFilter) {
|
|
284
333
|
const { data } = await threadOperationsSearchThreads({
|
|
285
334
|
path: { mailboxId },
|
|
286
335
|
query: {
|
|
@@ -300,8 +349,14 @@ function MailboxPaneProvider({
|
|
|
300
349
|
},
|
|
301
350
|
initialPageParam: undefined as string | undefined,
|
|
302
351
|
getNextPageParam: (lastPage) => lastPage.continuationToken,
|
|
303
|
-
|
|
304
|
-
|
|
352
|
+
// Previous rows while the next page is in flight, but only under the
|
|
353
|
+
// filter that fetched them — a chip change restarts the list on the
|
|
354
|
+
// skeleton rather than showing the old predicate's mail under the new
|
|
355
|
+
// chip for one round trip.
|
|
356
|
+
placeholderData: (previousData, previousQuery) =>
|
|
357
|
+
sameInboxFilter(previousQuery?.queryKey, filterParams)
|
|
358
|
+
? previousData
|
|
359
|
+
: undefined,
|
|
305
360
|
});
|
|
306
361
|
|
|
307
362
|
const handleDeselectIfRemoved = useCallback(
|
|
@@ -337,21 +392,15 @@ function MailboxPaneProvider({
|
|
|
337
392
|
onAfterOptimisticRemove: handleDeselectIfRemoved,
|
|
338
393
|
});
|
|
339
394
|
|
|
340
|
-
|
|
395
|
+
// The server answered the active predicate, so these rows are the list: the
|
|
396
|
+
// dedupe spans pages and the deleted drop repeats the server's own
|
|
397
|
+
// `excludeDeleted`, and neither result changes when another page loads.
|
|
398
|
+
const threads = dropDeletedThreads(
|
|
341
399
|
dedupeThreadMessages(
|
|
342
400
|
threadsData?.pages.flatMap((page) => page.items ?? []) ?? [],
|
|
343
401
|
),
|
|
344
402
|
);
|
|
345
403
|
|
|
346
|
-
const [filterCategory, setFilterCategory] = useState("all");
|
|
347
|
-
const [filterAttributes, setFilterAttributes] = useState<ReadonlySet<string>>(
|
|
348
|
-
new Set(),
|
|
349
|
-
);
|
|
350
|
-
const threads = useMemo(
|
|
351
|
-
() => applyInboxFilters(rawThreads, filterCategory, filterAttributes),
|
|
352
|
-
[rawThreads, filterCategory, filterAttributes],
|
|
353
|
-
);
|
|
354
|
-
|
|
355
404
|
const onSelectFilterCategory = useCallback((id: string) => {
|
|
356
405
|
setFilterCategory(id);
|
|
357
406
|
}, []);
|
|
@@ -368,11 +417,36 @@ function MailboxPaneProvider({
|
|
|
368
417
|
setFilterAttributes(new Set());
|
|
369
418
|
}, []);
|
|
370
419
|
|
|
420
|
+
// The empty state has to say how much was read, and the reach comes off the
|
|
421
|
+
// request rather than the call site: the day a chip is answered over a window
|
|
422
|
+
// instead of the whole mailbox, the sentence changes with it.
|
|
423
|
+
const filterLabel = CATEGORY_LABELS.get(filterCategory);
|
|
424
|
+
const listFilter: MessageListFilter | undefined = filterLabel
|
|
425
|
+
? {
|
|
426
|
+
label: filterLabel,
|
|
427
|
+
reach: filterReach(searchThreadsQuery),
|
|
428
|
+
onClear: onClearFilters,
|
|
429
|
+
}
|
|
430
|
+
: undefined;
|
|
431
|
+
|
|
371
432
|
const isSearchPending = computeIsSearchPending(searchInput, searchQuery);
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
433
|
+
const listedThread = resolveSelectedThread(
|
|
434
|
+
threads,
|
|
435
|
+
selectedMessageId,
|
|
436
|
+
isSearchPending,
|
|
437
|
+
);
|
|
438
|
+
// There is no unfiltered set in the client any more, so a chip the open
|
|
439
|
+
// message does not match would otherwise close the reading pane under the
|
|
440
|
+
// user. Snapshot what they opened and let it answer for itself.
|
|
441
|
+
const [openedThread, setOpenedThread] = useState<
|
|
442
|
+
RemitImapThreadMessageResponse | undefined
|
|
443
|
+
>(undefined);
|
|
444
|
+
useEffect(() => {
|
|
445
|
+
if (listedThread) setOpenedThread(listedThread);
|
|
446
|
+
}, [listedThread]);
|
|
447
|
+
const selectedThread = resolveOpenThread(
|
|
448
|
+
listedThread,
|
|
449
|
+
openedThread,
|
|
376
450
|
selectedMessageId,
|
|
377
451
|
isSearchPending,
|
|
378
452
|
);
|
|
@@ -444,9 +518,10 @@ function MailboxPaneProvider({
|
|
|
444
518
|
onSetIntelligenceOpen,
|
|
445
519
|
]);
|
|
446
520
|
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
521
|
+
// The mailbox's own unseen total. A count over the loaded pages undercounts
|
|
522
|
+
// every mailbox larger than one page and creeps upward as the user scrolls,
|
|
523
|
+
// so there is no fallback: until the mailbox resolves there is no number.
|
|
524
|
+
const unreadCount = useCurrentMailboxUnseenCount({ accounts }) ?? 0;
|
|
450
525
|
|
|
451
526
|
const queryClient = useQueryClient();
|
|
452
527
|
const { pushError } = useErrorBanners();
|
|
@@ -745,6 +820,7 @@ function MailboxPaneProvider({
|
|
|
745
820
|
onSelectFilterCategory,
|
|
746
821
|
onToggleFilterAttribute,
|
|
747
822
|
onClearFilters,
|
|
823
|
+
listFilter,
|
|
748
824
|
intelligenceOpen,
|
|
749
825
|
onToggleIntelligence,
|
|
750
826
|
searchPredicate: hasSearchQuery ? searchThreadsQuery : undefined,
|
|
@@ -817,6 +893,7 @@ function MailboxList() {
|
|
|
817
893
|
onSelectFilterCategory,
|
|
818
894
|
onToggleFilterAttribute,
|
|
819
895
|
onClearFilters,
|
|
896
|
+
listFilter,
|
|
820
897
|
searchPredicate,
|
|
821
898
|
} = useMailboxPane();
|
|
822
899
|
const { searchQuery, searchInput, accounts, resultFolderIndex } =
|
|
@@ -909,6 +986,8 @@ function MailboxList() {
|
|
|
909
986
|
isLoadingMore={isLoadingMore}
|
|
910
987
|
accountId={mailboxAccountId}
|
|
911
988
|
listTitle={listTitle}
|
|
989
|
+
listFilter={listFilter}
|
|
990
|
+
listScopeLabel={listTitle}
|
|
912
991
|
hideHeader
|
|
913
992
|
onTriageContextChange={onTriageContextChange}
|
|
914
993
|
commandsRef={listCommandsRef}
|
|
@@ -2,6 +2,8 @@ import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/type
|
|
|
2
2
|
import {
|
|
3
3
|
Banner,
|
|
4
4
|
type Density,
|
|
5
|
+
type MessageListFilter,
|
|
6
|
+
MessageListLoadingMore,
|
|
5
7
|
MessageListPane,
|
|
6
8
|
SELECTION_SHEET_TEASER_HEIGHT,
|
|
7
9
|
SelectionSheet,
|
|
@@ -123,6 +125,15 @@ interface MessageListProps {
|
|
|
123
125
|
* Optional subtitle (e.g. "3 unread") shown alongside the title.
|
|
124
126
|
*/
|
|
125
127
|
listMeta?: string;
|
|
128
|
+
/**
|
|
129
|
+
* The active category filter, when the view has one. The empty state needs
|
|
130
|
+
* it to say it is filtered and how much of the collection the request
|
|
131
|
+
* reached; without it a narrowed list renders as an empty mailbox, which is
|
|
132
|
+
* the shape #315's bug hid behind.
|
|
133
|
+
*/
|
|
134
|
+
listFilter?: MessageListFilter;
|
|
135
|
+
/** Name of the collection being listed, e.g. "Inbox", for the empty state. */
|
|
136
|
+
listScopeLabel?: string;
|
|
126
137
|
/**
|
|
127
138
|
* Triage-layer context bridge (#429). The roving focus cursor and the
|
|
128
139
|
* multi-selection live here; the parent route's global keyboard dispatcher
|
|
@@ -179,17 +190,17 @@ const readStoredDensity = (): Density => {
|
|
|
179
190
|
return "comfortable";
|
|
180
191
|
};
|
|
181
192
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
}) => (
|
|
193
|
+
/**
|
|
194
|
+
* Names what the list is showing. No number: the only figure available here is
|
|
195
|
+
* the length of the loaded pages, and a page length presented as a result total
|
|
196
|
+
* contradicts the completeness the filtered empty state states in the same view
|
|
197
|
+
* (#306). The exact count is #307's.
|
|
198
|
+
*/
|
|
199
|
+
const SearchResultsHeader = ({ query }: { query: string }) => (
|
|
189
200
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-line bg-surface-sunken/30">
|
|
190
201
|
<Search className="size-4 text-fg-muted" />
|
|
191
202
|
<span className="text-sm text-fg-muted">
|
|
192
|
-
|
|
203
|
+
Results for “{query}”
|
|
193
204
|
</span>
|
|
194
205
|
</div>
|
|
195
206
|
);
|
|
@@ -214,6 +225,8 @@ export const MessageList = ({
|
|
|
214
225
|
accountId,
|
|
215
226
|
listTitle,
|
|
216
227
|
listMeta,
|
|
228
|
+
listFilter,
|
|
229
|
+
listScopeLabel,
|
|
217
230
|
onTriageContextChange,
|
|
218
231
|
commandsRef,
|
|
219
232
|
hideHeader = false,
|
|
@@ -1363,7 +1376,7 @@ export const MessageList = ({
|
|
|
1363
1376
|
const virtualBody = (
|
|
1364
1377
|
<>
|
|
1365
1378
|
{isSearching && searchQuery && (
|
|
1366
|
-
<SearchResultsHeader query={searchQuery}
|
|
1379
|
+
<SearchResultsHeader query={searchQuery} />
|
|
1367
1380
|
)}
|
|
1368
1381
|
<div
|
|
1369
1382
|
ref={parentRef}
|
|
@@ -1427,11 +1440,7 @@ export const MessageList = ({
|
|
|
1427
1440
|
);
|
|
1428
1441
|
})}
|
|
1429
1442
|
</div>
|
|
1430
|
-
{isLoadingMore &&
|
|
1431
|
-
<div className="flex justify-center py-4">
|
|
1432
|
-
<div className="h-5 w-5 animate-spin rounded-full border-2 border-fg-muted border-t-transparent" />
|
|
1433
|
-
</div>
|
|
1434
|
-
)}
|
|
1443
|
+
{isLoadingMore && <MessageListLoadingMore />}
|
|
1435
1444
|
</div>
|
|
1436
1445
|
</>
|
|
1437
1446
|
);
|
|
@@ -1469,6 +1478,8 @@ export const MessageList = ({
|
|
|
1469
1478
|
flatList
|
|
1470
1479
|
listState={listState}
|
|
1471
1480
|
searchQuery={isSearching ? searchQuery : undefined}
|
|
1481
|
+
listFilter={listFilter}
|
|
1482
|
+
listScopeLabel={listScopeLabel}
|
|
1472
1483
|
errorMessage={errorMessage}
|
|
1473
1484
|
onRetry={onRetry}
|
|
1474
1485
|
onReportError={handleReportError}
|
|
@@ -33,7 +33,14 @@ import {
|
|
|
33
33
|
* filters the visible list is searching with, minus pagination/count knobs. */
|
|
34
34
|
export type EscalationSearchQuery = Pick<
|
|
35
35
|
NonNullable<ThreadOperationsSearchThreadsData["query"]>,
|
|
36
|
-
|
|
36
|
+
| "order"
|
|
37
|
+
| "query"
|
|
38
|
+
| "subject"
|
|
39
|
+
| "from"
|
|
40
|
+
| "unread"
|
|
41
|
+
| "starred"
|
|
42
|
+
| "attachments"
|
|
43
|
+
| "category"
|
|
37
44
|
>;
|
|
38
45
|
|
|
39
46
|
/**
|
|
@@ -1,106 +1,134 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { describe, test } from "node:test";
|
|
3
|
-
import
|
|
4
|
-
|
|
3
|
+
import {
|
|
4
|
+
filterReach,
|
|
5
|
+
hasInboxFilter,
|
|
6
|
+
type InboxFilterCriteria,
|
|
7
|
+
inboxFilterParams,
|
|
8
|
+
sameInboxFilter,
|
|
9
|
+
} from "./inbox-filters.js";
|
|
5
10
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
): RemitImapThreadMessageResponse =>
|
|
11
|
-
({ isRead: false, ...fields }) as unknown as RemitImapThreadMessageResponse;
|
|
11
|
+
const criteria = (
|
|
12
|
+
category: string,
|
|
13
|
+
attributes: string[] = [],
|
|
14
|
+
): InboxFilterCriteria => ({ category, attributes: new Set(attributes) });
|
|
12
15
|
|
|
13
|
-
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
+
/** The shape `threadOperationsSearchThreadsQueryKey` produces. */
|
|
17
|
+
const key = (query: Record<string, unknown>): unknown => [
|
|
18
|
+
{ _id: "threadOperationsSearchThreads", path: { mailboxId: "mb1" }, query },
|
|
19
|
+
];
|
|
16
20
|
|
|
17
|
-
|
|
18
|
-
|
|
21
|
+
describe("hasInboxFilter", () => {
|
|
22
|
+
test("an untouched chip row narrows nothing", () => {
|
|
23
|
+
assert.equal(hasInboxFilter(criteria("all")), false);
|
|
24
|
+
});
|
|
19
25
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
assert.deepEqual(applyInboxFilters(threads, "all", new Set()), threads);
|
|
26
|
+
test("a category or an attribute is a filter", () => {
|
|
27
|
+
assert.equal(hasInboxFilter(criteria("personal")), true);
|
|
28
|
+
assert.equal(hasInboxFilter(criteria("all", ["unread"])), true);
|
|
24
29
|
});
|
|
25
30
|
|
|
26
|
-
test("
|
|
27
|
-
assert.
|
|
28
|
-
ids(applyInboxFilters([personal, unclassified], "personal", new Set())),
|
|
29
|
-
["m1"],
|
|
30
|
-
);
|
|
31
|
+
test("an id that names no category narrows nothing", () => {
|
|
32
|
+
assert.equal(hasInboxFilter(criteria("nonsense")), false);
|
|
31
33
|
});
|
|
34
|
+
});
|
|
32
35
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
ids(
|
|
39
|
-
applyInboxFilters(
|
|
40
|
-
[personal, unclassified, preClassification],
|
|
41
|
-
"uncategorized",
|
|
42
|
-
new Set(),
|
|
43
|
-
),
|
|
44
|
-
),
|
|
45
|
-
["m2", "m3"],
|
|
46
|
-
);
|
|
36
|
+
describe("inboxFilterParams", () => {
|
|
37
|
+
test("sends the category to the server instead of filtering here (#306)", () => {
|
|
38
|
+
assert.deepEqual(inboxFilterParams(criteria("personal")), {
|
|
39
|
+
category: ["personal"],
|
|
40
|
+
});
|
|
47
41
|
});
|
|
48
42
|
|
|
49
|
-
test("
|
|
43
|
+
test("asks for unclassified mail by name, never as absence (#45)", () => {
|
|
44
|
+
assert.deepEqual(inboxFilterParams(criteria("uncategorized")), {
|
|
45
|
+
category: ["uncategorized"],
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("sets no category parameter for `all`", () => {
|
|
50
|
+
assert.deepEqual(inboxFilterParams(criteria("all")), {});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("maps each attribute chip onto the parameter the API names", () => {
|
|
50
54
|
assert.deepEqual(
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
"personal",
|
|
54
|
-
new Set(),
|
|
55
|
-
),
|
|
56
|
-
[],
|
|
55
|
+
inboxFilterParams(criteria("all", ["unread", "flagged", "attachment"])),
|
|
56
|
+
{ unread: true, starred: true, attachments: true },
|
|
57
57
|
);
|
|
58
58
|
});
|
|
59
59
|
|
|
60
|
-
test("
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
isRead: true,
|
|
60
|
+
test("carries a category and its attributes together", () => {
|
|
61
|
+
assert.deepEqual(inboxFilterParams(criteria("social", ["unread"])), {
|
|
62
|
+
category: ["social"],
|
|
63
|
+
unread: true,
|
|
65
64
|
});
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("ignores an attribute id with no parameter behind it", () => {
|
|
68
|
+
assert.deepEqual(inboxFilterParams(criteria("all", ["nonsense"])), {});
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("filterReach", () => {
|
|
73
|
+
test("a category is a column on the row, so the whole folder was read", () => {
|
|
74
|
+
assert.equal(
|
|
75
|
+
filterReach({ category: ["personal"], unread: true }),
|
|
76
|
+
"whole-folder",
|
|
69
77
|
);
|
|
70
78
|
});
|
|
71
79
|
|
|
72
|
-
test("
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
["m1"],
|
|
80
|
+
test("an unfiltered request still reaches the whole folder", () => {
|
|
81
|
+
assert.equal(filterReach({ order: "desc" }), "whole-folder");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("an off-row criterion bounds the read, whatever else is set", () => {
|
|
85
|
+
assert.equal(
|
|
86
|
+
filterReach({ category: ["personal"], senderTrust: ["unknown"] }),
|
|
87
|
+
"loaded-pages",
|
|
81
88
|
);
|
|
89
|
+
assert.equal(filterReach({ dkimMismatch: true }), "loaded-pages");
|
|
90
|
+
assert.equal(filterReach({ dkimMismatch: false }), "loaded-pages");
|
|
82
91
|
});
|
|
83
92
|
|
|
84
|
-
test("
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
93
|
+
test("an empty trust list bounds nothing", () => {
|
|
94
|
+
assert.equal(filterReach({ senderTrust: [] }), "whole-folder");
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("sameInboxFilter", () => {
|
|
99
|
+
test("holds across a query the user is still typing", () => {
|
|
100
|
+
assert.equal(
|
|
101
|
+
sameInboxFilter(key({ query: "inv", category: ["personal"] }), {
|
|
102
|
+
category: ["personal"],
|
|
103
|
+
}),
|
|
104
|
+
true,
|
|
92
105
|
);
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("fails the moment the category changes", () => {
|
|
109
|
+
assert.equal(
|
|
110
|
+
sameInboxFilter(key({ category: ["personal"] }), {
|
|
111
|
+
category: ["social"],
|
|
112
|
+
}),
|
|
113
|
+
false,
|
|
96
114
|
);
|
|
97
115
|
});
|
|
98
116
|
|
|
99
|
-
test("
|
|
100
|
-
|
|
101
|
-
assert.
|
|
102
|
-
|
|
103
|
-
|
|
117
|
+
test("fails when a chip is cleared", () => {
|
|
118
|
+
assert.equal(sameInboxFilter(key({ category: ["personal"] }), {}), false);
|
|
119
|
+
assert.equal(
|
|
120
|
+
sameInboxFilter(key({ unread: true }), { category: ["personal"] }),
|
|
121
|
+
false,
|
|
104
122
|
);
|
|
105
123
|
});
|
|
124
|
+
|
|
125
|
+
test("holds between the unfiltered listing and an unfiltered search", () => {
|
|
126
|
+
assert.equal(sameInboxFilter(key({ order: "desc" }), {}), true);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("answers false for a key it cannot read", () => {
|
|
130
|
+
assert.equal(sameInboxFilter(undefined, {}), false);
|
|
131
|
+
assert.equal(sameInboxFilter([], {}), false);
|
|
132
|
+
assert.equal(sameInboxFilter([{ path: { mailboxId: "mb1" } }], {}), false);
|
|
133
|
+
});
|
|
106
134
|
});
|
package/src/lib/inbox-filters.ts
CHANGED
|
@@ -1,44 +1,123 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
|
|
1
|
+
import type {
|
|
2
|
+
RemitImapMessageCategory,
|
|
3
|
+
ThreadOperationsSearchThreadsData,
|
|
4
|
+
} from "@remit/api-http-client/types.gen.ts";
|
|
5
|
+
import { MessageCategory } from "@remit/domain-enums";
|
|
6
|
+
import type { FilterReach } from "@remit/ui";
|
|
7
|
+
|
|
8
|
+
/** The request `threadOperationsSearchThreads` takes, whole. */
|
|
9
|
+
export type ThreadSearchQuery = NonNullable<
|
|
10
|
+
ThreadOperationsSearchThreadsData["query"]
|
|
11
|
+
>;
|
|
12
|
+
|
|
13
|
+
/** The parameters the inbox chips set on that request. */
|
|
14
|
+
export type InboxFilterParams = Pick<
|
|
15
|
+
ThreadSearchQuery,
|
|
16
|
+
"category" | "unread" | "starred" | "attachments"
|
|
17
|
+
>;
|
|
18
|
+
|
|
19
|
+
/** The chip state the inbox holds: one category, any number of attributes. */
|
|
20
|
+
export interface InboxFilterCriteria {
|
|
21
|
+
/** A category id, or `"all"` when the category is cleared. */
|
|
22
|
+
category: string;
|
|
23
|
+
attributes: ReadonlySet<string>;
|
|
24
|
+
}
|
|
3
25
|
|
|
4
26
|
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* id is the wire name for IMAP \Flagged; the label is "Starred".
|
|
27
|
+
* The search parameter each attribute chip sets. `flagged` is the wire name for
|
|
28
|
+
* IMAP \Flagged, which the API calls `starred`; the label is "Starred".
|
|
8
29
|
*/
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
unread: (t) => !t.isRead,
|
|
14
|
-
flagged: (t) => t.hasStars === true,
|
|
15
|
-
attachment: (t) => Boolean(t.hasAttachment),
|
|
30
|
+
const ATTRIBUTE_PARAMS: Record<string, "unread" | "starred" | "attachments"> = {
|
|
31
|
+
unread: "unread",
|
|
32
|
+
flagged: "starred",
|
|
33
|
+
attachment: "attachments",
|
|
16
34
|
};
|
|
17
35
|
|
|
36
|
+
const CATEGORY_VALUES = new Set<string>(Object.values(MessageCategory));
|
|
37
|
+
|
|
38
|
+
const isMessageCategory = (id: string): id is RemitImapMessageCategory =>
|
|
39
|
+
CATEGORY_VALUES.has(id);
|
|
40
|
+
|
|
41
|
+
/** Whether the chips narrow the list at all — `"all"` and no attributes do not. */
|
|
42
|
+
export const hasInboxFilter = (criteria: InboxFilterCriteria): boolean =>
|
|
43
|
+
isMessageCategory(criteria.category) || criteria.attributes.size > 0;
|
|
44
|
+
|
|
18
45
|
/**
|
|
19
|
-
*
|
|
20
|
-
*
|
|
46
|
+
* The chips as search parameters, so the server returns a page of matches
|
|
47
|
+
* rather than a page the browser then thins out. A category chip that names no
|
|
48
|
+
* category — `"all"`, or an id the enum no longer carries — sets no parameter,
|
|
49
|
+
* and `uncategorized` is a category like any other: the pending state's name,
|
|
50
|
+
* never the absence of one (issue #45).
|
|
21
51
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* pre-classification thread renders an `uncategorized` badge, so a raw
|
|
26
|
-
* comparison made the row you can see vanish under its own chip (#45).
|
|
52
|
+
* Only the parameters a chip switches on are present, so this merges with the
|
|
53
|
+
* search tokens (`is:unread`, `has:attachment`) as a union: where a chip and a
|
|
54
|
+
* token set the same parameter they agree on `true`.
|
|
27
55
|
*/
|
|
28
|
-
export
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
)
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
);
|
|
38
|
-
|
|
39
|
-
return
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
56
|
+
export const inboxFilterParams = (
|
|
57
|
+
criteria: InboxFilterCriteria,
|
|
58
|
+
): InboxFilterParams => {
|
|
59
|
+
const params: InboxFilterParams = {};
|
|
60
|
+
if (isMessageCategory(criteria.category)) {
|
|
61
|
+
params.category = [criteria.category];
|
|
62
|
+
}
|
|
63
|
+
for (const id of criteria.attributes) {
|
|
64
|
+
const param = ATTRIBUTE_PARAMS[id];
|
|
65
|
+
if (param) params[param] = true;
|
|
66
|
+
}
|
|
67
|
+
return params;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* How much of the mailbox a request reaches, read off the request itself.
|
|
72
|
+
*
|
|
73
|
+
* Every criterion the inbox sends today is a column on the row, so the server
|
|
74
|
+
* answers with a `where` over the whole mailbox and an empty result means the
|
|
75
|
+
* folder holds no matching mail. `senderTrust` and `dkimMismatch` are resolved
|
|
76
|
+
* by enriching a bounded window instead (design D7, D10), so a request carrying
|
|
77
|
+
* either has only seen the pages it read. Derived rather than declared: the
|
|
78
|
+
* completeness sentence the empty state renders is true of the query that was
|
|
79
|
+
* issued, not of the criteria that happen to ship today.
|
|
80
|
+
*/
|
|
81
|
+
export const filterReach = (query: ThreadSearchQuery): FilterReach =>
|
|
82
|
+
(query.senderTrust?.length ?? 0) > 0 || query.dkimMismatch !== undefined
|
|
83
|
+
? "loaded-pages"
|
|
84
|
+
: "whole-folder";
|
|
85
|
+
|
|
86
|
+
const FILTER_PARAM_NAMES = [
|
|
87
|
+
"category",
|
|
88
|
+
"unread",
|
|
89
|
+
"starred",
|
|
90
|
+
"attachments",
|
|
91
|
+
] as const;
|
|
92
|
+
|
|
93
|
+
const filterIdentity = (query: Record<string, unknown> | undefined): string =>
|
|
94
|
+
JSON.stringify(FILTER_PARAM_NAMES.map((name) => query?.[name] ?? null));
|
|
95
|
+
|
|
96
|
+
const queryOf = (queryKey: unknown): Record<string, unknown> | undefined => {
|
|
97
|
+
if (!Array.isArray(queryKey)) return undefined;
|
|
98
|
+
const [entry] = queryKey;
|
|
99
|
+
if (typeof entry !== "object" || entry === null) return undefined;
|
|
100
|
+
const query: unknown = (entry as { query?: unknown }).query;
|
|
101
|
+
if (typeof query !== "object" || query === null) return undefined;
|
|
102
|
+
return query as Record<string, unknown>;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Whether a cached list query was fetched under the same filter the request
|
|
107
|
+
* being made now carries.
|
|
108
|
+
*
|
|
109
|
+
* `keepPreviousData` renders the previous key's rows while a new page is in
|
|
110
|
+
* flight. That is right for a query the user is still typing and wrong the
|
|
111
|
+
* moment the filter changes: the previous predicate's mail would show under the
|
|
112
|
+
* new chip for one round trip, which is the failure this correction is about
|
|
113
|
+
* (design D18). An unrecognized key answers `false`, so the doubtful case
|
|
114
|
+
* restarts the list rather than rendering rows it cannot vouch for.
|
|
115
|
+
*/
|
|
116
|
+
export const sameInboxFilter = (
|
|
117
|
+
queryKey: unknown,
|
|
118
|
+
params: InboxFilterParams,
|
|
119
|
+
): boolean => {
|
|
120
|
+
const previous = queryOf(queryKey);
|
|
121
|
+
if (!previous) return false;
|
|
122
|
+
return filterIdentity(previous) === filterIdentity(params);
|
|
123
|
+
};
|
|
@@ -10,7 +10,11 @@
|
|
|
10
10
|
|
|
11
11
|
import assert from "node:assert";
|
|
12
12
|
import { describe, test } from "node:test";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
isSearchPending,
|
|
15
|
+
resolveOpenThread,
|
|
16
|
+
resolveSelectedThread,
|
|
17
|
+
} from "./search-pending.ts";
|
|
14
18
|
|
|
15
19
|
describe("isSearchPending (reading-pane suppression guard)", () => {
|
|
16
20
|
test("no search: both empty — not pending", () => {
|
|
@@ -59,3 +63,37 @@ describe("resolveSelectedThread (#623 reading-pane resolution)", () => {
|
|
|
59
63
|
assert.equal(selectedThread, undefined);
|
|
60
64
|
});
|
|
61
65
|
});
|
|
66
|
+
|
|
67
|
+
describe("resolveOpenThread (#306 filtered list)", () => {
|
|
68
|
+
const listed = { messageId: "msg-001", subject: "listed" };
|
|
69
|
+
const snapshot = { messageId: "msg-001", subject: "snapshot" };
|
|
70
|
+
|
|
71
|
+
test("prefers the row the list returned", () => {
|
|
72
|
+
assert.equal(resolveOpenThread(listed, snapshot, "msg-001", false), listed);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("keeps the open thread when a filter pages it out of the list", () => {
|
|
76
|
+
assert.equal(
|
|
77
|
+
resolveOpenThread(undefined, snapshot, "msg-001", false),
|
|
78
|
+
snapshot,
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("drops a snapshot of a message that is no longer selected", () => {
|
|
83
|
+
assert.equal(
|
|
84
|
+
resolveOpenThread(undefined, snapshot, "msg-002", false),
|
|
85
|
+
undefined,
|
|
86
|
+
);
|
|
87
|
+
assert.equal(
|
|
88
|
+
resolveOpenThread(undefined, snapshot, undefined, false),
|
|
89
|
+
undefined,
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("a pending search still closes the pane (#539)", () => {
|
|
94
|
+
assert.equal(
|
|
95
|
+
resolveOpenThread(undefined, snapshot, "msg-001", true),
|
|
96
|
+
undefined,
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -22,3 +22,28 @@ export const resolveSelectedThread = <T extends { messageId: string }>(
|
|
|
22
22
|
if (pending || !selectedMessageId) return undefined;
|
|
23
23
|
return threads.find((t) => t.messageId === selectedMessageId);
|
|
24
24
|
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The thread the reading pane shows, once the list is a server-side query
|
|
28
|
+
* (#306).
|
|
29
|
+
*
|
|
30
|
+
* The list used to hold every loaded row and filter a copy, so an open thread
|
|
31
|
+
* could always be found again in the unfiltered set. A filtered list is now the
|
|
32
|
+
* server's answer to one predicate, and a chip the open message does not match
|
|
33
|
+
* pages it out — so what the user opened is kept as a snapshot and answers for
|
|
34
|
+
* itself until they open something else. That is a derivation over the user's
|
|
35
|
+
* own selection, which stays on this side of the boundary.
|
|
36
|
+
*
|
|
37
|
+
* The snapshot is ignored while a search debounce is pending, so the pane still
|
|
38
|
+
* clears the instant a new search starts (#539).
|
|
39
|
+
*/
|
|
40
|
+
export const resolveOpenThread = <T extends { messageId: string }>(
|
|
41
|
+
listed: T | undefined,
|
|
42
|
+
opened: T | undefined,
|
|
43
|
+
selectedMessageId: string | undefined,
|
|
44
|
+
pending: boolean,
|
|
45
|
+
): T | undefined => {
|
|
46
|
+
if (listed) return listed;
|
|
47
|
+
if (pending || !selectedMessageId) return undefined;
|
|
48
|
+
return opened?.messageId === selectedMessageId ? opened : undefined;
|
|
49
|
+
};
|