@remit/web-client 0.0.86 → 0.0.88

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 (70) hide show
  1. package/package.json +1 -1
  2. package/src/auth/BetterAuthShell.tsx +2 -2
  3. package/src/components/compose/AddressField.tsx +60 -77
  4. package/src/components/mail/AutoMovedIndicator.tsx +7 -9
  5. package/src/components/mail/BriefPane.tsx +4 -2
  6. package/src/components/mail/DailyBrief.selection.test.ts +49 -0
  7. package/src/components/mail/DailyBrief.tsx +484 -76
  8. package/src/components/mail/FlaggedList.tsx +3 -2
  9. package/src/components/mail/FlaggedPane.tsx +4 -2
  10. package/src/components/mail/MailListHeader.tsx +157 -38
  11. package/src/components/mail/MailViewChrome.tsx +18 -1
  12. package/src/components/mail/MailboxPane.tsx +55 -20
  13. package/src/components/mail/MessageCard.tsx +0 -1
  14. package/src/components/mail/MessageList.tsx +70 -15
  15. package/src/components/mail/MessageListItem.tsx +4 -21
  16. package/src/components/mail/MessageRow.tsx +13 -31
  17. package/src/components/mail/SwipeableMessageRow.tsx +13 -6
  18. package/src/components/mail/ThreadListInteraction.tsx +51 -6
  19. package/src/components/mail/organize/OrganizeRuleEditor.tsx +57 -2
  20. package/src/components/mail/organize/SearchFilterDialog.render.test.ts +13 -2
  21. package/src/components/mail/organize/SearchFilterDialog.tsx +5 -6
  22. package/src/components/mail/organize/SearchFilterEditor.render.test.ts +37 -3
  23. package/src/components/mail/organize/SearchFilterEditor.tsx +10 -6
  24. package/src/components/mail/organize/smart-organize.stories.tsx +86 -5
  25. package/src/components/mail/useModifierSelect.render.test.ts +247 -0
  26. package/src/components/mail/useModifierSelect.ts +109 -0
  27. package/src/components/onboarding/OnboardingWizard.tsx +53 -9
  28. package/src/components/settings/AccountFormPanel.tsx +10 -5
  29. package/src/hooks/bulk-chunking.render.test.ts +157 -0
  30. package/src/hooks/useApplyLabel.ts +6 -3
  31. package/src/hooks/useClauseSuggestions.ts +57 -0
  32. package/src/hooks/useDeleteMessages.ts +6 -3
  33. package/src/hooks/useFollowFocusOpen.render.test.ts +155 -0
  34. package/src/hooks/useFollowFocusOpen.ts +81 -0
  35. package/src/hooks/useInitialSyncProgress.render.test.ts +280 -0
  36. package/src/hooks/useInitialSyncProgress.ts +134 -0
  37. package/src/hooks/useListCursor.ts +36 -5
  38. package/src/hooks/useMarkAsRead.ts +6 -3
  39. package/src/hooks/useMoveMessages.ts +6 -3
  40. package/src/hooks/useOrganizeWiden.ts +1 -2
  41. package/src/hooks/useRulePreview.ts +11 -1
  42. package/src/hooks/useSearchFilterSeed.render.test.ts +50 -15
  43. package/src/hooks/useSearchFilterSeed.ts +21 -3
  44. package/src/hooks/useSearchSuggestions.ts +126 -0
  45. package/src/hooks/useSelectedSubjects.ts +0 -0
  46. package/src/hooks/useSemanticSearch.ts +26 -7
  47. package/src/hooks/useThreadActions.ts +11 -2
  48. package/src/lib/brief.test.ts +67 -0
  49. package/src/lib/brief.ts +11 -1
  50. package/src/lib/bulk-actions.ts +29 -0
  51. package/src/lib/drafts.ts +1 -1
  52. package/src/lib/organize/clause-suggestions.test.ts +113 -0
  53. package/src/lib/organize/clause-suggestions.ts +89 -0
  54. package/src/lib/organize/rule-model.test.ts +57 -0
  55. package/src/lib/organize/rule-model.ts +117 -38
  56. package/src/lib/organize/search-to-rule.test.ts +8 -28
  57. package/src/lib/organize/search-to-rule.ts +33 -89
  58. package/src/lib/organize/sender-fallback.test.ts +1 -97
  59. package/src/lib/organize/sender-fallback.ts +8 -71
  60. package/src/lib/search-result.ts +2 -0
  61. package/src/lib/search-suggestions.test.ts +249 -0
  62. package/src/lib/search-suggestions.ts +296 -0
  63. package/src/lib/search-token-index.test.ts +99 -0
  64. package/src/lib/search-token-index.ts +67 -1
  65. package/src/lib/search-tokens.test.ts +236 -0
  66. package/src/lib/search-tokens.ts +303 -36
  67. package/src/lib/thread-cache.ts +1 -1
  68. package/src/lib/thread-search-tokens.test.ts +193 -0
  69. package/src/lib/thread-search-tokens.ts +148 -0
  70. package/src/routes/onboarding.tsx +9 -4
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.86",
3
+ "version": "0.0.88",
4
4
  "type": "module",
5
5
  "description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
6
6
  "exports": {
@@ -5,6 +5,7 @@ import {
5
5
  Banner,
6
6
  Button,
7
7
  Input,
8
+ PasswordInput,
8
9
  } from "@remit/ui";
9
10
  import { type FormEvent, type ReactNode, useState } from "react";
10
11
  import { AppShellSkeleton } from "@/components/layout/AppShellSkeleton";
@@ -161,9 +162,8 @@ export const SignInForm = () => {
161
162
  <label htmlFor="ba-password" className="text-fg-muted">
162
163
  Password
163
164
  </label>
164
- <Input
165
+ <PasswordInput
165
166
  id="ba-password"
166
- type="password"
167
167
  autoComplete={
168
168
  activeMode === "signUp" ? "new-password" : "current-password"
169
169
  }
@@ -1,10 +1,14 @@
1
1
  import { addressOperationsSearchAddressesOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
2
2
  import type { RemitImapAddressResponse } from "@remit/api-http-client/types.gen.ts";
3
- import { AddressTag } from "@remit/ui";
3
+ import {
4
+ AddressTag,
5
+ type Suggestion,
6
+ SuggestList,
7
+ useSuggestList,
8
+ } from "@remit/ui";
4
9
  import { useQuery } from "@tanstack/react-query";
5
- import { useCallback, useEffect, useRef, useState } from "react";
10
+ import { useCallback, useMemo, useRef, useState } from "react";
6
11
  import { useDebouncedValue } from "@/hooks/useDebouncedValue";
7
- import { cn } from "@/lib/utils";
8
12
 
9
13
  export interface AddressEntry {
10
14
  email: string;
@@ -18,12 +22,8 @@ interface AddressFieldProps {
18
22
  placeholder?: string;
19
23
  }
20
24
 
21
- const formatSuggestion = (addr: RemitImapAddressResponse): string => {
22
- if (addr.displayName) {
23
- return `${addr.displayName} <${addr.normalizedEmail}>`;
24
- }
25
- return addr.normalizedEmail;
26
- };
25
+ /** Beyond Enter, the keys that take the highlighted suggestion in a chips field. */
26
+ const ACCEPT_KEYS = ["Tab", ","] as const;
27
27
 
28
28
  const isValidEmail = (value: string): boolean =>
29
29
  /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim());
@@ -50,10 +50,7 @@ export const AddressField = ({
50
50
  placeholder,
51
51
  }: AddressFieldProps) => {
52
52
  const [inputValue, setInputValue] = useState("");
53
- const [highlightedIndex, setHighlightedIndex] = useState(-1);
54
- const [isOpen, setIsOpen] = useState(false);
55
53
  const inputRef = useRef<HTMLInputElement>(null);
56
- const listRef = useRef<HTMLUListElement>(null);
57
54
 
58
55
  const debouncedQuery = useDebouncedValue(inputValue, 200);
59
56
 
@@ -66,21 +63,18 @@ export const AddressField = ({
66
63
 
67
64
  const suggestions = suggestionsData?.items ?? [];
68
65
  const existingEmails = new Set(addresses.map((a) => a.email.toLowerCase()));
69
- const filteredSuggestions = suggestions.filter(
70
- (s) => !existingEmails.has(s.normalizedEmail.toLowerCase()),
71
- );
72
-
73
- useEffect(() => {
74
- setHighlightedIndex(-1);
75
- setIsOpen(filteredSuggestions.length > 0 && inputValue.length >= 2);
76
- }, [filteredSuggestions.length, inputValue.length]);
66
+ const filteredSuggestions =
67
+ inputValue.length >= 2
68
+ ? suggestions.filter(
69
+ (s) => !existingEmails.has(s.normalizedEmail.toLowerCase()),
70
+ )
71
+ : [];
77
72
 
78
73
  const addAddress = useCallback(
79
74
  (entry: AddressEntry) => {
80
75
  if (existingEmails.has(entry.email.toLowerCase())) return;
81
76
  onChange([...addresses, entry]);
82
77
  setInputValue("");
83
- setIsOpen(false);
84
78
  },
85
79
  [addresses, existingEmails, onChange],
86
80
  );
@@ -110,6 +104,24 @@ export const AddressField = ({
110
104
  }
111
105
  }, [inputValue, addAddress]);
112
106
 
107
+ // The open state, the highlight, and the arrow/Enter/Escape handling are the
108
+ // app's one typeahead behaviour, shared with the filter-rule value field.
109
+ const suggest = useSuggestList({
110
+ count: filteredSuggestions.length,
111
+ acceptKeys: ACCEPT_KEYS,
112
+ onAccept: (index) => selectSuggestion(filteredSuggestions[index]),
113
+ });
114
+
115
+ const options = useMemo<Suggestion[]>(
116
+ () =>
117
+ filteredSuggestions.map((suggestion) => ({
118
+ value: suggestion.normalizedEmail,
119
+ label: suggestion.displayName ?? suggestion.normalizedEmail,
120
+ ...(suggestion.displayName ? { hint: suggestion.normalizedEmail } : {}),
121
+ })),
122
+ [filteredSuggestions],
123
+ );
124
+
113
125
  const handleKeyDown = useCallback(
114
126
  (e: React.KeyboardEvent<HTMLInputElement>) => {
115
127
  if (e.key === "Backspace" && inputValue === "" && addresses.length > 0) {
@@ -117,48 +129,20 @@ export const AddressField = ({
117
129
  return;
118
130
  }
119
131
 
132
+ if (suggest.handleKeyDown(e)) return;
133
+
120
134
  if (e.key === "Enter" || e.key === "Tab" || e.key === ",") {
121
- if (highlightedIndex >= 0 && filteredSuggestions[highlightedIndex]) {
122
- e.preventDefault();
123
- selectSuggestion(filteredSuggestions[highlightedIndex]);
124
- return;
125
- }
126
135
  if (inputValue.trim()) {
127
136
  e.preventDefault();
128
137
  commitInput();
129
138
  }
130
- return;
131
- }
132
-
133
- if (e.key === "ArrowDown" && isOpen) {
134
- e.preventDefault();
135
- setHighlightedIndex((prev) =>
136
- prev < filteredSuggestions.length - 1 ? prev + 1 : 0,
137
- );
138
- return;
139
- }
140
-
141
- if (e.key === "ArrowUp" && isOpen) {
142
- e.preventDefault();
143
- setHighlightedIndex((prev) =>
144
- prev > 0 ? prev - 1 : filteredSuggestions.length - 1,
145
- );
146
- return;
147
- }
148
-
149
- if (e.key === "Escape") {
150
- setIsOpen(false);
151
- setHighlightedIndex(-1);
152
139
  }
153
140
  },
154
141
  [
155
142
  inputValue,
156
143
  addresses.length,
157
- highlightedIndex,
158
- filteredSuggestions,
159
- isOpen,
160
144
  removeAddress,
161
- selectSuggestion,
145
+ suggest.handleKeyDown,
162
146
  commitInput,
163
147
  ],
164
148
  );
@@ -166,9 +150,9 @@ export const AddressField = ({
166
150
  const handleBlur = useCallback(() => {
167
151
  setTimeout(() => {
168
152
  commitInput();
169
- setIsOpen(false);
153
+ suggest.dismiss();
170
154
  }, 150);
171
- }, [commitInput]);
155
+ }, [commitInput, suggest.dismiss]);
172
156
 
173
157
  return (
174
158
  <div className="relative">
@@ -200,37 +184,36 @@ export const AddressField = ({
200
184
  id={`address-field-${label}`}
201
185
  type="text"
202
186
  value={inputValue}
203
- onChange={(e) => setInputValue(e.target.value)}
187
+ onChange={(e) => {
188
+ suggest.reopen();
189
+ setInputValue(e.target.value);
190
+ }}
204
191
  onKeyDown={handleKeyDown}
205
192
  onBlur={handleBlur}
206
193
  placeholder={addresses.length === 0 ? placeholder : ""}
207
194
  className="flex-1 min-w-[120px] bg-transparent outline-none text-sm py-0.5"
195
+ autoComplete="off"
196
+ {...suggest.comboboxProps}
208
197
  />
209
198
  </div>
210
199
  </div>
211
200
 
212
- {isOpen && filteredSuggestions.length > 0 && (
213
- <ul
214
- ref={listRef}
215
- className="absolute left-12 right-0 mt-1 bg-surface border border-line rounded-md shadow-lg z-50 max-h-[200px] overflow-auto"
216
- >
217
- {filteredSuggestions.map((suggestion, index) => (
218
- <li
219
- key={suggestion.addressId}
220
- className={cn(
221
- "px-3 py-2 text-sm cursor-pointer transition-colors",
222
- index === highlightedIndex && "bg-accent-2-soft",
223
- )}
224
- onMouseDown={(e) => {
225
- e.preventDefault();
226
- selectSuggestion(suggestion);
227
- }}
228
- onMouseEnter={() => setHighlightedIndex(index)}
229
- >
230
- {formatSuggestion(suggestion)}
231
- </li>
232
- ))}
233
- </ul>
201
+ {suggest.open && (
202
+ <SuggestList
203
+ id={suggest.listId}
204
+ suggestions={options}
205
+ activeIndex={suggest.activeIndex}
206
+ optionId={suggest.optionId}
207
+ onPick={(option) => {
208
+ const picked = filteredSuggestions.find(
209
+ (suggestion) => suggestion.normalizedEmail === option.value,
210
+ );
211
+ if (picked) selectSuggestion(picked);
212
+ }}
213
+ onHighlight={suggest.setActiveIndex}
214
+ label={`${label} suggestions`}
215
+ className="absolute left-12 right-0 z-50 mt-1 max-h-[200px] shadow-lg"
216
+ />
234
217
  )}
235
218
  </div>
236
219
  );
@@ -8,15 +8,15 @@ interface AutoMovedIndicatorProps {
8
8
  threadId: string;
9
9
  mailboxId: string;
10
10
  autoMoved: RemitImapAutoMovedInfo | undefined;
11
- /** `md` (reading view) adds the inline actions (Undo, and the Manage-filter link for a filter move); `sm` (list row) shows the icon + label. */
12
- size?: "sm" | "md";
13
11
  }
14
12
 
15
13
  /**
16
- * Renders the "auto-moved by Remit" badge only while the move is still in
17
- * effect (current mailbox matches the verdict's implied destination) — see
18
- * `useAutoMovedBadge`. Mount this only when `autoMoved` is present on the row
19
- * (callers gate with `thread.autoMoved &&`) so rows without a move never pay
14
+ * Renders the "auto-moved by Remit" badge on the open message only while the
15
+ * move is still in effect (current mailbox matches the verdict's implied
16
+ * destination) — see `useAutoMovedBadge`. Mailbox list rows deliberately do not
17
+ * show it: there the move is noise, and here it carries the Undo and
18
+ * Manage-filter actions. Mount this only when `autoMoved` is present (callers
19
+ * gate with `threadMessage.autoMoved &&`) so messages without a move never pay
20
20
  * for the Inbox/Junk mailbox lookups.
21
21
  */
22
22
  export function AutoMovedIndicator({
@@ -25,7 +25,6 @@ export function AutoMovedIndicator({
25
25
  threadId,
26
26
  mailboxId,
27
27
  autoMoved,
28
- size = "sm",
29
28
  }: AutoMovedIndicatorProps) {
30
29
  const badge = useAutoMovedBadge({
31
30
  accountId,
@@ -40,10 +39,9 @@ export function AutoMovedIndicator({
40
39
  return (
41
40
  <AutoMovedBadge
42
41
  label={badge.label}
43
- size={size}
44
42
  onUndo={badge.onUndo}
45
43
  undoLabel={badge.isUndoing ? "Undoing…" : "Undo"}
46
- filtersHref={size === "md" ? badge.filtersHref : undefined}
44
+ filtersHref={badge.filtersHref}
47
45
  />
48
46
  );
49
47
  }
@@ -33,6 +33,7 @@ import { ConversationView } from "@/components/mail/ConversationView";
33
33
  import { DailyBrief } from "@/components/mail/DailyBrief";
34
34
  import { IntelligencePane } from "@/components/mail/IntelligencePane";
35
35
  import { MessageToolbar } from "@/components/mail/MessageToolbar";
36
+ import type { OpenMessageOptions } from "@/components/mail/ThreadListInteraction";
36
37
  import { useDeleteMessages } from "@/hooks/useDeleteMessages";
37
38
  import { useToggleReadFor } from "@/hooks/useMarkAsRead";
38
39
  import { type ThreadActions, useThreadActions } from "@/hooks/useThreadActions";
@@ -56,7 +57,7 @@ interface BriefPaneContextValue {
56
57
  selectedThread: RemitImapThreadMessageResponse | undefined;
57
58
  /** The conversation to open — the loaded thread, or a tapped "Related" hit. */
58
59
  conversation: ConversationTarget | undefined;
59
- onSelectMessage: (id: string) => void;
60
+ onSelectMessage: (id: string, options?: OpenMessageOptions) => void;
60
61
  onSelectSearchResult: (result: SearchResult) => void;
61
62
  onCloseThread: () => void;
62
63
  /**
@@ -120,7 +121,7 @@ function BriefPaneProvider({ selectedMessageId, children }: BriefPaneProps) {
120
121
  );
121
122
 
122
123
  const handleSelectMessage = useCallback(
123
- (id: string) => {
124
+ (id: string, options?: OpenMessageOptions) => {
124
125
  navigate({
125
126
  to: "/mail",
126
127
  search: (prev) => ({
@@ -129,6 +130,7 @@ function BriefPaneProvider({ selectedMessageId, children }: BriefPaneProps) {
129
130
  selectedThreadId: undefined,
130
131
  selectedMailboxId: undefined,
131
132
  }),
133
+ replace: options?.replace,
132
134
  });
133
135
  },
134
136
  [navigate],
@@ -0,0 +1,49 @@
1
+ import assert from "node:assert/strict";
2
+ import { readFileSync } from "node:fs";
3
+ import { dirname, resolve } from "node:path";
4
+ import { describe, it } from "node:test";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ /**
8
+ * The brief raises two selection surfaces — the desktop toolbar and the touch
9
+ * sheet — and they offer the same verbs. Select-all was wired into the sheet
10
+ * only, so the same list could be selected whole on a phone and not on a
11
+ * desktop (#453).
12
+ *
13
+ * As with `MessageList.selection.test.ts`, `DailyBrief` weaves routing, the
14
+ * cursor and several data hooks together, so the rule is enforced by reading
15
+ * the source. `resolveBriefSelectionScope` carries the decisions that can be
16
+ * unit-tested on their own.
17
+ */
18
+
19
+ const here = dirname(fileURLToPath(import.meta.url));
20
+ const source = readFileSync(resolve(here, "DailyBrief.tsx"), "utf8");
21
+
22
+ const chromeBody = (): string => {
23
+ const start = source.indexOf("function BriefSelectionChrome(");
24
+ assert.notEqual(start, -1, "BriefSelectionChrome is defined");
25
+ const next = source.indexOf("\nfunction ", start + 1);
26
+ return source.slice(start, next === -1 ? undefined : next);
27
+ };
28
+
29
+ describe("the brief's select-all", () => {
30
+ it("reaches the desktop toolbar and the touch sheet from one derivation", () => {
31
+ const body = chromeBody();
32
+ const derivations = body.match(/const selectAll = useMemo\(/g) ?? [];
33
+ assert.equal(
34
+ derivations.length,
35
+ 1,
36
+ "one select-all, so the two surfaces cannot drift",
37
+ );
38
+ const wired = body.match(/selectAll=\{selectAll\}/g) ?? [];
39
+ assert.equal(
40
+ wired.length,
41
+ 2,
42
+ "both SelectionToolbar and SelectionSheet take it",
43
+ );
44
+ });
45
+
46
+ it("is offered only when there are rows to select", () => {
47
+ assert.match(chromeBody(), /orderedIds\.length > 0/);
48
+ });
49
+ });