@remit/web-client 0.0.85 → 0.0.87
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/auth/BetterAuthShell.tsx +2 -2
- package/src/components/compose/AddressField.tsx +60 -77
- package/src/components/mail/AutoMovedIndicator.tsx +7 -9
- package/src/components/mail/BriefPane.tsx +4 -2
- package/src/components/mail/DailyBrief.selection.test.ts +49 -0
- package/src/components/mail/DailyBrief.tsx +484 -76
- package/src/components/mail/FlaggedList.tsx +3 -2
- package/src/components/mail/FlaggedPane.tsx +4 -2
- package/src/components/mail/MailListHeader.tsx +155 -34
- package/src/components/mail/MailViewChrome.tsx +18 -1
- package/src/components/mail/MailboxPane.tsx +55 -20
- package/src/components/mail/MessageCard.tsx +0 -1
- package/src/components/mail/MessageList.tsx +70 -15
- package/src/components/mail/MessageListItem.tsx +4 -21
- package/src/components/mail/MessageRow.tsx +13 -31
- package/src/components/mail/SwipeableMessageRow.tsx +13 -6
- package/src/components/mail/ThreadListInteraction.tsx +51 -6
- package/src/components/mail/organize/OrganizeRuleEditor.tsx +57 -2
- package/src/components/mail/organize/SearchFilterDialog.render.test.ts +13 -2
- package/src/components/mail/organize/SearchFilterDialog.tsx +3 -1
- package/src/components/mail/organize/SearchFilterEditor.render.test.ts +37 -3
- package/src/components/mail/organize/SearchFilterEditor.tsx +8 -2
- package/src/components/mail/organize/smart-organize.stories.tsx +86 -5
- package/src/components/mail/useModifierSelect.render.test.ts +247 -0
- package/src/components/mail/useModifierSelect.ts +109 -0
- package/src/components/onboarding/OnboardingWizard.tsx +53 -9
- package/src/components/settings/AccountFormPanel.tsx +10 -5
- package/src/components/settings/TlsRootCaDownload.render.test.ts +37 -0
- package/src/components/settings/TlsRootCaDownload.tsx +33 -0
- package/src/hooks/bulk-chunking.render.test.ts +157 -0
- package/src/hooks/useApplyLabel.ts +6 -3
- package/src/hooks/useClauseSuggestions.ts +57 -0
- package/src/hooks/useDeleteMessages.ts +6 -3
- package/src/hooks/useFollowFocusOpen.render.test.ts +155 -0
- package/src/hooks/useFollowFocusOpen.ts +81 -0
- package/src/hooks/useInitialSyncProgress.render.test.ts +280 -0
- package/src/hooks/useInitialSyncProgress.ts +134 -0
- package/src/hooks/useListCursor.ts +36 -5
- package/src/hooks/useMarkAsRead.ts +6 -3
- package/src/hooks/useMoveMessages.ts +6 -3
- package/src/hooks/useRulePreview.ts +11 -1
- package/src/hooks/useSearchFilterSeed.render.test.ts +50 -15
- package/src/hooks/useSearchFilterSeed.ts +21 -3
- package/src/hooks/useSearchSuggestions.ts +126 -0
- package/src/hooks/useSelectedSubjects.ts +0 -0
- package/src/hooks/useSemanticSearch.ts +26 -7
- package/src/hooks/useThreadActions.ts +11 -2
- package/src/lib/brief.test.ts +67 -0
- package/src/lib/brief.ts +11 -1
- package/src/lib/bulk-actions.ts +29 -0
- package/src/lib/drafts.ts +1 -1
- package/src/lib/organize/clause-suggestions.test.ts +113 -0
- package/src/lib/organize/clause-suggestions.ts +90 -0
- package/src/lib/organize/property-prefill.test.ts +173 -0
- package/src/lib/organize/property-prefill.ts +151 -0
- package/src/lib/organize/rule-model.test.ts +57 -0
- package/src/lib/organize/rule-model.ts +110 -31
- package/src/lib/organize/search-to-rule.ts +12 -4
- package/src/lib/organize/sender-fallback.ts +11 -1
- package/src/lib/search-result.ts +2 -0
- package/src/lib/search-suggestions.test.ts +249 -0
- package/src/lib/search-suggestions.ts +296 -0
- package/src/lib/search-token-index.test.ts +99 -0
- package/src/lib/search-token-index.ts +67 -1
- package/src/lib/search-tokens.test.ts +236 -0
- package/src/lib/search-tokens.ts +303 -36
- package/src/lib/thread-cache.ts +1 -1
- package/src/lib/thread-search-tokens.test.ts +193 -0
- package/src/lib/thread-search-tokens.ts +148 -0
- package/src/routes/onboarding.tsx +9 -4
- package/src/routes/settings/advanced.tsx +2 -0
- package/src/runtime-config.test.ts +26 -0
- package/src/runtime-config.ts +15 -0
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
type EscalationSearchQuery,
|
|
22
22
|
useEscalatedActions,
|
|
23
23
|
} from "@/hooks/useEscalatedActions";
|
|
24
|
+
import { useFollowFocusOpen } from "@/hooks/useFollowFocusOpen";
|
|
24
25
|
import { useToggleReadFor } from "@/hooks/useMarkAsRead";
|
|
25
26
|
import { useIsDesktop } from "@/hooks/useMediaQuery";
|
|
26
27
|
import {
|
|
@@ -245,13 +246,33 @@ export const MessageList = ({
|
|
|
245
246
|
|
|
246
247
|
// Roving focus cursor (#429): the keyboard "where am I" pointer, distinct
|
|
247
248
|
// from the open thread (`selectedMessageId` in the URL). j/k move this
|
|
248
|
-
// cursor
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
|
|
249
|
+
// cursor; Enter opens the focused row → sets selected, and on desktop the
|
|
250
|
+
// reading pane follows the cursor of its own accord (see the follow-focus
|
|
251
|
+
// wiring below). It seeds from the open thread so opening a message also
|
|
252
|
+
// focuses its row, and click-to-open keeps working unchanged (the route
|
|
253
|
+
// still navigates).
|
|
254
|
+
const [focusedMessageId, setFocusedId] = useState<string | undefined>(
|
|
252
255
|
selectedMessageId,
|
|
253
256
|
);
|
|
254
257
|
|
|
258
|
+
// Which of those moves came from a keyboard command, so the reading pane can
|
|
259
|
+
// follow the cursor without following a click that already opened its own row.
|
|
260
|
+
const [keyboardFocusedMessageId, setKeyboardFocusedMessageId] = useState<
|
|
261
|
+
string | undefined
|
|
262
|
+
>();
|
|
263
|
+
|
|
264
|
+
// Every non-keyboard move — a click, Tab, a thread opening, a refetch snapping
|
|
265
|
+
// the cursor to a survivor — drops the keyboard mark, so nothing follows it.
|
|
266
|
+
// A row taking DOM focus as the *consequence* of a keyboard move arrives here
|
|
267
|
+
// with the id that move just set; keeping the mark in that case is what stops
|
|
268
|
+
// the browser's own focus event from cancelling the load the move started.
|
|
269
|
+
const setFocusedMessageId = useCallback((id: string | undefined) => {
|
|
270
|
+
setKeyboardFocusedMessageId((current) =>
|
|
271
|
+
current === id ? current : undefined,
|
|
272
|
+
);
|
|
273
|
+
setFocusedId(id);
|
|
274
|
+
}, []);
|
|
275
|
+
|
|
255
276
|
// Density toggle: comfortable (default) or compact (mutt mode).
|
|
256
277
|
// Persisted to localStorage so the choice survives reloads.
|
|
257
278
|
const [density, setDensity] = useState<Density>(readStoredDensity);
|
|
@@ -448,7 +469,8 @@ export const MessageList = ({
|
|
|
448
469
|
}
|
|
449
470
|
pendingDomFocusRef.current = thread.messageId;
|
|
450
471
|
cursorMovedByPointerRef.current = false;
|
|
451
|
-
|
|
472
|
+
setKeyboardFocusedMessageId(thread.messageId);
|
|
473
|
+
setFocusedId(thread.messageId);
|
|
452
474
|
},
|
|
453
475
|
[threads, isMultiSelectMode, toggleCheck],
|
|
454
476
|
);
|
|
@@ -564,10 +586,12 @@ export const MessageList = ({
|
|
|
564
586
|
selectRange(orderedIds, target);
|
|
565
587
|
// Shift+arrow moves the focus cursor (not the open thread) and grows
|
|
566
588
|
// the selection from the anchor — the keyboard equivalent of
|
|
567
|
-
// shift-click.
|
|
589
|
+
// shift-click. It is building a range, not reading, so the reading pane
|
|
590
|
+
// stays on whatever is open rather than chasing the range's edge.
|
|
568
591
|
pendingDomFocusRef.current = target;
|
|
569
592
|
cursorMovedByPointerRef.current = false;
|
|
570
|
-
|
|
593
|
+
setKeyboardFocusedMessageId(undefined);
|
|
594
|
+
setFocusedId(target);
|
|
571
595
|
},
|
|
572
596
|
[orderedIds, focusedMessageId, selectRange],
|
|
573
597
|
);
|
|
@@ -621,6 +645,34 @@ export const MessageList = ({
|
|
|
621
645
|
});
|
|
622
646
|
}, [focusedMessageId, navigate, mailboxId]);
|
|
623
647
|
|
|
648
|
+
// The reading pane follows the cursor on desktop: j/k load the row they land
|
|
649
|
+
// on, without the user having to press Enter for every message. It replaces
|
|
650
|
+
// the history entry rather than pushing one — a preview the cursor produced
|
|
651
|
+
// is not a navigation the user asked for, and Back should still leave the
|
|
652
|
+
// mailbox instead of walking the cursor's path back up the list.
|
|
653
|
+
//
|
|
654
|
+
// Suspended while rows are selected: the cursor is then picking out a set,
|
|
655
|
+
// and the selection toolbar — not a message — is what the user is looking at.
|
|
656
|
+
// Suspended off desktop too, where the reading pane replaces the list
|
|
657
|
+
// entirely, so following the cursor would throw the user off the list.
|
|
658
|
+
const followFocusOpen = useCallback(
|
|
659
|
+
(messageId: string) => {
|
|
660
|
+
navigate({
|
|
661
|
+
to: "/mail/$mailboxId",
|
|
662
|
+
params: { mailboxId },
|
|
663
|
+
search: (prev) => ({ ...prev, selectedMessageId: messageId }),
|
|
664
|
+
replace: true,
|
|
665
|
+
});
|
|
666
|
+
},
|
|
667
|
+
[navigate, mailboxId],
|
|
668
|
+
);
|
|
669
|
+
useFollowFocusOpen({
|
|
670
|
+
keyboardFocusedMessageId,
|
|
671
|
+
openMessageId: selectedMessageId,
|
|
672
|
+
enabled: isDesktop && selectedCount === 0,
|
|
673
|
+
open: followFocusOpen,
|
|
674
|
+
});
|
|
675
|
+
|
|
624
676
|
// Toolbar Trash2: confirm-delete the current selection.
|
|
625
677
|
const handleDelete = useCallback(() => {
|
|
626
678
|
if (selectedCount > 0) {
|
|
@@ -742,6 +794,7 @@ export const MessageList = ({
|
|
|
742
794
|
mailboxId,
|
|
743
795
|
isDesktop,
|
|
744
796
|
runChunkedConfirmDelete,
|
|
797
|
+
setFocusedMessageId,
|
|
745
798
|
]);
|
|
746
799
|
|
|
747
800
|
// Every way out of the confirmation that isn't the delete — Escape, Cancel,
|
|
@@ -756,7 +809,7 @@ export const MessageList = ({
|
|
|
756
809
|
pendingDomFocusRef.current = restoreTo;
|
|
757
810
|
cursorMovedByPointerRef.current = false;
|
|
758
811
|
setFocusedMessageId(restoreTo);
|
|
759
|
-
}, []);
|
|
812
|
+
}, [setFocusedMessageId]);
|
|
760
813
|
|
|
761
814
|
// Mark read, bounded and escalated alike (#114). Both go through the same
|
|
762
815
|
// chunked run: the bulk flags call caps at 100 ids, so a selection past
|
|
@@ -925,7 +978,7 @@ export const MessageList = ({
|
|
|
925
978
|
cursorMovedByPointerRef.current = false;
|
|
926
979
|
setFocusedMessageId(selectedMessageId);
|
|
927
980
|
}
|
|
928
|
-
}, [selectedMessageId]);
|
|
981
|
+
}, [selectedMessageId, setFocusedMessageId]);
|
|
929
982
|
|
|
930
983
|
// Keep the focus cursor valid as the thread list changes (after delete /
|
|
931
984
|
// move / refetch). If the focused row vanished, snap to the nearest
|
|
@@ -934,7 +987,7 @@ export const MessageList = ({
|
|
|
934
987
|
if (!focusedMessageId) return;
|
|
935
988
|
if (threads.some((t) => t.messageId === focusedMessageId)) return;
|
|
936
989
|
setFocusedMessageId(threads[0]?.messageId);
|
|
937
|
-
}, [threads, focusedMessageId]);
|
|
990
|
+
}, [threads, focusedMessageId, setFocusedMessageId]);
|
|
938
991
|
|
|
939
992
|
// Bridge the roving cursor + selection up to the route's global keyboard
|
|
940
993
|
// dispatcher (#429) so the action verbs can target the focused row, or the
|
|
@@ -1364,10 +1417,13 @@ export const MessageList = ({
|
|
|
1364
1417
|
|
|
1365
1418
|
// A row focused by Tab or click becomes the cursor, so the keys act on what
|
|
1366
1419
|
// the browser says is focused.
|
|
1367
|
-
const handleRowFocus = useCallback(
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1420
|
+
const handleRowFocus = useCallback(
|
|
1421
|
+
(messageId: string) => {
|
|
1422
|
+
cursorMovedByPointerRef.current = true;
|
|
1423
|
+
setFocusedMessageId(messageId);
|
|
1424
|
+
},
|
|
1425
|
+
[setFocusedMessageId],
|
|
1426
|
+
);
|
|
1371
1427
|
|
|
1372
1428
|
// The virtualized list body: rows + search header + load-more indicator.
|
|
1373
1429
|
// Passed to MessageListPane as `listBody` so the kit provides the chrome
|
|
@@ -1421,7 +1477,6 @@ export const MessageList = ({
|
|
|
1421
1477
|
<SwipeableMessageRow
|
|
1422
1478
|
thread={thread}
|
|
1423
1479
|
mailboxId={mailboxId}
|
|
1424
|
-
accountId={accountId}
|
|
1425
1480
|
isSelected={selectedMessageId === thread.messageId}
|
|
1426
1481
|
isFocused={focusedMessageId === thread.messageId}
|
|
1427
1482
|
isTabStop={tabStopMessageId === thread.messageId}
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* MessageListItem — the mailbox list's adapter onto the shared `MessageRow`.
|
|
3
3
|
*
|
|
4
|
-
* It maps an API thread to the row's `ThreadRowData` shape and supplies the
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* brief and Flagged render too.
|
|
4
|
+
* It maps an API thread to the row's `ThreadRowData` shape and supplies the one
|
|
5
|
+
* thing only the mailbox list has: route-linking rows. Everything visual and
|
|
6
|
+
* interactive lives in `MessageRow`, which the brief and Flagged render too.
|
|
8
7
|
*/
|
|
9
8
|
import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
10
9
|
import type { Density, SenderTrustLevel, ThreadRowData } from "@remit/ui";
|
|
@@ -12,14 +11,11 @@ import { memo } from "react";
|
|
|
12
11
|
import type { SelectionModifiers } from "@/hooks/useSelection";
|
|
13
12
|
import { toDisplayCategory } from "@/lib/display-category";
|
|
14
13
|
import { formatEmailDate } from "@/lib/format";
|
|
15
|
-
import { AutoMovedIndicator } from "./AutoMovedIndicator";
|
|
16
14
|
import { MessageRow } from "./MessageRow";
|
|
17
15
|
|
|
18
16
|
interface MessageListItemProps {
|
|
19
17
|
thread: RemitImapThreadMessageResponse;
|
|
20
18
|
mailboxId: string;
|
|
21
|
-
/** Owning account, used to resolve the Inbox/Junk mailboxes for the auto-moved badge's undo action. */
|
|
22
|
-
accountId?: string;
|
|
23
19
|
isSelected: boolean;
|
|
24
20
|
isFocused?: boolean;
|
|
25
21
|
isTabStop?: boolean;
|
|
@@ -43,7 +39,7 @@ export const threadToRowData = (
|
|
|
43
39
|
messageCount?: number,
|
|
44
40
|
): ThreadRowData => ({
|
|
45
41
|
id: thread.messageId,
|
|
46
|
-
accountId: thread.
|
|
42
|
+
accountId: thread.accountId,
|
|
47
43
|
mailboxId: thread.mailboxId,
|
|
48
44
|
fromName: thread.fromName ?? thread.fromEmail ?? "Unknown",
|
|
49
45
|
fromEmail: thread.fromEmail ?? "",
|
|
@@ -66,7 +62,6 @@ export const threadToRowData = (
|
|
|
66
62
|
const MessageListItemComponent = ({
|
|
67
63
|
thread,
|
|
68
64
|
mailboxId,
|
|
69
|
-
accountId,
|
|
70
65
|
isSelected,
|
|
71
66
|
isFocused,
|
|
72
67
|
isTabStop,
|
|
@@ -97,18 +92,6 @@ const MessageListItemComponent = ({
|
|
|
97
92
|
isMultiSelectMode,
|
|
98
93
|
onLongPress,
|
|
99
94
|
}}
|
|
100
|
-
badge={
|
|
101
|
-
thread.autoMoved ? (
|
|
102
|
-
<AutoMovedIndicator
|
|
103
|
-
accountId={accountId}
|
|
104
|
-
messageId={thread.messageId}
|
|
105
|
-
threadId={thread.threadId}
|
|
106
|
-
mailboxId={thread.mailboxId}
|
|
107
|
-
autoMoved={thread.autoMoved}
|
|
108
|
-
size="sm"
|
|
109
|
-
/>
|
|
110
|
-
) : undefined
|
|
111
|
-
}
|
|
112
95
|
/>
|
|
113
96
|
);
|
|
114
97
|
|
|
@@ -28,6 +28,7 @@ import { type MouseEvent, memo, type ReactNode, useCallback } from "react";
|
|
|
28
28
|
import type { SelectionModifiers } from "@/hooks/useSelection";
|
|
29
29
|
import { cn } from "@/lib/utils";
|
|
30
30
|
import { useThreadRowInteraction } from "./ThreadListInteraction";
|
|
31
|
+
import { modifiersOf, useModifierSelect } from "./useModifierSelect";
|
|
31
32
|
|
|
32
33
|
interface MailboxLinkSearch {
|
|
33
34
|
selectedMessageId?: string;
|
|
@@ -125,21 +126,18 @@ const MessageRowComponent = ({
|
|
|
125
126
|
[onToggleCheck, messageId],
|
|
126
127
|
);
|
|
127
128
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
// modified click and leaves the anchor's own default in place, which in a
|
|
134
|
-
// browser means shift-click opens a new window and cmd-click a new tab.
|
|
135
|
-
// Shift-click also drags a native text selection across the rows it spans,
|
|
136
|
-
// so drop it — the row highlight is the selection the user asked for.
|
|
129
|
+
const modifierSelect = useModifierSelect(messageId, onRowSelect);
|
|
130
|
+
|
|
131
|
+
// A plain click falls through to the Link's navigation (or `onClick` on a
|
|
132
|
+
// non-linking row). A modified click belongs to selection and is consumed by
|
|
133
|
+
// `claimClick`, whichever of the press or the click the engine delivered.
|
|
137
134
|
//
|
|
138
135
|
// On mobile, once selection mode is active (#92) a plain tap toggles the row
|
|
139
136
|
// instead of opening it — the same tap-to-toggle contract every reference
|
|
140
137
|
// mail client uses once you're mid-selection.
|
|
141
138
|
const handleRowClick = useCallback(
|
|
142
139
|
(e: MouseEvent) => {
|
|
140
|
+
if (modifierSelect.claimClick(e)) return;
|
|
143
141
|
if (!isDesktop) {
|
|
144
142
|
if (isMultiSelectMode) {
|
|
145
143
|
e.preventDefault();
|
|
@@ -150,20 +148,13 @@ const MessageRowComponent = ({
|
|
|
150
148
|
onClick?.();
|
|
151
149
|
return;
|
|
152
150
|
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
ctrlKey: e.ctrlKey,
|
|
157
|
-
};
|
|
158
|
-
if (onRowSelect?.(messageId, modifiers)) {
|
|
159
|
-
e.preventDefault();
|
|
160
|
-
e.stopPropagation();
|
|
161
|
-
if (modifiers.shiftKey) window.getSelection()?.removeAllRanges();
|
|
162
|
-
return;
|
|
163
|
-
}
|
|
151
|
+
// A plain desktop click collapses any multi-selection and re-anchors it
|
|
152
|
+
// before the row opens.
|
|
153
|
+
onRowSelect?.(messageId, modifiersOf(e));
|
|
164
154
|
onClick?.();
|
|
165
155
|
},
|
|
166
156
|
[
|
|
157
|
+
modifierSelect,
|
|
167
158
|
isDesktop,
|
|
168
159
|
isMultiSelectMode,
|
|
169
160
|
onToggleCheck,
|
|
@@ -173,16 +164,6 @@ const MessageRowComponent = ({
|
|
|
173
164
|
],
|
|
174
165
|
);
|
|
175
166
|
|
|
176
|
-
// Shift-click starts a native text selection on mousedown; suppressing it
|
|
177
|
-
// there keeps the drag from painting a text range over the rows.
|
|
178
|
-
const handleRowMouseDown = useCallback(
|
|
179
|
-
(e: MouseEvent) => {
|
|
180
|
-
if (!isDesktop) return;
|
|
181
|
-
if (e.shiftKey) e.preventDefault();
|
|
182
|
-
},
|
|
183
|
-
[isDesktop],
|
|
184
|
-
);
|
|
185
|
-
|
|
186
167
|
const handleLongPress = useCallback(() => {
|
|
187
168
|
onLongPress?.(messageId);
|
|
188
169
|
}, [onLongPress, messageId]);
|
|
@@ -222,7 +203,8 @@ const MessageRowComponent = ({
|
|
|
222
203
|
: {}),
|
|
223
204
|
tabIndex: isTabStop ? 0 : -1,
|
|
224
205
|
onClick: handleRowClick,
|
|
225
|
-
onMouseDown:
|
|
206
|
+
onMouseDown: modifierSelect.onMouseDown,
|
|
207
|
+
onContextMenu: modifierSelect.onContextMenu,
|
|
226
208
|
onMouseEnter: prefetchMessage,
|
|
227
209
|
onFocus: handleRowFocus,
|
|
228
210
|
},
|
|
@@ -11,6 +11,7 @@ import type { SelectionModifiers } from "@/hooks/useSelection";
|
|
|
11
11
|
import { toDisplayCategory } from "@/lib/display-category";
|
|
12
12
|
import { formatEmailDate } from "@/lib/format";
|
|
13
13
|
import { MessageListItem } from "./MessageListItem";
|
|
14
|
+
import { useModifierSelect } from "./useModifierSelect";
|
|
14
15
|
|
|
15
16
|
interface MailboxLinkSearch {
|
|
16
17
|
selectedMessageId?: string;
|
|
@@ -20,8 +21,6 @@ interface MailboxLinkSearch {
|
|
|
20
21
|
interface SwipeableMessageRowProps {
|
|
21
22
|
thread: RemitImapThreadMessageResponse;
|
|
22
23
|
mailboxId: string;
|
|
23
|
-
/** Owning account, forwarded to `MessageListItem` for the auto-moved badge's undo action. */
|
|
24
|
-
accountId?: string;
|
|
25
24
|
isSelected: boolean;
|
|
26
25
|
/** Roving keyboard focus cursor — renders the left accent rail (#429). */
|
|
27
26
|
isFocused?: boolean;
|
|
@@ -46,7 +45,7 @@ const toThreadRowData = (
|
|
|
46
45
|
const suspicious = thread.authenticity?.dkimMismatch === true;
|
|
47
46
|
return {
|
|
48
47
|
id: thread.messageId,
|
|
49
|
-
accountId: thread.
|
|
48
|
+
accountId: thread.accountId,
|
|
50
49
|
fromName: thread.fromName ?? thread.fromEmail ?? "Unknown",
|
|
51
50
|
fromEmail: thread.fromEmail ?? "",
|
|
52
51
|
subject: thread.subject ?? "(No subject)",
|
|
@@ -64,7 +63,6 @@ const toThreadRowData = (
|
|
|
64
63
|
export const SwipeableMessageRow = ({
|
|
65
64
|
thread,
|
|
66
65
|
mailboxId,
|
|
67
|
-
accountId,
|
|
68
66
|
isSelected,
|
|
69
67
|
isFocused,
|
|
70
68
|
isTabStop,
|
|
@@ -102,12 +100,16 @@ export const SwipeableMessageRow = ({
|
|
|
102
100
|
onToggleCheck(thread.messageId);
|
|
103
101
|
}, [onToggleCheck, thread.messageId]);
|
|
104
102
|
|
|
103
|
+
// The swipe row is the layout tier's row, not the input device's: a mouse at
|
|
104
|
+
// a narrow width still gets it, and a shift or cmd click on it belongs to
|
|
105
|
+
// selection rather than to the anchor's own new-window/new-tab gesture.
|
|
106
|
+
const modifierSelect = useModifierSelect(thread.messageId, onRowSelect);
|
|
107
|
+
|
|
105
108
|
if (isDesktop || isMultiSelectMode) {
|
|
106
109
|
return (
|
|
107
110
|
<MessageListItem
|
|
108
111
|
thread={thread}
|
|
109
112
|
mailboxId={mailboxId}
|
|
110
|
-
accountId={accountId}
|
|
111
113
|
isSelected={isSelected}
|
|
112
114
|
isFocused={isFocused}
|
|
113
115
|
isTabStop={isTabStop}
|
|
@@ -145,7 +147,12 @@ export const SwipeableMessageRow = ({
|
|
|
145
147
|
selectedMessageId: thread.messageId,
|
|
146
148
|
})}
|
|
147
149
|
data-message-row
|
|
148
|
-
|
|
150
|
+
onMouseDown={modifierSelect.onMouseDown}
|
|
151
|
+
onContextMenu={modifierSelect.onContextMenu}
|
|
152
|
+
onClick={(e) => {
|
|
153
|
+
if (modifierSelect.claimClick(e)) return;
|
|
154
|
+
onOpenClick(e);
|
|
155
|
+
}}
|
|
149
156
|
>
|
|
150
157
|
{children}
|
|
151
158
|
</Link>
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
useState,
|
|
29
29
|
} from "react";
|
|
30
30
|
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
|
|
31
|
+
import { useFollowFocusOpen } from "@/hooks/useFollowFocusOpen";
|
|
31
32
|
import { useListCursor } from "@/hooks/useListCursor";
|
|
32
33
|
import { useIsDesktop } from "@/hooks/useMediaQuery";
|
|
33
34
|
import type { TriageContextUpdate } from "@/hooks/useTriageLayer";
|
|
@@ -52,6 +53,12 @@ interface ThreadListInteractionValue {
|
|
|
52
53
|
exitSelection: () => void;
|
|
53
54
|
/** Opens the move-to-Trash confirmation for the current selection. */
|
|
54
55
|
requestDeleteSelection: () => void;
|
|
56
|
+
/** Rendered rows in display order — the same order a shift-range spans. */
|
|
57
|
+
orderedIds: string[];
|
|
58
|
+
/** Whether every rendered row is selected, for a select-all control. */
|
|
59
|
+
allSelected: boolean;
|
|
60
|
+
/** Select every rendered row, or clear when they already all are. */
|
|
61
|
+
toggleAllLoaded: () => void;
|
|
55
62
|
}
|
|
56
63
|
|
|
57
64
|
const ThreadListInteractionCtx =
|
|
@@ -121,10 +128,19 @@ const useRenderedRowIds = (
|
|
|
121
128
|
return rowIds;
|
|
122
129
|
};
|
|
123
130
|
|
|
131
|
+
/**
|
|
132
|
+
* How a row was opened. `replace: true` marks a reading-pane preview the cursor
|
|
133
|
+
* produced rather than a navigation the user asked for, so Back still leaves the
|
|
134
|
+
* list instead of walking the cursor's path back up it.
|
|
135
|
+
*/
|
|
136
|
+
export interface OpenMessageOptions {
|
|
137
|
+
replace?: boolean;
|
|
138
|
+
}
|
|
139
|
+
|
|
124
140
|
interface ThreadListInteractionProps {
|
|
125
141
|
selectedMessageId: string | undefined;
|
|
126
142
|
/** Opens a row — the same navigation a click performs. */
|
|
127
|
-
onOpen: (messageId: string) => void;
|
|
143
|
+
onOpen: (messageId: string, options?: OpenMessageOptions) => void;
|
|
128
144
|
/** Deletes a set of messages. Absent disables the delete key for this list. */
|
|
129
145
|
onDeleteMessages?: (messageIds: string[]) => void;
|
|
130
146
|
isDeleting?: boolean;
|
|
@@ -160,7 +176,14 @@ export function ThreadListInteraction({
|
|
|
160
176
|
exitSelection,
|
|
161
177
|
handleRowSelect,
|
|
162
178
|
} = cursor;
|
|
163
|
-
const { selectedIds, selectedCount, isSelected, toggle, select } =
|
|
179
|
+
const { selectedIds, selectedCount, isSelected, toggle, select, toggleAll } =
|
|
180
|
+
selection;
|
|
181
|
+
|
|
182
|
+
const allSelected =
|
|
183
|
+
orderedIds.length > 0 && orderedIds.every((id) => selectedIds.has(id));
|
|
184
|
+
const toggleAllLoaded = useCallback(() => {
|
|
185
|
+
if (orderedIds.length > 0) toggleAll(orderedIds);
|
|
186
|
+
}, [orderedIds, toggleAll]);
|
|
164
187
|
|
|
165
188
|
// A row that leaves the list — a chip filter, a collapsed section, a
|
|
166
189
|
// completed delete — cannot stay selected. Survivors keep their selection.
|
|
@@ -199,6 +222,20 @@ export function ThreadListInteraction({
|
|
|
199
222
|
if (focusedMessageId) onOpen(focusedMessageId);
|
|
200
223
|
}, [focusedMessageId, onOpen]);
|
|
201
224
|
|
|
225
|
+
// The reading pane follows the cursor on desktop. Suspended while rows are
|
|
226
|
+
// selected: the cursor is then picking out a set, and the selection bar — not
|
|
227
|
+
// a message — is what the user is looking at.
|
|
228
|
+
const followOpen = useCallback(
|
|
229
|
+
(messageId: string) => onOpen(messageId, { replace: true }),
|
|
230
|
+
[onOpen],
|
|
231
|
+
);
|
|
232
|
+
useFollowFocusOpen({
|
|
233
|
+
keyboardFocusedMessageId: cursor.keyboardFocusedMessageId,
|
|
234
|
+
openMessageId: selectedMessageId,
|
|
235
|
+
enabled: isDesktop && selectedCount === 0,
|
|
236
|
+
open: followOpen,
|
|
237
|
+
});
|
|
238
|
+
|
|
202
239
|
// Pending move-to-Trash, awaiting confirmation. The ids are snapshotted at
|
|
203
240
|
// request time so a selection change behind the dialog cannot retarget it —
|
|
204
241
|
// the same contract the mailbox list's delete has.
|
|
@@ -320,6 +357,9 @@ export function ThreadListInteraction({
|
|
|
320
357
|
selectedCount,
|
|
321
358
|
exitSelection,
|
|
322
359
|
requestDeleteSelection,
|
|
360
|
+
orderedIds,
|
|
361
|
+
allSelected,
|
|
362
|
+
toggleAllLoaded,
|
|
323
363
|
rowInteraction: (messageId: string) => ({
|
|
324
364
|
focused: messageId === focusedMessageId,
|
|
325
365
|
isTabStop: messageId === tabStop,
|
|
@@ -339,6 +379,9 @@ export function ThreadListInteraction({
|
|
|
339
379
|
selectedCount,
|
|
340
380
|
exitSelection,
|
|
341
381
|
requestDeleteSelection,
|
|
382
|
+
orderedIds,
|
|
383
|
+
allSelected,
|
|
384
|
+
toggleAllLoaded,
|
|
342
385
|
focusedMessageId,
|
|
343
386
|
tabStop,
|
|
344
387
|
isDesktop,
|
|
@@ -378,11 +421,13 @@ interface ThreadListSelectionBarProps {
|
|
|
378
421
|
}
|
|
379
422
|
|
|
380
423
|
/**
|
|
381
|
-
*
|
|
424
|
+
* Reduced selection bar for Flagged, mounted inside `ThreadListInteraction`.
|
|
382
425
|
*
|
|
383
|
-
* Move is not offered here:
|
|
384
|
-
*
|
|
385
|
-
*
|
|
426
|
+
* Move is not offered here: Flagged spans accounts and mailboxes, and a move
|
|
427
|
+
* picker needs one account and one source folder to be honest about where the
|
|
428
|
+
* messages go. Delete and mark-read carry no such scope. The brief resolves
|
|
429
|
+
* that scope from the selection instead and mounts the mailbox list's full
|
|
430
|
+
* `SelectionToolbar` — see `DailyBrief`.
|
|
386
431
|
*/
|
|
387
432
|
export function ThreadListSelectionBar({
|
|
388
433
|
onMarkAsRead,
|
|
@@ -4,16 +4,19 @@ import {
|
|
|
4
4
|
FilterRuleEditor,
|
|
5
5
|
type FolderOption,
|
|
6
6
|
type LabelOption,
|
|
7
|
+
type RuleMatchMode,
|
|
7
8
|
type RuleScope,
|
|
8
9
|
} from "@remit/ui";
|
|
9
10
|
import { useQuery } from "@tanstack/react-query";
|
|
10
11
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
12
|
+
import { useClauseSuggestions } from "@/hooks/useClauseSuggestions";
|
|
11
13
|
import { useCreateMailbox } from "@/hooks/useCreateMailbox";
|
|
12
14
|
import { useCreateFilter } from "@/hooks/useFilters";
|
|
13
15
|
import { useCreateLabel, useLabelList } from "@/hooks/useLabels";
|
|
14
16
|
import { useOrganizeJob } from "@/hooks/useOrganizeJob";
|
|
15
17
|
import { useRuleEditorState } from "@/hooks/useRuleEditorState";
|
|
16
18
|
import { useRulePreview } from "@/hooks/useRulePreview";
|
|
19
|
+
import { useSelectedSubjects } from "@/hooks/useSelectedSubjects";
|
|
17
20
|
import { getMailboxDisplayName } from "@/lib/folder-roles";
|
|
18
21
|
import { buildMoveTargets } from "@/lib/move-targets";
|
|
19
22
|
import {
|
|
@@ -22,6 +25,8 @@ import {
|
|
|
22
25
|
} from "@/lib/organize/organize-model";
|
|
23
26
|
import {
|
|
24
27
|
buildInitialRule,
|
|
28
|
+
defaultMatchMode,
|
|
29
|
+
matchersForMode,
|
|
25
30
|
rulePredicate,
|
|
26
31
|
ruleToDraft,
|
|
27
32
|
SUPPORTED_CLAUSE_FIELDS,
|
|
@@ -46,6 +51,12 @@ interface OrganizeRuleEditorProps {
|
|
|
46
51
|
semanticUnavailable?: boolean;
|
|
47
52
|
/** Distinct sender addresses, for the fallback clauses and the progress copy. */
|
|
48
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;
|
|
49
60
|
/** A folder a "Something else" shortcut pre-picked. */
|
|
50
61
|
seedMailboxId?: string;
|
|
51
62
|
/** A scope a "Something else" shortcut pre-picked. */
|
|
@@ -70,28 +81,65 @@ export function OrganizeRuleEditor({
|
|
|
70
81
|
seedCount,
|
|
71
82
|
semanticUnavailable = false,
|
|
72
83
|
senders = [],
|
|
84
|
+
seedMatchMode,
|
|
73
85
|
seedMailboxId,
|
|
74
86
|
seedScope,
|
|
75
87
|
onClose,
|
|
76
88
|
}: OrganizeRuleEditorProps) {
|
|
77
89
|
const anchorMessageId = selectedMessageIds[0];
|
|
78
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
|
+
);
|
|
79
98
|
|
|
80
99
|
const [initialRule] = useState<FilterRule>(() =>
|
|
81
100
|
buildInitialRule({
|
|
82
101
|
anchorMessageId,
|
|
83
102
|
semanticUnavailable,
|
|
103
|
+
matchMode,
|
|
84
104
|
senders,
|
|
105
|
+
subjects,
|
|
85
106
|
selectionCount: selectedMessageIds.length,
|
|
86
107
|
seedMailboxId,
|
|
87
108
|
seedScope,
|
|
88
109
|
}),
|
|
89
110
|
);
|
|
90
|
-
const { rule, handlers } = useRuleEditorState({
|
|
111
|
+
const { rule, setRule, handlers } = useRuleEditorState({
|
|
91
112
|
initialRule,
|
|
92
113
|
widenAnchorCount: selectedMessageIds.length,
|
|
93
114
|
});
|
|
94
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
|
+
|
|
95
143
|
const { data: mailboxesData } = useQuery({
|
|
96
144
|
...mailboxOperationsListMailboxesOptions({ path: { accountId } }),
|
|
97
145
|
staleTime: Number.POSITIVE_INFINITY,
|
|
@@ -178,7 +226,9 @@ export function OrganizeRuleEditor({
|
|
|
178
226
|
runningLabel={
|
|
179
227
|
senderFallback
|
|
180
228
|
? "Organizing mail from these senders…"
|
|
181
|
-
:
|
|
229
|
+
: matchMode === "properties"
|
|
230
|
+
? "Organizing matching mail…"
|
|
231
|
+
: "Organizing similar mail…"
|
|
182
232
|
}
|
|
183
233
|
onClose={onClose}
|
|
184
234
|
/>
|
|
@@ -219,7 +269,12 @@ export function OrganizeRuleEditor({
|
|
|
219
269
|
labels={labels}
|
|
220
270
|
preview={preview}
|
|
221
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}
|
|
222
276
|
clauseFields={SUPPORTED_CLAUSE_FIELDS}
|
|
277
|
+
clauseSuggestions={clauseSuggestions}
|
|
223
278
|
{...handlers}
|
|
224
279
|
onCreateFolder={createFolder}
|
|
225
280
|
onCreateLabel={onCreateLabel}
|
|
@@ -10,7 +10,7 @@ import { SearchFilterDialog } from "./SearchFilterDialog";
|
|
|
10
10
|
// runtime, which references a global `React`.
|
|
11
11
|
(globalThis as { React?: typeof React }).React = React;
|
|
12
12
|
|
|
13
|
-
const render = (open: boolean) =>
|
|
13
|
+
const render = (open: boolean, query = "from:receipts@stripe.com") =>
|
|
14
14
|
renderToString(
|
|
15
15
|
createElement(
|
|
16
16
|
QueryClientProvider,
|
|
@@ -18,7 +18,7 @@ const render = (open: boolean) =>
|
|
|
18
18
|
createElement(SearchFilterDialog, {
|
|
19
19
|
open,
|
|
20
20
|
accountId: "acc-1",
|
|
21
|
-
parsed: parseSearchTokens(
|
|
21
|
+
parsed: parseSearchTokens(query, {}),
|
|
22
22
|
searchHadSemanticReach: true,
|
|
23
23
|
onClose: () => undefined,
|
|
24
24
|
}),
|
|
@@ -33,4 +33,15 @@ describe("SearchFilterDialog", () => {
|
|
|
33
33
|
it("shows the conversion step while the seed preview is in flight", () => {
|
|
34
34
|
assert.match(render(true), /Turning your search into a filter/);
|
|
35
35
|
});
|
|
36
|
+
|
|
37
|
+
it("opens the editor for a free-text search instead of failing to count it", () => {
|
|
38
|
+
// The free text converts to a `HasWords` clause, which the vector-free
|
|
39
|
+
// matcher refuses outright. Asking for a count is a 500, so nothing is
|
|
40
|
+
// asked: the editor opens and the count region carries the reason.
|
|
41
|
+
const html = render(true, "receipts");
|
|
42
|
+
assert.doesNotMatch(html, /Turning your search into a filter/);
|
|
43
|
+
assert.doesNotMatch(html, /Couldn't build the filter/);
|
|
44
|
+
assert.match(html, /These chips are the whole rule/);
|
|
45
|
+
assert.match(html, /reads message bodies/);
|
|
46
|
+
});
|
|
36
47
|
});
|
|
@@ -51,6 +51,8 @@ export function SearchFilterDialog({
|
|
|
51
51
|
open ? accountId : undefined,
|
|
52
52
|
literalPredicate,
|
|
53
53
|
);
|
|
54
|
+
// A search kept as a `HasWords` clause has no seed count and never will; the
|
|
55
|
+
// editor opens on the uncountable reason rather than a dead end.
|
|
54
56
|
|
|
55
57
|
if (!open) return null;
|
|
56
58
|
|
|
@@ -71,7 +73,7 @@ export function SearchFilterDialog({
|
|
|
71
73
|
</Button>
|
|
72
74
|
</div>
|
|
73
75
|
</div>
|
|
74
|
-
) : isPending
|
|
76
|
+
) : isPending ? (
|
|
75
77
|
<div className="flex flex-col items-center gap-3 px-5 py-10 text-center">
|
|
76
78
|
<Loader2 className="size-8 animate-spin text-accent-2" />
|
|
77
79
|
<p className="text-sm font-medium text-fg">
|