@remit/web-client 0.0.92 → 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.
Files changed (30) hide show
  1. package/package.json +1 -1
  2. package/src/components/mail/DailyBrief.selection.test.ts +6 -2
  3. package/src/components/mail/DailyBrief.tsx +47 -110
  4. package/src/components/mail/MessageList.selection.test.ts +10 -5
  5. package/src/components/mail/MessageList.tsx +78 -82
  6. package/src/components/mail/SelectionWizardHost.tsx +648 -60
  7. package/src/components/mail/ThreadListInteraction.tsx +0 -9
  8. package/src/components/mail/organize/SearchFilterEditor.tsx +5 -1
  9. package/src/components/settings/FilterEditor.tsx +1 -1
  10. package/src/hooks/useCreateMailbox.ts +16 -4
  11. package/src/hooks/useFilters.ts +30 -1
  12. package/src/hooks/useMatchSample.ts +63 -0
  13. package/src/hooks/useRulePreview.ts +23 -3
  14. package/src/lib/mail-context.ts +0 -9
  15. package/src/lib/organize/organize-model.test.ts +110 -0
  16. package/src/lib/organize/organize-model.ts +69 -0
  17. package/src/lib/organize/rule-model.ts +2 -0
  18. package/src/lib/wizard-history.ts +15 -0
  19. package/src/routes/mail.tsx +0 -7
  20. package/src/components/mail/organize/MobileOrganizeFlow.render.test.ts +0 -45
  21. package/src/components/mail/organize/MobileOrganizeFlow.tsx +0 -157
  22. package/src/components/mail/organize/OrganizeDialog.render.test.ts +0 -37
  23. package/src/components/mail/organize/OrganizeDialog.tsx +0 -91
  24. package/src/components/mail/organize/OrganizeRuleEditor.render.test.ts +0 -498
  25. package/src/components/mail/organize/OrganizeRuleEditor.tsx +0 -285
  26. package/src/components/mail/organize/SomethingElsePanel.render.test.ts +0 -54
  27. package/src/components/mail/organize/SomethingElsePanel.tsx +0 -159
  28. package/src/components/mail/organize/smart-organize.stories.tsx +0 -342
  29. package/src/lib/organize/mobile-organize-flow.test.ts +0 -96
  30. 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
- }