@remit/ui 0.0.56 → 0.0.58

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.
Files changed (40) hide show
  1. package/package.json +2 -1
  2. package/src/components/app-shell-types.ts +8 -1
  3. package/src/components/auto-moved-badge.stories.tsx +3 -21
  4. package/src/components/auto-moved-badge.tsx +8 -17
  5. package/src/components/brief-empty.stories.tsx +51 -0
  6. package/src/components/brief-empty.tsx +60 -0
  7. package/src/components/dialog.tsx +10 -5
  8. package/src/components/filter-clause-chip.tsx +39 -1
  9. package/src/components/filter-rule-editor.stories.tsx +163 -1
  10. package/src/components/filter-rule-editor.tsx +47 -0
  11. package/src/components/filter-rule.render.test.ts +33 -0
  12. package/src/components/filter-rule.ts +141 -0
  13. package/src/components/input.tsx +7 -0
  14. package/src/components/mail-header.tsx +10 -0
  15. package/src/components/message-row.tsx +41 -3
  16. package/src/components/mobile-search-view.render.test.ts +31 -0
  17. package/src/components/mobile-search-view.stories.tsx +115 -6
  18. package/src/components/mobile-search-view.tsx +34 -8
  19. package/src/components/password-input.render.test.ts +149 -0
  20. package/src/components/password-input.tsx +45 -0
  21. package/src/components/primitives.stories.tsx +28 -0
  22. package/src/components/search-bar.tsx +9 -0
  23. package/src/components/search-chip-input.tsx +85 -3
  24. package/src/components/search-result-row.tsx +68 -82
  25. package/src/components/search-results.stories.tsx +12 -0
  26. package/src/components/search-results.tsx +20 -8
  27. package/src/components/suggest-list.render.test.ts +59 -0
  28. package/src/components/suggest-list.tsx +96 -0
  29. package/src/index.ts +55 -0
  30. package/src/lib/property-prefill.test.ts +173 -0
  31. package/src/lib/property-prefill.ts +151 -0
  32. package/src/lib/search-rule.test.ts +73 -0
  33. package/src/lib/search-rule.ts +96 -0
  34. package/src/lib/sender-derivation.test.ts +106 -0
  35. package/src/lib/sender-derivation.ts +85 -0
  36. package/src/lib/suggest-keys.test.ts +101 -0
  37. package/src/lib/suggest-keys.ts +63 -0
  38. package/src/lib/use-long-press.ts +70 -37
  39. package/src/lib/use-suggest-list.test.ts +169 -0
  40. package/src/lib/use-suggest-list.ts +124 -0
package/src/index.ts CHANGED
@@ -71,6 +71,11 @@ export {
71
71
  BottomSheet,
72
72
  type BottomSheetProps,
73
73
  } from "./components/bottom-sheet.js";
74
+ export {
75
+ BriefEmpty,
76
+ type BriefEmptyProps,
77
+ type BriefSyncProgress,
78
+ } from "./components/brief-empty.js";
74
79
  export {
75
80
  BriefSection,
76
81
  type BriefSectionProps,
@@ -143,23 +148,31 @@ export {
143
148
  clauseFieldOrder,
144
149
  commitBlockedReason,
145
150
  commitLabel,
151
+ demoClauseSuggestions,
146
152
  demoFolders,
147
153
  demoLabels,
148
154
  demoRule,
149
155
  demoSenderFallbackRule,
156
+ demoSubjectPrefillRule,
150
157
  demoVocabularyRule,
151
158
  type FilterRule,
152
159
  type FolderOption,
160
+ hasActiveWiden,
153
161
  type LabelOption,
154
162
  type MatchOperator,
163
+ matchesBodyText,
155
164
  matchJoinWord,
165
+ matchModeHint,
166
+ matchModeLabel,
156
167
  matchOperatorLabel,
157
168
  type PreviewCount,
158
169
  previewCountSummary,
159
170
  type RuleClause,
171
+ type RuleMatchMode,
160
172
  type RuleScope,
161
173
  type RuleWiden,
162
174
  scopeLabel,
175
+ unreadableBodyClauses,
163
176
  widenChipLabel,
164
177
  } from "./components/filter-rule.js";
165
178
  export {
@@ -302,6 +315,10 @@ export {
302
315
  type OutboxStatusBadgeProps,
303
316
  outboxStatusConfig,
304
317
  } from "./components/outbox-status-badge.js";
318
+ export {
319
+ PasswordInput,
320
+ type PasswordInputProps,
321
+ } from "./components/password-input.js";
305
322
  export {
306
323
  PopoverMenu,
307
324
  type PopoverMenuItem,
@@ -397,9 +414,11 @@ export {
397
414
  } from "./components/row-actions.js";
398
415
  export { SearchBar, type SearchBarProps } from "./components/search-bar.js";
399
416
  export {
417
+ type SearchCaretRequest,
400
418
  type SearchChip,
401
419
  SearchChipInput,
402
420
  type SearchChipInputProps,
421
+ type SearchFieldSuggest,
403
422
  } from "./components/search-chip-input.js";
404
423
  export {
405
424
  DROPPED_SEMANTIC_COPY,
@@ -419,6 +438,8 @@ export {
419
438
  type SearchResultTone,
420
439
  } from "./components/search-result-row.js";
421
440
  export {
441
+ MakeFilterAction,
442
+ type MakeFilterActionProps,
422
443
  partitionSpamResults,
423
444
  type SearchResultSection,
424
445
  SearchResults,
@@ -523,6 +544,11 @@ export {
523
544
  SpamResultsOffer,
524
545
  type SpamResultsOfferProps,
525
546
  } from "./components/spam-results-offer.js";
547
+ export {
548
+ type Suggestion,
549
+ SuggestList,
550
+ type SuggestListProps,
551
+ } from "./components/suggest-list.js";
526
552
  export {
527
553
  commitPeek,
528
554
  SwipeableRow,
@@ -572,6 +598,11 @@ export {
572
598
  labelColorOptions,
573
599
  labelDotClass,
574
600
  } from "./lib/label-color.js";
601
+ export {
602
+ derivePropertyClauses,
603
+ normalizeSubject,
604
+ sharedSubjectFragment,
605
+ } from "./lib/property-prefill.js";
575
606
  export {
576
607
  LIST_ROW_ATTRIBUTE,
577
608
  LIST_ROW_SELECTOR,
@@ -580,8 +611,32 @@ export {
580
611
  type UseRovingFocusOptions,
581
612
  useRovingFocus,
582
613
  } from "./lib/roving-focus.js";
614
+ export {
615
+ buildSearchRule,
616
+ type DroppedFacet,
617
+ type DroppedFacetType,
618
+ isConvertible,
619
+ type SearchConversion,
620
+ } from "./lib/search-rule.js";
621
+ export {
622
+ collapsibleDomain,
623
+ deriveSenderClauses,
624
+ distinctSenders,
625
+ senderDomain,
626
+ } from "./lib/sender-derivation.js";
627
+ export {
628
+ type SuggestAction,
629
+ type SuggestKeyState,
630
+ suggestKeyAction,
631
+ } from "./lib/suggest-keys.js";
583
632
  export {
584
633
  type UseLongPressOptions,
585
634
  type UseLongPressResult,
586
635
  useLongPress,
587
636
  } from "./lib/use-long-press.js";
637
+ export {
638
+ type ComboboxProps,
639
+ type SuggestListState,
640
+ type UseSuggestListInput,
641
+ useSuggestList,
642
+ } from "./lib/use-suggest-list.js";
@@ -0,0 +1,173 @@
1
+ /**
2
+ * The opening clauses a properties-only rule offers (RFC 038 D2/D4). The
3
+ * prefill is a starting point the user edits, so the contract worth holding is
4
+ * the order of evidence — sender before subject, and nothing at all rather than
5
+ * a guess that matches half a mailbox.
6
+ */
7
+
8
+ import assert from "node:assert/strict";
9
+ import { describe, it } from "node:test";
10
+ import {
11
+ derivePropertyClauses,
12
+ normalizeSubject,
13
+ sharedSubjectFragment,
14
+ } from "./property-prefill.js";
15
+
16
+ describe("normalizeSubject", () => {
17
+ it("strips stacked reply, forward, and list decorations", () => {
18
+ assert.equal(
19
+ normalizeSubject("Re: Fwd: [ops] Invoice 1841"),
20
+ "Invoice 1841",
21
+ );
22
+ assert.equal(normalizeSubject("RE[2]: Invoice 1841"), "Invoice 1841");
23
+ assert.equal(normalizeSubject("AW: SV: Invoice 1841"), "Invoice 1841");
24
+ });
25
+
26
+ it("collapses whitespace and trims", () => {
27
+ assert.equal(normalizeSubject(" Invoice 1841 "), "Invoice 1841");
28
+ });
29
+
30
+ it("leaves a subject that is only decoration empty", () => {
31
+ assert.equal(normalizeSubject("Re: "), "");
32
+ });
33
+ });
34
+
35
+ describe("sharedSubjectFragment", () => {
36
+ it("takes the longest run of whole words every subject carries", () => {
37
+ assert.equal(
38
+ sharedSubjectFragment([
39
+ "Invoice 1841",
40
+ "Invoice 1902",
41
+ "Re: Invoice 2003",
42
+ ]),
43
+ "Invoice",
44
+ );
45
+ });
46
+
47
+ it("matches case-insensitively and answers in the first subject's casing", () => {
48
+ assert.equal(
49
+ sharedSubjectFragment(["Invoice 1841", "INVOICE 1902"]),
50
+ "Invoice",
51
+ );
52
+ });
53
+
54
+ it("compares whole words, never a partial one", () => {
55
+ // "Invoice 18" is a shared character run and not a shared word run.
56
+ assert.equal(
57
+ sharedSubjectFragment(["Invoice 1841", "Invoice 1892"]),
58
+ "Invoice",
59
+ );
60
+ });
61
+
62
+ it("keeps a single subject as its own fragment", () => {
63
+ assert.equal(sharedSubjectFragment(["Invoice 1841"]), "Invoice 1841");
64
+ });
65
+
66
+ it("rejects a single subject that is nothing but filler", () => {
67
+ assert.equal(sharedSubjectFragment(["for you"]), undefined);
68
+ });
69
+
70
+ it("rejects a shared run of filler words", () => {
71
+ assert.equal(
72
+ sharedSubjectFragment(["the report", "the summary"]),
73
+ undefined,
74
+ );
75
+ });
76
+
77
+ it("rejects a shared run too short to be worth matching", () => {
78
+ assert.equal(
79
+ sharedSubjectFragment(["Q3 numbers", "Q3 results"]),
80
+ undefined,
81
+ );
82
+ });
83
+
84
+ it("is undefined when the subjects share nothing", () => {
85
+ assert.equal(
86
+ sharedSubjectFragment(["Invoice 1841", "Standup notes"]),
87
+ undefined,
88
+ );
89
+ });
90
+
91
+ it("is undefined when no subject survives normalizing", () => {
92
+ assert.equal(sharedSubjectFragment(["Re:", " "]), undefined);
93
+ });
94
+ });
95
+
96
+ describe("derivePropertyClauses", () => {
97
+ it("matches on the sender when the whole selection is from one address", () => {
98
+ assert.deepEqual(
99
+ derivePropertyClauses(
100
+ ["npm@github.com", "npm@github.com", "npm@github.com"],
101
+ ["Invoice 1841", "Standup notes", "Deploy failed"],
102
+ ),
103
+ [{ field: "From", value: "npm@github.com" }],
104
+ );
105
+ });
106
+
107
+ it("matches on the domain when several senders share one", () => {
108
+ assert.deepEqual(
109
+ derivePropertyClauses(
110
+ ["npm@github.com", "notifications@github.com", "ci@sub.github.com"],
111
+ ["Invoice 1841", "Standup notes", "Deploy failed"],
112
+ ),
113
+ [{ field: "FromDomain", value: "github.com" }],
114
+ );
115
+ });
116
+
117
+ it("prefers the sender over the subject even when the subjects share a run", () => {
118
+ assert.deepEqual(
119
+ derivePropertyClauses(
120
+ ["npm@github.com", "npm@github.com"],
121
+ ["Invoice 1841", "Invoice 1902"],
122
+ ),
123
+ [{ field: "From", value: "npm@github.com" }],
124
+ );
125
+ });
126
+
127
+ it("falls back to what mixed senders' subjects have in common", () => {
128
+ assert.deepEqual(
129
+ derivePropertyClauses(
130
+ ["billing@acme.test", "accounts@globex.test", "ap@initech.test"],
131
+ ["Invoice 1841", "Invoice 1902", "Re: Invoice 2003"],
132
+ ),
133
+ [{ field: "Subject", value: "Invoice" }],
134
+ );
135
+ });
136
+
137
+ it("offers one sender chip each when mixed senders share no subject either", () => {
138
+ assert.deepEqual(
139
+ derivePropertyClauses(
140
+ ["billing@acme.test", "accounts@globex.test"],
141
+ ["Invoice 1841", "Standup notes"],
142
+ ),
143
+ [
144
+ { field: "From", value: "billing@acme.test" },
145
+ { field: "From", value: "accounts@globex.test" },
146
+ ],
147
+ );
148
+ });
149
+
150
+ it("refuses a filler-only subject run rather than prefilling a wide match", () => {
151
+ assert.deepEqual(
152
+ derivePropertyClauses(
153
+ ["billing@acme.test", "accounts@globex.test"],
154
+ ["the report", "the summary"],
155
+ ),
156
+ [
157
+ { field: "From", value: "billing@acme.test" },
158
+ { field: "From", value: "accounts@globex.test" },
159
+ ],
160
+ );
161
+ });
162
+
163
+ it("reads a single message as its own sender", () => {
164
+ assert.deepEqual(
165
+ derivePropertyClauses(["billing@acme.test"], ["Invoice 1841"]),
166
+ [{ field: "From", value: "billing@acme.test" }],
167
+ );
168
+ });
169
+
170
+ it("offers nothing when the selection carries no sender and no shared subject", () => {
171
+ assert.deepEqual(derivePropertyClauses([], ["Re:", " "]), []);
172
+ });
173
+ });
@@ -0,0 +1,151 @@
1
+ import type { RuleClause } from "../components/filter-rule.js";
2
+ import { deriveSenderClauses, distinctSenders } from "./sender-derivation.js";
3
+
4
+ /**
5
+ * The opening clauses for a rule matched on properties alone — no semantic
6
+ * anchor (RFC 038 D2/D4). The selection is the only evidence available, so the
7
+ * prefill reads it in the order the evidence is strongest:
8
+ *
9
+ * 1. One sender across the whole selection, or several that collapse to one
10
+ * registrable domain — the sender derivation (#251, #262) already decides
11
+ * between a `From` and a `FromDomain` clause, and this reuses it rather than
12
+ * deciding again.
13
+ * 2. Mixed senders — what the messages share is their subject, so match on the
14
+ * part the subjects have in common instead.
15
+ * 3. Neither — no clauses. The editor opens empty and asks for one; a wrong
16
+ * guess costs more than an absent one.
17
+ *
18
+ * Every clause it produces is an ordinary chip: visible, editable, removable.
19
+ * It is where the user starts, never what the rule is.
20
+ */
21
+
22
+ /** Reply, forward, and list-tag decorations that carry no meaning for a match. */
23
+ const SUBJECT_PREFIX =
24
+ /^\s*(?:(?:re|aw|fwd?|vs|sv|antw|res|enc|tr)\s*(?:\[\d+\])?\s*:|\[[^\]]{1,32}\])\s*/i;
25
+
26
+ /**
27
+ * A subject reduced to the part worth matching on: reply and forward markers
28
+ * and leading list tags stripped (repeatedly — real threads stack them), and
29
+ * whitespace collapsed.
30
+ */
31
+ export const normalizeSubject = (subject: string): string => {
32
+ let value = subject.replace(/\s+/g, " ").trim();
33
+ for (;;) {
34
+ const stripped = value.replace(SUBJECT_PREFIX, "");
35
+ if (stripped === value) return value.trim();
36
+ value = stripped;
37
+ }
38
+ };
39
+
40
+ /**
41
+ * Words too common to be a rule on their own. A single shared word from this
42
+ * list matches half a mailbox, which is worse than offering nothing.
43
+ */
44
+ const WEAK_WORDS = new Set([
45
+ "a",
46
+ "an",
47
+ "and",
48
+ "are",
49
+ "as",
50
+ "at",
51
+ "be",
52
+ "by",
53
+ "for",
54
+ "from",
55
+ "has",
56
+ "have",
57
+ "in",
58
+ "is",
59
+ "it",
60
+ "me",
61
+ "my",
62
+ "new",
63
+ "of",
64
+ "on",
65
+ "or",
66
+ "our",
67
+ "the",
68
+ "this",
69
+ "to",
70
+ "was",
71
+ "we",
72
+ "with",
73
+ "you",
74
+ "your",
75
+ ]);
76
+
77
+ /** The shortest fragment worth prefilling as a `Subject` clause. */
78
+ const MIN_FRAGMENT_LENGTH = 3;
79
+
80
+ const words = (subject: string): string[] =>
81
+ subject.split(" ").filter((word) => word !== "");
82
+
83
+ const isUseful = (fragment: string[]): boolean => {
84
+ if (fragment.length === 0) return false;
85
+ if (fragment.join(" ").length < MIN_FRAGMENT_LENGTH) return false;
86
+ return fragment.some((word) => !WEAK_WORDS.has(word.toLowerCase()));
87
+ };
88
+
89
+ const runAt = (haystack: string[], needle: string[]): number =>
90
+ haystack.findIndex((_, start) =>
91
+ needle.every(
92
+ (word, offset) =>
93
+ haystack[start + offset]?.toLowerCase() === word.toLowerCase(),
94
+ ),
95
+ );
96
+
97
+ /**
98
+ * The longest run of whole words every subject carries, compared
99
+ * case-insensitively and returned in the casing the first subject uses. Whole
100
+ * words rather than raw characters, so "Invoice 1841" and "Invoice 1902" share
101
+ * "Invoice" and not "Invoice 18".
102
+ *
103
+ * `undefined` when the subjects share nothing worth matching on — a single
104
+ * filler word does not count.
105
+ */
106
+ export const sharedSubjectFragment = (
107
+ subjects: readonly string[],
108
+ ): string | undefined => {
109
+ const normalized = subjects
110
+ .map(normalizeSubject)
111
+ .filter((subject) => subject !== "");
112
+ if (normalized.length === 0) return undefined;
113
+ const [first, ...rest] = normalized;
114
+ if (rest.length === 0) return isUseful(words(first)) ? first : undefined;
115
+
116
+ const firstWords = words(first);
117
+ for (let length = firstWords.length; length > 0; length -= 1) {
118
+ for (let start = 0; start + length <= firstWords.length; start += 1) {
119
+ const candidate = firstWords.slice(start, start + length);
120
+ if (!isUseful(candidate)) continue;
121
+ if (rest.every((subject) => runAt(words(subject), candidate) >= 0)) {
122
+ return candidate.join(" ");
123
+ }
124
+ }
125
+ }
126
+ return undefined;
127
+ };
128
+
129
+ /**
130
+ * The clauses a properties-only rule opens on, derived from the selected
131
+ * messages' senders and subjects. Empty when the selection gives nothing to go
132
+ * on — the editor then holds the commit until the user adds a clause, which is
133
+ * the honest state.
134
+ */
135
+ export const derivePropertyClauses = (
136
+ senders: readonly string[],
137
+ subjects: readonly string[],
138
+ ): Omit<RuleClause, "id">[] => {
139
+ const senderClauses = deriveSenderClauses(senders);
140
+ // One sender across the selection, or several on one registrable domain:
141
+ // a single sharp clause, and sharper than any subject fragment.
142
+ if (distinctSenders(senders).length === 1) return senderClauses;
143
+ if (senderClauses.length === 1 && senderClauses[0].field === "FromDomain")
144
+ return senderClauses;
145
+
146
+ const fragment = sharedSubjectFragment(subjects);
147
+ if (fragment !== undefined) return [{ field: "Subject", value: fragment }];
148
+ // Mixed senders with nothing shared in their subjects: one `From` chip each,
149
+ // the sender derivation's own answer (#251).
150
+ return senderClauses;
151
+ };
@@ -0,0 +1,73 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import {
4
+ buildSearchRule,
5
+ isConvertible,
6
+ type SearchConversion,
7
+ } from "./search-rule.js";
8
+
9
+ const conversion = (
10
+ overrides: Partial<SearchConversion> = {},
11
+ ): SearchConversion => ({
12
+ clauses: [],
13
+ matchOperator: "all",
14
+ droppedFacets: [],
15
+ keptTerms: false,
16
+ droppedSemantic: false,
17
+ ...overrides,
18
+ });
19
+
20
+ describe("isConvertible", () => {
21
+ it("is false when the search yields no clause", () => {
22
+ assert.equal(
23
+ isConvertible(
24
+ conversion({
25
+ droppedFacets: [{ type: "hasAttachment", label: "Has attachment" }],
26
+ scopedOut: { mailboxId: "mbx-archive", label: "archive" },
27
+ }),
28
+ ),
29
+ false,
30
+ );
31
+ });
32
+
33
+ it("is true once a term or sender is present", () => {
34
+ assert.equal(
35
+ isConvertible(
36
+ conversion({
37
+ clauses: [{ field: "HasWords", value: "receipts" }],
38
+ keptTerms: true,
39
+ }),
40
+ ),
41
+ true,
42
+ );
43
+ });
44
+ });
45
+
46
+ describe("buildSearchRule", () => {
47
+ it("builds a standing rule with stable clause ids, no widen, empty name", () => {
48
+ const rule = buildSearchRule(
49
+ conversion({
50
+ clauses: [
51
+ { field: "From", value: "a@b.com" },
52
+ { field: "HasWords", value: "nightly" },
53
+ ],
54
+ }),
55
+ );
56
+ assert.equal(rule.scope, "standing");
57
+ assert.equal(rule.widen, undefined);
58
+ assert.equal(rule.name, "");
59
+ assert.deepEqual(
60
+ rule.clauses.map((clause) => clause.id),
61
+ ["search-0", "search-1"],
62
+ );
63
+ });
64
+
65
+ it("honors an explicit scope and move target", () => {
66
+ const rule = buildSearchRule(
67
+ conversion({ clauses: [{ field: "HasWords", value: "nightly" }] }),
68
+ { scope: "once", moveMailboxId: "mbx-archive" },
69
+ );
70
+ assert.equal(rule.scope, "once");
71
+ assert.equal(rule.moveMailboxId, "mbx-archive");
72
+ });
73
+ });
@@ -0,0 +1,96 @@
1
+ /**
2
+ * What a search converts to, and the rule built from it (RFC 038 D5). The shape
3
+ * carries the clauses alongside everything the search held that a filter cannot,
4
+ * so a conversion can never drop a facet without saying so; `search-conversion.ts`
5
+ * beside it owns the copy that states it.
6
+ */
7
+
8
+ import type {
9
+ ClauseField,
10
+ FilterRule,
11
+ MatchOperator,
12
+ RuleScope,
13
+ } from "../components/filter-rule.js";
14
+
15
+ /**
16
+ * A search facet a filter has no clause for. Attachment, read state, starred,
17
+ * category and the date bounds are attributes of a message, not text a clause
18
+ * matches on.
19
+ */
20
+ export type DroppedFacetType =
21
+ | "hasAttachment"
22
+ | "isUnread"
23
+ | "isRead"
24
+ | "isStarred"
25
+ | "category"
26
+ | "before"
27
+ | "after";
28
+
29
+ export interface DroppedFacet {
30
+ type: DroppedFacetType;
31
+ /** What was dropped, named for the user (e.g. "Has attachment", "Before 2026-01-01"). */
32
+ label: string;
33
+ }
34
+
35
+ export interface ScopedOutFolder {
36
+ mailboxId: string;
37
+ /** The folder the search was limited to. */
38
+ label: string;
39
+ }
40
+
41
+ export interface SearchConversion {
42
+ /** Clauses derived from the search — `From` (a `from:` facet) and `HasWords` (the free text). */
43
+ clauses: { field: ClauseField; value: string }[];
44
+ matchOperator: MatchOperator;
45
+ /** A folder an `in:` facet scoped the search to — kept OUT of the rule (never silently unscoped). */
46
+ scopedOut?: ScopedOutFolder;
47
+ /** Facets with no clause equivalent, each named. */
48
+ droppedFacets: DroppedFacet[];
49
+ /** The account an `account:` facet targets — the filter is created for it. */
50
+ targetAccountId?: string;
51
+ /** The search carried free-text terms, kept as a `HasWords` clause. */
52
+ keptTerms: boolean;
53
+ /**
54
+ * The filter this conversion builds is always literal-only — free text has no
55
+ * anchor message for a semantic widen — so the search's semantic "similar mail"
56
+ * reach is dropped whenever the search had one (RFC 038 D5). True exactly when
57
+ * free text was kept AND the search surfaced semantically-similar mail; on a
58
+ * deployment with no semantic reach there was nothing to drop, so no note.
59
+ */
60
+ droppedSemantic: boolean;
61
+ }
62
+
63
+ /**
64
+ * Whether the conversion yields a rule with something to match. A search of only
65
+ * dropped facets or a bare folder scope converts to no clauses, so there is no
66
+ * filter to open — the entry point offers nothing rather than an empty editor.
67
+ */
68
+ export const isConvertible = (conversion: SearchConversion): boolean =>
69
+ conversion.clauses.length > 0;
70
+
71
+ interface BuildRuleOptions {
72
+ scope?: RuleScope;
73
+ moveMailboxId?: string;
74
+ }
75
+
76
+ /**
77
+ * The rule the editor opens on, from a conversion. A search-derived rule defaults
78
+ * to a standing filter — "make this a filter" is a request to keep applying it —
79
+ * and the editor lets the user drop it back to a one-time apply. It carries no
80
+ * widen: a free-text query has no message anchor, so the semantic chip is not
81
+ * offered on this surface (its loss is stated in the conversion notice instead).
82
+ */
83
+ export const buildSearchRule = (
84
+ conversion: SearchConversion,
85
+ { scope = "standing", moveMailboxId }: BuildRuleOptions = {},
86
+ ): FilterRule => ({
87
+ clauses: conversion.clauses.map((clause, index) => ({
88
+ id: `search-${index}`,
89
+ field: clause.field,
90
+ value: clause.value,
91
+ })),
92
+ matchOperator: conversion.matchOperator,
93
+ moveMailboxId,
94
+ scope,
95
+ name: "",
96
+ });