@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,342 +0,0 @@
1
- import {
2
- mailboxOperationsListMailboxesQueryKey,
3
- threadOperationsListThreadsQueryKey,
4
- } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
5
- import type { RemitImapMailboxResponse } from "@remit/api-http-client/types.gen.ts";
6
- import { BottomSheet } from "@remit/ui";
7
- import type { Meta, StoryObj } from "@storybook/react-vite";
8
- import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
9
- import type { ReactNode } from "react";
10
- import { ErrorBannerProvider } from "@/components/ui/ErrorBannerProvider";
11
- import { OrganizeRuleEditor } from "./OrganizeRuleEditor";
12
- import type { FolderOption } from "./SomethingElsePanel";
13
- import { SomethingElsePanel } from "./SomethingElsePanel";
14
-
15
- /**
16
- * `Flows/Smart Organize` — the Organize surface as the chip editor (RFC 038 D1),
17
- * rendered from the real web-client component the desktop dialog and the mobile
18
- * sheet both mount, not a prototype copy. Each story opens the editor on a
19
- * seeded state — a widen-anchored rule, the sender-fallback clauses (#251), a
20
- * pre-seeded standing rule, and the capability-absent cases — so the surface
21
- * can't drift from what ships.
22
- */
23
-
24
- const ACCOUNT_ID = "acc-1";
25
-
26
- const makeMailbox = (
27
- mailboxId: string,
28
- fullPath: string,
29
- ): RemitImapMailboxResponse => ({
30
- mailboxId,
31
- accountId: ACCOUNT_ID,
32
- namespaceType: "personal",
33
- namespacePrefix: "",
34
- hierarchyDelimiter: "/",
35
- fullPath,
36
- messageCount: 0,
37
- unseenCount: 0,
38
- deletedCount: 0,
39
- lastSyncUid: 0,
40
- highWaterMarkUid: 0,
41
- lastMessageSyncAt: 0,
42
- createdAt: 0,
43
- updatedAt: 0,
44
- });
45
-
46
- const MAILBOXES: RemitImapMailboxResponse[] = [
47
- makeMailbox("mbx-inbox", "INBOX"),
48
- makeMailbox("mbx-archive", "Archive"),
49
- makeMailbox("mbx-receipts", "Receipts"),
50
- makeMailbox("mbx-travel", "Travel"),
51
- makeMailbox("mbx-junk", "Junk"),
52
- ];
53
-
54
- const FOLDER_OPTIONS: FolderOption[] = [
55
- { id: "mbx-inbox", label: "Inbox" },
56
- { id: "mbx-archive", label: "Archive" },
57
- { id: "mbx-receipts", label: "Receipts" },
58
- { id: "mbx-junk", label: "Junk" },
59
- ];
60
-
61
- /**
62
- * A QueryClient pre-seeded with the account's mailboxes, so the editor's folder
63
- * picker renders its real options without a network round-trip, and optionally
64
- * with the thread rows the properties prefill reads its subjects off — the same
65
- * rows the mailbox list renders behind the sheet.
66
- */
67
- function seededClient(
68
- subjects?: Record<string, string>,
69
- senderEmail = "receipts@example.com",
70
- ): QueryClient {
71
- const client = new QueryClient({
72
- defaultOptions: { queries: { retry: false } },
73
- });
74
- client.setQueryData(
75
- mailboxOperationsListMailboxesQueryKey({ path: { accountId: ACCOUNT_ID } }),
76
- { items: MAILBOXES },
77
- );
78
- if (subjects) {
79
- client.setQueryData(
80
- threadOperationsListThreadsQueryKey({ path: { mailboxId: "mbx-inbox" } }),
81
- {
82
- items: Object.entries(subjects).map(([messageId, subject]) => ({
83
- messageId,
84
- threadId: messageId,
85
- subject,
86
- fromEmail: senderEmail,
87
- })),
88
- },
89
- );
90
- }
91
- return client;
92
- }
93
-
94
- /** Phone frame + a bottom sheet holding the editor, mirroring the mobile home. */
95
- function SheetStage({
96
- children,
97
- subjects,
98
- }: {
99
- children: ReactNode;
100
- subjects?: Record<string, string>;
101
- }) {
102
- return (
103
- <QueryClientProvider client={seededClient(subjects)}>
104
- <ErrorBannerProvider>
105
- <div className="relative mx-auto h-dvh w-full shrink-0 overflow-hidden bg-surface sm:my-6 sm:h-[760px] sm:w-[390px] sm:rounded-[2rem] sm:border sm:border-line sm:shadow-sm">
106
- <div className="divide-y divide-line opacity-50">
107
- {Array.from({ length: 8 }).map((_, index) => (
108
- <div
109
- // biome-ignore lint/suspicious/noArrayIndexKey: static backdrop skeleton, no ids
110
- key={index}
111
- className="flex items-start gap-3 px-row-inset py-2.5"
112
- >
113
- <div className="mt-0.5 size-7 shrink-0 rounded-full bg-surface-sunken" />
114
- <div className="min-w-0 flex-1 space-y-1">
115
- <div className="h-2.5 w-1/3 rounded bg-surface-sunken" />
116
- <div className="h-2 w-2/3 rounded bg-surface-sunken" />
117
- </div>
118
- </div>
119
- ))}
120
- </div>
121
- <BottomSheet open onClose={() => undefined}>
122
- {children}
123
- </BottomSheet>
124
- </div>
125
- </ErrorBannerProvider>
126
- </QueryClientProvider>
127
- );
128
- }
129
-
130
- const meta: Meta = {
131
- title: "Flows/Smart Organize",
132
- parameters: { layout: "fullscreen" },
133
- };
134
- export default meta;
135
-
136
- type Story = StoryObj;
137
-
138
- /** The rule editor on a widened selection — the widen chip and a live count. */
139
- export const Organize: Story = {
140
- render: () => (
141
- <SheetStage>
142
- <OrganizeRuleEditor
143
- accountId={ACCOUNT_ID}
144
- selectedMessageIds={["msg-1", "msg-2", "msg-3"]}
145
- seedCount={47}
146
- onClose={() => undefined}
147
- />
148
- </SheetStage>
149
- ),
150
- };
151
-
152
- /** A larger widen — the same editor over a broad match set. */
153
- export const FromSearch: Story = {
154
- name: "From Search",
155
- render: () => (
156
- <SheetStage>
157
- <OrganizeRuleEditor
158
- accountId={ACCOUNT_ID}
159
- selectedMessageIds={["msg-1"]}
160
- seedCount={412}
161
- seedMailboxId="mbx-archive"
162
- onClose={() => undefined}
163
- />
164
- </SheetStage>
165
- ),
166
- };
167
-
168
- /** The standing scope pre-selected — the rule that keeps working on new mail. */
169
- export const AlwaysRule: Story = {
170
- name: "Always Rule",
171
- render: () => (
172
- <SheetStage>
173
- <OrganizeRuleEditor
174
- accountId={ACCOUNT_ID}
175
- selectedMessageIds={["msg-1", "msg-2"]}
176
- seedCount={47}
177
- seedScope="standing"
178
- seedMailboxId="mbx-travel"
179
- onClose={() => undefined}
180
- />
181
- </SheetStage>
182
- ),
183
- };
184
-
185
- /**
186
- * The deployment ships no vector pipeline and the selection has no senders to
187
- * match on — the widen is not offered and the rule opens empty, awaiting a
188
- * clause (#226/#201).
189
- */
190
- export const SemanticUnavailable: Story = {
191
- name: "Semantic Unavailable",
192
- render: () => (
193
- <SheetStage>
194
- <OrganizeRuleEditor
195
- accountId={ACCOUNT_ID}
196
- selectedMessageIds={["msg-1", "msg-2"]}
197
- seedCount={0}
198
- semanticUnavailable
199
- onClose={() => undefined}
200
- />
201
- </SheetStage>
202
- ),
203
- };
204
-
205
- /**
206
- * No vector pipeline, but the selection has senders to match on — the widen fell
207
- * back to the sender `From` clauses (#251). The senders span different domains,
208
- * so the fallback keeps one editable `From` chip per address, combined with
209
- * "or". Every commit carries exactly these clauses.
210
- */
211
- export const SenderFallback: Story = {
212
- name: "Sender Fallback",
213
- render: () => (
214
- <SheetStage>
215
- <OrganizeRuleEditor
216
- accountId={ACCOUNT_ID}
217
- selectedMessageIds={["msg-1", "msg-2"]}
218
- seedCount={128}
219
- semanticUnavailable
220
- senders={["npm@github.com", "noreply@medium.com"]}
221
- onClose={() => undefined}
222
- />
223
- </SheetStage>
224
- ),
225
- };
226
-
227
- /**
228
- * The sender fallback where every selected sender shares one registrable domain
229
- * (RFC 038 D2): the per-address `From` chips collapse to a single `FromDomain`
230
- * chip — "anyone at github.com" — rather than listing addresses. Matches
231
- * `sub.github.com` too, never a look-alike like `github.com.evil.example`.
232
- */
233
- export const SenderFallbackDomain: Story = {
234
- name: "Sender Fallback (domain)",
235
- render: () => (
236
- <SheetStage>
237
- <OrganizeRuleEditor
238
- accountId={ACCOUNT_ID}
239
- selectedMessageIds={["msg-1", "msg-2", "msg-3"]}
240
- seedCount={342}
241
- semanticUnavailable
242
- senders={[
243
- "npm@github.com",
244
- "notifications@github.com",
245
- "ci@sub.github.com",
246
- ]}
247
- onClose={() => undefined}
248
- />
249
- </SheetStage>
250
- ),
251
- };
252
-
253
- /** The sender fallback with a standing rule pre-selected. */
254
- export const SenderFallbackStanding: Story = {
255
- name: "Sender Fallback (standing)",
256
- render: () => (
257
- <SheetStage>
258
- <OrganizeRuleEditor
259
- accountId={ACCOUNT_ID}
260
- selectedMessageIds={["msg-1", "msg-2", "msg-3", "msg-4"]}
261
- seedCount={412}
262
- semanticUnavailable
263
- senders={[
264
- "npm@github.com",
265
- "notifications@github.com",
266
- "noreply@medium.com",
267
- "digest@substack.com",
268
- ]}
269
- seedScope="standing"
270
- seedMailboxId="mbx-archive"
271
- onClose={() => undefined}
272
- />
273
- </SheetStage>
274
- ),
275
- };
276
-
277
- /**
278
- * Matching on properties instead of similarity — the third way to build a rule,
279
- * with no semantics in it at all. Every selected message came from one sender,
280
- * so the rule opens on a single editable `From` chip. The mode control stays on
281
- * screen: "Anything similar" is one tap away and is still where the flow opens.
282
- */
283
- export const MatchOnPropertiesSender: Story = {
284
- name: "Match on Properties (sender)",
285
- render: () => (
286
- <SheetStage>
287
- <OrganizeRuleEditor
288
- accountId={ACCOUNT_ID}
289
- selectedMessageIds={["msg-1", "msg-2", "msg-3"]}
290
- seedCount={47}
291
- seedMatchMode="properties"
292
- senders={["receipts@example.com"]}
293
- onClose={() => undefined}
294
- />
295
- </SheetStage>
296
- ),
297
- };
298
-
299
- /**
300
- * The same mode where the selection's senders differ: there is no one sender to
301
- * name, so the prefill drops to the part the subjects share — "Your receipt
302
- * from" out of three differently-numbered receipts — as an ordinary `Subject`
303
- * chip the user can widen or narrow.
304
- */
305
- export const MatchOnPropertiesSubject: Story = {
306
- name: "Match on Properties (subject)",
307
- render: () => (
308
- <SheetStage
309
- subjects={{
310
- "msg-1": "Your receipt from Blue Bottle #4821",
311
- "msg-2": "Re: Your receipt from Ritual Coffee #119",
312
- "msg-3": "Your receipt from Sightglass #77",
313
- }}
314
- >
315
- <OrganizeRuleEditor
316
- accountId={ACCOUNT_ID}
317
- selectedMessageIds={["msg-1", "msg-2", "msg-3"]}
318
- seedCount={62}
319
- seedMatchMode="properties"
320
- senders={[
321
- "receipts@bluebottle.example",
322
- "hello@ritual.example",
323
- "no-reply@sightglass.example",
324
- ]}
325
- onClose={() => undefined}
326
- />
327
- </SheetStage>
328
- ),
329
- };
330
-
331
- /** The "Something else" fallback: shortcuts derived from folders + an input. */
332
- export const SomethingElse: Story = {
333
- render: () => (
334
- <SheetStage>
335
- <SomethingElsePanel
336
- folderOptions={FOLDER_OPTIONS}
337
- junkMailboxId="mbx-junk"
338
- onSeed={() => undefined}
339
- />
340
- </SheetStage>
341
- ),
342
- };
@@ -1,96 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { describe, it } from "node:test";
3
- import {
4
- type OrganizeStageInput,
5
- resolveOrganizeStage,
6
- } from "./mobile-organize-flow";
7
-
8
- const base: OrganizeStageInput = {
9
- entry: "select-similar",
10
- hasSeed: true,
11
- previewStatus: "idle",
12
- matchedCount: undefined,
13
- };
14
-
15
- describe("resolveOrganizeStage — the guided mobile flow's state machine", () => {
16
- it("select-similar shows the widening state before the preview resolves", () => {
17
- assert.deepEqual(resolveOrganizeStage({ ...base, previewStatus: "idle" }), {
18
- kind: "widening",
19
- });
20
- assert.deepEqual(
21
- resolveOrganizeStage({ ...base, previewStatus: "pending" }),
22
- { kind: "widening" },
23
- );
24
- });
25
-
26
- it("opens the organize sentence on the widened set once the preview matches", () => {
27
- assert.deepEqual(
28
- resolveOrganizeStage({
29
- ...base,
30
- previewStatus: "success",
31
- matchedCount: 47,
32
- }),
33
- { kind: "organize", matchedCount: 47, fallback: false },
34
- );
35
- });
36
-
37
- it("falls back to organizing the selection when the widen matches nothing — no dead end", () => {
38
- assert.deepEqual(
39
- resolveOrganizeStage({
40
- ...base,
41
- previewStatus: "success",
42
- matchedCount: 0,
43
- }),
44
- { kind: "organize", matchedCount: 0, fallback: true },
45
- );
46
- // A success with an undefined count is treated as zero, not a crash.
47
- assert.deepEqual(
48
- resolveOrganizeStage({
49
- ...base,
50
- previewStatus: "success",
51
- matchedCount: undefined,
52
- }),
53
- { kind: "organize", matchedCount: 0, fallback: true },
54
- );
55
- });
56
-
57
- it("surfaces the error branch when the widen fails", () => {
58
- assert.deepEqual(
59
- resolveOrganizeStage({ ...base, previewStatus: "error" }),
60
- { kind: "error" },
61
- );
62
- });
63
-
64
- it("something-else shows the shortcuts + input until a seed is chosen", () => {
65
- assert.deepEqual(
66
- resolveOrganizeStage({
67
- entry: "something-else",
68
- hasSeed: false,
69
- previewStatus: "success",
70
- matchedCount: 47,
71
- }),
72
- { kind: "something-else" },
73
- );
74
- });
75
-
76
- it("something-else widens after the seed, then opens the seeded sentence", () => {
77
- assert.deepEqual(
78
- resolveOrganizeStage({
79
- entry: "something-else",
80
- hasSeed: true,
81
- previewStatus: "pending",
82
- matchedCount: undefined,
83
- }),
84
- { kind: "widening" },
85
- );
86
- assert.deepEqual(
87
- resolveOrganizeStage({
88
- entry: "something-else",
89
- hasSeed: true,
90
- previewStatus: "success",
91
- matchedCount: 12,
92
- }),
93
- { kind: "organize", matchedCount: 12, fallback: false },
94
- );
95
- });
96
- });
@@ -1,73 +0,0 @@
1
- import type { OrganizeScope } from "./organize-model";
2
-
3
- /**
4
- * How the guided mobile flow was entered from the selection sheet:
5
- *
6
- * - `select-similar` — widen straight into the organize sentence.
7
- * - `something-else` — collect a folder/scope seed from shortcuts or a
8
- * plain-language input first, then widen into the seeded sentence.
9
- */
10
- export type OrganizeEntry = "select-similar" | "something-else";
11
-
12
- /** The read-only widen preview's lifecycle (POST /organize/preview). */
13
- export type PreviewStatus = "idle" | "pending" | "success" | "error";
14
-
15
- /** A folder/scope the "Something else" panel pre-fills the sentence with. */
16
- export interface OrganizeSeed {
17
- moveMailboxId?: string;
18
- scope?: OrganizeScope;
19
- }
20
-
21
- /**
22
- * The stage the guided flow renders, resolved from the entry, whether a
23
- * "Something else" seed has been chosen yet, and where the widen preview is:
24
- *
25
- * - `something-else` — the shortcuts + input, shown until a seed is picked.
26
- * - `widening` — the brief widening state between the tap and the sentence.
27
- * - `error` — the widen failed; a dead end only in the sense that it offers a
28
- * close, never a broken sentence.
29
- * - `organize` — the committed sentence, on the widened set. `fallback` is true
30
- * when the widen matched nothing, so the sentence organizes just the
31
- * selection instead of a dead end (issue #211).
32
- */
33
- export type OrganizeStage =
34
- | { kind: "something-else" }
35
- | { kind: "widening" }
36
- | { kind: "error" }
37
- | { kind: "organize"; matchedCount: number; fallback: boolean };
38
-
39
- export interface OrganizeStageInput {
40
- entry: OrganizeEntry;
41
- /** "Something else" only: whether the user has chosen a seed yet. */
42
- hasSeed: boolean;
43
- previewStatus: PreviewStatus;
44
- /** The widen's matched total; defined once `previewStatus` is `success`. */
45
- matchedCount: number | undefined;
46
- }
47
-
48
- /**
49
- * The pure state machine behind the guided organize sheet:
50
- * `idle → widening → organize` for select-similar, with a `something-else`
51
- * seeding step in front for that entry, an `error` branch when the widen
52
- * fails, and the zero-match `fallback` that keeps the organize sentence usable
53
- * on the selection alone. Kept pure so every transition is testable without a
54
- * DOM, React Query, or a network — the component only reads the stage back.
55
- */
56
- export const resolveOrganizeStage = ({
57
- entry,
58
- hasSeed,
59
- previewStatus,
60
- matchedCount,
61
- }: OrganizeStageInput): OrganizeStage => {
62
- if (entry === "something-else" && !hasSeed) {
63
- return { kind: "something-else" };
64
- }
65
- if (previewStatus === "error") {
66
- return { kind: "error" };
67
- }
68
- if (previewStatus !== "success") {
69
- return { kind: "widening" };
70
- }
71
- const matched = matchedCount ?? 0;
72
- return { kind: "organize", matchedCount: matched, fallback: matched === 0 };
73
- };