@remit/web-client 0.0.246 → 0.0.248
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/config-import/ConfigImportWizard.tsx +3 -1
- package/src/components/config-import/config-import.fixtures.ts +4 -4
- package/src/components/config-import/config-import.stories.tsx +13 -11
- package/src/components/config-import/steps.tsx +14 -17
- package/src/components/mail/FlaggedList.tsx +78 -28
- package/src/components/mail/MailListHeader.tsx +12 -4
- package/src/components/mail/MailViewChrome.tsx +2 -1
- package/src/components/mail/MailboxPane.tsx +5 -11
- package/src/hooks/useStarredThreads.test.ts +76 -0
- package/src/hooks/useStarredThreads.ts +99 -8
- package/src/lib/config-import.test.ts +45 -29
- package/src/lib/config-import.ts +30 -29
- package/src/lib/flagged-criteria.test.ts +99 -0
- package/src/lib/flagged-criteria.ts +61 -0
- package/src/lib/inbox-filters.ts +24 -3
- package/src/lib/starred-rows.ts +10 -5
- package/src/lib/thread-search-tokens.ts +32 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.248",
|
|
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": {
|
|
@@ -565,7 +565,9 @@ export function ConfigImportWizard({ onDone }: ConfigImportWizardProps) {
|
|
|
565
565
|
results={sectionResults(report)}
|
|
566
566
|
message={failed?.message ?? "The import stopped before it finished."}
|
|
567
567
|
raw={`${failed?.code ?? WRITE_FAILURE_CODE}: ${failed?.message ?? "no message"}`}
|
|
568
|
-
|
|
568
|
+
// `applied` is the server's answer to what survived: false means the
|
|
569
|
+
// write was undone whole, and no section may read as landed.
|
|
570
|
+
undone={!report.applied}
|
|
569
571
|
onBack={goToFile}
|
|
570
572
|
onNext={() => runImport("apply", "merge")}
|
|
571
573
|
/>
|
|
@@ -182,11 +182,11 @@ export const partialImportReport: RemitImapConfigImportReport = report({
|
|
|
182
182
|
});
|
|
183
183
|
|
|
184
184
|
/**
|
|
185
|
-
* The same stop
|
|
186
|
-
*
|
|
187
|
-
*
|
|
185
|
+
* The same stop on a backend that imports inside one transaction (#1093):
|
|
186
|
+
* `applied` is false and `items` holds only the rejected entry, because the
|
|
187
|
+
* rollback emptied the rest. No section may read as landed.
|
|
188
188
|
*/
|
|
189
|
-
export const
|
|
189
|
+
export const rolledBackImportReport: RemitImapConfigImportReport = report({
|
|
190
190
|
applied: false,
|
|
191
191
|
items: [
|
|
192
192
|
item(
|
|
@@ -19,9 +19,9 @@ import {
|
|
|
19
19
|
cleanRunReport,
|
|
20
20
|
dryRunReport,
|
|
21
21
|
importedAccounts,
|
|
22
|
-
nothingLandedImportReport,
|
|
23
22
|
partialImportReport,
|
|
24
23
|
rejectedReports,
|
|
24
|
+
rolledBackImportReport,
|
|
25
25
|
} from "./config-import.fixtures";
|
|
26
26
|
import {
|
|
27
27
|
AccountCredentialsList,
|
|
@@ -417,7 +417,6 @@ export const PartiallyLandedImportUnnamed: Story = {
|
|
|
417
417
|
results={sectionResults(report)}
|
|
418
418
|
message={writeFailure(report)?.message ?? ""}
|
|
419
419
|
raw="import_write_failed: the store refused the write"
|
|
420
|
-
nothingLanded={!report.applied}
|
|
421
420
|
/>
|
|
422
421
|
);
|
|
423
422
|
},
|
|
@@ -430,23 +429,26 @@ export const PartiallyLandedImport: Story = {
|
|
|
430
429
|
results={sectionResults(partialImportReport)}
|
|
431
430
|
message={writeFailure(partialImportReport)?.message ?? ""}
|
|
432
431
|
raw={`import_write_failed: ${writeFailure(partialImportReport)?.message ?? ""}`}
|
|
433
|
-
nothingLanded={!partialImportReport.applied}
|
|
434
432
|
/>
|
|
435
433
|
),
|
|
436
434
|
};
|
|
437
435
|
|
|
438
436
|
/**
|
|
439
|
-
* The same stop
|
|
440
|
-
*
|
|
441
|
-
* tick, and the screen counts nothing as landed.
|
|
437
|
+
* The same stop on a transactional backend: nothing landed, and the screen
|
|
438
|
+
* says the import was undone rather than counting sections (#1093).
|
|
442
439
|
*/
|
|
443
|
-
export const
|
|
440
|
+
export const RolledBackImport: Story = {
|
|
444
441
|
render: () => (
|
|
445
442
|
<StepPartialImport
|
|
446
|
-
results={sectionResults(
|
|
447
|
-
message={
|
|
448
|
-
|
|
449
|
-
|
|
443
|
+
results={sectionResults(rolledBackImportReport)}
|
|
444
|
+
message={
|
|
445
|
+
writeFailure(rolledBackImportReport)?.message ??
|
|
446
|
+
"The import stopped before it finished."
|
|
447
|
+
}
|
|
448
|
+
raw={`import_write_failed: ${
|
|
449
|
+
writeFailure(rolledBackImportReport)?.message ?? "no message"
|
|
450
|
+
}`}
|
|
451
|
+
undone={!rolledBackImportReport.applied}
|
|
450
452
|
/>
|
|
451
453
|
),
|
|
452
454
|
};
|
|
@@ -704,15 +704,16 @@ export function StepFileRejected({
|
|
|
704
704
|
}
|
|
705
705
|
|
|
706
706
|
/**
|
|
707
|
-
* A section nobody can vouch for reads as failed, never as pending
|
|
708
|
-
*
|
|
709
|
-
*
|
|
710
|
-
*
|
|
711
|
-
*
|
|
707
|
+
* A section nobody can vouch for reads as failed, never as pending. Pending
|
|
708
|
+
* says "still coming"; this section is finished and its outcome is unknown,
|
|
709
|
+
* which is the row the reader has to go and check for themselves. A rolled-
|
|
710
|
+
* back section is the same answer from the server's side: it is not coming,
|
|
711
|
+
* because the transaction that wrote it undid itself.
|
|
712
712
|
*/
|
|
713
713
|
function resultState(state: SectionResult["state"]) {
|
|
714
714
|
if (state === "landed") return "ok" as const;
|
|
715
|
-
if (state === "failed" || state === "unknown"
|
|
715
|
+
if (state === "failed" || state === "unknown" || state === "rolled-back")
|
|
716
|
+
return "failed" as const;
|
|
716
717
|
return "pending" as const;
|
|
717
718
|
}
|
|
718
719
|
|
|
@@ -720,7 +721,7 @@ export function StepPartialImport({
|
|
|
720
721
|
results,
|
|
721
722
|
message,
|
|
722
723
|
raw,
|
|
723
|
-
|
|
724
|
+
undone,
|
|
724
725
|
onBack,
|
|
725
726
|
onNext,
|
|
726
727
|
}: {
|
|
@@ -728,8 +729,8 @@ export function StepPartialImport({
|
|
|
728
729
|
/** The server's own account of what survived, which only it knows. */
|
|
729
730
|
message: string;
|
|
730
731
|
raw: string;
|
|
731
|
-
/**
|
|
732
|
-
|
|
732
|
+
/** `report.applied === false`: the import ran in a transaction and wrote nothing, so no section landed. */
|
|
733
|
+
undone?: boolean;
|
|
733
734
|
} & StepNav) {
|
|
734
735
|
const landed = results.filter((result) => result.state === "landed").length;
|
|
735
736
|
const unknown = results.some((result) => result.state === "unknown");
|
|
@@ -737,14 +738,10 @@ export function StepPartialImport({
|
|
|
737
738
|
<WizardShell
|
|
738
739
|
steps={IMPORT_STEPS}
|
|
739
740
|
activeStep={1}
|
|
740
|
-
title=
|
|
741
|
-
nothingLanded
|
|
742
|
-
? "The import stopped, and nothing landed"
|
|
743
|
-
: "The import stopped part-way"
|
|
744
|
-
}
|
|
741
|
+
title="The import stopped part-way"
|
|
745
742
|
subtitle={
|
|
746
|
-
|
|
747
|
-
? "
|
|
743
|
+
undone
|
|
744
|
+
? "Nothing was written — the whole import was undone."
|
|
748
745
|
: unknown
|
|
749
746
|
? "It did not say how far it got, so check Settings before importing again."
|
|
750
747
|
: `${landed} of ${results.length} sections landed.`
|
|
@@ -755,7 +752,7 @@ export function StepPartialImport({
|
|
|
755
752
|
Choose another file
|
|
756
753
|
</Button>
|
|
757
754
|
<Button variant="primary" onClick={onNext}>
|
|
758
|
-
|
|
755
|
+
Retry the rest
|
|
759
756
|
</Button>
|
|
760
757
|
</>
|
|
761
758
|
}
|
|
@@ -12,9 +12,16 @@
|
|
|
12
12
|
* the `MailHeader` + filter expando; the kit `MessageListPane` (flat, no
|
|
13
13
|
* `briefFilters`) owns the loading / empty / error chrome and keyboard hints,
|
|
14
14
|
* with a consumer-supplied `listBody` so the real rows render at every width.
|
|
15
|
+
*
|
|
16
|
+
* The category and attribute chips are query parameters, and the header's
|
|
17
|
+
* unread count is the server's own (#308). Both used to be computed over the
|
|
18
|
+
* pages the user happened to have loaded, so a category whose mail sat below
|
|
19
|
+
* the newest page showed an empty list, and the count grew with every press of
|
|
20
|
+
* "load more" while being presented as a total.
|
|
15
21
|
*/
|
|
16
22
|
import {
|
|
17
23
|
flaggedFilterConfig,
|
|
24
|
+
type MessageListFilter,
|
|
18
25
|
MessageListPane,
|
|
19
26
|
type SearchResult,
|
|
20
27
|
type ThreadRowData,
|
|
@@ -24,14 +31,21 @@ import { type RefObject, useCallback, useMemo, useState } from "react";
|
|
|
24
31
|
import { formatErrorMessage } from "@/components/ui/ErrorState";
|
|
25
32
|
import { useIsDesktop } from "@/hooks/useMediaQuery";
|
|
26
33
|
import { useSearchTokenContext } from "@/hooks/useSearchTokenContext";
|
|
27
|
-
import {
|
|
34
|
+
import {
|
|
35
|
+
useStarredTextSearch,
|
|
36
|
+
useStarredThreads,
|
|
37
|
+
useStarredUnreadCount,
|
|
38
|
+
} from "@/hooks/useStarredThreads";
|
|
28
39
|
import type { TriageContextUpdate } from "@/hooks/useTriageLayer";
|
|
29
40
|
import {
|
|
30
41
|
matchesBriefSearch,
|
|
31
42
|
matchesSearchTokens,
|
|
43
|
+
mergeSearchRows,
|
|
32
44
|
toThreadRowData,
|
|
33
45
|
} from "@/lib/brief";
|
|
34
46
|
import { buildBugReportContext, buildGitHubIssueUrl } from "@/lib/bug-report";
|
|
47
|
+
import { flaggedCriteria } from "@/lib/flagged-criteria";
|
|
48
|
+
import { categoryLabel } from "@/lib/inbox-filters";
|
|
35
49
|
import { useListHeaderChrome } from "@/lib/list-header-chrome";
|
|
36
50
|
import { useMailContext } from "@/lib/mail-context";
|
|
37
51
|
import { rowToSearchResult } from "@/lib/search-result";
|
|
@@ -51,10 +65,8 @@ import {
|
|
|
51
65
|
useThreadListSelection,
|
|
52
66
|
} from "./ThreadListInteraction";
|
|
53
67
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
attachment: (t) => t.hasAttachment === true,
|
|
57
|
-
};
|
|
68
|
+
/** One page of the server-filtered text search, merged with the listing below. */
|
|
69
|
+
const TEXT_SEARCH_PAGE_SIZE = 200;
|
|
58
70
|
|
|
59
71
|
/**
|
|
60
72
|
* The wizard this view's verbs walk, and the one its search entry lands on.
|
|
@@ -138,6 +150,26 @@ export function FlaggedList({
|
|
|
138
150
|
setActiveFilters(new Set());
|
|
139
151
|
}, []);
|
|
140
152
|
|
|
153
|
+
const { freeText: sq, tokens: queryTokens } = parseSearchTokens(
|
|
154
|
+
searchQuery.trim().toLowerCase(),
|
|
155
|
+
tokenContext,
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
// The chips and the tokens together, as query parameters: a category typed as
|
|
159
|
+
// `category:personal` narrows the request exactly as the chip does.
|
|
160
|
+
const { criteria, residual: residualTokens } = useMemo(
|
|
161
|
+
() =>
|
|
162
|
+
flaggedCriteria(
|
|
163
|
+
{ category: selectedCategory, attributes: activeFilters },
|
|
164
|
+
queryTokens,
|
|
165
|
+
),
|
|
166
|
+
[selectedCategory, activeFilters, queryTokens],
|
|
167
|
+
);
|
|
168
|
+
const textCriteria = useMemo(
|
|
169
|
+
() => (sq ? { ...criteria, query: sq } : criteria),
|
|
170
|
+
[criteria, sq],
|
|
171
|
+
);
|
|
172
|
+
|
|
141
173
|
const {
|
|
142
174
|
threads,
|
|
143
175
|
isLoading,
|
|
@@ -147,27 +179,27 @@ export function FlaggedList({
|
|
|
147
179
|
fetchNextPage,
|
|
148
180
|
hasNextPage,
|
|
149
181
|
isFetchingNextPage,
|
|
150
|
-
} = useStarredThreads();
|
|
151
|
-
|
|
152
|
-
const { freeText: sq, tokens: queryTokens } = parseSearchTokens(
|
|
153
|
-
searchQuery.trim().toLowerCase(),
|
|
154
|
-
tokenContext,
|
|
155
|
-
);
|
|
182
|
+
} = useStarredThreads(criteria);
|
|
183
|
+
const textMatches = useStarredTextSearch(textCriteria, TEXT_SEARCH_PAGE_SIZE);
|
|
156
184
|
|
|
157
185
|
const rows = useMemo<ThreadRowData[]>(() => {
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
(
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
186
|
+
const listed = dedupeByThread(threads).map(toThreadRowData);
|
|
187
|
+
// No free text: the server-filtered listing as it comes.
|
|
188
|
+
const matched = sq
|
|
189
|
+
? mergeSearchRows(
|
|
190
|
+
// The snippet half, over the rows already loaded. It only ever adds
|
|
191
|
+
// to the server's set — it is never what decides membership.
|
|
192
|
+
listed.filter((t) => matchesBriefSearch(t, sq)),
|
|
193
|
+
dedupeByThread(textMatches).map(toThreadRowData),
|
|
194
|
+
)
|
|
195
|
+
: listed;
|
|
196
|
+
// What is left is what no parameter can carry: `before:`, `after:`, `in:`,
|
|
197
|
+
// `account:`, and a token the chips overruled. The collapse runs last so
|
|
198
|
+
// the two halves of a text search cannot land one conversation twice.
|
|
199
|
+
return dedupeByThread(matched).filter((t) =>
|
|
200
|
+
matchesSearchTokens(t, residualTokens),
|
|
201
|
+
);
|
|
202
|
+
}, [threads, textMatches, sq, residualTokens]);
|
|
171
203
|
|
|
172
204
|
const openRow = useCallback(
|
|
173
205
|
(id: string, options?: OpenMessageOptions) => {
|
|
@@ -197,10 +229,24 @@ export function FlaggedList({
|
|
|
197
229
|
[rows, resultFolderIndex],
|
|
198
230
|
);
|
|
199
231
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
);
|
|
232
|
+
// The server's count over the whole collection under the active criteria.
|
|
233
|
+
// `null` while it is in flight and `null` when it cannot be had, and the
|
|
234
|
+
// header then shows no number — never a page length dressed as a total.
|
|
235
|
+
const unreadCount = useStarredUnreadCount(textCriteria) ?? null;
|
|
236
|
+
|
|
237
|
+
// An empty list has to say how much was looked at, and the answer comes off
|
|
238
|
+
// the request. Every chip and every carried token is a column on the row, so
|
|
239
|
+
// the server answered over the whole collection; a residual token, or the
|
|
240
|
+
// snippet half of a free-text search, only ever saw the pages loaded so far.
|
|
241
|
+
const filterLabel = categoryLabel(selectedCategory);
|
|
242
|
+
const listFilter: MessageListFilter | undefined = filterLabel
|
|
243
|
+
? {
|
|
244
|
+
label: filterLabel,
|
|
245
|
+
reach:
|
|
246
|
+
residualTokens.length > 0 || sq ? "loaded-pages" : "whole-folder",
|
|
247
|
+
onClear: clearFilters,
|
|
248
|
+
}
|
|
249
|
+
: undefined;
|
|
204
250
|
|
|
205
251
|
const listState = isLoading
|
|
206
252
|
? "loading"
|
|
@@ -277,6 +323,10 @@ export function FlaggedList({
|
|
|
277
323
|
flatList
|
|
278
324
|
hideHeader
|
|
279
325
|
listState={chrome.searchResults ? "ready" : listState}
|
|
326
|
+
listFilter={listFilter}
|
|
327
|
+
// Flagged spans accounts and folders, so it is a collection and
|
|
328
|
+
// never "this mailbox". It names itself.
|
|
329
|
+
listScopeLabel="Starred"
|
|
280
330
|
searchQuery={sq ? searchQuery : undefined}
|
|
281
331
|
errorMessage={isError ? formatErrorMessage(error) : undefined}
|
|
282
332
|
onRetry={() => refetch()}
|
|
@@ -95,7 +95,13 @@ import {
|
|
|
95
95
|
|
|
96
96
|
export interface MailListHeaderProps {
|
|
97
97
|
title: string;
|
|
98
|
-
|
|
98
|
+
/**
|
|
99
|
+
* Unread count beside the title. `null` is the one way to say the number is
|
|
100
|
+
* not known, and the header then shows none: a count the server has not
|
|
101
|
+
* answered is never replaced by a figure derived from the rows that happen to
|
|
102
|
+
* be loaded (#308). Required so a caller that has no number says so.
|
|
103
|
+
*/
|
|
104
|
+
unreadCount: number | null;
|
|
99
105
|
/** The list body (filter sheet / sections / virtualized rows). */
|
|
100
106
|
children: ReactNode;
|
|
101
107
|
/**
|
|
@@ -442,9 +448,11 @@ export function MailListHeader({
|
|
|
442
448
|
),
|
|
443
449
|
titleMeta: (
|
|
444
450
|
<>
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
451
|
+
{unreadCount === null ? null : (
|
|
452
|
+
<span className="shrink-0 text-2xs text-fg-subtle">
|
|
453
|
+
{unreadCount.toLocaleString()} unread
|
|
454
|
+
</span>
|
|
455
|
+
)}
|
|
448
456
|
<FilterToggle />
|
|
449
457
|
{refreshControl}
|
|
450
458
|
</>
|
|
@@ -30,7 +30,8 @@ import { MailListHeader } from "./MailListHeader";
|
|
|
30
30
|
|
|
31
31
|
interface MailViewChromeProps {
|
|
32
32
|
title: string;
|
|
33
|
-
|
|
33
|
+
/** Unread count beside the title; `null` shows no number — see `MailListHeader`. */
|
|
34
|
+
unreadCount: number | null;
|
|
34
35
|
preset: FilterPreset;
|
|
35
36
|
selectedCategory: string;
|
|
36
37
|
activeFilters: ReadonlySet<string>;
|
|
@@ -98,6 +98,7 @@ import { useUpdateAddressFlags } from "@/hooks/useUpdateAddressFlags";
|
|
|
98
98
|
import type { ConversationTarget } from "@/lib/conversation-target";
|
|
99
99
|
import { dedupeThreadMessages } from "@/lib/dedupe-thread-messages";
|
|
100
100
|
import {
|
|
101
|
+
categoryLabel,
|
|
101
102
|
filterReach,
|
|
102
103
|
hasInboxFilter,
|
|
103
104
|
type InboxFilterCriteria,
|
|
@@ -156,7 +157,8 @@ interface MailboxPaneContextValue {
|
|
|
156
157
|
mailboxAccountId: string | undefined;
|
|
157
158
|
mailboxAccountLoading: boolean;
|
|
158
159
|
mailboxName: string | null;
|
|
159
|
-
|
|
160
|
+
/** The mailbox's unseen total, or null until it resolves — never a page length. */
|
|
161
|
+
unreadCount: number | null;
|
|
160
162
|
isDraftsMailbox: boolean;
|
|
161
163
|
// Rescue-from-Spam: true on the account's Junk/Spam folder, with the
|
|
162
164
|
// suspected-safe messages `useRescueCandidates` fetched. Drives the rescue
|
|
@@ -240,14 +242,6 @@ interface MailboxPaneContextValue {
|
|
|
240
242
|
* filtered path pages like the unfiltered one. */
|
|
241
243
|
const THREADS_PAGE_SIZE = 50;
|
|
242
244
|
|
|
243
|
-
/** Chip id → the label the empty state names the filter by. `all` is absent:
|
|
244
|
-
* it is how the category is cleared, not a category. */
|
|
245
|
-
const CATEGORY_LABELS = new Map(
|
|
246
|
-
inboxFilterConfig()
|
|
247
|
-
.categories.filter((category) => category.id !== "all")
|
|
248
|
-
.map((category) => [category.id, category.label]),
|
|
249
|
-
);
|
|
250
|
-
|
|
251
245
|
const MailboxPaneCtx = createContext<MailboxPaneContextValue | null>(null);
|
|
252
246
|
|
|
253
247
|
function useMailboxPane(): MailboxPaneContextValue {
|
|
@@ -433,7 +427,7 @@ function MailboxPaneProvider({
|
|
|
433
427
|
// The empty state has to say how much was read, and the reach comes off the
|
|
434
428
|
// request rather than the call site: the day a chip is answered over a window
|
|
435
429
|
// instead of the whole mailbox, the sentence changes with it.
|
|
436
|
-
const filterLabel =
|
|
430
|
+
const filterLabel = categoryLabel(filterCategory);
|
|
437
431
|
const listFilter: MessageListFilter | undefined = filterLabel
|
|
438
432
|
? {
|
|
439
433
|
label: filterLabel,
|
|
@@ -512,7 +506,7 @@ function MailboxPaneProvider({
|
|
|
512
506
|
// The mailbox's own unseen total. A count over the loaded pages undercounts
|
|
513
507
|
// every mailbox larger than one page and creeps upward as the user scrolls,
|
|
514
508
|
// so there is no fallback: until the mailbox resolves there is no number.
|
|
515
|
-
const unreadCount = useCurrentMailboxUnseenCount({ accounts }) ??
|
|
509
|
+
const unreadCount = useCurrentMailboxUnseenCount({ accounts }) ?? null;
|
|
516
510
|
|
|
517
511
|
const toolbarActions = useThreadActions({
|
|
518
512
|
thread: selectedThread,
|
|
@@ -22,6 +22,7 @@ import { renderToString } from "react-dom/server";
|
|
|
22
22
|
import {
|
|
23
23
|
starredThreadsQueryKey,
|
|
24
24
|
useStarredThreads,
|
|
25
|
+
useStarredUnreadCount,
|
|
25
26
|
} from "./useStarredThreads.js";
|
|
26
27
|
|
|
27
28
|
interface Page {
|
|
@@ -141,3 +142,78 @@ describe("useStarredThreads", () => {
|
|
|
141
142
|
assert.deepEqual(threads, []);
|
|
142
143
|
});
|
|
143
144
|
});
|
|
145
|
+
|
|
146
|
+
// #308: the chips were a pass over the pages loaded so far, and the header
|
|
147
|
+
// count was their length. Both are the server's answer now.
|
|
148
|
+
describe("starred criteria", () => {
|
|
149
|
+
test("carries the chips as query parameters", () => {
|
|
150
|
+
const [params] = starredThreadsQueryKey({
|
|
151
|
+
category: ["social"],
|
|
152
|
+
unread: true,
|
|
153
|
+
attachments: true,
|
|
154
|
+
}) as [
|
|
155
|
+
{
|
|
156
|
+
query?: {
|
|
157
|
+
starred?: boolean;
|
|
158
|
+
category?: string[];
|
|
159
|
+
unread?: boolean;
|
|
160
|
+
attachments?: boolean;
|
|
161
|
+
};
|
|
162
|
+
},
|
|
163
|
+
];
|
|
164
|
+
assert.equal(params.query?.starred, true);
|
|
165
|
+
assert.deepEqual(params.query?.category, ["social"]);
|
|
166
|
+
assert.equal(params.query?.unread, true);
|
|
167
|
+
assert.equal(params.query?.attachments, true);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("an unfiltered listing asks for exactly what it asked for before", () => {
|
|
171
|
+
assert.deepEqual(starredThreadsQueryKey({}), starredThreadsQueryKey());
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe("useStarredUnreadCount", () => {
|
|
176
|
+
function renderCount(seed?: { count?: number }): number | undefined {
|
|
177
|
+
const client = new QueryClient({
|
|
178
|
+
defaultOptions: { queries: { retry: false } },
|
|
179
|
+
});
|
|
180
|
+
if (seed) {
|
|
181
|
+
client.setQueryData(
|
|
182
|
+
unifiedThreadOperationsListAllThreadsQueryKey({
|
|
183
|
+
query: {
|
|
184
|
+
starred: true,
|
|
185
|
+
order: "desc",
|
|
186
|
+
unread: true,
|
|
187
|
+
count: true,
|
|
188
|
+
results: false,
|
|
189
|
+
},
|
|
190
|
+
}),
|
|
191
|
+
seed,
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
let captured: number | undefined;
|
|
196
|
+
function Capture() {
|
|
197
|
+
captured = useStarredUnreadCount();
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
renderToString(
|
|
201
|
+
createElement(
|
|
202
|
+
QueryClientProvider,
|
|
203
|
+
{ client },
|
|
204
|
+
createElement(Capture),
|
|
205
|
+
) as never,
|
|
206
|
+
);
|
|
207
|
+
return captured;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
test("reports the server's count", () => {
|
|
211
|
+
assert.equal(renderCount({ count: 137 }), 137);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// A count the server has not answered is no number at all. A page length
|
|
215
|
+
// dressed as a total is what this replaces.
|
|
216
|
+
test("reports nothing when the server has not answered", () => {
|
|
217
|
+
assert.equal(renderCount(), undefined);
|
|
218
|
+
});
|
|
219
|
+
});
|
|
@@ -8,20 +8,43 @@
|
|
|
8
8
|
* selection from any other listing reintroduces issue #70 — the INBOX-scoped
|
|
9
9
|
* unified listing cannot see a starred thread filed elsewhere, so its row is
|
|
10
10
|
* clickable but resolves to nothing and no reading pane opens.
|
|
11
|
+
*
|
|
12
|
+
* The filters travel as query parameters rather than as a pass over the pages
|
|
13
|
+
* loaded so far. A starred collection is paged, so a category whose mail sits
|
|
14
|
+
* below the newest page rendered an empty list however much of it the
|
|
15
|
+
* collection held (#308); the server answers the predicate over the whole
|
|
16
|
+
* collection instead, and a page comes back full of matches.
|
|
11
17
|
*/
|
|
12
18
|
import { unifiedThreadOperationsListAllThreadsQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
13
19
|
import { unifiedThreadOperationsListAllThreads } from "@remit/api-http-client/sdk.gen.ts";
|
|
14
20
|
import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
15
|
-
import { useInfiniteQuery } from "@tanstack/react-query";
|
|
21
|
+
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
|
16
22
|
import { useMemo } from "react";
|
|
23
|
+
import type { InboxFilterParams } from "@/lib/inbox-filters";
|
|
24
|
+
|
|
25
|
+
/** The criteria the Flagged view narrows its listing by, all server-applied. */
|
|
26
|
+
export interface StarredCriteria extends InboxFilterParams {
|
|
27
|
+
/** Free text, matched against subject and From over the whole collection. */
|
|
28
|
+
query?: string;
|
|
29
|
+
/** Page size, set only by the single-page text search. */
|
|
30
|
+
limit?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// `starred` and `order` are the view itself, not a criterion, so they are set
|
|
34
|
+
// last: no chip can widen the listing past starred mail or reorder it.
|
|
35
|
+
const starredQuery = (criteria: StarredCriteria) => ({
|
|
36
|
+
...criteria,
|
|
37
|
+
starred: true as const,
|
|
38
|
+
order: "desc" as const,
|
|
39
|
+
});
|
|
17
40
|
|
|
18
|
-
export const starredThreadsQueryKey = () =>
|
|
41
|
+
export const starredThreadsQueryKey = (criteria: StarredCriteria = {}) =>
|
|
19
42
|
unifiedThreadOperationsListAllThreadsQueryKey({
|
|
20
|
-
query:
|
|
43
|
+
query: starredQuery(criteria),
|
|
21
44
|
});
|
|
22
45
|
|
|
23
46
|
interface StarredThreads {
|
|
24
|
-
/** Every starred thread across the pages loaded so far, newest first. */
|
|
47
|
+
/** Every matching starred thread across the pages loaded so far, newest first. */
|
|
25
48
|
threads: RemitImapThreadMessageResponse[];
|
|
26
49
|
isLoading: boolean;
|
|
27
50
|
isError: boolean;
|
|
@@ -32,7 +55,9 @@ interface StarredThreads {
|
|
|
32
55
|
isFetchingNextPage: boolean;
|
|
33
56
|
}
|
|
34
57
|
|
|
35
|
-
export function useStarredThreads(
|
|
58
|
+
export function useStarredThreads(
|
|
59
|
+
criteria: StarredCriteria = {},
|
|
60
|
+
): StarredThreads {
|
|
36
61
|
const {
|
|
37
62
|
data,
|
|
38
63
|
isLoading,
|
|
@@ -43,12 +68,11 @@ export function useStarredThreads(): StarredThreads {
|
|
|
43
68
|
hasNextPage,
|
|
44
69
|
isFetchingNextPage,
|
|
45
70
|
} = useInfiniteQuery({
|
|
46
|
-
queryKey: starredThreadsQueryKey(),
|
|
71
|
+
queryKey: starredThreadsQueryKey(criteria),
|
|
47
72
|
queryFn: async ({ pageParam }) => {
|
|
48
73
|
const { data: page } = await unifiedThreadOperationsListAllThreads({
|
|
49
74
|
query: {
|
|
50
|
-
|
|
51
|
-
order: "desc",
|
|
75
|
+
...starredQuery(criteria),
|
|
52
76
|
continuationToken: pageParam,
|
|
53
77
|
},
|
|
54
78
|
throwOnError: true,
|
|
@@ -76,3 +100,70 @@ export function useStarredThreads(): StarredThreads {
|
|
|
76
100
|
isFetchingNextPage,
|
|
77
101
|
};
|
|
78
102
|
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The server's own text match over the whole starred collection, as one page.
|
|
106
|
+
*
|
|
107
|
+
* The free-text filter has two halves that no single request covers: the server
|
|
108
|
+
* matches subject and From over every starred message, and the client can also
|
|
109
|
+
* match a snippet, but only on rows it has already loaded. The server's set is
|
|
110
|
+
* the filter — it is the half that sees the mail below the newest page — and
|
|
111
|
+
* the snippet pass complements it (#308). Same shape as the daily brief's
|
|
112
|
+
* unscoped search, which merges the same two halves.
|
|
113
|
+
*/
|
|
114
|
+
export function useStarredTextSearch(
|
|
115
|
+
criteria: StarredCriteria,
|
|
116
|
+
limit: number,
|
|
117
|
+
): RemitImapThreadMessageResponse[] {
|
|
118
|
+
const text = criteria.query ?? "";
|
|
119
|
+
const query = { ...starredQuery(criteria), limit };
|
|
120
|
+
const { data } = useQuery({
|
|
121
|
+
queryKey: unifiedThreadOperationsListAllThreadsQueryKey({ query }),
|
|
122
|
+
queryFn: async () => {
|
|
123
|
+
const { data: page } = await unifiedThreadOperationsListAllThreads({
|
|
124
|
+
query,
|
|
125
|
+
throwOnError: true,
|
|
126
|
+
});
|
|
127
|
+
return page;
|
|
128
|
+
},
|
|
129
|
+
enabled: text.length > 0,
|
|
130
|
+
staleTime: 30_000,
|
|
131
|
+
});
|
|
132
|
+
return useMemo(() => data?.items ?? [], [data]);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* How many of the matching starred messages are unread, counted by the server.
|
|
137
|
+
*
|
|
138
|
+
* Its own request, so no cursor enters the key and pressing "load more" cannot
|
|
139
|
+
* move the number — the count is over the whole collection under the active
|
|
140
|
+
* criteria, which is what the header claims it is. A page-length count grows
|
|
141
|
+
* with every press and reads as a total, which is the defect this replaces
|
|
142
|
+
* (#308). `results: false` reads the count alone: no rows are fetched or
|
|
143
|
+
* enriched to answer it.
|
|
144
|
+
*
|
|
145
|
+
* Undefined until the server answers, and undefined when it cannot. The header
|
|
146
|
+
* shows no number rather than a made-up one.
|
|
147
|
+
*/
|
|
148
|
+
export function useStarredUnreadCount(
|
|
149
|
+
criteria: StarredCriteria = {},
|
|
150
|
+
): number | undefined {
|
|
151
|
+
const query = {
|
|
152
|
+
...starredQuery(criteria),
|
|
153
|
+
unread: true,
|
|
154
|
+
count: true,
|
|
155
|
+
results: false,
|
|
156
|
+
};
|
|
157
|
+
const { data } = useQuery({
|
|
158
|
+
queryKey: unifiedThreadOperationsListAllThreadsQueryKey({ query }),
|
|
159
|
+
queryFn: async () => {
|
|
160
|
+
const { data: page } = await unifiedThreadOperationsListAllThreads({
|
|
161
|
+
query,
|
|
162
|
+
throwOnError: true,
|
|
163
|
+
});
|
|
164
|
+
return page;
|
|
165
|
+
},
|
|
166
|
+
staleTime: 60_000,
|
|
167
|
+
});
|
|
168
|
+
return data?.count;
|
|
169
|
+
}
|
|
@@ -151,13 +151,13 @@ test("a stopped import splits the write order into landed, failed and never reac
|
|
|
151
151
|
assert.equal(byId.get("settings")?.state, "not-attempted");
|
|
152
152
|
});
|
|
153
153
|
|
|
154
|
-
test("
|
|
155
|
-
|
|
154
|
+
test("a rolled-back import lands nothing, however far the items say it got", () => {
|
|
155
|
+
// The shape a transactional backend answers with (#1093): `items` holds
|
|
156
|
+
// only the rejected entry, because the rollback emptied the rest.
|
|
157
|
+
const rolled = sectionResults(
|
|
156
158
|
reportOf({
|
|
157
159
|
applied: false,
|
|
158
|
-
items: [
|
|
159
|
-
item("filters", "Receipts", "rejected", "the store refused the write"),
|
|
160
|
-
],
|
|
160
|
+
items: [item("filters", "Receipts", "rejected", "the store refused")],
|
|
161
161
|
errors: [
|
|
162
162
|
{
|
|
163
163
|
code: "import_write_failed",
|
|
@@ -169,55 +169,71 @@ test("an import the server says wrote nothing marks no section landed, whatever
|
|
|
169
169
|
}),
|
|
170
170
|
);
|
|
171
171
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
byId.get("accounts")?.detail ?? "",
|
|
176
|
-
/Nothing from this section was written/,
|
|
172
|
+
assert.equal(
|
|
173
|
+
rolled.some((result) => result.state === "landed"),
|
|
174
|
+
false,
|
|
177
175
|
);
|
|
178
|
-
|
|
176
|
+
const byId = new Map(rolled.map((result) => [result.section, result]));
|
|
177
|
+
assert.equal(byId.get("accounts")?.state, "rolled-back");
|
|
178
|
+
assert.match(byId.get("accounts")?.detail ?? "", /Nothing was written/);
|
|
179
|
+
assert.equal(byId.get("labels")?.state, "rolled-back");
|
|
179
180
|
assert.equal(byId.get("filters")?.state, "failed");
|
|
180
|
-
assert.
|
|
181
|
-
assert.equal(byId.get("
|
|
182
|
-
assert.equal(
|
|
181
|
+
assert.match(byId.get("filters")?.detail ?? "", /Nothing was written/);
|
|
182
|
+
assert.equal(byId.get("addressFlags")?.state, "rolled-back");
|
|
183
|
+
assert.equal(byId.get("settings")?.state, "rolled-back");
|
|
183
184
|
});
|
|
184
185
|
|
|
185
|
-
test("
|
|
186
|
-
|
|
186
|
+
test("item verdicts cannot turn a rolled-back import back into a landed one", () => {
|
|
187
|
+
// `applied` is the server's own answer, and it outranks the counts: a
|
|
188
|
+
// report that claims writes while saying nothing was applied still lands
|
|
189
|
+
// nothing, because "your accounts are in" is the one answer this screen
|
|
190
|
+
// must never give without the server saying so.
|
|
191
|
+
const rolled = sectionResults(
|
|
187
192
|
reportOf({
|
|
188
193
|
applied: false,
|
|
189
194
|
items: [
|
|
190
|
-
item("accounts", "a@example.test", "
|
|
195
|
+
item("accounts", "a@example.test", "created"),
|
|
196
|
+
item("labels", "Receipts", "created"),
|
|
191
197
|
],
|
|
192
198
|
errors: [
|
|
193
199
|
{
|
|
194
200
|
code: "import_write_failed",
|
|
195
|
-
message:
|
|
196
|
-
|
|
197
|
-
details: { section: "accounts", key: "a@example.test" },
|
|
201
|
+
message: "the store refused the write",
|
|
202
|
+
details: { section: "filters", key: "Receipts" },
|
|
198
203
|
},
|
|
199
204
|
],
|
|
200
205
|
}),
|
|
201
206
|
);
|
|
202
207
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
208
|
+
assert.equal(
|
|
209
|
+
rolled.some((result) => result.state === "landed"),
|
|
210
|
+
false,
|
|
211
|
+
);
|
|
212
|
+
assert.deepEqual(
|
|
213
|
+
rolled
|
|
214
|
+
.filter((result) => result.section !== "filters")
|
|
215
|
+
.map((result) => result.state),
|
|
216
|
+
["rolled-back", "rolled-back", "rolled-back", "rolled-back"],
|
|
217
|
+
);
|
|
207
218
|
});
|
|
208
219
|
|
|
209
|
-
test("
|
|
210
|
-
const
|
|
220
|
+
test("a rolled-back import that names no section still says what happened", () => {
|
|
221
|
+
const rolled = sectionResults(
|
|
211
222
|
reportOf({
|
|
212
223
|
applied: false,
|
|
213
224
|
errors: [{ code: "import_write_failed", message: "the store refused" }],
|
|
214
225
|
}),
|
|
215
226
|
);
|
|
216
227
|
|
|
217
|
-
assert.
|
|
218
|
-
|
|
219
|
-
|
|
228
|
+
assert.equal(
|
|
229
|
+
rolled.some((result) => result.state === "landed"),
|
|
230
|
+
false,
|
|
231
|
+
);
|
|
232
|
+
assert.equal(
|
|
233
|
+
rolled.some((result) => result.state === "unknown"),
|
|
234
|
+
false,
|
|
220
235
|
);
|
|
236
|
+
assert.match(rolled[0].detail, /Nothing was written/);
|
|
221
237
|
});
|
|
222
238
|
|
|
223
239
|
test("a pending folder names the setting waiting on it, and repeats are folded", () => {
|
package/src/lib/config-import.ts
CHANGED
|
@@ -287,9 +287,9 @@ export const writeFailure = (
|
|
|
287
287
|
|
|
288
288
|
export type SectionOutcome =
|
|
289
289
|
| "landed"
|
|
290
|
-
| "not-landed"
|
|
291
290
|
| "failed"
|
|
292
291
|
| "not-attempted"
|
|
292
|
+
| "rolled-back"
|
|
293
293
|
| "unknown";
|
|
294
294
|
|
|
295
295
|
export interface SectionResult {
|
|
@@ -299,24 +299,23 @@ export interface SectionResult {
|
|
|
299
299
|
detail: string;
|
|
300
300
|
}
|
|
301
301
|
|
|
302
|
-
const NOT_LANDED_DETAIL = "Nothing from this section was written.";
|
|
303
|
-
|
|
304
302
|
/**
|
|
305
|
-
* What a stopped import
|
|
306
|
-
* the section the failure names splits the document in three: what is
|
|
307
|
-
* ran, it did not, and what is after it was never reached.
|
|
308
|
-
*
|
|
309
|
-
*
|
|
310
|
-
* only the backend knows whether it had a transaction: false means no section
|
|
311
|
-
* of the file was written, however far the write order got. It does not say
|
|
312
|
-
* why — a transaction undid the earlier writes, or the first one failed — and
|
|
313
|
-
* the report carries no flag that does, so this says what the file put in and
|
|
314
|
-
* never what the instance now holds.
|
|
303
|
+
* What a stopped import left behind, section by section. The write order is
|
|
304
|
+
* fixed, so the section the failure names splits the document in three: what is
|
|
305
|
+
* before it ran, it did not, and what is after it was never reached. The
|
|
306
|
+
* server's own sentence says whether the earlier writes survived, because only
|
|
307
|
+
* the backend knows whether it had a transaction.
|
|
315
308
|
*
|
|
316
|
-
* A failure that names no section leaves
|
|
309
|
+
* A failure that names no section leaves that split undecidable, and every
|
|
317
310
|
* section reads `unknown` rather than `landed`. Telling someone their accounts
|
|
318
311
|
* are in when nothing said so is the one answer this screen must never give:
|
|
319
312
|
* they would stop looking.
|
|
313
|
+
*
|
|
314
|
+
* `applied` says so when the import ran in a transaction: `false` means the
|
|
315
|
+
* write was undone whole, and the item counts — empty precisely because the
|
|
316
|
+
* rollback emptied them — must not turn sections before the failure back into
|
|
317
|
+
* `landed`. Every section then reads `rolled-back` except the one the failure
|
|
318
|
+
* named, which keeps its `failed` row and the server's own sentence.
|
|
320
319
|
*/
|
|
321
320
|
export const sectionResults = (
|
|
322
321
|
report: RemitImapConfigImportReport,
|
|
@@ -324,7 +323,6 @@ export const sectionResults = (
|
|
|
324
323
|
const failure = writeFailure(report);
|
|
325
324
|
const named = failure?.details?.section;
|
|
326
325
|
const failedSection = named ? asSection(named) : undefined;
|
|
327
|
-
const nothingLanded = !report.applied;
|
|
328
326
|
const counts = new Map<ImportSection, number>();
|
|
329
327
|
for (const item of report.items) {
|
|
330
328
|
if (asVerdict(item.verdict) === "rejected") continue;
|
|
@@ -333,20 +331,31 @@ export const sectionResults = (
|
|
|
333
331
|
counts.set(section, (counts.get(section) ?? 0) + 1);
|
|
334
332
|
}
|
|
335
333
|
|
|
336
|
-
|
|
337
|
-
IMPORT_SECTION_ORDER.map((section) => ({
|
|
334
|
+
if (failure && report.applied === false) {
|
|
335
|
+
return IMPORT_SECTION_ORDER.map((section) => ({
|
|
338
336
|
section,
|
|
339
337
|
title: SECTION_TITLES[section],
|
|
340
|
-
state
|
|
341
|
-
|
|
338
|
+
state:
|
|
339
|
+
section === failedSection
|
|
340
|
+
? ("failed" as const)
|
|
341
|
+
: ("rolled-back" as const),
|
|
342
|
+
detail:
|
|
343
|
+
section === failedSection
|
|
344
|
+
? (failure.message ?? "This section could not be written.")
|
|
345
|
+
: "Nothing was written — the import ran as one transaction and was undone.",
|
|
342
346
|
}));
|
|
347
|
+
}
|
|
343
348
|
|
|
344
349
|
if (!failedSection) {
|
|
345
|
-
if (nothingLanded) return everySection("not-landed", NOT_LANDED_DETAIL);
|
|
346
350
|
const detail = named
|
|
347
351
|
? `The import stopped in "${named}", which this instance does not recognise, so it cannot say whether this section was written.`
|
|
348
352
|
: "The import stopped without naming where, so it cannot say whether this section was written. Open Settings to see what is here.";
|
|
349
|
-
return
|
|
353
|
+
return IMPORT_SECTION_ORDER.map((section) => ({
|
|
354
|
+
section,
|
|
355
|
+
title: SECTION_TITLES[section],
|
|
356
|
+
state: "unknown" as const,
|
|
357
|
+
detail,
|
|
358
|
+
}));
|
|
350
359
|
}
|
|
351
360
|
|
|
352
361
|
const failedAt = IMPORT_SECTION_ORDER.indexOf(failedSection);
|
|
@@ -369,14 +378,6 @@ export const sectionResults = (
|
|
|
369
378
|
detail: "Not attempted — the import stopped before this section.",
|
|
370
379
|
};
|
|
371
380
|
}
|
|
372
|
-
if (nothingLanded) {
|
|
373
|
-
return {
|
|
374
|
-
section,
|
|
375
|
-
title,
|
|
376
|
-
state: "not-landed" as const,
|
|
377
|
-
detail: NOT_LANDED_DETAIL,
|
|
378
|
-
};
|
|
379
|
-
}
|
|
380
381
|
const written = counts.get(section) ?? 0;
|
|
381
382
|
return {
|
|
382
383
|
section,
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the Flagged view asks the server for.
|
|
3
|
+
*
|
|
4
|
+
* #308: the chips became query parameters, and the tokens that ask for the same
|
|
5
|
+
* thing have to travel the same way. A token evaluated over the pages loaded so
|
|
6
|
+
* far reintroduces the defect one control down — the field would show an empty
|
|
7
|
+
* list for a category whose starred mail sits below the newest page, while the
|
|
8
|
+
* chip beside it showed the mail.
|
|
9
|
+
*/
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import { describe, test } from "node:test";
|
|
12
|
+
import { flaggedCriteria } from "./flagged-criteria.js";
|
|
13
|
+
import type { InboxFilterCriteria } from "./inbox-filters.js";
|
|
14
|
+
import { parseSearchTokens, type SearchToken } from "./search-tokens.js";
|
|
15
|
+
|
|
16
|
+
const NO_CHIPS: InboxFilterCriteria = {
|
|
17
|
+
category: "all",
|
|
18
|
+
attributes: new Set<string>(),
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const chips = (
|
|
22
|
+
category: string,
|
|
23
|
+
attributes: string[] = [],
|
|
24
|
+
): InboxFilterCriteria => ({ category, attributes: new Set(attributes) });
|
|
25
|
+
|
|
26
|
+
const tokensOf = (query: string): SearchToken[] =>
|
|
27
|
+
parseSearchTokens(query, {
|
|
28
|
+
accountsByName: new Map([["alice", "acc_1"]]),
|
|
29
|
+
mailboxesByName: new Map([["sent", "mb2"]]),
|
|
30
|
+
}).tokens;
|
|
31
|
+
|
|
32
|
+
const criteriaOf = (query: string, filters = NO_CHIPS) =>
|
|
33
|
+
flaggedCriteria(filters, tokensOf(query)).criteria;
|
|
34
|
+
|
|
35
|
+
const residualTypes = (query: string, filters = NO_CHIPS) =>
|
|
36
|
+
flaggedCriteria(filters, tokensOf(query)).residual.map((t) => t.type);
|
|
37
|
+
|
|
38
|
+
describe("flaggedCriteria", () => {
|
|
39
|
+
test("a typed token asks for exactly what its chip asks for", () => {
|
|
40
|
+
assert.deepEqual(
|
|
41
|
+
criteriaOf("is:unread"),
|
|
42
|
+
criteriaOf("", chips("all", ["unread"])),
|
|
43
|
+
);
|
|
44
|
+
assert.deepEqual(
|
|
45
|
+
criteriaOf("has:attachment"),
|
|
46
|
+
criteriaOf("", chips("all", ["attachment"])),
|
|
47
|
+
);
|
|
48
|
+
assert.deepEqual(
|
|
49
|
+
criteriaOf("category:personal"),
|
|
50
|
+
criteriaOf("", chips("personal")),
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("carries the row criteria as parameters", () => {
|
|
55
|
+
assert.deepEqual(criteriaOf("is:unread"), { unread: true });
|
|
56
|
+
assert.deepEqual(criteriaOf("is:read"), { unread: false });
|
|
57
|
+
assert.deepEqual(criteriaOf("has:attachment"), { attachments: true });
|
|
58
|
+
assert.deepEqual(criteriaOf("category:social"), { category: ["social"] });
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("leaves nothing carried in the residue", () => {
|
|
62
|
+
assert.deepEqual(
|
|
63
|
+
residualTypes("is:unread has:attachment category:personal"),
|
|
64
|
+
[],
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// The view is starred mail, so the request always carries `starred`. A token
|
|
69
|
+
// asking for what the request already asks for is answered, not re-applied.
|
|
70
|
+
test("answers is:starred from the view itself", () => {
|
|
71
|
+
assert.deepEqual(residualTypes("is:starred"), []);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// No `from`/`subject` parameter on this endpoint: its one text parameter
|
|
75
|
+
// matches both at once, so these stay a pass over the returned rows rather
|
|
76
|
+
// than being dropped from a request that never carried them.
|
|
77
|
+
test("keeps the tokens no parameter carries", () => {
|
|
78
|
+
assert.deepEqual(residualTypes("from:alice"), ["from"]);
|
|
79
|
+
assert.deepEqual(residualTypes("before:2026-01-01"), ["before"]);
|
|
80
|
+
assert.deepEqual(residualTypes("in:sent"), ["in"]);
|
|
81
|
+
assert.deepEqual(residualTypes("account:alice"), ["account"]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("the chip wins over a token that contradicts it", () => {
|
|
85
|
+
const { criteria, residual } = flaggedCriteria(
|
|
86
|
+
chips("all", ["unread"]),
|
|
87
|
+
tokensOf("is:read"),
|
|
88
|
+
);
|
|
89
|
+
assert.equal(criteria.unread, true);
|
|
90
|
+
assert.deepEqual(
|
|
91
|
+
residual.map((t) => t.type),
|
|
92
|
+
["isRead"],
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("a cleared category sets no category parameter", () => {
|
|
97
|
+
assert.deepEqual(criteriaOf("", NO_CHIPS), {});
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the Flagged view asks `listAllThreads` for, and what is left over.
|
|
3
|
+
*
|
|
4
|
+
* Every criterion the view offers — the category chip, the Unread and Has
|
|
5
|
+
* attachment chips, and the `category:` / `is:unread` / `is:read` /
|
|
6
|
+
* `has:attachment` / `is:starred` tokens typed in the field — is a column on
|
|
7
|
+
* the ThreadMessage row, so all of them travel as query parameters. A criterion
|
|
8
|
+
* applied over the pages loaded so far is the defect this replaces: a starred
|
|
9
|
+
* collection is paged, so anything below the newest page was invisible to it
|
|
10
|
+
* (#308).
|
|
11
|
+
*
|
|
12
|
+
* `starred` is the view rather than a criterion: the request always carries it,
|
|
13
|
+
* which is what makes `is:starred` a carried token instead of residue.
|
|
14
|
+
*
|
|
15
|
+
* A chip beats a token where both set the same parameter — a control on screen
|
|
16
|
+
* beats text in a field — and the overruled token drops to the residue, where
|
|
17
|
+
* it matches nothing, which is the honest answer to a contradiction.
|
|
18
|
+
*/
|
|
19
|
+
import type {
|
|
20
|
+
InboxFilterCriteria,
|
|
21
|
+
InboxFilterParams,
|
|
22
|
+
} from "./inbox-filters.js";
|
|
23
|
+
import { inboxFilterParams } from "./inbox-filters.js";
|
|
24
|
+
import type { SearchToken } from "./search-tokens.js";
|
|
25
|
+
import {
|
|
26
|
+
type ThreadSearchTokenParamName,
|
|
27
|
+
threadSearchTokens,
|
|
28
|
+
} from "./thread-search-tokens.js";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The token parameters `listAllThreads` carries. It has no `from` or `subject`
|
|
32
|
+
* of its own — its one text parameter matches both at once — so those two
|
|
33
|
+
* tokens stay residue rather than being dropped from a request that never had
|
|
34
|
+
* them.
|
|
35
|
+
*/
|
|
36
|
+
export const FLAGGED_TOKEN_PARAMS: readonly ThreadSearchTokenParamName[] = [
|
|
37
|
+
"category",
|
|
38
|
+
"unread",
|
|
39
|
+
"starred",
|
|
40
|
+
"attachments",
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
export interface FlaggedCriteria {
|
|
44
|
+
/** What the request narrows by, chips and carried tokens together. */
|
|
45
|
+
criteria: InboxFilterParams;
|
|
46
|
+
/** The tokens no parameter carries; apply these over the returned rows. */
|
|
47
|
+
residual: SearchToken[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const flaggedCriteria = (
|
|
51
|
+
chips: InboxFilterCriteria,
|
|
52
|
+
tokens: readonly SearchToken[],
|
|
53
|
+
): FlaggedCriteria => {
|
|
54
|
+
const chipParams = inboxFilterParams(chips);
|
|
55
|
+
const { params, residual } = threadSearchTokens(
|
|
56
|
+
tokens,
|
|
57
|
+
{ ...chipParams, starred: true },
|
|
58
|
+
FLAGGED_TOKEN_PARAMS,
|
|
59
|
+
);
|
|
60
|
+
return { criteria: { ...params, ...chipParams }, residual };
|
|
61
|
+
};
|
package/src/lib/inbox-filters.ts
CHANGED
|
@@ -3,20 +3,26 @@ import type {
|
|
|
3
3
|
ThreadOperationsSearchThreadsData,
|
|
4
4
|
} from "@remit/api-http-client/types.gen.ts";
|
|
5
5
|
import { MessageCategory } from "@remit/domain-enums";
|
|
6
|
-
import type
|
|
6
|
+
import { type FilterReach, inboxFilterConfig } from "@remit/ui";
|
|
7
7
|
|
|
8
8
|
/** The request `threadOperationsSearchThreads` takes, whole. */
|
|
9
9
|
export type ThreadSearchQuery = NonNullable<
|
|
10
10
|
ThreadOperationsSearchThreadsData["query"]
|
|
11
11
|
>;
|
|
12
12
|
|
|
13
|
-
/**
|
|
13
|
+
/**
|
|
14
|
+
* The parameters the filter chips set on a thread listing.
|
|
15
|
+
*
|
|
16
|
+
* Named for the inbox, which had them first, but shared: `listAllThreads` takes
|
|
17
|
+
* the same four (#308), so the Flagged view sends the chips through this rather
|
|
18
|
+
* than growing a second translation of the same chip ids.
|
|
19
|
+
*/
|
|
14
20
|
export type InboxFilterParams = Pick<
|
|
15
21
|
ThreadSearchQuery,
|
|
16
22
|
"category" | "unread" | "starred" | "attachments"
|
|
17
23
|
>;
|
|
18
24
|
|
|
19
|
-
/** The chip state
|
|
25
|
+
/** The chip state a filtered list holds: one category, any number of attributes. */
|
|
20
26
|
export interface InboxFilterCriteria {
|
|
21
27
|
/** A category id, or `"all"` when the category is cleared. */
|
|
22
28
|
category: string;
|
|
@@ -121,3 +127,18 @@ export const sameInboxFilter = (
|
|
|
121
127
|
if (!previous) return false;
|
|
122
128
|
return filterIdentity(previous) === filterIdentity(params);
|
|
123
129
|
};
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The label the empty state names an active category filter by, or undefined
|
|
133
|
+
* when the id names no category — `"all"` is how the category is cleared, not a
|
|
134
|
+
* category. Read off the preset so the chip and the sentence about it can never
|
|
135
|
+
* disagree; every list preset draws its categories from the same set.
|
|
136
|
+
*/
|
|
137
|
+
const CATEGORY_LABELS = new Map(
|
|
138
|
+
inboxFilterConfig()
|
|
139
|
+
.categories.filter((category) => category.id !== "all")
|
|
140
|
+
.map((category) => [category.id, category.label]),
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
export const categoryLabel = (id: string): string | undefined =>
|
|
144
|
+
CATEGORY_LABELS.get(id);
|
package/src/lib/starred-rows.ts
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
2
|
-
|
|
3
1
|
/**
|
|
4
2
|
* Collapse rows that are the same conversation.
|
|
5
3
|
*
|
|
@@ -12,12 +10,19 @@ import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/type
|
|
|
12
10
|
* conversation single even when its copies straddle a page boundary — a single
|
|
13
11
|
* page cannot know what earlier pages already showed. The first row wins, which
|
|
14
12
|
* is the newest under the server's descending order.
|
|
13
|
+
*
|
|
14
|
+
* Generic over anything naming its thread, so the same collapse runs over the
|
|
15
|
+
* API rows and over the merged display rows a text search produces — two
|
|
16
|
+
* definitions of "same conversation" is one too many. A row that names no
|
|
17
|
+
* thread is never merged: there is nothing to say it is the same conversation
|
|
18
|
+
* as any other.
|
|
15
19
|
*/
|
|
16
|
-
export const dedupeByThread = (
|
|
17
|
-
items:
|
|
18
|
-
):
|
|
20
|
+
export const dedupeByThread = <T extends { threadId?: string }>(
|
|
21
|
+
items: T[],
|
|
22
|
+
): T[] => {
|
|
19
23
|
const seen = new Set<string>();
|
|
20
24
|
return items.filter((item) => {
|
|
25
|
+
if (item.threadId === undefined) return true;
|
|
21
26
|
if (seen.has(item.threadId)) return false;
|
|
22
27
|
seen.add(item.threadId);
|
|
23
28
|
return true;
|
|
@@ -33,6 +33,19 @@ export type ThreadSearchTokenParams = Pick<
|
|
|
33
33
|
"from" | "subject" | "category" | "unread" | "starred" | "attachments"
|
|
34
34
|
>;
|
|
35
35
|
|
|
36
|
+
/** A parameter name a listing endpoint accepts. */
|
|
37
|
+
export type ThreadSearchTokenParamName = keyof ThreadSearchTokenParams;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Every parameter `searchThreads` takes, the default set.
|
|
41
|
+
*
|
|
42
|
+
* `listAllThreads` takes all of these except `from` and `subject`, so a caller
|
|
43
|
+
* on that endpoint names its own set and the two tokens come back as residue
|
|
44
|
+
* rather than being silently dropped from a request that never carried them.
|
|
45
|
+
*/
|
|
46
|
+
export const THREAD_SEARCH_TOKEN_PARAMS: readonly ThreadSearchTokenParamName[] =
|
|
47
|
+
["from", "subject", "category", "unread", "starred", "attachments"];
|
|
48
|
+
|
|
36
49
|
export interface ThreadSearchTokens {
|
|
37
50
|
/** What the tokens add to the request. */
|
|
38
51
|
params: ThreadSearchTokenParams;
|
|
@@ -106,17 +119,32 @@ const isCarried = (
|
|
|
106
119
|
}
|
|
107
120
|
};
|
|
108
121
|
|
|
122
|
+
const narrowTo = (
|
|
123
|
+
params: ThreadSearchTokenParams,
|
|
124
|
+
carries: readonly ThreadSearchTokenParamName[],
|
|
125
|
+
): ThreadSearchTokenParams =>
|
|
126
|
+
Object.fromEntries(
|
|
127
|
+
Object.entries(params).filter(([name]) =>
|
|
128
|
+
carries.includes(name as ThreadSearchTokenParamName),
|
|
129
|
+
),
|
|
130
|
+
);
|
|
131
|
+
|
|
109
132
|
/**
|
|
110
|
-
* Split the tokens into the parameters the thread
|
|
133
|
+
* Split the tokens into the parameters the thread listing takes and the residue
|
|
111
134
|
* the caller has to apply itself. `chipParams` are the parameters already set
|
|
112
|
-
* by the
|
|
135
|
+
* by the filter chips, which win where they overlap; `carries` names the
|
|
136
|
+
* parameters the endpoint being called actually accepts.
|
|
113
137
|
*/
|
|
114
138
|
export function threadSearchTokens(
|
|
115
139
|
tokens: readonly SearchToken[],
|
|
116
140
|
chipParams: InboxFilterParams = {},
|
|
141
|
+
carries: readonly ThreadSearchTokenParamName[] = THREAD_SEARCH_TOKEN_PARAMS,
|
|
117
142
|
): ThreadSearchTokens {
|
|
118
|
-
const params = paramsFromTokens(tokens);
|
|
119
|
-
const request: ThreadSearchTokenParams = {
|
|
143
|
+
const params = narrowTo(paramsFromTokens(tokens), carries);
|
|
144
|
+
const request: ThreadSearchTokenParams = {
|
|
145
|
+
...params,
|
|
146
|
+
...narrowTo(chipParams, carries),
|
|
147
|
+
};
|
|
120
148
|
return {
|
|
121
149
|
params,
|
|
122
150
|
residual: tokens.filter((token) => !isCarried(request, token)),
|