@remit/ui 0.0.142 → 0.0.144
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/compose-address-field.stories.tsx +195 -1
- package/src/components/compose-address-field.tsx +133 -37
- package/src/components/filter-rule-editor.create.test.ts +24 -1
- package/src/components/filter-rule-editor.stories.tsx +31 -1
- package/src/components/filter-rule-editor.tsx +2 -2
- package/src/components/folder-tree-picker.stories.tsx +23 -0
- package/src/index.ts +3 -0
- package/src/lib/folder-tree.test.ts +48 -0
- package/src/lib/folder-tree.ts +24 -9
- package/src/lib/parse-address-input.test.ts +79 -0
- package/src/lib/parse-address-input.ts +81 -0
package/package.json
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
-
import { useState } from "react";
|
|
2
|
+
import { useRef, useState } from "react";
|
|
3
3
|
import { expect, userEvent, waitFor, within } from "storybook/test";
|
|
4
4
|
import {
|
|
5
5
|
type AddressEntry,
|
|
6
6
|
ComposeAddressField,
|
|
7
|
+
type ComposeAddressFieldHandle,
|
|
8
|
+
type ParsedAddressInput,
|
|
7
9
|
} from "./compose-address-field.js";
|
|
8
10
|
|
|
9
11
|
const KNOWN: AddressEntry[] = [
|
|
@@ -114,6 +116,198 @@ export const IncompleteAddressIsNotTaken: Story = {
|
|
|
114
116
|
},
|
|
115
117
|
};
|
|
116
118
|
|
|
119
|
+
const NO_RECIPIENT_REFUSAL = "Add a To address before sending.";
|
|
120
|
+
|
|
121
|
+
const notAnAddress = (text: string) =>
|
|
122
|
+
`To holds "${text}", which is not an address.`;
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* A press elsewhere is what an address typed and left in the field has to
|
|
126
|
+
* survive, and this is the shape the composer holds the field in for it.
|
|
127
|
+
*
|
|
128
|
+
* The press reads the field through `commitPending`, in the same tick, rather
|
|
129
|
+
* than the list the field has got round to committing — the blur timer is still
|
|
130
|
+
* 150 ms away from that. What the button says before it is pressed comes from
|
|
131
|
+
* `onPendingChange`, so neither refusal ever stands while an address is on
|
|
132
|
+
* screen, and text that is not an address stops the send instead of leaving
|
|
133
|
+
* with it.
|
|
134
|
+
*/
|
|
135
|
+
const SendHarness = ({ initial = [] }: { initial?: AddressEntry[] }) => {
|
|
136
|
+
const [addresses, setAddresses] = useState<AddressEntry[]>(initial);
|
|
137
|
+
const [pending, setPending] = useState<ParsedAddressInput>({
|
|
138
|
+
entries: [],
|
|
139
|
+
unparsed: "",
|
|
140
|
+
});
|
|
141
|
+
const [sentTo, setSentTo] = useState<string[] | undefined>(undefined);
|
|
142
|
+
const [refusal, setRefusal] = useState<string | undefined>(undefined);
|
|
143
|
+
const field = useRef<ComposeAddressFieldHandle>(null);
|
|
144
|
+
|
|
145
|
+
const refuse = (committed: ParsedAddressInput["unparsed"], count: number) => {
|
|
146
|
+
if (committed.trim()) return notAnAddress(committed.trim());
|
|
147
|
+
if (count === 0) return NO_RECIPIENT_REFUSAL;
|
|
148
|
+
return undefined;
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
return (
|
|
152
|
+
<div className="w-[520px]">
|
|
153
|
+
<ComposeAddressField
|
|
154
|
+
label="To"
|
|
155
|
+
addresses={addresses}
|
|
156
|
+
onChange={setAddresses}
|
|
157
|
+
placeholder="Recipients"
|
|
158
|
+
onPendingChange={setPending}
|
|
159
|
+
ref={field}
|
|
160
|
+
/>
|
|
161
|
+
<button
|
|
162
|
+
type="button"
|
|
163
|
+
onClick={() => {
|
|
164
|
+
const beforePress = refuse(
|
|
165
|
+
pending.unparsed,
|
|
166
|
+
addresses.length + pending.entries.length,
|
|
167
|
+
);
|
|
168
|
+
if (beforePress !== undefined) {
|
|
169
|
+
setRefusal(beforePress);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
const committed = field.current?.commitPending();
|
|
173
|
+
const recipients = committed?.addresses ?? addresses;
|
|
174
|
+
const onPress = refuse(committed?.unparsed ?? "", recipients.length);
|
|
175
|
+
if (onPress !== undefined) {
|
|
176
|
+
setRefusal(onPress);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
setSentTo(recipients.map((recipient) => recipient.email));
|
|
180
|
+
}}
|
|
181
|
+
>
|
|
182
|
+
Send
|
|
183
|
+
</button>
|
|
184
|
+
{sentTo !== undefined && <p data-testid="sent-to">{sentTo.join(", ")}</p>}
|
|
185
|
+
{refusal !== undefined && <p data-testid="refusal">{refusal}</p>}
|
|
186
|
+
</div>
|
|
187
|
+
);
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
export const SendTakesTheAddressStillInTheField: Story = {
|
|
191
|
+
render: () => <SendHarness />,
|
|
192
|
+
play: async ({ canvasElement }) => {
|
|
193
|
+
const canvas = within(canvasElement);
|
|
194
|
+
await userEvent.type(
|
|
195
|
+
canvas.getByLabelText("To:"),
|
|
196
|
+
"typed@northwind.example",
|
|
197
|
+
);
|
|
198
|
+
await userEvent.click(canvas.getByRole("button", { name: "Send" }));
|
|
199
|
+
await expect(canvas.getByTestId("sent-to")).toHaveTextContent(
|
|
200
|
+
"typed@northwind.example",
|
|
201
|
+
);
|
|
202
|
+
await expect(canvas.queryByTestId("refusal")).not.toBeInTheDocument();
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
/** With nothing typed and no chip there is nothing to send to, and it says so. */
|
|
207
|
+
export const SendRefusesAnEmptyField: Story = {
|
|
208
|
+
render: () => <SendHarness />,
|
|
209
|
+
play: async ({ canvasElement }) => {
|
|
210
|
+
const canvas = within(canvasElement);
|
|
211
|
+
await userEvent.click(canvas.getByRole("button", { name: "Send" }));
|
|
212
|
+
await expect(canvas.getByTestId("refusal")).toHaveTextContent(
|
|
213
|
+
NO_RECIPIENT_REFUSAL,
|
|
214
|
+
);
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
export const SendTakesTheAddressAfterAChip: Story = {
|
|
219
|
+
render: () => (
|
|
220
|
+
<SendHarness initial={[{ email: "chipped@northwind.example" }]} />
|
|
221
|
+
),
|
|
222
|
+
play: async ({ canvasElement }) => {
|
|
223
|
+
const canvas = within(canvasElement);
|
|
224
|
+
await userEvent.type(
|
|
225
|
+
canvas.getByLabelText("To:"),
|
|
226
|
+
"typed@northwind.example",
|
|
227
|
+
);
|
|
228
|
+
await userEvent.click(canvas.getByRole("button", { name: "Send" }));
|
|
229
|
+
await expect(canvas.getByTestId("sent-to")).toHaveTextContent(
|
|
230
|
+
"chipped@northwind.example, typed@northwind.example",
|
|
231
|
+
);
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
/** A pasted list arrives in one onChange and never sees the comma keydown. */
|
|
236
|
+
export const SendTakesAPastedList: Story = {
|
|
237
|
+
render: () => <SendHarness />,
|
|
238
|
+
play: async ({ canvasElement }) => {
|
|
239
|
+
const canvas = within(canvasElement);
|
|
240
|
+
await userEvent.click(canvas.getByLabelText("To:"));
|
|
241
|
+
await userEvent.paste("alice@northwind.example, bob@northwind.example");
|
|
242
|
+
await userEvent.click(canvas.getByRole("button", { name: "Send" }));
|
|
243
|
+
await expect(canvas.getByTestId("sent-to")).toHaveTextContent(
|
|
244
|
+
"alice@northwind.example, bob@northwind.example",
|
|
245
|
+
);
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Text that is not an address stops the send and is quoted back. Going ahead
|
|
251
|
+
* would deliver the message to everyone but the person that text was for, and
|
|
252
|
+
* the composer closing on it would take the text away unread.
|
|
253
|
+
*/
|
|
254
|
+
export const SendRefusesTextThatIsNotAnAddress: Story = {
|
|
255
|
+
render: () => (
|
|
256
|
+
<SendHarness initial={[{ email: "chipped@northwind.example" }]} />
|
|
257
|
+
),
|
|
258
|
+
play: async ({ canvasElement }) => {
|
|
259
|
+
const canvas = within(canvasElement);
|
|
260
|
+
const input = canvas.getByLabelText<HTMLInputElement>("To:");
|
|
261
|
+
await userEvent.type(input, "alice@northwind");
|
|
262
|
+
await userEvent.click(canvas.getByRole("button", { name: "Send" }));
|
|
263
|
+
|
|
264
|
+
await expect(canvas.getByTestId("refusal")).toHaveTextContent(
|
|
265
|
+
notAnAddress("alice@northwind"),
|
|
266
|
+
);
|
|
267
|
+
await expect(canvas.queryByTestId("sent-to")).not.toBeInTheDocument();
|
|
268
|
+
await expect(input).toHaveValue("alice@northwind");
|
|
269
|
+
},
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Candidates a complete address is a substring of, so the typed text is itself
|
|
274
|
+
* committable and the suggestion picked is somebody else. That is what makes
|
|
275
|
+
* the story below a claim about the blur timer rather than about deduplication.
|
|
276
|
+
*/
|
|
277
|
+
const NEARBY: AddressEntry[] = [
|
|
278
|
+
{ email: "beta@northwind.example", displayName: "Beta Team" },
|
|
279
|
+
{ email: "a@northwind.example.org", displayName: "Alpha Team" },
|
|
280
|
+
];
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* The other press the field has to survive, and the reason the blur commit is
|
|
284
|
+
* still on a timer: a click travelling towards a suggestion must not be answered
|
|
285
|
+
* by the typed text becoming a chip and the list going with it.
|
|
286
|
+
*/
|
|
287
|
+
export const SuggestionSurvivesTheBlurItCauses: Story = {
|
|
288
|
+
render: () => <Harness candidates={NEARBY} />,
|
|
289
|
+
play: async ({ canvasElement }) => {
|
|
290
|
+
const canvas = within(canvasElement);
|
|
291
|
+
const input = canvas.getByLabelText<HTMLInputElement>("To:");
|
|
292
|
+
await userEvent.type(input, "a@northwind.example");
|
|
293
|
+
const list = await canvas.findByRole("listbox");
|
|
294
|
+
await userEvent.click(within(list).getByText("Beta Team"));
|
|
295
|
+
|
|
296
|
+
await expect(canvas.getByText("Beta Team")).toBeVisible();
|
|
297
|
+
await expect(input).toHaveValue("");
|
|
298
|
+
|
|
299
|
+
// Past the blur timer, not merely past the click: the commit it scheduled
|
|
300
|
+
// is cancelled, so the address that was typed never becomes a second chip.
|
|
301
|
+
await new Promise((resolve) => setTimeout(resolve, 400));
|
|
302
|
+
await expect(
|
|
303
|
+
canvas.getAllByRole("button", { name: /^Remove / }),
|
|
304
|
+
).toHaveLength(1);
|
|
305
|
+
await expect(
|
|
306
|
+
canvas.queryByText("a@northwind.example"),
|
|
307
|
+
).not.toBeInTheDocument();
|
|
308
|
+
},
|
|
309
|
+
};
|
|
310
|
+
|
|
117
311
|
export const BackspaceRemovesTheLastChip: Story = {
|
|
118
312
|
render: () => (
|
|
119
313
|
<Harness
|
|
@@ -1,11 +1,50 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { Ref } from "react";
|
|
2
|
+
import {
|
|
3
|
+
useCallback,
|
|
4
|
+
useEffect,
|
|
5
|
+
useImperativeHandle,
|
|
6
|
+
useMemo,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
} from "react";
|
|
10
|
+
import {
|
|
11
|
+
type AddressEntry,
|
|
12
|
+
type ParsedAddressInput,
|
|
13
|
+
parseAddressInput,
|
|
14
|
+
} from "../lib/parse-address-input.js";
|
|
2
15
|
import { useSuggestList } from "../lib/use-suggest-list.js";
|
|
3
16
|
import { AddressTag } from "./address-tag.js";
|
|
4
17
|
import { type Suggestion, SuggestList } from "./suggest-list.js";
|
|
5
18
|
|
|
6
|
-
export
|
|
7
|
-
|
|
8
|
-
|
|
19
|
+
export type { AddressEntry, ParsedAddressInput };
|
|
20
|
+
|
|
21
|
+
/** What the field holds, after a commit. */
|
|
22
|
+
export interface ComposeAddressCommit {
|
|
23
|
+
/** Every address the field now has, the ones just taken included. */
|
|
24
|
+
addresses: AddressEntry[];
|
|
25
|
+
/** What was left in the field because it is not an address. */
|
|
26
|
+
unparsed: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* What a caller acting on the field's contents holds it by.
|
|
31
|
+
*
|
|
32
|
+
* The field commits on blur behind a timer, so that a click travelling towards a
|
|
33
|
+
* suggestion is not answered by the list disappearing under it. Anything that
|
|
34
|
+
* acts on the recipients — sending — is a press that blurs the field, and so
|
|
35
|
+
* lands inside that window and reads the list as it stood before the last
|
|
36
|
+
* address was typed. `commitPending` closes it: it takes what is in the field
|
|
37
|
+
* and hands back the list including it, in the same tick as the press.
|
|
38
|
+
*/
|
|
39
|
+
export interface ComposeAddressFieldHandle {
|
|
40
|
+
/**
|
|
41
|
+
* Take the addresses the field is holding and report what it holds after.
|
|
42
|
+
* Anything that is not an address stays in the field and is named in
|
|
43
|
+
* `unparsed`, for the caller to refuse on rather than send without.
|
|
44
|
+
*/
|
|
45
|
+
commitPending: () => ComposeAddressCommit;
|
|
46
|
+
/** Drop what is typed and not committed — the field's share of a new document. */
|
|
47
|
+
clearPending: () => void;
|
|
9
48
|
}
|
|
10
49
|
|
|
11
50
|
export interface ComposeAddressFieldProps {
|
|
@@ -17,28 +56,19 @@ export interface ComposeAddressFieldProps {
|
|
|
17
56
|
suggestions?: readonly AddressEntry[];
|
|
18
57
|
/** The current text, reported so the caller can look candidates up. */
|
|
19
58
|
onQueryChange?: (query: string) => void;
|
|
59
|
+
/**
|
|
60
|
+
* What the field is holding but has not committed, so a caller whose own
|
|
61
|
+
* state turns on having a recipient counts what is on screen.
|
|
62
|
+
*/
|
|
63
|
+
onPendingChange?: (pending: ParsedAddressInput) => void;
|
|
64
|
+
ref?: Ref<ComposeAddressFieldHandle>;
|
|
20
65
|
}
|
|
21
66
|
|
|
22
67
|
/** Beyond Enter, the keys that take the highlighted suggestion in a chips field. */
|
|
23
68
|
const ACCEPT_KEYS = ["Tab", ","] as const;
|
|
24
69
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const parseEmailInput = (value: string): AddressEntry | undefined => {
|
|
29
|
-
const trimmed = value.trim();
|
|
30
|
-
if (!trimmed) return undefined;
|
|
31
|
-
|
|
32
|
-
const angleMatch = trimmed.match(/^(.+?)\s*<([^>]+)>$/);
|
|
33
|
-
if (angleMatch) {
|
|
34
|
-
const displayName = angleMatch[1].trim();
|
|
35
|
-
const email = angleMatch[2].trim();
|
|
36
|
-
if (isValidEmail(email)) return { email, displayName };
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
if (isValidEmail(trimmed)) return { email: trimmed };
|
|
40
|
-
return undefined;
|
|
41
|
-
};
|
|
70
|
+
/** How long a click has to reach a suggestion before the blur commits the field. */
|
|
71
|
+
const BLUR_COMMIT_MS = 150;
|
|
42
72
|
|
|
43
73
|
export const ComposeAddressField = ({
|
|
44
74
|
label,
|
|
@@ -47,24 +77,49 @@ export const ComposeAddressField = ({
|
|
|
47
77
|
placeholder,
|
|
48
78
|
suggestions = [],
|
|
49
79
|
onQueryChange,
|
|
80
|
+
onPendingChange,
|
|
81
|
+
ref,
|
|
50
82
|
}: ComposeAddressFieldProps) => {
|
|
51
83
|
const [inputValue, setInputValue] = useState("");
|
|
52
84
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
53
85
|
|
|
86
|
+
const pending = useMemo(() => parseAddressInput(inputValue), [inputValue]);
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
onPendingChange?.(pending);
|
|
89
|
+
}, [pending, onPendingChange]);
|
|
90
|
+
|
|
91
|
+
const blurCommitRef = useRef<ReturnType<typeof setTimeout> | undefined>(
|
|
92
|
+
undefined,
|
|
93
|
+
);
|
|
94
|
+
const cancelBlurCommit = useCallback(() => {
|
|
95
|
+
if (blurCommitRef.current === undefined) return;
|
|
96
|
+
clearTimeout(blurCommitRef.current);
|
|
97
|
+
blurCommitRef.current = undefined;
|
|
98
|
+
}, []);
|
|
99
|
+
useEffect(() => cancelBlurCommit, [cancelBlurCommit]);
|
|
100
|
+
|
|
54
101
|
const existingEmails = new Set(addresses.map((a) => a.email.toLowerCase()));
|
|
55
102
|
const filteredSuggestions =
|
|
56
103
|
inputValue.length >= 2
|
|
57
104
|
? suggestions.filter((s) => !existingEmails.has(s.email.toLowerCase()))
|
|
58
105
|
: [];
|
|
59
106
|
|
|
107
|
+
const setInput = useCallback(
|
|
108
|
+
(next: string) => {
|
|
109
|
+
setInputValue(next);
|
|
110
|
+
onQueryChange?.(next);
|
|
111
|
+
},
|
|
112
|
+
[onQueryChange],
|
|
113
|
+
);
|
|
114
|
+
|
|
60
115
|
const addAddress = useCallback(
|
|
61
116
|
(entry: AddressEntry) => {
|
|
117
|
+
cancelBlurCommit();
|
|
62
118
|
if (existingEmails.has(entry.email.toLowerCase())) return;
|
|
63
119
|
onChange([...addresses, entry]);
|
|
64
|
-
|
|
65
|
-
onQueryChange?.("");
|
|
120
|
+
setInput("");
|
|
66
121
|
},
|
|
67
|
-
[addresses, existingEmails, onChange,
|
|
122
|
+
[addresses, existingEmails, onChange, setInput, cancelBlurCommit],
|
|
68
123
|
);
|
|
69
124
|
|
|
70
125
|
const removeAddress = useCallback(
|
|
@@ -82,12 +137,49 @@ export const ComposeAddressField = ({
|
|
|
82
137
|
[addAddress],
|
|
83
138
|
);
|
|
84
139
|
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
if (
|
|
88
|
-
|
|
140
|
+
const commitPending = useCallback((): ComposeAddressCommit => {
|
|
141
|
+
cancelBlurCommit();
|
|
142
|
+
if (pending.entries.length === 0) {
|
|
143
|
+
return { addresses, unparsed: pending.unparsed };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const taken = new Set(existingEmails);
|
|
147
|
+
const next = [...addresses];
|
|
148
|
+
for (const entry of pending.entries) {
|
|
149
|
+
const key = entry.email.toLowerCase();
|
|
150
|
+
if (taken.has(key)) continue;
|
|
151
|
+
taken.add(key);
|
|
152
|
+
next.push(entry);
|
|
89
153
|
}
|
|
90
|
-
|
|
154
|
+
|
|
155
|
+
onChange(next);
|
|
156
|
+
setInput(pending.unparsed);
|
|
157
|
+
return { addresses: next, unparsed: pending.unparsed };
|
|
158
|
+
}, [
|
|
159
|
+
pending,
|
|
160
|
+
addresses,
|
|
161
|
+
existingEmails,
|
|
162
|
+
onChange,
|
|
163
|
+
setInput,
|
|
164
|
+
cancelBlurCommit,
|
|
165
|
+
]);
|
|
166
|
+
|
|
167
|
+
const clearPending = useCallback(() => {
|
|
168
|
+
cancelBlurCommit();
|
|
169
|
+
setInput("");
|
|
170
|
+
}, [setInput, cancelBlurCommit]);
|
|
171
|
+
|
|
172
|
+
useImperativeHandle(ref, () => ({ commitPending, clearPending }), [
|
|
173
|
+
commitPending,
|
|
174
|
+
clearPending,
|
|
175
|
+
]);
|
|
176
|
+
|
|
177
|
+
// A blur that has already been answered — by a send committing the field in
|
|
178
|
+
// the same press — must not commit again off the value it saw on the way out.
|
|
179
|
+
const latestCommitRef = useRef(commitPending);
|
|
180
|
+
useEffect(() => {
|
|
181
|
+
latestCommitRef.current = commitPending;
|
|
182
|
+
}, [commitPending]);
|
|
91
183
|
|
|
92
184
|
// The open state, the highlight, and the arrow/Enter/Escape handling are the
|
|
93
185
|
// app's one typeahead behaviour, shared with the filter-rule value field.
|
|
@@ -119,7 +211,7 @@ export const ComposeAddressField = ({
|
|
|
119
211
|
if (e.key === "Enter" || e.key === "Tab" || e.key === ",") {
|
|
120
212
|
if (inputValue.trim()) {
|
|
121
213
|
e.preventDefault();
|
|
122
|
-
|
|
214
|
+
commitPending();
|
|
123
215
|
}
|
|
124
216
|
}
|
|
125
217
|
},
|
|
@@ -128,16 +220,21 @@ export const ComposeAddressField = ({
|
|
|
128
220
|
addresses.length,
|
|
129
221
|
removeAddress,
|
|
130
222
|
suggest.handleKeyDown,
|
|
131
|
-
|
|
223
|
+
commitPending,
|
|
132
224
|
],
|
|
133
225
|
);
|
|
134
226
|
|
|
227
|
+
// The delay is what keeps a click on a suggestion alive: the press blurs the
|
|
228
|
+
// input before it lands, and committing straight away would take the list out
|
|
229
|
+
// from under the pointer.
|
|
135
230
|
const handleBlur = useCallback(() => {
|
|
136
|
-
|
|
137
|
-
|
|
231
|
+
cancelBlurCommit();
|
|
232
|
+
blurCommitRef.current = setTimeout(() => {
|
|
233
|
+
blurCommitRef.current = undefined;
|
|
234
|
+
latestCommitRef.current();
|
|
138
235
|
suggest.dismiss();
|
|
139
|
-
},
|
|
140
|
-
}, [
|
|
236
|
+
}, BLUR_COMMIT_MS);
|
|
237
|
+
}, [cancelBlurCommit, suggest.dismiss]);
|
|
141
238
|
|
|
142
239
|
return (
|
|
143
240
|
<div className="relative" data-address-field={label}>
|
|
@@ -171,8 +268,7 @@ export const ComposeAddressField = ({
|
|
|
171
268
|
value={inputValue}
|
|
172
269
|
onChange={(e) => {
|
|
173
270
|
suggest.reopen();
|
|
174
|
-
|
|
175
|
-
onQueryChange?.(e.target.value);
|
|
271
|
+
setInput(e.target.value);
|
|
176
272
|
}}
|
|
177
273
|
onKeyDown={handleKeyDown}
|
|
178
274
|
onBlur={handleBlur}
|
|
@@ -29,6 +29,13 @@ const folders: FolderTreeNode[] = [
|
|
|
29
29
|
},
|
|
30
30
|
];
|
|
31
31
|
|
|
32
|
+
// A server that reports no hierarchy delimiter has a flat namespace, where a
|
|
33
|
+
// path carries no separator to read a trail out of.
|
|
34
|
+
const flatFolders: FolderTreeNode[] = [
|
|
35
|
+
{ id: "mbx-inbox", label: "Inbox", path: "INBOX" },
|
|
36
|
+
{ id: "mbx-work", label: "Work", path: "Work" },
|
|
37
|
+
];
|
|
38
|
+
|
|
32
39
|
const rule: FilterRule = {
|
|
33
40
|
clauses: [{ id: "c1", field: "From", value: "a@example.com" }],
|
|
34
41
|
matchOperator: "all",
|
|
@@ -61,6 +68,8 @@ interface MountOptions {
|
|
|
61
68
|
signal?: AbortSignal,
|
|
62
69
|
) => Promise<FolderTreeNode>;
|
|
63
70
|
onChangeMove?: (id: string) => void;
|
|
71
|
+
options?: readonly FolderTreeNode[];
|
|
72
|
+
delimiter?: string;
|
|
64
73
|
}
|
|
65
74
|
|
|
66
75
|
/** Holds the destination the way the app does, so a pick shows on screen. */
|
|
@@ -68,6 +77,8 @@ const mount = async ({
|
|
|
68
77
|
initialDestination,
|
|
69
78
|
onCreateFolder,
|
|
70
79
|
onChangeMove,
|
|
80
|
+
options = folders,
|
|
81
|
+
delimiter,
|
|
71
82
|
}: MountOptions = {}) => {
|
|
72
83
|
const Controlled = () => {
|
|
73
84
|
const [moveMailboxId, setMoveMailboxId] = useState<string | undefined>(
|
|
@@ -75,7 +86,8 @@ const mount = async ({
|
|
|
75
86
|
);
|
|
76
87
|
return createElement(FilterRuleEditor, {
|
|
77
88
|
rule: { ...rule, moveMailboxId },
|
|
78
|
-
folders,
|
|
89
|
+
folders: options,
|
|
90
|
+
delimiter,
|
|
79
91
|
preview,
|
|
80
92
|
onChangeMove: (id: string) => {
|
|
81
93
|
setMoveMailboxId(id || undefined);
|
|
@@ -235,6 +247,17 @@ describe("FilterRuleEditor move destination", () => {
|
|
|
235
247
|
assert.deepEqual(picked, [""]);
|
|
236
248
|
});
|
|
237
249
|
|
|
250
|
+
it("reads a flat-namespace destination as its whole path, not per character", async () => {
|
|
251
|
+
await mount({ options: flatFolders, delimiter: "" });
|
|
252
|
+
await openTree();
|
|
253
|
+
await click(byAriaLabel("Move to Work"));
|
|
254
|
+
assert.ok(
|
|
255
|
+
byText("Move matches to Work"),
|
|
256
|
+
"the whole path is one segment when nothing nests",
|
|
257
|
+
);
|
|
258
|
+
assert.equal(byText("Move matches to W / o / r / k"), undefined);
|
|
259
|
+
});
|
|
260
|
+
|
|
238
261
|
it("offers no create affordance without onCreateFolder", async () => {
|
|
239
262
|
await mount();
|
|
240
263
|
await openTree();
|
|
@@ -60,10 +60,15 @@ function LiveEditor({
|
|
|
60
60
|
propertyRule,
|
|
61
61
|
labels = demoLabels,
|
|
62
62
|
initialClauseEdit,
|
|
63
|
+
folders = demoFolders,
|
|
64
|
+
delimiter,
|
|
63
65
|
onCreateFolder,
|
|
64
66
|
onCreateLabel,
|
|
65
67
|
}: {
|
|
66
68
|
initialRule: FilterRule;
|
|
69
|
+
folders?: FolderTreeNode[];
|
|
70
|
+
/** The provider's hierarchy separator; `""` is a flat namespace. */
|
|
71
|
+
delimiter?: string;
|
|
67
72
|
semanticAvailable?: boolean;
|
|
68
73
|
/** Offers the match-mode control; omit to render the editor without one. */
|
|
69
74
|
initialMatchMode?: RuleMatchMode;
|
|
@@ -179,7 +184,8 @@ function LiveEditor({
|
|
|
179
184
|
return (
|
|
180
185
|
<FilterRuleEditor
|
|
181
186
|
rule={rule}
|
|
182
|
-
folders={
|
|
187
|
+
folders={folders}
|
|
188
|
+
delimiter={delimiter}
|
|
183
189
|
labels={labels}
|
|
184
190
|
preview={preview}
|
|
185
191
|
semanticAvailable={semanticAvailable}
|
|
@@ -459,6 +465,30 @@ export const DestinationNestedFolders: Story = {
|
|
|
459
465
|
},
|
|
460
466
|
};
|
|
461
467
|
|
|
468
|
+
// A server that reports no hierarchy delimiter has a flat namespace: every
|
|
469
|
+
// folder sits at the top level and a path is a name, not a trail.
|
|
470
|
+
const flatFolders: FolderTreeNode[] = [
|
|
471
|
+
{ id: "mbx-inbox", label: "Inbox", path: "INBOX" },
|
|
472
|
+
{ id: "mbx-archive", label: "Archive", path: "Archive" },
|
|
473
|
+
{ id: "mbx-work", label: "Work", path: "Work" },
|
|
474
|
+
{ id: "mbx-workshop", label: "Workshop", path: "Workshop" },
|
|
475
|
+
];
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* A flat namespace, where the chosen destination reads as its whole path —
|
|
479
|
+
* `Work`, not one segment per character.
|
|
480
|
+
*/
|
|
481
|
+
export const DestinationFlatNamespace: Story = {
|
|
482
|
+
name: "Destination — a flat namespace (server reports no delimiter)",
|
|
483
|
+
render: () => (
|
|
484
|
+
<LiveEditor
|
|
485
|
+
initialRule={{ ...demoRule, moveMailboxId: "mbx-work" }}
|
|
486
|
+
folders={flatFolders}
|
|
487
|
+
delimiter=""
|
|
488
|
+
/>
|
|
489
|
+
),
|
|
490
|
+
};
|
|
491
|
+
|
|
462
492
|
/**
|
|
463
493
|
* A new folder is made inside the folder the tree is looking at, so a filter can
|
|
464
494
|
* point at `Travel/Car hire` without leaving the editor.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Fragment, type ReactNode, useMemo, useState } from "react";
|
|
2
|
-
import type
|
|
2
|
+
import { type FolderTreeNode, folderPathSegments } from "../lib/folder-tree.js";
|
|
3
3
|
import { Button } from "./button.js";
|
|
4
4
|
import {
|
|
5
5
|
AddChipButton,
|
|
@@ -216,7 +216,7 @@ function MoveDestinationField({
|
|
|
216
216
|
|
|
217
217
|
/** Two folders can share a leaf name, so a destination reads as its trail. */
|
|
218
218
|
const trail = (folder: FolderTreeNode): string => {
|
|
219
|
-
const segments = folder.path
|
|
219
|
+
const segments = folderPathSegments(folder.path, delimiter);
|
|
220
220
|
return segments
|
|
221
221
|
.map((segment, index) => {
|
|
222
222
|
const path = segments.slice(0, index + 1).join(delimiter);
|
|
@@ -47,6 +47,17 @@ const folders: FolderTreeNode[] = [
|
|
|
47
47
|
{ id: "mbx-work-recruiting", label: "Recruiting", path: "Work/Recruiting" },
|
|
48
48
|
];
|
|
49
49
|
|
|
50
|
+
// A server that reports no hierarchy delimiter has a flat namespace: nothing
|
|
51
|
+
// nests, and `Work` is not a parent of `Workshop`.
|
|
52
|
+
const flatFolders: FolderTreeNode[] = [
|
|
53
|
+
{ id: "mbx-inbox", label: "Inbox", path: "INBOX", isCurrent: true },
|
|
54
|
+
{ id: "mbx-archive", label: "Archive", path: "Archive" },
|
|
55
|
+
{ id: "mbx-work", label: "Work", path: "Work" },
|
|
56
|
+
{ id: "mbx-workshop", label: "Workshop", path: "Workshop" },
|
|
57
|
+
{ id: "mbx-sent", label: "Sent", path: "Sent Items" },
|
|
58
|
+
{ id: "mbx-trash", label: "Trash", path: "Deleted Messages" },
|
|
59
|
+
];
|
|
60
|
+
|
|
50
61
|
const longFolders: FolderTreeNode[] = [
|
|
51
62
|
...folders,
|
|
52
63
|
...Array.from({ length: 36 }, (_, i) => ({
|
|
@@ -100,6 +111,7 @@ const rejects = (message: string) => (): Promise<FolderTreeNode> =>
|
|
|
100
111
|
function Picker({
|
|
101
112
|
options = folders,
|
|
102
113
|
onCreateFolder = createFolder,
|
|
114
|
+
delimiter,
|
|
103
115
|
}: {
|
|
104
116
|
options?: FolderTreeNode[];
|
|
105
117
|
onCreateFolder?: (
|
|
@@ -107,6 +119,7 @@ function Picker({
|
|
|
107
119
|
parentPath: string,
|
|
108
120
|
signal?: AbortSignal,
|
|
109
121
|
) => Promise<FolderTreeNode>;
|
|
122
|
+
delimiter?: string;
|
|
110
123
|
}) {
|
|
111
124
|
const [selected, setSelected] = useState<string>();
|
|
112
125
|
const [known, setKnown] = useState(options);
|
|
@@ -115,6 +128,7 @@ function Picker({
|
|
|
115
128
|
<FolderTreePicker
|
|
116
129
|
folders={known}
|
|
117
130
|
selectedId={selected}
|
|
131
|
+
delimiter={delimiter}
|
|
118
132
|
onSelect={setSelected}
|
|
119
133
|
onCreateFolder={(name, parentPath, signal) =>
|
|
120
134
|
onCreateFolder(name, parentPath, signal).then((created) => {
|
|
@@ -160,6 +174,15 @@ export const LongList: Story = {
|
|
|
160
174
|
render: () => <Picker options={longFolders} />,
|
|
161
175
|
};
|
|
162
176
|
|
|
177
|
+
/**
|
|
178
|
+
* A flat namespace: every folder sits at the top level and none of them opens,
|
|
179
|
+
* so a new folder can only be made at the top.
|
|
180
|
+
*/
|
|
181
|
+
export const FlatNamespace: Story = {
|
|
182
|
+
name: "Flat namespace (server reports no delimiter)",
|
|
183
|
+
render: () => <Picker options={flatFolders} delimiter="" />,
|
|
184
|
+
};
|
|
185
|
+
|
|
163
186
|
/** An account with nothing to list: the message states that, not a filter. */
|
|
164
187
|
export const Empty: Story = {
|
|
165
188
|
name: "No folders",
|
package/src/index.ts
CHANGED
|
@@ -171,8 +171,11 @@ export {
|
|
|
171
171
|
} from "./components/compose-action-bar.js";
|
|
172
172
|
export {
|
|
173
173
|
type AddressEntry,
|
|
174
|
+
type ComposeAddressCommit,
|
|
174
175
|
ComposeAddressField,
|
|
176
|
+
type ComposeAddressFieldHandle,
|
|
175
177
|
type ComposeAddressFieldProps,
|
|
178
|
+
type ParsedAddressInput,
|
|
176
179
|
} from "./components/compose-address-field.js";
|
|
177
180
|
export { ComposeBodySkeleton } from "./components/compose-body-skeleton.js";
|
|
178
181
|
export {
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
folderDepth,
|
|
9
9
|
folderLeaf,
|
|
10
10
|
folderParent,
|
|
11
|
+
folderPathSegments,
|
|
11
12
|
matchesQuery,
|
|
12
13
|
orderFolderNodes,
|
|
13
14
|
queryExpandedPaths,
|
|
@@ -298,3 +299,50 @@ describe("folderLeaf", () => {
|
|
|
298
299
|
assert.equal(folderLeaf("INBOX", "."), "INBOX");
|
|
299
300
|
});
|
|
300
301
|
});
|
|
302
|
+
|
|
303
|
+
describe("a flat namespace", () => {
|
|
304
|
+
const flat: FolderTreeNode[] = [
|
|
305
|
+
node("work", "Work", "Work"),
|
|
306
|
+
node("workshop", "Workshop", "Workshop"),
|
|
307
|
+
node("inbox", "Inbox", "INBOX"),
|
|
308
|
+
];
|
|
309
|
+
|
|
310
|
+
it("keeps a path whole rather than splitting it into characters", () => {
|
|
311
|
+
assert.deepEqual(folderPathSegments("Projects/Q3", ""), ["Projects/Q3"]);
|
|
312
|
+
assert.deepEqual(folderPathSegments("Projects/Q3", "/"), [
|
|
313
|
+
"Projects",
|
|
314
|
+
"Q3",
|
|
315
|
+
]);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("makes every folder a root with no parent, depth or ancestors", () => {
|
|
319
|
+
assert.equal(folderParent("Projects/Q3", ""), "");
|
|
320
|
+
assert.equal(folderDepth("Projects/Q3", ""), 0);
|
|
321
|
+
assert.deepEqual(folderAncestors("Projects/Q3", ""), []);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it("puts the whole list on screen at the top level", () => {
|
|
325
|
+
const rows = collapseFolderTree(orderFolderNodes(flat, ""), new Set(), "");
|
|
326
|
+
assert.deepEqual(paths(rows), ["Work", "Workshop", "INBOX"]);
|
|
327
|
+
assert.deepEqual(
|
|
328
|
+
rows.map((row) => row.depth),
|
|
329
|
+
[0, 0, 0],
|
|
330
|
+
);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it("offers no create action inside a folder, not even a prefix match", () => {
|
|
334
|
+
const rows = collapseFolderTree(
|
|
335
|
+
orderFolderNodes(flat, ""),
|
|
336
|
+
new Set(["Work"]),
|
|
337
|
+
"",
|
|
338
|
+
);
|
|
339
|
+
assert.deepEqual(
|
|
340
|
+
withCreateRows(rows, "").map((entry) =>
|
|
341
|
+
entry.kind === "create"
|
|
342
|
+
? `new inside ${entry.parent.path}`
|
|
343
|
+
: entry.row.folder.path,
|
|
344
|
+
),
|
|
345
|
+
["Work", "Workshop", "INBOX"],
|
|
346
|
+
);
|
|
347
|
+
});
|
|
348
|
+
});
|
package/src/lib/folder-tree.ts
CHANGED
|
@@ -38,28 +38,39 @@ export type FolderTreeDisplayRow =
|
|
|
38
38
|
| { kind: "folder"; row: FolderTreeRow; index: number }
|
|
39
39
|
| { kind: "create"; parent: FolderTreeNode; depth: number };
|
|
40
40
|
|
|
41
|
-
// A server that reports no delimiter has a flat namespace
|
|
42
|
-
//
|
|
41
|
+
// A server that reports no delimiter has a flat namespace: the path is its own
|
|
42
|
+
// leaf and every folder is a root. Splitting on "" would return single
|
|
43
|
+
// characters, and `"Inbox".lastIndexOf("")` is 5 rather than -1, so each of
|
|
44
|
+
// these answers the flat case before it touches the path.
|
|
45
|
+
export const folderPathSegments = (
|
|
46
|
+
path: string,
|
|
47
|
+
delimiter: string,
|
|
48
|
+
): string[] => (delimiter.length === 0 ? [path] : path.split(delimiter));
|
|
49
|
+
|
|
43
50
|
export const folderLeaf = (path: string, delimiter: string): string => {
|
|
44
|
-
|
|
45
|
-
const parts = path.split(delimiter);
|
|
51
|
+
const parts = folderPathSegments(path, delimiter);
|
|
46
52
|
return parts[parts.length - 1] || path;
|
|
47
53
|
};
|
|
48
54
|
|
|
49
55
|
export const folderParent = (path: string, delimiter: string): string => {
|
|
56
|
+
if (delimiter.length === 0) return "";
|
|
50
57
|
const cut = path.lastIndexOf(delimiter);
|
|
51
58
|
return cut === -1 ? "" : path.slice(0, cut);
|
|
52
59
|
};
|
|
53
60
|
|
|
54
61
|
export const folderDepth = (path: string, delimiter: string): number =>
|
|
55
|
-
path
|
|
62
|
+
folderPathSegments(path, delimiter).length - 1;
|
|
56
63
|
|
|
64
|
+
// Every step up is strictly shorter than the path below it, so the walk is
|
|
65
|
+
// bounded by the length of the path whatever a parent comes back as.
|
|
57
66
|
export const folderAncestors = (path: string, delimiter: string): string[] => {
|
|
58
67
|
const out: string[] = [];
|
|
59
|
-
let
|
|
60
|
-
|
|
68
|
+
let child = path;
|
|
69
|
+
let parent = folderParent(child, delimiter);
|
|
70
|
+
while (parent && parent.length < child.length) {
|
|
61
71
|
out.push(parent);
|
|
62
|
-
|
|
72
|
+
child = parent;
|
|
73
|
+
parent = folderParent(child, delimiter);
|
|
63
74
|
}
|
|
64
75
|
return out;
|
|
65
76
|
};
|
|
@@ -182,12 +193,16 @@ export const collapseFolderTree = (
|
|
|
182
193
|
|
|
183
194
|
/**
|
|
184
195
|
* Drops a create action at the end of every open folder's children, so "New
|
|
185
|
-
* folder" reads as the last folder inside the one you opened.
|
|
196
|
+
* folder" reads as the last folder inside the one you opened. A flat namespace
|
|
197
|
+
* has no inside, so it gets the rows on their own and creates at the top level.
|
|
186
198
|
*/
|
|
187
199
|
export const withCreateRows = (
|
|
188
200
|
rows: readonly FolderTreeRow[],
|
|
189
201
|
delimiter: string,
|
|
190
202
|
): FolderTreeDisplayRow[] => {
|
|
203
|
+
if (delimiter.length === 0)
|
|
204
|
+
return rows.map((row, index) => ({ kind: "folder", row, index }));
|
|
205
|
+
|
|
191
206
|
const out: FolderTreeDisplayRow[] = [];
|
|
192
207
|
const open: FolderTreeRow[] = [];
|
|
193
208
|
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { parseAddressInput } from "./parse-address-input.js";
|
|
4
|
+
|
|
5
|
+
describe("parseAddressInput", () => {
|
|
6
|
+
it("reads a bare address", () => {
|
|
7
|
+
assert.deepEqual(parseAddressInput("ada@example.com"), {
|
|
8
|
+
entries: [{ email: "ada@example.com" }],
|
|
9
|
+
unparsed: "",
|
|
10
|
+
});
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("reads a display name and the address it wraps", () => {
|
|
14
|
+
assert.deepEqual(parseAddressInput("Ada Lovelace <ada@example.com>"), {
|
|
15
|
+
entries: [{ email: "ada@example.com", displayName: "Ada Lovelace" }],
|
|
16
|
+
unparsed: "",
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("reads a pasted list", () => {
|
|
21
|
+
assert.deepEqual(
|
|
22
|
+
parseAddressInput("alice@example.com, bob@example.com").entries,
|
|
23
|
+
[{ email: "alice@example.com" }, { email: "bob@example.com" }],
|
|
24
|
+
);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("takes a semicolon as a separator too", () => {
|
|
28
|
+
assert.equal(
|
|
29
|
+
parseAddressInput("alice@example.com; bob@example.com").entries.length,
|
|
30
|
+
2,
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("keeps a quoted display name whole", () => {
|
|
35
|
+
assert.deepEqual(parseAddressInput('"Hopper, Grace" <grace@example.com>'), {
|
|
36
|
+
entries: [{ email: "grace@example.com", displayName: "Hopper, Grace" }],
|
|
37
|
+
unparsed: "",
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("keeps what is not an address rather than dropping it", () => {
|
|
42
|
+
assert.deepEqual(parseAddressInput("not-an-address"), {
|
|
43
|
+
entries: [],
|
|
44
|
+
unparsed: "not-an-address",
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("separates the addresses in a list from what is not one", () => {
|
|
49
|
+
assert.deepEqual(parseAddressInput("bob@example.com, alice@example"), {
|
|
50
|
+
entries: [{ email: "bob@example.com" }],
|
|
51
|
+
unparsed: "alice@example",
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("reports every leftover, not only the first", () => {
|
|
56
|
+
assert.equal(
|
|
57
|
+
parseAddressInput("one, bob@example.com, two").unparsed,
|
|
58
|
+
"one, two",
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("reads empty text as holding nothing", () => {
|
|
63
|
+
assert.deepEqual(parseAddressInput(" "), { entries: [], unparsed: "" });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("ignores the separators an empty slot leaves behind", () => {
|
|
67
|
+
assert.deepEqual(parseAddressInput("ada@example.com,,"), {
|
|
68
|
+
entries: [{ email: "ada@example.com" }],
|
|
69
|
+
unparsed: "",
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("rejects an address with no top-level domain", () => {
|
|
74
|
+
assert.deepEqual(parseAddressInput("ada@example"), {
|
|
75
|
+
entries: [],
|
|
76
|
+
unparsed: "ada@example",
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a recipient field is holding in its text input, read as addresses.
|
|
3
|
+
*
|
|
4
|
+
* A field takes one address at a time when it is typed, and a whole list when
|
|
5
|
+
* one is pasted, so what is in it is a list either way. Anything in that list
|
|
6
|
+
* that is not an address is kept rather than dropped: it is what the reader
|
|
7
|
+
* typed, and losing it silently is how a message goes out to fewer people than
|
|
8
|
+
* it was addressed to.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface AddressEntry {
|
|
12
|
+
email: string;
|
|
13
|
+
displayName?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ParsedAddressInput {
|
|
17
|
+
/** The addresses the text names, in the order it names them. */
|
|
18
|
+
entries: AddressEntry[];
|
|
19
|
+
/** The parts that are not addresses, rejoined. Empty when there are none. */
|
|
20
|
+
unparsed: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const isValidEmail = (value: string): boolean =>
|
|
24
|
+
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim());
|
|
25
|
+
|
|
26
|
+
const parseOne = (value: string): AddressEntry | undefined => {
|
|
27
|
+
const trimmed = value.trim();
|
|
28
|
+
if (!trimmed) return undefined;
|
|
29
|
+
|
|
30
|
+
const angleMatch = trimmed.match(/^(.+?)\s*<([^>]+)>$/);
|
|
31
|
+
if (angleMatch) {
|
|
32
|
+
const displayName = angleMatch[1].trim().replace(/^"(.*)"$/, "$1");
|
|
33
|
+
const email = angleMatch[2].trim();
|
|
34
|
+
if (isValidEmail(email)) return { email, displayName };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (isValidEmail(trimmed)) return { email: trimmed };
|
|
38
|
+
return undefined;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Split on the separators a mail client writes between addresses, ignoring the
|
|
43
|
+
* ones inside a quoted display name or an angle-bracketed address — `"Hopper,
|
|
44
|
+
* Grace" <grace@example.com>` is one address, not two.
|
|
45
|
+
*/
|
|
46
|
+
const splitAddresses = (value: string): string[] => {
|
|
47
|
+
const parts: string[] = [];
|
|
48
|
+
let current = "";
|
|
49
|
+
let quoted = false;
|
|
50
|
+
let angled = false;
|
|
51
|
+
|
|
52
|
+
for (const char of value) {
|
|
53
|
+
if (char === '"') quoted = !quoted;
|
|
54
|
+
if (char === "<") angled = true;
|
|
55
|
+
if (char === ">") angled = false;
|
|
56
|
+
if ((char === "," || char === ";") && !quoted && !angled) {
|
|
57
|
+
parts.push(current);
|
|
58
|
+
current = "";
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
current += char;
|
|
62
|
+
}
|
|
63
|
+
parts.push(current);
|
|
64
|
+
return parts;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export const parseAddressInput = (value: string): ParsedAddressInput => {
|
|
68
|
+
const entries: AddressEntry[] = [];
|
|
69
|
+
const leftovers: string[] = [];
|
|
70
|
+
|
|
71
|
+
for (const part of splitAddresses(value)) {
|
|
72
|
+
const entry = parseOne(part);
|
|
73
|
+
if (entry) {
|
|
74
|
+
entries.push(entry);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (part.trim()) leftovers.push(part.trim());
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return { entries, unparsed: leftovers.join(", ") };
|
|
81
|
+
};
|