@remit/web-client 0.0.91 → 0.0.93
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/components/mail/DailyBrief.selection.test.ts +6 -2
- package/src/components/mail/DailyBrief.tsx +47 -110
- package/src/components/mail/MessageList.selection.test.ts +18 -6
- package/src/components/mail/MessageList.tsx +83 -83
- package/src/components/mail/SelectionWizardHost.tsx +779 -0
- package/src/components/mail/organize/SearchFilterEditor.tsx +5 -1
- package/src/components/settings/FilterEditor.tsx +1 -1
- package/src/hooks/useCreateMailbox.ts +16 -4
- package/src/hooks/useFilters.ts +30 -1
- package/src/hooks/useMatchSample.ts +63 -0
- package/src/hooks/useRulePreview.ts +23 -3
- package/src/lib/organize/organize-model.test.ts +110 -0
- package/src/lib/organize/organize-model.ts +69 -0
- package/src/lib/organize/rule-model.ts +2 -0
- package/src/lib/selection-mode.test.ts +20 -6
- package/src/lib/selection-mode.ts +8 -1
- package/src/lib/wizard-history.test.ts +176 -0
- package/src/lib/wizard-history.ts +131 -0
- package/src/routes/mail.tsx +4 -0
- package/src/components/mail/organize/MobileOrganizeFlow.render.test.ts +0 -45
- package/src/components/mail/organize/MobileOrganizeFlow.tsx +0 -157
- package/src/components/mail/organize/OrganizeDialog.render.test.ts +0 -37
- package/src/components/mail/organize/OrganizeDialog.tsx +0 -91
- package/src/components/mail/organize/OrganizeRuleEditor.render.test.ts +0 -498
- package/src/components/mail/organize/OrganizeRuleEditor.tsx +0 -285
- package/src/components/mail/organize/SomethingElsePanel.render.test.ts +0 -54
- package/src/components/mail/organize/SomethingElsePanel.tsx +0 -159
- package/src/components/mail/organize/smart-organize.stories.tsx +0 -342
- package/src/lib/organize/mobile-organize-flow.test.ts +0 -96
- package/src/lib/organize/mobile-organize-flow.ts +0 -73
|
@@ -1,285 +0,0 @@
|
|
|
1
|
-
import { mailboxOperationsListMailboxesOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
2
|
-
import {
|
|
3
|
-
type FilterRule,
|
|
4
|
-
FilterRuleEditor,
|
|
5
|
-
type FolderOption,
|
|
6
|
-
type LabelOption,
|
|
7
|
-
type RuleMatchMode,
|
|
8
|
-
type RuleScope,
|
|
9
|
-
} from "@remit/ui";
|
|
10
|
-
import { useQuery } from "@tanstack/react-query";
|
|
11
|
-
import { useEffect, useMemo, useRef, useState } from "react";
|
|
12
|
-
import { useClauseSuggestions } from "@/hooks/useClauseSuggestions";
|
|
13
|
-
import { useCreateMailbox } from "@/hooks/useCreateMailbox";
|
|
14
|
-
import { useCreateFilter } from "@/hooks/useFilters";
|
|
15
|
-
import { useCreateLabel, useLabelList } from "@/hooks/useLabels";
|
|
16
|
-
import { useOrganizeJob } from "@/hooks/useOrganizeJob";
|
|
17
|
-
import { useRuleEditorState } from "@/hooks/useRuleEditorState";
|
|
18
|
-
import { useRulePreview } from "@/hooks/useRulePreview";
|
|
19
|
-
import { useSelectedSubjects } from "@/hooks/useSelectedSubjects";
|
|
20
|
-
import { getMailboxDisplayName } from "@/lib/folder-roles";
|
|
21
|
-
import { buildMoveTargets } from "@/lib/move-targets";
|
|
22
|
-
import {
|
|
23
|
-
canBackApplyDraft,
|
|
24
|
-
type OrganizeDraft,
|
|
25
|
-
} from "@/lib/organize/organize-model";
|
|
26
|
-
import {
|
|
27
|
-
buildInitialRule,
|
|
28
|
-
defaultMatchMode,
|
|
29
|
-
matchersForMode,
|
|
30
|
-
rulePredicate,
|
|
31
|
-
ruleToDraft,
|
|
32
|
-
SUPPORTED_CLAUSE_FIELDS,
|
|
33
|
-
} from "@/lib/organize/rule-model";
|
|
34
|
-
import {
|
|
35
|
-
BackApplyError,
|
|
36
|
-
CommitError,
|
|
37
|
-
FilterSaved,
|
|
38
|
-
JobProgress,
|
|
39
|
-
SavingState,
|
|
40
|
-
} from "./rule-editor-states";
|
|
41
|
-
|
|
42
|
-
interface OrganizeRuleEditorProps {
|
|
43
|
-
accountId: string;
|
|
44
|
-
selectedMessageIds: string[];
|
|
45
|
-
/** The widen probe's matched total — seeds the live count without a re-fetch. */
|
|
46
|
-
seedCount: number;
|
|
47
|
-
/**
|
|
48
|
-
* The deployment ships no vector pipeline, so the widen cannot run. The rule
|
|
49
|
-
* opens on the sender-fallback `From` chips instead of the anchor.
|
|
50
|
-
*/
|
|
51
|
-
semanticUnavailable?: boolean;
|
|
52
|
-
/** Distinct sender addresses, for the fallback clauses and the progress copy. */
|
|
53
|
-
senders?: string[];
|
|
54
|
-
/**
|
|
55
|
-
* The mode the editor opens on. Omitted, it opens on the semantic widen
|
|
56
|
-
* wherever the deployment can run it — a caller passes `properties` only to
|
|
57
|
-
* enter the flow on a rule built from the selection's own properties.
|
|
58
|
-
*/
|
|
59
|
-
seedMatchMode?: RuleMatchMode;
|
|
60
|
-
/** A folder a "Something else" shortcut pre-picked. */
|
|
61
|
-
seedMailboxId?: string;
|
|
62
|
-
/** A scope a "Something else" shortcut pre-picked. */
|
|
63
|
-
seedScope?: RuleScope;
|
|
64
|
-
onClose: () => void;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* The Organize surface as the chip editor (RFC 038 D1). The rule is rendered and
|
|
69
|
-
* edited over the existing preview/apply endpoints: clause chips, a
|
|
70
|
-
* match-operator toggle, a move action, and a scope that maps one-time apply to
|
|
71
|
-
* a back-apply job and standing/until to a `Filter`. Creating a filter also runs
|
|
72
|
-
* the back-apply once, so the rule reaches the mail already in the mailbox and
|
|
73
|
-
* not only the mail that arrives next. The count is live and the
|
|
74
|
-
* commit gate holds apply until it settles, so the set the editor shows is the
|
|
75
|
-
* set a commit acts on. Rendered inside the desktop dialog and the mobile sheet
|
|
76
|
-
* alike, so the two cannot drift.
|
|
77
|
-
*/
|
|
78
|
-
export function OrganizeRuleEditor({
|
|
79
|
-
accountId,
|
|
80
|
-
selectedMessageIds,
|
|
81
|
-
seedCount,
|
|
82
|
-
semanticUnavailable = false,
|
|
83
|
-
senders = [],
|
|
84
|
-
seedMatchMode,
|
|
85
|
-
seedMailboxId,
|
|
86
|
-
seedScope,
|
|
87
|
-
onClose,
|
|
88
|
-
}: OrganizeRuleEditorProps) {
|
|
89
|
-
const anchorMessageId = selectedMessageIds[0];
|
|
90
|
-
const senderFallback = semanticUnavailable && senders.length > 0;
|
|
91
|
-
const subjects = useSelectedSubjects(selectedMessageIds);
|
|
92
|
-
|
|
93
|
-
// Semantic stays the default wherever the deployment can serve it; the
|
|
94
|
-
// properties mode is the third way in, not a replacement (RFC 038 D2).
|
|
95
|
-
const [matchMode, setMatchMode] = useState<RuleMatchMode>(
|
|
96
|
-
() => seedMatchMode ?? defaultMatchMode(semanticUnavailable),
|
|
97
|
-
);
|
|
98
|
-
|
|
99
|
-
const [initialRule] = useState<FilterRule>(() =>
|
|
100
|
-
buildInitialRule({
|
|
101
|
-
anchorMessageId,
|
|
102
|
-
semanticUnavailable,
|
|
103
|
-
matchMode,
|
|
104
|
-
senders,
|
|
105
|
-
subjects,
|
|
106
|
-
selectionCount: selectedMessageIds.length,
|
|
107
|
-
seedMailboxId,
|
|
108
|
-
seedScope,
|
|
109
|
-
}),
|
|
110
|
-
);
|
|
111
|
-
const { rule, setRule, handlers } = useRuleEditorState({
|
|
112
|
-
initialRule,
|
|
113
|
-
widenAnchorCount: selectedMessageIds.length,
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
// Switching mode rebuilds only what the rule matches on — the derived chips
|
|
117
|
-
// and the widen. The destination, label, scope, name, and any clause the user
|
|
118
|
-
// typed themselves survive, so the choice stays reversible.
|
|
119
|
-
const changeMatchMode = (next: RuleMatchMode) => {
|
|
120
|
-
if (next === matchMode) return;
|
|
121
|
-
setMatchMode(next);
|
|
122
|
-
setRule((current) => ({
|
|
123
|
-
...current,
|
|
124
|
-
...matchersForMode(next, {
|
|
125
|
-
anchorMessageId,
|
|
126
|
-
senders,
|
|
127
|
-
subjects,
|
|
128
|
-
selectionCount: selectedMessageIds.length,
|
|
129
|
-
keepClauses: current.clauses.filter((clause) => !clause.derived),
|
|
130
|
-
currentOperator: current.matchOperator,
|
|
131
|
-
}),
|
|
132
|
-
}));
|
|
133
|
-
};
|
|
134
|
-
|
|
135
|
-
// The selection's own senders are the likeliest values for an address clause
|
|
136
|
-
// and are already in hand, so they lead the list before anything is typed.
|
|
137
|
-
const clauseSuggestions = useClauseSuggestions(
|
|
138
|
-
handlers.clauseEdit?.draft.field,
|
|
139
|
-
handlers.clauseEdit?.draft.value ?? "",
|
|
140
|
-
senders,
|
|
141
|
-
);
|
|
142
|
-
|
|
143
|
-
const { data: mailboxesData } = useQuery({
|
|
144
|
-
...mailboxOperationsListMailboxesOptions({ path: { accountId } }),
|
|
145
|
-
staleTime: Number.POSITIVE_INFINITY,
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
const folders: FolderOption[] = useMemo(
|
|
149
|
-
() =>
|
|
150
|
-
buildMoveTargets(mailboxesData?.items ?? []).map((mailbox) => ({
|
|
151
|
-
id: mailbox.mailboxId,
|
|
152
|
-
label: getMailboxDisplayName(mailbox.fullPath),
|
|
153
|
-
})),
|
|
154
|
-
[mailboxesData?.items],
|
|
155
|
-
);
|
|
156
|
-
|
|
157
|
-
const { labels: labelItems } = useLabelList(accountId);
|
|
158
|
-
const labels: LabelOption[] = useMemo(
|
|
159
|
-
() =>
|
|
160
|
-
labelItems.map((label) => ({
|
|
161
|
-
id: label.labelId,
|
|
162
|
-
name: label.name,
|
|
163
|
-
color: label.color,
|
|
164
|
-
})),
|
|
165
|
-
[labelItems],
|
|
166
|
-
);
|
|
167
|
-
const { createLabel } = useCreateLabel(accountId);
|
|
168
|
-
const onCreateLabel = async (name: string): Promise<LabelOption> => {
|
|
169
|
-
const label = await createLabel(name);
|
|
170
|
-
return { id: label.labelId, name: label.name, color: label.color };
|
|
171
|
-
};
|
|
172
|
-
|
|
173
|
-
const preview = useRulePreview(
|
|
174
|
-
accountId,
|
|
175
|
-
rulePredicate(rule, anchorMessageId),
|
|
176
|
-
seedCount,
|
|
177
|
-
);
|
|
178
|
-
|
|
179
|
-
const organizeJob = useOrganizeJob(accountId);
|
|
180
|
-
const createFilter = useCreateFilter(accountId);
|
|
181
|
-
const { createFolder } = useCreateMailbox(accountId);
|
|
182
|
-
|
|
183
|
-
// Creating a filter also moves the mail that already matches, not only the
|
|
184
|
-
// mail that arrives next: the same retroactive back-apply the one-time scope
|
|
185
|
-
// runs. The filter is created first so the rule is live before the pass, then
|
|
186
|
-
// the pass runs over the existing corpus. `backApplyDraft` is the predicate
|
|
187
|
-
// to run once the create succeeds, and is undefined when the rule cannot be
|
|
188
|
-
// back-applied (a `HasWords` clause the vector-free pass can't evaluate — the
|
|
189
|
-
// filter still saves and applies to incoming mail). It is kept, not cleared,
|
|
190
|
-
// so a failed start can be retried; `backApplyStarted` guards the one-shot
|
|
191
|
-
// auto-start against re-firing on re-render.
|
|
192
|
-
const [backApplyDraft, setBackApplyDraft] = useState<OrganizeDraft>();
|
|
193
|
-
const backApplyStarted = useRef(false);
|
|
194
|
-
|
|
195
|
-
const commit = () => {
|
|
196
|
-
const draft = ruleToDraft(rule, anchorMessageId);
|
|
197
|
-
if (rule.scope === "once") {
|
|
198
|
-
organizeJob.start(draft);
|
|
199
|
-
return;
|
|
200
|
-
}
|
|
201
|
-
backApplyStarted.current = false;
|
|
202
|
-
setBackApplyDraft(canBackApplyDraft(draft) ? draft : undefined);
|
|
203
|
-
createFilter.createFilter(
|
|
204
|
-
draft,
|
|
205
|
-
rule.scope === "standing" ? "standing" : "temporary",
|
|
206
|
-
(rule.name ?? "").trim(),
|
|
207
|
-
);
|
|
208
|
-
};
|
|
209
|
-
|
|
210
|
-
useEffect(() => {
|
|
211
|
-
if (!backApplyDraft || backApplyStarted.current || !createFilter.isSuccess)
|
|
212
|
-
return;
|
|
213
|
-
backApplyStarted.current = true;
|
|
214
|
-
organizeJob.start(backApplyDraft);
|
|
215
|
-
}, [backApplyDraft, createFilter.isSuccess, organizeJob.start]);
|
|
216
|
-
|
|
217
|
-
const retryBackApply = () => {
|
|
218
|
-
if (backApplyDraft) organizeJob.start(backApplyDraft);
|
|
219
|
-
};
|
|
220
|
-
|
|
221
|
-
if (organizeJob.isStarting || organizeJob.isRunning || organizeJob.isDone) {
|
|
222
|
-
return (
|
|
223
|
-
<JobProgress
|
|
224
|
-
progress={organizeJob.progress}
|
|
225
|
-
isDone={organizeJob.isDone}
|
|
226
|
-
runningLabel={
|
|
227
|
-
senderFallback
|
|
228
|
-
? "Organizing mail from these senders…"
|
|
229
|
-
: matchMode === "properties"
|
|
230
|
-
? "Organizing matching mail…"
|
|
231
|
-
: "Organizing similar mail…"
|
|
232
|
-
}
|
|
233
|
-
onClose={onClose}
|
|
234
|
-
/>
|
|
235
|
-
);
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
// The filter saved, but the back-apply's own request failed (a 5xx or dropped
|
|
239
|
-
// connection on start, or a failed retry) — no job id was ever assigned, so no
|
|
240
|
-
// JobProgress state holds. Surface it distinctly instead of falling through to
|
|
241
|
-
// "Filter saved" and swallowing it: the rule is live, the move is what to
|
|
242
|
-
// retry.
|
|
243
|
-
if (createFilter.isSuccess && organizeJob.isError) {
|
|
244
|
-
return <BackApplyError onRetry={retryBackApply} onClose={onClose} />;
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
if (createFilter.isPending) {
|
|
248
|
-
return <SavingState />;
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
if (createFilter.isSuccess) {
|
|
252
|
-
// The filter is saved; the back-apply is about to start (the effect hands
|
|
253
|
-
// off to the job on the next tick). Keep the saving state until it takes
|
|
254
|
-
// over so the success screen never flashes between them. When the rule
|
|
255
|
-
// can't be back-applied, `backApplyDraft` is undefined and this is the
|
|
256
|
-
// terminal state — saved, applying to incoming mail only.
|
|
257
|
-
if (backApplyDraft) return <SavingState />;
|
|
258
|
-
return <FilterSaved onClose={onClose} />;
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
if (createFilter.isError) {
|
|
262
|
-
return <CommitError onRetry={createFilter.reset} onClose={onClose} />;
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
return (
|
|
266
|
-
<FilterRuleEditor
|
|
267
|
-
rule={rule}
|
|
268
|
-
folders={folders}
|
|
269
|
-
labels={labels}
|
|
270
|
-
preview={preview}
|
|
271
|
-
semanticAvailable={!semanticUnavailable}
|
|
272
|
-
// A deployment without the vector pipeline has only one mode to be in,
|
|
273
|
-
// so it gets no choice to make — the rule already matches on properties.
|
|
274
|
-
matchMode={semanticUnavailable ? undefined : matchMode}
|
|
275
|
-
onChangeMatchMode={changeMatchMode}
|
|
276
|
-
clauseFields={SUPPORTED_CLAUSE_FIELDS}
|
|
277
|
-
clauseSuggestions={clauseSuggestions}
|
|
278
|
-
{...handlers}
|
|
279
|
-
onCreateFolder={createFolder}
|
|
280
|
-
onCreateLabel={onCreateLabel}
|
|
281
|
-
onCommit={commit}
|
|
282
|
-
onCancel={onClose}
|
|
283
|
-
/>
|
|
284
|
-
);
|
|
285
|
-
}
|
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { describe, it } from "node:test";
|
|
3
|
-
import React, { createElement } from "react";
|
|
4
|
-
import { renderToString } from "react-dom/server";
|
|
5
|
-
import type { OrganizeSeed } from "@/lib/organize/mobile-organize-flow";
|
|
6
|
-
import { type FolderOption, SomethingElsePanel } from "./SomethingElsePanel";
|
|
7
|
-
|
|
8
|
-
// The node test loader transpiles remit-ui's `.tsx` with the classic JSX
|
|
9
|
-
// runtime, which references a global `React`. Vite uses the automatic runtime,
|
|
10
|
-
// so this shim only exists for the SSR test harness.
|
|
11
|
-
(globalThis as { React?: typeof React }).React = React;
|
|
12
|
-
|
|
13
|
-
const FOLDERS: FolderOption[] = [
|
|
14
|
-
{ id: "mbx-inbox", label: "Inbox" },
|
|
15
|
-
{ id: "mbx-archive", label: "Archive" },
|
|
16
|
-
{ id: "mbx-junk", label: "Spam" },
|
|
17
|
-
];
|
|
18
|
-
|
|
19
|
-
const render = (
|
|
20
|
-
folderOptions: FolderOption[] = FOLDERS,
|
|
21
|
-
junkMailboxId?: string,
|
|
22
|
-
) =>
|
|
23
|
-
renderToString(
|
|
24
|
-
createElement(SomethingElsePanel, {
|
|
25
|
-
folderOptions,
|
|
26
|
-
junkMailboxId,
|
|
27
|
-
onSeed: (_seed: OrganizeSeed) => undefined,
|
|
28
|
-
}) as never,
|
|
29
|
-
);
|
|
30
|
-
|
|
31
|
-
describe("SomethingElsePanel", () => {
|
|
32
|
-
it("shows the plain-language prompt and input", () => {
|
|
33
|
-
const html = render();
|
|
34
|
-
assert.match(html, /What should Remit do\?/);
|
|
35
|
-
assert.match(html, /Tell Remit what to do/);
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
it("derives shortcuts from the account's real folders", () => {
|
|
39
|
-
const html = render();
|
|
40
|
-
assert.match(html, /Always keep in Inbox/);
|
|
41
|
-
assert.match(html, /File in Archive/);
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
it("offers the appointed Junk mailbox by its own label", () => {
|
|
45
|
-
const html = render(FOLDERS, "mbx-junk");
|
|
46
|
-
assert.match(html, /Move to Spam/);
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
it("offers no folder shortcuts when the account has none", () => {
|
|
50
|
-
const html = render([]);
|
|
51
|
-
assert.doesNotMatch(html, /Always keep in Inbox/);
|
|
52
|
-
assert.doesNotMatch(html, /File in Archive/);
|
|
53
|
-
});
|
|
54
|
-
});
|
|
@@ -1,159 +0,0 @@
|
|
|
1
|
-
import { Button, Input } from "@remit/ui";
|
|
2
|
-
import { ArrowUp } from "lucide-react";
|
|
3
|
-
import { useMemo, useState } from "react";
|
|
4
|
-
import type { OrganizeSeed } from "@/lib/organize/mobile-organize-flow";
|
|
5
|
-
|
|
6
|
-
export interface FolderOption {
|
|
7
|
-
id: string;
|
|
8
|
-
label: string;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
interface SomethingElsePanelProps {
|
|
12
|
-
/** The account's move destinations, the same set the sentence's folder picker uses. */
|
|
13
|
-
folderOptions: FolderOption[];
|
|
14
|
-
/** The appointed Junk mailbox, offered as a shortcut when present. */
|
|
15
|
-
junkMailboxId?: string;
|
|
16
|
-
/** Chosen shortcut or parsed input — seeds the organize sentence's folder/scope. */
|
|
17
|
-
onSeed: (seed: OrganizeSeed) => void;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
interface Shortcut {
|
|
21
|
-
id: string;
|
|
22
|
-
label: string;
|
|
23
|
-
seed: OrganizeSeed;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
const findFolder = (
|
|
27
|
-
folderOptions: FolderOption[],
|
|
28
|
-
label: string,
|
|
29
|
-
): FolderOption | undefined =>
|
|
30
|
-
folderOptions.find((folder) => folder.label.toLowerCase() === label);
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Shortcuts are derived from the account's real folders — there is no
|
|
34
|
-
* suggestion endpoint, so the panel offers the moves it can actually commit:
|
|
35
|
-
* keep it in the Inbox as a standing rule, file it in Archive, or send it to
|
|
36
|
-
* the appointed Junk mailbox. Each seeds the organize sentence; the user still
|
|
37
|
-
* confirms the scope.
|
|
38
|
-
*/
|
|
39
|
-
const buildShortcuts = (
|
|
40
|
-
folderOptions: FolderOption[],
|
|
41
|
-
junkMailboxId: string | undefined,
|
|
42
|
-
): Shortcut[] => {
|
|
43
|
-
const shortcuts: Shortcut[] = [];
|
|
44
|
-
const inbox = findFolder(folderOptions, "inbox");
|
|
45
|
-
if (inbox) {
|
|
46
|
-
shortcuts.push({
|
|
47
|
-
id: "keep-inbox",
|
|
48
|
-
label: "Always keep in Inbox",
|
|
49
|
-
seed: { moveMailboxId: inbox.id, scope: "standing" },
|
|
50
|
-
});
|
|
51
|
-
}
|
|
52
|
-
const archive = findFolder(folderOptions, "archive");
|
|
53
|
-
if (archive) {
|
|
54
|
-
shortcuts.push({
|
|
55
|
-
id: "file-archive",
|
|
56
|
-
label: "File in Archive",
|
|
57
|
-
seed: { moveMailboxId: archive.id },
|
|
58
|
-
});
|
|
59
|
-
}
|
|
60
|
-
const junk = junkMailboxId
|
|
61
|
-
? folderOptions.find((folder) => folder.id === junkMailboxId)
|
|
62
|
-
: undefined;
|
|
63
|
-
if (junk) {
|
|
64
|
-
shortcuts.push({
|
|
65
|
-
id: "move-junk",
|
|
66
|
-
label: `Move to ${junk.label}`,
|
|
67
|
-
seed: { moveMailboxId: junk.id },
|
|
68
|
-
});
|
|
69
|
-
}
|
|
70
|
-
return shortcuts;
|
|
71
|
-
};
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* Reads a plain-language instruction into a seed without an NLP endpoint: it
|
|
75
|
-
* matches the typed words against the account's own folder names and reads an
|
|
76
|
-
* "always"/"keep" phrasing as the standing scope. Whatever it can't resolve to
|
|
77
|
-
* a folder still opens the sentence, where the picker is one tap away — never a
|
|
78
|
-
* dead end.
|
|
79
|
-
*/
|
|
80
|
-
const parseInstruction = (
|
|
81
|
-
text: string,
|
|
82
|
-
folderOptions: FolderOption[],
|
|
83
|
-
): OrganizeSeed => {
|
|
84
|
-
const lower = text.toLowerCase();
|
|
85
|
-
const folder = folderOptions.find((option) =>
|
|
86
|
-
lower.includes(option.label.toLowerCase()),
|
|
87
|
-
);
|
|
88
|
-
const standing = /\b(always|keep|future|from now)\b/.test(lower);
|
|
89
|
-
return {
|
|
90
|
-
moveMailboxId: folder?.id,
|
|
91
|
-
scope: standing ? "standing" : undefined,
|
|
92
|
-
};
|
|
93
|
-
};
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* The "Something else" fallback (issue #211): smart shortcuts plus a
|
|
97
|
-
* plain-language input, both of which seed the organize sentence's folder and
|
|
98
|
-
* scope. Presentational — the flow owns the folder data and what a seed does.
|
|
99
|
-
*/
|
|
100
|
-
export function SomethingElsePanel({
|
|
101
|
-
folderOptions,
|
|
102
|
-
junkMailboxId,
|
|
103
|
-
onSeed,
|
|
104
|
-
}: SomethingElsePanelProps) {
|
|
105
|
-
const [text, setText] = useState("");
|
|
106
|
-
const shortcuts = useMemo(
|
|
107
|
-
() => buildShortcuts(folderOptions, junkMailboxId),
|
|
108
|
-
[folderOptions, junkMailboxId],
|
|
109
|
-
);
|
|
110
|
-
|
|
111
|
-
const submit = () => {
|
|
112
|
-
const trimmed = text.trim();
|
|
113
|
-
if (!trimmed) return;
|
|
114
|
-
onSeed(parseInstruction(trimmed, folderOptions));
|
|
115
|
-
};
|
|
116
|
-
|
|
117
|
-
return (
|
|
118
|
-
<div className="flex min-h-0 flex-col">
|
|
119
|
-
<div className="border-b border-line px-5 py-3">
|
|
120
|
-
<h2 className="text-sm font-semibold text-fg">What should Remit do?</h2>
|
|
121
|
-
<p className="mt-0.5 text-xs text-fg-muted">
|
|
122
|
-
Pick a shortcut or say it in your own words.
|
|
123
|
-
</p>
|
|
124
|
-
</div>
|
|
125
|
-
|
|
126
|
-
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto px-5 py-4">
|
|
127
|
-
{shortcuts.map((shortcut) => (
|
|
128
|
-
<Button
|
|
129
|
-
key={shortcut.id}
|
|
130
|
-
variant="secondary"
|
|
131
|
-
onClick={() => onSeed(shortcut.seed)}
|
|
132
|
-
className="h-12 w-full justify-start px-4"
|
|
133
|
-
>
|
|
134
|
-
{shortcut.label}
|
|
135
|
-
</Button>
|
|
136
|
-
))}
|
|
137
|
-
</div>
|
|
138
|
-
|
|
139
|
-
<div className="flex items-center gap-2 border-t border-line px-5 py-3">
|
|
140
|
-
<Input
|
|
141
|
-
value={text}
|
|
142
|
-
onChange={(event) => setText(event.target.value)}
|
|
143
|
-
onKeyDown={(event) => event.key === "Enter" && submit()}
|
|
144
|
-
placeholder="Tell Remit what to do…"
|
|
145
|
-
aria-label="Tell Remit what to do"
|
|
146
|
-
className="flex-1"
|
|
147
|
-
/>
|
|
148
|
-
<Button
|
|
149
|
-
variant="primary"
|
|
150
|
-
aria-label="Send"
|
|
151
|
-
onClick={submit}
|
|
152
|
-
disabled={!text.trim()}
|
|
153
|
-
icon={<ArrowUp className="size-4" />}
|
|
154
|
-
className="size-9 shrink-0 px-0"
|
|
155
|
-
/>
|
|
156
|
-
</div>
|
|
157
|
-
</div>
|
|
158
|
-
);
|
|
159
|
-
}
|