@remit/web-client 0.0.92 → 0.0.94

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 (43) 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 +57 -112
  4. package/src/components/mail/FlaggedList.tsx +42 -0
  5. package/src/components/mail/MailListHeader.tsx +41 -39
  6. package/src/components/mail/MessageList.selection.test.ts +10 -5
  7. package/src/components/mail/MessageList.tsx +78 -82
  8. package/src/components/mail/SelectionWizardHost.tsx +721 -64
  9. package/src/components/mail/ThreadListInteraction.tsx +0 -9
  10. package/src/components/mail/make-filter-entry.test.ts +63 -0
  11. package/src/components/settings/FilterEditor.tsx +1 -1
  12. package/src/hooks/useCreateMailbox.ts +16 -4
  13. package/src/hooks/useFilters.ts +30 -1
  14. package/src/hooks/useMatchSample.ts +63 -0
  15. package/src/hooks/useRulePreview.ts +24 -5
  16. package/src/lib/list-header-chrome.ts +10 -0
  17. package/src/lib/mail-context.ts +0 -9
  18. package/src/lib/organize/organize-model.test.ts +110 -0
  19. package/src/lib/organize/organize-model.ts +69 -0
  20. package/src/lib/organize/rule-model.ts +2 -4
  21. package/src/lib/wizard-history.test.ts +31 -0
  22. package/src/lib/wizard-history.ts +65 -2
  23. package/src/routes/mail.tsx +3 -8
  24. package/src/components/mail/organize/MobileOrganizeFlow.render.test.ts +0 -45
  25. package/src/components/mail/organize/MobileOrganizeFlow.tsx +0 -157
  26. package/src/components/mail/organize/OrganizeDialog.render.test.ts +0 -37
  27. package/src/components/mail/organize/OrganizeDialog.tsx +0 -91
  28. package/src/components/mail/organize/OrganizeRuleEditor.render.test.ts +0 -498
  29. package/src/components/mail/organize/OrganizeRuleEditor.tsx +0 -285
  30. package/src/components/mail/organize/SearchFilterDialog.render.test.ts +0 -47
  31. package/src/components/mail/organize/SearchFilterDialog.tsx +0 -90
  32. package/src/components/mail/organize/SearchFilterEditor.render.test.ts +0 -260
  33. package/src/components/mail/organize/SearchFilterEditor.tsx +0 -136
  34. package/src/components/mail/organize/SomethingElsePanel.render.test.ts +0 -54
  35. package/src/components/mail/organize/SomethingElsePanel.tsx +0 -159
  36. package/src/components/mail/organize/rule-editor-states.stories.tsx +0 -147
  37. package/src/components/mail/organize/rule-editor-states.tsx +0 -139
  38. package/src/components/mail/organize/smart-organize.stories.tsx +0 -342
  39. package/src/hooks/useRuleEditorState.ts +0 -182
  40. package/src/hooks/useSearchFilterSeed.render.test.ts +0 -118
  41. package/src/hooks/useSearchFilterSeed.ts +0 -79
  42. package/src/lib/organize/mobile-organize-flow.test.ts +0 -96
  43. package/src/lib/organize/mobile-organize-flow.ts +0 -73
@@ -37,6 +37,33 @@ export const wizardStepFromParam = (value: unknown): StepId | undefined => {
37
37
  return parsed.success ? parsed.data : undefined;
38
38
  };
39
39
 
40
+ /**
41
+ * Which affordance opened the wizard. Absent is the selection bar; `search` is
42
+ * the make-filter affordance in the search results header, whose clauses come
43
+ * from the query rather than from ticked rows (#477 1.8).
44
+ *
45
+ * It rides the URL beside the step for the same reason the step does: a
46
+ * reloaded document has to know which walk it is in the middle of, and the
47
+ * query it was seeded from is in the URL already. It decides two things and no
48
+ * more — which step the wizard opens on, and whether the query seeds the
49
+ * clauses (#477 3.4). One host answers either way.
50
+ */
51
+ const wizardEntry = z.literal("search");
52
+
53
+ export type WizardEntry = z.infer<typeof wizardEntry>;
54
+
55
+ export const wizardEntryFromParam = (
56
+ value: unknown,
57
+ ): WizardEntry | undefined => {
58
+ const parsed = wizardEntry.safeParse(value);
59
+ return parsed.success ? parsed.data : undefined;
60
+ };
61
+
62
+ export const wizardEntryValue = z.unknown().transform(wizardEntryFromParam);
63
+
64
+ export const useWizardEntryValue = (): WizardEntry | undefined =>
65
+ useSearch({ from: "/mail", select: (search) => search.wizardFrom });
66
+
40
67
  /**
41
68
  * The route's `wizard` field. A value the wizard cannot be on reads as no step
42
69
  * rather than as a validation failure, so a truncated or hand-typed link lands
@@ -52,6 +79,28 @@ export const ownedHistoryEntries = (
52
79
  export const useWizardStepValue = (): StepId | undefined =>
53
80
  useSearch({ from: "/mail", select: (search) => search.wizard });
54
81
 
82
+ /**
83
+ * Opens the wizard on a step, from a surface that does not drive it — a verb on
84
+ * the selection bar, or the make-filter affordance on a search. The push is the
85
+ * wizard's first owned entry, so the back that leaves it lands on the list with
86
+ * the selection still ticked.
87
+ */
88
+ export const useOpenWizard = (): ((
89
+ step: StepId,
90
+ entry?: WizardEntry,
91
+ ) => void) => {
92
+ const navigate = useNavigate();
93
+ return useCallback(
94
+ (step: StepId, entry?: WizardEntry) => {
95
+ navigate({
96
+ to: ".",
97
+ search: (prev) => ({ ...prev, wizard: step, wizardFrom: entry }),
98
+ });
99
+ },
100
+ [navigate],
101
+ );
102
+ };
103
+
55
104
  export interface WizardStepNavigation {
56
105
  step: StepId | undefined;
57
106
  goToStep: (step: StepId) => void;
@@ -63,10 +112,15 @@ export const useWizardStep = (openingStep: StepId): WizardStepNavigation => {
63
112
  const router = useRouter();
64
113
  const navigate = useNavigate();
65
114
  const step = useWizardStepValue();
115
+ const entry = useWizardEntryValue();
66
116
  // Whether this document loaded already holding a step, which is the one
67
117
  // entrance that leaves the wizard unrooted. A step the app itself pushed
68
118
  // arrives rooted, so re-rooting it would duplicate the entry underneath it.
69
119
  const loadedHoldingStep = useRef(step !== undefined);
120
+ // The entry that step was reached by, so the root the wizard is put back on
121
+ // carries neither the step nor the affordance that opened it, and the entry
122
+ // pushed over it carries both.
123
+ const loadedEntry = useRef(entry);
70
124
  const pushedTo = useRef<StepId | undefined>(undefined);
71
125
 
72
126
  // Two taps on Continue land before the URL settles, and both would push the
@@ -79,15 +133,24 @@ export const useWizardStep = (openingStep: StepId): WizardStepNavigation => {
79
133
  useEffect(() => {
80
134
  if (!loadedHoldingStep.current) return;
81
135
  loadedHoldingStep.current = false;
136
+ const openingEntry = loadedEntry.current;
82
137
  void (async () => {
83
138
  await navigate({
84
139
  to: ".",
85
- search: (prev) => ({ ...prev, wizard: undefined }),
140
+ search: (prev) => ({
141
+ ...prev,
142
+ wizard: undefined,
143
+ wizardFrom: undefined,
144
+ }),
86
145
  replace: true,
87
146
  });
88
147
  await navigate({
89
148
  to: ".",
90
- search: (prev) => ({ ...prev, wizard: openingStep }),
149
+ search: (prev) => ({
150
+ ...prev,
151
+ wizard: openingStep,
152
+ wizardFrom: openingEntry,
153
+ }),
91
154
  });
92
155
  })();
93
156
  }, [openingStep, navigate]);
@@ -21,7 +21,6 @@ import { FlaggedPane } from "@/components/mail/FlaggedPane";
21
21
  import { MailboxPane } from "@/components/mail/MailboxPane";
22
22
  import { MailNav } from "@/components/mail/MailNav";
23
23
  import { OutboxPane } from "@/components/mail/OutboxPane";
24
- import { SelectionWizardHost } from "@/components/mail/SelectionWizardHost";
25
24
  import { ErrorState } from "@/components/ui/ErrorState";
26
25
  import { KeyboardShortcutsModal } from "@/components/ui/KeyboardShortcutsModal";
27
26
  import { useDebouncedValue } from "@/hooks/useDebouncedValue";
@@ -44,7 +43,7 @@ import {
44
43
  searchInputForView,
45
44
  shouldMirrorQuery,
46
45
  } from "@/lib/search-view";
47
- import { wizardStepValue } from "@/lib/wizard-history";
46
+ import { wizardEntryValue, wizardStepValue } from "@/lib/wizard-history";
48
47
  import "@/lib/client";
49
48
 
50
49
  // `MailContext` / `useMailContext` live in `@/lib/mail-context` so the provider
@@ -58,6 +57,8 @@ const mailSearchSchema = z.object({
58
57
  // The selection wizard's step (#477 clause 1.6). The router owns history, so
59
58
  // the step is a validated search param rather than a raw pushState entry.
60
59
  wizard: wizardStepValue,
60
+ // Which affordance opened it, so a reload lands back on the walk it was in.
61
+ wizardFrom: wizardEntryValue,
61
62
  });
62
63
 
63
64
  export const Route = createFileRoute("/mail")({
@@ -97,9 +98,6 @@ function MailLayout() {
97
98
  // collapse preference there (#782). DKIM-mismatch auto-open still fires on
98
99
  // every tier. Explicit toggles persist the user's choice.
99
100
  const [intelligenceOpen, setIntelligenceOpen] = useState(false);
100
- // The list owns the selection; the wizard is mounted here, beside it, and
101
- // counts the same rows the selection bar is counting.
102
- const [selectedCount, setSelectedCount] = useState(0);
103
101
  const handleSetIntelligenceOpen = useCallback((open: boolean) => {
104
102
  setIntelligenceOpen(open);
105
103
  writeIntelligencePref(open);
@@ -312,8 +310,6 @@ function MailLayout() {
312
310
  intelligenceOpen,
313
311
  onToggleIntelligence: handleToggleIntelligence,
314
312
  onSetIntelligenceOpen: handleSetIntelligenceOpen,
315
- selectedCount,
316
- onSelectedCountChange: setSelectedCount,
317
313
  };
318
314
 
319
315
  // Single nav node: the kit renders it as a pane (≥1024px) or inside its
@@ -478,7 +474,6 @@ function MailLayout() {
478
474
  isOpen={showShortcuts}
479
475
  onClose={() => setShowShortcuts(false)}
480
476
  />
481
- <SelectionWizardHost verb="organize" selectedCount={selectedCount} />
482
477
  {/* Outlet is required for TanStack Router to activate child routes.
483
478
  Routes that manage their own rendering (brief, mailbox, outbox) return
484
479
  null from their component — the parent shell owns the layout. */}
@@ -1,45 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { describe, it } from "node:test";
3
- import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
4
- import React, { createElement } from "react";
5
- import { renderToString } from "react-dom/server";
6
- import { ErrorBannerProvider } from "@/components/ui/ErrorBannerProvider";
7
- import type { OrganizeEntry } from "@/lib/organize/mobile-organize-flow";
8
- import { MobileOrganizeFlow } from "./MobileOrganizeFlow";
9
-
10
- // The node test loader transpiles remit-ui's `.tsx` with the classic JSX
11
- // runtime, which references a global `React`. Vite uses the automatic runtime,
12
- // so this shim only exists for the SSR test harness.
13
- (globalThis as { React?: typeof React }).React = React;
14
-
15
- const render = (entry: OrganizeEntry) =>
16
- renderToString(
17
- createElement(
18
- QueryClientProvider,
19
- { client: new QueryClient() },
20
- createElement(
21
- ErrorBannerProvider,
22
- null,
23
- createElement(MobileOrganizeFlow, {
24
- entry,
25
- accountId: "acc-1",
26
- selectedMessageIds: ["msg-1", "msg-2"],
27
- selectedSenders: ["npm@github.com"],
28
- onClose: () => undefined,
29
- }),
30
- ),
31
- ) as never,
32
- );
33
-
34
- describe("MobileOrganizeFlow", () => {
35
- it("select-similar opens on the widening state before the preview resolves", () => {
36
- const html = render("select-similar");
37
- assert.match(html, /Finding similar messages/);
38
- });
39
-
40
- it("something-else opens on the shortcuts + plain-language input", () => {
41
- const html = render("something-else");
42
- assert.match(html, /What should Remit do\?/);
43
- assert.match(html, /Tell Remit what to do/);
44
- });
45
- });
@@ -1,157 +0,0 @@
1
- import { mailboxOperationsListMailboxesOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
2
- import type { RuleScope } from "@remit/ui";
3
- import { BottomSheet, Button } from "@remit/ui";
4
- import { useQuery } from "@tanstack/react-query";
5
- import { Loader2 } from "lucide-react";
6
- import { useEffect, useMemo, useState } from "react";
7
- import { useOrganizeWiden } from "@/hooks/useOrganizeWiden";
8
- import { getMailboxDisplayName } from "@/lib/folder-roles";
9
- import { buildMoveTargets } from "@/lib/move-targets";
10
- import {
11
- type OrganizeEntry,
12
- type OrganizeSeed,
13
- type PreviewStatus,
14
- resolveOrganizeStage,
15
- } from "@/lib/organize/mobile-organize-flow";
16
- import { OrganizeRuleEditor } from "./OrganizeRuleEditor";
17
- import { SomethingElsePanel } from "./SomethingElsePanel";
18
-
19
- /**
20
- * Map a "Something else" seed's scope onto the rule editor's scope. Only the
21
- * standing shortcut carries one; everything else lets the editor default.
22
- */
23
- const seedRuleScope = (
24
- seed: OrganizeSeed | undefined,
25
- ): RuleScope | undefined =>
26
- seed?.scope === "standing" ? "standing" : undefined;
27
-
28
- interface MobileOrganizeFlowProps {
29
- entry: OrganizeEntry;
30
- accountId: string;
31
- selectedMessageIds: string[];
32
- /**
33
- * Sender addresses of the selected messages, driving the literal fallback on
34
- * a deployment without the vector pipeline (semantic-capability.ts).
35
- */
36
- selectedSenders: string[];
37
- junkMailboxId?: string;
38
- /** Close the flow and return to the list — dismiss, "Not now", and Done all use it. */
39
- onClose: () => void;
40
- }
41
-
42
- const previewStatusOf = (
43
- isError: boolean,
44
- isPending: boolean,
45
- matchedCount: number | undefined,
46
- ): PreviewStatus => {
47
- if (isError) return "error";
48
- if (isPending) return "pending";
49
- return matchedCount !== undefined ? "success" : "idle";
50
- };
51
-
52
- /**
53
- * The guided select-similar → organize flow, the mobile home for organizing
54
- * (issue #211). Entered from the selection sheet, it widens the selection once
55
- * with the read-only matcher (POST /organize/preview), shows a brief widening
56
- * state, and renders the organize sentence inside a bottom sheet on that
57
- * widened set — the same {@link OrganizePanel} the desktop dialog uses, so the
58
- * two never drift. "Something else" collects a folder/scope seed first; a widen
59
- * that matches nothing falls back to organizing the selection. Desktop keeps
60
- * its `OrganizeDialog` — this is the touch surface only.
61
- */
62
- export function MobileOrganizeFlow({
63
- entry,
64
- accountId,
65
- selectedMessageIds,
66
- selectedSenders,
67
- junkMailboxId,
68
- onClose,
69
- }: MobileOrganizeFlowProps) {
70
- const anchorMessageId = selectedMessageIds[0];
71
- const [seed, setSeed] = useState<OrganizeSeed | undefined>();
72
-
73
- const {
74
- preview,
75
- matchedCount,
76
- semanticUnavailable,
77
- senders,
78
- isPending,
79
- isError,
80
- error,
81
- } = useOrganizeWiden(accountId, anchorMessageId, selectedSenders);
82
-
83
- useEffect(() => {
84
- preview();
85
- }, [preview]);
86
-
87
- const { data: mailboxesData } = useQuery({
88
- ...mailboxOperationsListMailboxesOptions({ path: { accountId } }),
89
- staleTime: Number.POSITIVE_INFINITY,
90
- });
91
-
92
- const folderOptions = useMemo(
93
- () =>
94
- buildMoveTargets(mailboxesData?.items ?? []).map((mailbox) => ({
95
- id: mailbox.mailboxId,
96
- label: getMailboxDisplayName(mailbox.fullPath),
97
- })),
98
- [mailboxesData?.items],
99
- );
100
-
101
- const stage = resolveOrganizeStage({
102
- entry,
103
- hasSeed: seed !== undefined,
104
- previewStatus: previewStatusOf(isError, isPending, matchedCount),
105
- matchedCount,
106
- });
107
-
108
- return (
109
- <BottomSheet open onClose={onClose} dismissLabel="Dismiss organize">
110
- {stage.kind === "something-else" && (
111
- <SomethingElsePanel
112
- folderOptions={folderOptions}
113
- junkMailboxId={junkMailboxId}
114
- onSeed={setSeed}
115
- />
116
- )}
117
-
118
- {stage.kind === "widening" && <WideningState />}
119
-
120
- {stage.kind === "error" && (
121
- <div className="flex flex-col items-center gap-3 px-5 py-10 text-center">
122
- <p className="text-sm font-medium text-danger">
123
- Couldn't find similar messages
124
- </p>
125
- <p className="max-w-xs text-xs text-fg-muted">
126
- {error instanceof Error ? error.message : "Please try again."}
127
- </p>
128
- <Button variant="ghost" onClick={onClose} className="mt-2">
129
- Close
130
- </Button>
131
- </div>
132
- )}
133
-
134
- {stage.kind === "organize" && (
135
- <OrganizeRuleEditor
136
- accountId={accountId}
137
- selectedMessageIds={selectedMessageIds}
138
- seedCount={stage.matchedCount}
139
- seedScope={seedRuleScope(seed)}
140
- seedMailboxId={seed?.moveMailboxId}
141
- semanticUnavailable={semanticUnavailable}
142
- senders={senders}
143
- onClose={onClose}
144
- />
145
- )}
146
- </BottomSheet>
147
- );
148
- }
149
-
150
- function WideningState() {
151
- return (
152
- <div className="flex flex-col items-center gap-3 px-5 py-10 text-center">
153
- <Loader2 className="size-8 animate-spin text-accent-2" />
154
- <p className="text-sm font-medium text-fg">Finding similar messages…</p>
155
- </div>
156
- );
157
- }
@@ -1,37 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { describe, it } from "node:test";
3
- import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
4
- import React, { createElement } from "react";
5
- import { renderToString } from "react-dom/server";
6
- import { OrganizeDialog } from "./OrganizeDialog";
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 render = (open: boolean) =>
14
- renderToString(
15
- createElement(
16
- QueryClientProvider,
17
- { client: new QueryClient() },
18
- createElement(OrganizeDialog, {
19
- open,
20
- accountId: "acc-1",
21
- selectedMessageIds: ["msg-1", "msg-2"],
22
- selectedSenders: ["npm@github.com"],
23
- onClose: () => undefined,
24
- }),
25
- ) as never,
26
- );
27
-
28
- describe("OrganizeDialog", () => {
29
- it("renders nothing when closed", () => {
30
- assert.equal(render(false), "");
31
- });
32
-
33
- it("shows the widen step while the preview is in flight", () => {
34
- const html = render(true);
35
- assert.match(html, /Finding similar messages/);
36
- });
37
- });
@@ -1,91 +0,0 @@
1
- import { Button, Dialog } from "@remit/ui";
2
- import { Loader2 } from "lucide-react";
3
- import { useEffect } from "react";
4
- import { useOrganizeWiden } from "@/hooks/useOrganizeWiden";
5
- import { OrganizeRuleEditor } from "./OrganizeRuleEditor";
6
-
7
- interface OrganizeDialogProps {
8
- open: boolean;
9
- accountId: string;
10
- selectedMessageIds: string[];
11
- /**
12
- * Sender addresses of the selected messages, driving the literal fallback on
13
- * a deployment without the vector pipeline (semantic-capability.ts).
14
- */
15
- selectedSenders: string[];
16
- onClose: () => void;
17
- }
18
-
19
- /**
20
- * Smart-organize flow entered from the selection toolbar. Widens the selection
21
- * once (POST /organize/preview) to seed the rule, then hands off to the chip
22
- * editor (RFC 038 D1), which counts and commits over the same endpoints. The
23
- * widen is only the opening count; the editor re-previews every edit.
24
- */
25
- export function OrganizeDialog({
26
- open,
27
- accountId,
28
- selectedMessageIds,
29
- selectedSenders,
30
- onClose,
31
- }: OrganizeDialogProps) {
32
- const anchorMessageId = selectedMessageIds[0];
33
- const {
34
- preview,
35
- reset,
36
- matchedCount,
37
- semanticUnavailable,
38
- senders,
39
- isPending,
40
- isError,
41
- error,
42
- } = useOrganizeWiden(accountId, anchorMessageId, selectedSenders);
43
-
44
- useEffect(() => {
45
- if (!open) return;
46
- preview();
47
- }, [open, preview]);
48
-
49
- const handleClose = () => {
50
- reset();
51
- onClose();
52
- };
53
-
54
- if (!open) return null;
55
-
56
- return (
57
- <Dialog open={open} onClose={handleClose} title="Filter rule">
58
- {isPending || matchedCount === undefined ? (
59
- isError ? (
60
- <div className="flex flex-col items-center gap-3 px-5 py-8 text-center">
61
- <p className="text-sm font-medium text-danger">
62
- Couldn't find similar messages
63
- </p>
64
- <p className="max-w-xs text-xs text-fg-muted">
65
- {error instanceof Error ? error.message : "Please try again."}
66
- </p>
67
- <Button variant="ghost" onClick={handleClose} className="mt-2">
68
- Close
69
- </Button>
70
- </div>
71
- ) : (
72
- <div className="flex flex-col items-center gap-3 px-5 py-10 text-center">
73
- <Loader2 className="size-8 animate-spin text-accent-2" />
74
- <p className="text-sm font-medium text-fg">
75
- Finding similar messages…
76
- </p>
77
- </div>
78
- )
79
- ) : (
80
- <OrganizeRuleEditor
81
- accountId={accountId}
82
- selectedMessageIds={selectedMessageIds}
83
- seedCount={matchedCount}
84
- semanticUnavailable={semanticUnavailable}
85
- senders={senders}
86
- onClose={handleClose}
87
- />
88
- )}
89
- </Dialog>
90
- );
91
- }