@remit/web-client 0.0.265 → 0.0.267
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/layout/Drawer.focus.test.ts +3 -4
- package/src/components/layout/Drawer.tsx +9 -74
- package/src/components/mail/IntelligencePane.tsx +25 -14
- package/src/components/mail/brief-server-sections.render.test.ts +3 -7
- package/src/components/ui/KeyboardShortcutsModal.tsx +7 -1
- package/src/lib/brief.test.ts +9 -11
- package/src/lib/brief.ts +16 -42
- package/src/lib/inbox-filters.ts +3 -10
- package/src/lib/search-tokens.ts +3 -17
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.267",
|
|
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": {
|
|
@@ -208,10 +208,9 @@ describe("Drawer's ring is what a reader can reach", () => {
|
|
|
208
208
|
});
|
|
209
209
|
|
|
210
210
|
/**
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
* backdrop covers.
|
|
211
|
+
* A dialog that does not take the shared trap leaves focus on the drawer
|
|
212
|
+
* control that opened it — now behind that dialog's backdrop. Tab has to
|
|
213
|
+
* enter the dialog's ring rather than walk the controls the backdrop covers.
|
|
215
214
|
*/
|
|
216
215
|
it("pulls focus behind a nested dialog's backdrop into its ring", () => {
|
|
217
216
|
const nestedDialog = createElement(
|
|
@@ -1,38 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
cn,
|
|
3
|
+
type OverlayAnswers,
|
|
4
|
+
useModalFocus,
|
|
5
|
+
useOverlayScope,
|
|
6
|
+
} from "@remit/ui";
|
|
2
7
|
import { X } from "lucide-react";
|
|
3
8
|
import { type ReactNode, useEffect, useRef } from "react";
|
|
4
9
|
|
|
5
|
-
const FOCUSABLE_SELECTOR =
|
|
6
|
-
"a[href], button, input, select, textarea, [tabindex]";
|
|
7
|
-
|
|
8
|
-
const isTabbable = (element: HTMLElement): boolean => {
|
|
9
|
-
if (element.tabIndex < 0) return false;
|
|
10
|
-
if (element.hasAttribute("disabled")) return false;
|
|
11
|
-
if (element.getAttribute("aria-disabled") === "true") return false;
|
|
12
|
-
if (element.closest("[inert], [hidden], [aria-hidden='true']")) return false;
|
|
13
|
-
const style = getComputedStyle(element);
|
|
14
|
-
return style.display !== "none" && style.visibility !== "hidden";
|
|
15
|
-
};
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* The elements Tab may reach, in the order it reaches them.
|
|
19
|
-
*
|
|
20
|
-
* A modal opened from inside the drawer takes the ring with it: the
|
|
21
|
-
* intelligence pane's reclassify dialog renders under the drawer's own panel,
|
|
22
|
-
* so a ring scoped to the panel puts its Cancel last and leaves every control
|
|
23
|
-
* behind that dialog's backdrop in the cycle — Tab walks onto buttons no
|
|
24
|
-
* pointer can reach, which is the bug the trap exists to prevent.
|
|
25
|
-
*/
|
|
26
|
-
const tabRing = (panel: HTMLElement): HTMLElement[] => {
|
|
27
|
-
const nested = panel.querySelectorAll<HTMLElement>(
|
|
28
|
-
'[role="dialog"][aria-modal="true"]',
|
|
29
|
-
);
|
|
30
|
-
const root = nested[nested.length - 1] ?? panel;
|
|
31
|
-
return [...root.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)].filter(
|
|
32
|
-
isTabbable,
|
|
33
|
-
);
|
|
34
|
-
};
|
|
35
|
-
|
|
36
10
|
interface DrawerProps {
|
|
37
11
|
isOpen: boolean;
|
|
38
12
|
onClose: () => void;
|
|
@@ -68,7 +42,6 @@ export const Drawer = ({
|
|
|
68
42
|
answers,
|
|
69
43
|
}: DrawerProps) => {
|
|
70
44
|
const drawerRef = useRef<HTMLDivElement>(null);
|
|
71
|
-
const previouslyFocusedRef = useRef<HTMLElement | null>(null);
|
|
72
45
|
|
|
73
46
|
useOverlayScope({
|
|
74
47
|
id: "drawer",
|
|
@@ -76,53 +49,14 @@ export const Drawer = ({
|
|
|
76
49
|
answers: { back: onClose, ...answers },
|
|
77
50
|
});
|
|
78
51
|
|
|
52
|
+
useModalFocus(drawerRef, isOpen);
|
|
53
|
+
|
|
79
54
|
useEffect(() => {
|
|
80
55
|
if (!isOpen) return;
|
|
81
|
-
|
|
82
|
-
previouslyFocusedRef.current = document.activeElement as HTMLElement | null;
|
|
83
|
-
|
|
84
|
-
const handleKey = (event: KeyboardEvent) => {
|
|
85
|
-
// Trap Tab inside the drawer's ring: walking off either end cycles
|
|
86
|
-
// round, and focus that is in the drawer but outside the ring — a
|
|
87
|
-
// control behind a nested dialog's backdrop — is pulled in, so Tab
|
|
88
|
-
// never lands where a pointer cannot reach (#747). The trap only
|
|
89
|
-
// applies while the drawer already holds focus; an error banner or
|
|
90
|
-
// the fatal-error overlay above it keeps its own Tab (#970).
|
|
91
|
-
if (event.key !== "Tab" || !drawerRef.current) return;
|
|
92
|
-
const active = document.activeElement;
|
|
93
|
-
if (!(active instanceof HTMLElement)) return;
|
|
94
|
-
if (!drawerRef.current.contains(active)) return;
|
|
95
|
-
const ring = tabRing(drawerRef.current);
|
|
96
|
-
if (ring.length === 0) return;
|
|
97
|
-
const first = ring[0];
|
|
98
|
-
const last = ring[ring.length - 1];
|
|
99
|
-
const inside = ring.includes(active);
|
|
100
|
-
if (event.shiftKey) {
|
|
101
|
-
if (!inside || active === first) {
|
|
102
|
-
event.preventDefault();
|
|
103
|
-
last.focus();
|
|
104
|
-
}
|
|
105
|
-
return;
|
|
106
|
-
}
|
|
107
|
-
if (!inside || active === last) {
|
|
108
|
-
event.preventDefault();
|
|
109
|
-
first.focus();
|
|
110
|
-
}
|
|
111
|
-
};
|
|
112
|
-
|
|
113
|
-
document.addEventListener("keydown", handleKey);
|
|
114
|
-
|
|
115
|
-
// Move focus into the drawer
|
|
116
|
-
if (drawerRef.current) tabRing(drawerRef.current)[0]?.focus();
|
|
117
|
-
|
|
118
|
-
// Lock body scroll
|
|
119
56
|
const previousOverflow = document.body.style.overflow;
|
|
120
57
|
document.body.style.overflow = "hidden";
|
|
121
|
-
|
|
122
58
|
return () => {
|
|
123
|
-
document.removeEventListener("keydown", handleKey);
|
|
124
59
|
document.body.style.overflow = previousOverflow;
|
|
125
|
-
previouslyFocusedRef.current?.focus();
|
|
126
60
|
};
|
|
127
61
|
}, [isOpen]);
|
|
128
62
|
|
|
@@ -150,6 +84,7 @@ export const Drawer = ({
|
|
|
150
84
|
{/* Drawer panel */}
|
|
151
85
|
<div
|
|
152
86
|
ref={drawerRef}
|
|
87
|
+
tabIndex={-1}
|
|
153
88
|
className={cn(
|
|
154
89
|
"safe-area-frame absolute top-0 bottom-0 bg-canvas border-line shadow-xl flex flex-col",
|
|
155
90
|
widthClassName,
|
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
2
2
|
import {
|
|
3
|
+
CATEGORY_PRESENTATION,
|
|
4
|
+
categoryLabels,
|
|
3
5
|
DialogBackdrop,
|
|
4
6
|
IntelligencePanel,
|
|
5
7
|
type IntelligenceQuickActions,
|
|
6
8
|
type SimilarMessageLinkComponent,
|
|
7
9
|
type SimilarState,
|
|
10
|
+
type ThreadCategory,
|
|
11
|
+
useModalFocus,
|
|
8
12
|
} from "@remit/ui";
|
|
9
13
|
import { Sparkles } from "lucide-react";
|
|
10
|
-
import { useCallback, useState } from "react";
|
|
14
|
+
import { useCallback, useRef, useState } from "react";
|
|
11
15
|
import { useIntelligenceData } from "@/hooks/useIntelligenceData";
|
|
12
16
|
import { useReportSpam } from "@/hooks/useReportSpam";
|
|
13
17
|
import { useUpdateAddressFlags } from "@/hooks/useUpdateAddressFlags";
|
|
@@ -52,18 +56,20 @@ export interface IntelligencePaneProps {
|
|
|
52
56
|
|
|
53
57
|
/**
|
|
54
58
|
* Category-override values accepted by the `AddressFlags.category` override
|
|
55
|
-
* (PATCH /addresses/{id})
|
|
56
|
-
*
|
|
59
|
+
* (PATCH /addresses/{id}), in the kit's chip-row order and named with the kit's
|
|
60
|
+
* words. `uncategorized` is left out: it names the classifier not having reached
|
|
61
|
+
* a message yet, which is not something a reader chooses for a sender.
|
|
57
62
|
*/
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
63
|
+
type CategoryOverride = Exclude<ThreadCategory, "uncategorized">;
|
|
64
|
+
|
|
65
|
+
const isCategoryOverride = (
|
|
66
|
+
category: ThreadCategory,
|
|
67
|
+
): category is CategoryOverride => category !== "uncategorized";
|
|
68
|
+
|
|
69
|
+
const CATEGORY_OVERRIDES: readonly CategoryOverride[] =
|
|
70
|
+
CATEGORY_PRESENTATION.map((entry) => entry.category).filter(
|
|
71
|
+
isCategoryOverride,
|
|
72
|
+
);
|
|
67
73
|
|
|
68
74
|
/**
|
|
69
75
|
* Decide which spam quick-action to offer for the current message (issue #648).
|
|
@@ -165,6 +171,9 @@ function ReclassifyDialog({
|
|
|
165
171
|
onSelect: (category: CategoryOverride) => void;
|
|
166
172
|
onCancel: () => void;
|
|
167
173
|
}) {
|
|
174
|
+
const dialogRef = useRef<HTMLDivElement>(null);
|
|
175
|
+
useModalFocus(dialogRef, isOpen);
|
|
176
|
+
|
|
168
177
|
if (!isOpen) return null;
|
|
169
178
|
return (
|
|
170
179
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
|
@@ -174,9 +183,11 @@ function ReclassifyDialog({
|
|
|
174
183
|
className="backdrop-blur-sm"
|
|
175
184
|
/>
|
|
176
185
|
<div
|
|
186
|
+
ref={dialogRef}
|
|
177
187
|
role="dialog"
|
|
178
188
|
aria-modal="true"
|
|
179
189
|
aria-label="Reclassify sender"
|
|
190
|
+
tabIndex={-1}
|
|
180
191
|
className="relative z-10 w-full max-w-sm rounded-sm border border-line bg-surface p-6 shadow-lg"
|
|
181
192
|
>
|
|
182
193
|
<h2 className="text-lg font-semibold">Reclassify this sender</h2>
|
|
@@ -189,11 +200,11 @@ function ReclassifyDialog({
|
|
|
189
200
|
key={cat}
|
|
190
201
|
type="button"
|
|
191
202
|
onClick={() => onSelect(cat)}
|
|
192
|
-
className={`flex min-h-11 items-center justify-between rounded px-3 text-left text-sm
|
|
203
|
+
className={`flex min-h-11 items-center justify-between rounded px-3 text-left text-sm transition-colors hover:bg-surface-raised ${
|
|
193
204
|
cat === current ? "font-semibold text-accent" : "text-fg"
|
|
194
205
|
}`}
|
|
195
206
|
>
|
|
196
|
-
{cat}
|
|
207
|
+
{categoryLabels[cat]}
|
|
197
208
|
{cat === current && (
|
|
198
209
|
<span className="text-2xs text-fg-subtle">current</span>
|
|
199
210
|
)}
|
|
@@ -605,14 +605,10 @@ describe("the brief's sections come from the server (#312)", () => {
|
|
|
605
605
|
shown.indexOf("Weekly digest for you"),
|
|
606
606
|
"an old newsletter still outranked a newer match",
|
|
607
607
|
);
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
!shown.includes(label),
|
|
611
|
-
`the search kept the ${label} section header`,
|
|
612
|
-
);
|
|
613
|
-
}
|
|
608
|
+
// A category's word still reaches the screen on a row badge, so the
|
|
609
|
+
// headers are counted rather than read off the text.
|
|
614
610
|
assert.equal(
|
|
615
|
-
mounted.queryAll(
|
|
611
|
+
mounted.queryAll("[data-section-header]").length,
|
|
616
612
|
0,
|
|
617
613
|
"the search rendered a section header",
|
|
618
614
|
);
|
|
@@ -3,10 +3,11 @@ import {
|
|
|
3
3
|
DialogBackdrop,
|
|
4
4
|
Kbd,
|
|
5
5
|
KEY_HINT_GROUPS,
|
|
6
|
+
useModalFocus,
|
|
6
7
|
useOverlayScope,
|
|
7
8
|
} from "@remit/ui";
|
|
8
9
|
import { X } from "lucide-react";
|
|
9
|
-
import { Fragment } from "react";
|
|
10
|
+
import { Fragment, useRef } from "react";
|
|
10
11
|
|
|
11
12
|
interface KeyboardShortcutsModalProps {
|
|
12
13
|
isOpen: boolean;
|
|
@@ -22,11 +23,14 @@ export const KeyboardShortcutsModal = ({
|
|
|
22
23
|
isOpen,
|
|
23
24
|
onClose,
|
|
24
25
|
}: KeyboardShortcutsModalProps) => {
|
|
26
|
+
const modalRef = useRef<HTMLDivElement>(null);
|
|
27
|
+
|
|
25
28
|
useOverlayScope({
|
|
26
29
|
id: "shortcuts",
|
|
27
30
|
open: isOpen,
|
|
28
31
|
answers: { back: onClose },
|
|
29
32
|
});
|
|
33
|
+
useModalFocus(modalRef, isOpen);
|
|
30
34
|
|
|
31
35
|
if (!isOpen) return null;
|
|
32
36
|
|
|
@@ -40,9 +44,11 @@ export const KeyboardShortcutsModal = ({
|
|
|
40
44
|
|
|
41
45
|
{/* Modal */}
|
|
42
46
|
<div
|
|
47
|
+
ref={modalRef}
|
|
43
48
|
role="dialog"
|
|
44
49
|
aria-modal="true"
|
|
45
50
|
aria-label="Keyboard shortcuts"
|
|
51
|
+
tabIndex={-1}
|
|
46
52
|
className={cn(
|
|
47
53
|
"relative z-10 max-h-[85vh] w-full max-w-2xl overflow-y-auto",
|
|
48
54
|
"rounded-lg border border-line bg-surface shadow-lg",
|
package/src/lib/brief.test.ts
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import assert from "node:assert";
|
|
2
2
|
import { describe, test } from "node:test";
|
|
3
3
|
import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
4
|
-
import type { ResultCount, ThreadCategory, ThreadRowData } from "@remit/ui";
|
|
5
4
|
import {
|
|
5
|
+
categoryLabels,
|
|
6
|
+
type ResultCount,
|
|
7
|
+
type ThreadCategory,
|
|
8
|
+
type ThreadRowData,
|
|
9
|
+
} from "@remit/ui";
|
|
10
|
+
import {
|
|
11
|
+
BRIEF_CATEGORIES,
|
|
6
12
|
type BriefCategoryResult,
|
|
7
13
|
briefSections,
|
|
8
14
|
briefSectionTotal,
|
|
@@ -109,16 +115,8 @@ describe("briefSections", () => {
|
|
|
109
115
|
});
|
|
110
116
|
|
|
111
117
|
test("each category answers for its own section, with its own label", () => {
|
|
112
|
-
const
|
|
113
|
-
[
|
|
114
|
-
["transactional", "Transactional"],
|
|
115
|
-
["newsletter", "Newsletter"],
|
|
116
|
-
["marketing", "Marketing"],
|
|
117
|
-
["social", "Social"],
|
|
118
|
-
["automated", "Automated"],
|
|
119
|
-
["uncategorized", "Unclassified"],
|
|
120
|
-
]);
|
|
121
|
-
for (const [category, label] of labels) {
|
|
118
|
+
for (const category of BRIEF_CATEGORIES) {
|
|
119
|
+
const label = categoryLabels[category];
|
|
122
120
|
const sections = briefSections([
|
|
123
121
|
result(category, [row({ id: "1", category })]),
|
|
124
122
|
]);
|
package/src/lib/brief.ts
CHANGED
|
@@ -36,13 +36,14 @@
|
|
|
36
36
|
*/
|
|
37
37
|
|
|
38
38
|
import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
39
|
-
import {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
39
|
+
import {
|
|
40
|
+
CATEGORY_SECTION_ORDER,
|
|
41
|
+
categoryLabels,
|
|
42
|
+
type ResultCount,
|
|
43
|
+
type SenderTrustLevel,
|
|
44
|
+
type ThreadCategory,
|
|
45
|
+
type ThreadRowData,
|
|
46
|
+
type ThreadSection,
|
|
46
47
|
} from "@remit/ui";
|
|
47
48
|
import { toDisplayCategory } from "./display-category.js";
|
|
48
49
|
import { formatEmailDate } from "./format.js";
|
|
@@ -145,39 +146,12 @@ export function mergeSearchRows(
|
|
|
145
146
|
}
|
|
146
147
|
|
|
147
148
|
/**
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
* the
|
|
149
|
+
* The categories the brief asks for, in the order it renders them. A category
|
|
150
|
+
* is both the section's own scope and the query parameter its request carries;
|
|
151
|
+
* the order and the label come from the kit's category table.
|
|
151
152
|
*/
|
|
152
|
-
export const CATEGORY_SECTIONS: ReadonlyArray<{
|
|
153
|
-
id: string;
|
|
154
|
-
label: string;
|
|
155
|
-
category: ThreadCategory;
|
|
156
|
-
}> = [
|
|
157
|
-
{ id: "personal", label: "Personal", category: MessageCategory.personal },
|
|
158
|
-
{
|
|
159
|
-
id: "transactional",
|
|
160
|
-
label: "Transactional",
|
|
161
|
-
category: MessageCategory.transactional,
|
|
162
|
-
},
|
|
163
|
-
{
|
|
164
|
-
id: "newsletter",
|
|
165
|
-
label: "Newsletter",
|
|
166
|
-
category: MessageCategory.newsletter,
|
|
167
|
-
},
|
|
168
|
-
{ id: "marketing", label: "Marketing", category: MessageCategory.marketing },
|
|
169
|
-
{ id: "social", label: "Social", category: MessageCategory.social },
|
|
170
|
-
{ id: "automated", label: "Automated", category: MessageCategory.automated },
|
|
171
|
-
{
|
|
172
|
-
id: "uncategorized",
|
|
173
|
-
label: "Unclassified",
|
|
174
|
-
category: MessageCategory.uncategorized,
|
|
175
|
-
},
|
|
176
|
-
];
|
|
177
|
-
|
|
178
|
-
/** The categories the brief asks for, in the order it renders them. */
|
|
179
153
|
export const BRIEF_CATEGORIES: readonly ThreadCategory[] =
|
|
180
|
-
|
|
154
|
+
CATEGORY_SECTION_ORDER;
|
|
181
155
|
|
|
182
156
|
/** One category's own server answer: its newest rows and how many it holds. */
|
|
183
157
|
export interface BriefCategoryResult {
|
|
@@ -214,8 +188,8 @@ export function briefSections(
|
|
|
214
188
|
results.map((result) => [result.category, result] as const),
|
|
215
189
|
);
|
|
216
190
|
const sections: ThreadSection[] = [];
|
|
217
|
-
for (const
|
|
218
|
-
const result = byCategory.get(
|
|
191
|
+
for (const category of BRIEF_CATEGORIES) {
|
|
192
|
+
const result = byCategory.get(category);
|
|
219
193
|
if (!result) continue;
|
|
220
194
|
const holdsMail =
|
|
221
195
|
result.loading ||
|
|
@@ -224,8 +198,8 @@ export function briefSections(
|
|
|
224
198
|
(result.total.kind === "exact" && result.total.value > 0);
|
|
225
199
|
if (!holdsMail) continue;
|
|
226
200
|
sections.push({
|
|
227
|
-
id:
|
|
228
|
-
label:
|
|
201
|
+
id: category,
|
|
202
|
+
label: categoryLabels[category],
|
|
229
203
|
threads: result.rows,
|
|
230
204
|
total: result.total,
|
|
231
205
|
atCap: result.atCap,
|
package/src/lib/inbox-filters.ts
CHANGED
|
@@ -3,7 +3,7 @@ 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 FilterReach,
|
|
6
|
+
import { categoryLabels, type FilterReach, isThreadCategory } from "@remit/ui";
|
|
7
7
|
|
|
8
8
|
/** The request `threadOperationsSearchThreads` takes, whole. */
|
|
9
9
|
export type ThreadSearchQuery = NonNullable<
|
|
@@ -131,14 +131,7 @@ export const sameInboxFilter = (
|
|
|
131
131
|
/**
|
|
132
132
|
* The label the empty state names an active category filter by, or undefined
|
|
133
133
|
* when the id names no category — `"all"` is how the category is cleared, not a
|
|
134
|
-
* category.
|
|
135
|
-
* disagree; every list preset draws its categories from the same set.
|
|
134
|
+
* category.
|
|
136
135
|
*/
|
|
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
136
|
export const categoryLabel = (id: string): string | undefined =>
|
|
144
|
-
|
|
137
|
+
isThreadCategory(id) ? categoryLabels[id] : undefined;
|
package/src/lib/search-tokens.ts
CHANGED
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
import type { RemitImapMessageCategory } from "@remit/api-http-client/types.gen.ts";
|
|
44
44
|
import { MessageCategory } from "@remit/domain-enums";
|
|
45
45
|
import {
|
|
46
|
+
categoryLabels,
|
|
46
47
|
quoteSearchTokenValue,
|
|
47
48
|
type SearchQueryWord,
|
|
48
49
|
type SearchTermParts,
|
|
@@ -134,24 +135,9 @@ export interface SearchTokenSpec {
|
|
|
134
135
|
options?: readonly SearchTokenValueOption[];
|
|
135
136
|
}
|
|
136
137
|
|
|
137
|
-
/**
|
|
138
|
-
* Category labels, keyed by the API enum. `uncategorized` reads "Unclassified"
|
|
139
|
-
* — the same word the filter chips use (issue #45): mail the classifier has not
|
|
140
|
-
* reached is a pending state with a name, never the absence of one.
|
|
141
|
-
*/
|
|
142
|
-
const CATEGORY_LABELS: Record<RemitImapMessageCategory, string> = {
|
|
143
|
-
personal: "Personal",
|
|
144
|
-
uncategorized: "Unclassified",
|
|
145
|
-
transactional: "Transactional",
|
|
146
|
-
newsletter: "Newsletter",
|
|
147
|
-
marketing: "Marketing",
|
|
148
|
-
social: "Social",
|
|
149
|
-
automated: "Automated",
|
|
150
|
-
};
|
|
151
|
-
|
|
152
138
|
const CATEGORY_OPTIONS: readonly SearchTokenValueOption[] = Object.values(
|
|
153
139
|
MessageCategory,
|
|
154
|
-
).map((value) => ({ value, label:
|
|
140
|
+
).map((value) => ({ value, label: categoryLabels[value] }));
|
|
155
141
|
|
|
156
142
|
/**
|
|
157
143
|
* Spellings that resolve to a category beyond the enum values themselves — the
|
|
@@ -313,7 +299,7 @@ export function searchTokenLabel(token: SearchToken): string {
|
|
|
313
299
|
case "subject":
|
|
314
300
|
return `Subject: ${token.value}`;
|
|
315
301
|
case "category":
|
|
316
|
-
return `Category: ${
|
|
302
|
+
return `Category: ${categoryLabels[token.category]}`;
|
|
317
303
|
case "hasAttachment":
|
|
318
304
|
return "Has attachment";
|
|
319
305
|
case "isUnread":
|