@remit/web-client 0.0.187 → 0.0.189
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/components/mail/DailyBrief.tsx +4 -15
- package/src/components/mail/FlaggedList.tsx +4 -15
- package/src/components/mail/MailboxPane.tsx +35 -9
- package/src/components/mail/MessageList.tsx +3 -2
- package/src/components/mail/SelectionWizardHost.tsx +55 -20
- package/src/components/mail/intelligence-shortcut-surface.render.test.ts +343 -0
- package/src/components/mail/intelligence-surface-reach.render.test.ts +9 -32
- package/src/hooks/cross-account-bulk.render.test.ts +164 -0
- package/src/hooks/useEscalatedActions.ts +27 -13
- package/src/hooks/useIntelligenceSurface.ts +41 -7
- package/src/lib/bulk-actions.test.ts +170 -5
- package/src/lib/bulk-actions.ts +61 -4
- package/src/lib/wizard-selection.test.ts +50 -0
- package/src/lib/wizard-selection.ts +37 -0
- package/src/test-support/intelligence-surface.ts +42 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.189",
|
|
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": {
|
|
@@ -106,15 +106,13 @@ import {
|
|
|
106
106
|
type SelectionWizardControl,
|
|
107
107
|
useSelectionWizard,
|
|
108
108
|
} from "@/lib/wizard-history";
|
|
109
|
+
import { wizardSelectionFrom } from "@/lib/wizard-selection";
|
|
109
110
|
import type { OpenThreadTarget } from "@/routing";
|
|
110
111
|
import { LabelApplyTrigger } from "./LabelApplyTrigger";
|
|
111
112
|
import { MailListHeader, type MailListHeaderProps } from "./MailListHeader";
|
|
112
113
|
import type { MessageListCommands } from "./MessageList";
|
|
113
114
|
import { MessageRow } from "./MessageRow";
|
|
114
|
-
import {
|
|
115
|
-
SelectionWizardHost,
|
|
116
|
-
type WizardSelectionMessage,
|
|
117
|
-
} from "./SelectionWizardHost";
|
|
115
|
+
import { SelectionWizardHost } from "./SelectionWizardHost";
|
|
118
116
|
import {
|
|
119
117
|
type OpenMessageOptions,
|
|
120
118
|
ThreadListInteraction,
|
|
@@ -283,17 +281,8 @@ function BriefSelectionChrome({
|
|
|
283
281
|
);
|
|
284
282
|
// The ticked rows as the wizard reads them — the sample under every screen
|
|
285
283
|
// that names a match, and the senders its widen falls back to.
|
|
286
|
-
const wizardSelection = useMemo
|
|
287
|
-
() =>
|
|
288
|
-
rows
|
|
289
|
-
.filter((row) => selectedIds.has(row.id))
|
|
290
|
-
.map((row) => ({
|
|
291
|
-
id: row.id,
|
|
292
|
-
sender: row.fromName,
|
|
293
|
-
email: row.fromEmail,
|
|
294
|
-
subject: row.subject,
|
|
295
|
-
date: row.timeLabel,
|
|
296
|
-
})),
|
|
284
|
+
const wizardSelection = useMemo(
|
|
285
|
+
() => wizardSelectionFrom(rows, selectedIds),
|
|
297
286
|
[rows, selectedIds],
|
|
298
287
|
);
|
|
299
288
|
|
|
@@ -38,14 +38,12 @@ import { rowToSearchResult } from "@/lib/search-result";
|
|
|
38
38
|
import { parseSearchTokens } from "@/lib/search-tokens";
|
|
39
39
|
import { dedupeByThread } from "@/lib/starred-rows";
|
|
40
40
|
import { useSelectionWizard } from "@/lib/wizard-history";
|
|
41
|
+
import { wizardSelectionFrom } from "@/lib/wizard-selection";
|
|
41
42
|
import type { OpenThreadTarget } from "@/routing";
|
|
42
43
|
import { MailViewChrome } from "./MailViewChrome";
|
|
43
44
|
import type { MessageListCommands } from "./MessageList";
|
|
44
45
|
import { MessageRow } from "./MessageRow";
|
|
45
|
-
import {
|
|
46
|
-
SelectionWizardHost,
|
|
47
|
-
type WizardSelectionMessage,
|
|
48
|
-
} from "./SelectionWizardHost";
|
|
46
|
+
import { SelectionWizardHost } from "./SelectionWizardHost";
|
|
49
47
|
import {
|
|
50
48
|
type OpenMessageOptions,
|
|
51
49
|
ThreadListInteraction,
|
|
@@ -78,17 +76,8 @@ function StarredWizardHost({
|
|
|
78
76
|
verb: Verb;
|
|
79
77
|
}) {
|
|
80
78
|
const { selectedIds, exitSelection } = useThreadListSelection();
|
|
81
|
-
const selection = useMemo
|
|
82
|
-
() =>
|
|
83
|
-
rows
|
|
84
|
-
.filter((row) => selectedIds.has(row.id))
|
|
85
|
-
.map((row) => ({
|
|
86
|
-
id: row.id,
|
|
87
|
-
sender: row.fromName,
|
|
88
|
-
email: row.fromEmail,
|
|
89
|
-
subject: row.subject,
|
|
90
|
-
date: row.timeLabel,
|
|
91
|
-
})),
|
|
79
|
+
const selection = useMemo(
|
|
80
|
+
() => wizardSelectionFrom(rows, selectedIds),
|
|
92
81
|
[rows, selectedIds],
|
|
93
82
|
);
|
|
94
83
|
return (
|
|
@@ -72,7 +72,11 @@ import {
|
|
|
72
72
|
import type { EscalationSearchQuery } from "@/hooks/useEscalatedActions";
|
|
73
73
|
import { useIntelligenceData } from "@/hooks/useIntelligenceData";
|
|
74
74
|
import { useIntelligenceDrawer } from "@/hooks/useIntelligenceDrawer";
|
|
75
|
-
import {
|
|
75
|
+
import {
|
|
76
|
+
type IntelligenceCommands,
|
|
77
|
+
useIntelligenceSurface,
|
|
78
|
+
usePublishIntelligenceCommands,
|
|
79
|
+
} from "@/hooks/useIntelligenceSurface";
|
|
76
80
|
import { useLayoutTier } from "@/hooks/useLayoutTier";
|
|
77
81
|
import { useMailboxAccount } from "@/hooks/useMailboxAccount";
|
|
78
82
|
import { useToggleReadFor } from "@/hooks/useMarkAsRead";
|
|
@@ -168,8 +172,14 @@ interface MailboxPaneContextValue {
|
|
|
168
172
|
* when no category is selected.
|
|
169
173
|
*/
|
|
170
174
|
listFilter: MessageListFilter | undefined;
|
|
171
|
-
intelligenceOpen: boolean;
|
|
172
175
|
onToggleIntelligence: () => void;
|
|
176
|
+
/**
|
|
177
|
+
* Where the mounted reading surface publishes the intelligence commands the
|
|
178
|
+
* keyboard layer drives. The rail's width gate is the shell's own
|
|
179
|
+
* measurement and this provider sits above the shell, so the surface that is
|
|
180
|
+
* mounted answers for its own tier rather than this one guessing.
|
|
181
|
+
*/
|
|
182
|
+
intelligenceRef: RefObject<IntelligenceCommands | null>;
|
|
173
183
|
/**
|
|
174
184
|
* Deselects the open message when it's the one a mutation just removed
|
|
175
185
|
* from this mailbox's list — wired into every mutation that can take the
|
|
@@ -260,8 +270,7 @@ function MailboxPaneProvider({
|
|
|
260
270
|
const threadId = thread?.threadId;
|
|
261
271
|
const pointedAtMessageId = thread?.messageId;
|
|
262
272
|
const telemetry = useTelemetry();
|
|
263
|
-
const { accounts, searchQuery,
|
|
264
|
-
useMailContext();
|
|
273
|
+
const { accounts, searchQuery, onToggleIntelligence } = useMailContext();
|
|
265
274
|
const tokenContext = useSearchTokenContext();
|
|
266
275
|
|
|
267
276
|
const normalizedSearchQuery = normalizeSearchQuery(searchQuery);
|
|
@@ -499,6 +508,8 @@ function MailboxPaneProvider({
|
|
|
499
508
|
onAfterOptimisticRemove: handleDeselectIfRemoved,
|
|
500
509
|
});
|
|
501
510
|
|
|
511
|
+
const intelligenceRef = useRef<IntelligenceCommands | null>(null);
|
|
512
|
+
|
|
502
513
|
const triage = useTriageContext();
|
|
503
514
|
const {
|
|
504
515
|
listCommandsRef,
|
|
@@ -691,9 +702,14 @@ function MailboxPaneProvider({
|
|
|
691
702
|
updateFocusedSenderFlags({ vip: { value: next } });
|
|
692
703
|
}, [focusedAddressId, focusedAddress, updateFocusedSenderFlags]);
|
|
693
704
|
|
|
705
|
+
// Block sender lives in intelligence, so the key raises whichever surface
|
|
706
|
+
// this width has: the rail above 1280, the drawer below it. Reaching for
|
|
707
|
+
// `onToggleIntelligence` from here wrote `#intelligence` at every tier, and
|
|
708
|
+
// a panel the address names with no renderer behind it is a panel that opens
|
|
709
|
+
// nothing (`docs/architecture/url-state.md`, R6).
|
|
694
710
|
const triageBlock = useCallback(() => {
|
|
695
|
-
|
|
696
|
-
}, [
|
|
711
|
+
intelligenceRef.current?.open();
|
|
712
|
+
}, []);
|
|
697
713
|
|
|
698
714
|
const goToRoute = useCallback(
|
|
699
715
|
(to: "/mail/brief" | "/mail/flagged" | "/settings") => {
|
|
@@ -736,10 +752,12 @@ function MailboxPaneProvider({
|
|
|
736
752
|
toggleStar: triageStar,
|
|
737
753
|
toggleRead: triageToggleRead,
|
|
738
754
|
muteSender: triageMute,
|
|
739
|
-
blockSender: triageBlock,
|
|
755
|
+
blockSender: selectedThread ? triageBlock : undefined,
|
|
740
756
|
vipSender: triageVip,
|
|
741
757
|
markJunk: triageMarkJunk,
|
|
742
|
-
toggleIntelligence: selectedThread
|
|
758
|
+
toggleIntelligence: selectedThread
|
|
759
|
+
? () => intelligenceRef.current?.toggle()
|
|
760
|
+
: undefined,
|
|
743
761
|
compose: openCompose,
|
|
744
762
|
goBrief: () => goToRoute("/mail/brief"),
|
|
745
763
|
goInbox: () => goToRoute("/mail/brief"),
|
|
@@ -783,8 +801,8 @@ function MailboxPaneProvider({
|
|
|
783
801
|
onToggleFilterAttribute,
|
|
784
802
|
onClearFilters,
|
|
785
803
|
listFilter,
|
|
786
|
-
intelligenceOpen,
|
|
787
804
|
onToggleIntelligence,
|
|
805
|
+
intelligenceRef,
|
|
788
806
|
handleDeselectIfRemoved,
|
|
789
807
|
// Escalation ("select all N matching") re-issues this predicate server-side
|
|
790
808
|
// and acts on every match, so it is only offered when the predicate IS the
|
|
@@ -1064,9 +1082,11 @@ function MailboxReading() {
|
|
|
1064
1082
|
onToolbarStar,
|
|
1065
1083
|
onToolbarMove,
|
|
1066
1084
|
handleDeselectIfRemoved,
|
|
1085
|
+
intelligenceRef,
|
|
1067
1086
|
} = useMailboxPane();
|
|
1068
1087
|
const hasThread = Boolean(conversation);
|
|
1069
1088
|
const intelligence = useIntelligenceSurface(conversation?.threadId);
|
|
1089
|
+
usePublishIntelligenceCommands(intelligenceRef, intelligence);
|
|
1070
1090
|
|
|
1071
1091
|
// The rail opens itself on a DKIM mismatch. It lives here, behind the rail's
|
|
1072
1092
|
// own width gate, because raising it writes `#intelligence` into the address
|
|
@@ -1182,8 +1202,14 @@ function MailboxPhone() {
|
|
|
1182
1202
|
nextThread,
|
|
1183
1203
|
previousThread,
|
|
1184
1204
|
handleDeselectIfRemoved,
|
|
1205
|
+
intelligenceRef,
|
|
1185
1206
|
} = useMailboxPane();
|
|
1207
|
+
// The drawer directly, not `useIntelligenceSurface`: this view is what the
|
|
1208
|
+
// shell mounts where it has one pane, so there is no rail to choose between
|
|
1209
|
+
// — and the rail's own width gate answers yes on a wide portrait tablet the
|
|
1210
|
+
// shell still put here.
|
|
1186
1211
|
const drawer = useIntelligenceDrawer(conversation?.threadId ?? null);
|
|
1212
|
+
usePublishIntelligenceCommands(intelligenceRef, drawer);
|
|
1187
1213
|
|
|
1188
1214
|
if (conversation) {
|
|
1189
1215
|
return (
|
|
@@ -52,13 +52,13 @@ import { useListHeaderChrome } from "@/lib/list-header-chrome";
|
|
|
52
52
|
import { listVerbRequest } from "@/lib/list-verb-request";
|
|
53
53
|
import { shouldExitSelectionOnNavigate } from "@/lib/selection-mode";
|
|
54
54
|
import { useSelectionWizard, useWizardStepValue } from "@/lib/wizard-history";
|
|
55
|
+
import type { WizardSelectionMessage } from "@/lib/wizard-selection";
|
|
55
56
|
import { useRetainOpenPanels } from "@/routing";
|
|
56
57
|
import { DeleteConfirmDialog } from "./DeleteConfirmDialog";
|
|
57
58
|
import { LabelApplyTrigger } from "./LabelApplyTrigger";
|
|
58
59
|
import {
|
|
59
60
|
type EscalatedSelection,
|
|
60
61
|
SelectionWizardHost,
|
|
61
|
-
type WizardSelectionMessage,
|
|
62
62
|
} from "./SelectionWizardHost";
|
|
63
63
|
import { SwipeableMessageRow } from "./SwipeableMessageRow";
|
|
64
64
|
|
|
@@ -497,10 +497,11 @@ export const MessageList = ({
|
|
|
497
497
|
email: thread.fromEmail ?? "",
|
|
498
498
|
subject: thread.subject ?? "(No subject)",
|
|
499
499
|
date: formatEmailDate(thread.sentDate),
|
|
500
|
+
accountId,
|
|
500
501
|
});
|
|
501
502
|
}
|
|
502
503
|
return rows;
|
|
503
|
-
}, [threads, selectedIds]);
|
|
504
|
+
}, [threads, selectedIds, accountId]);
|
|
504
505
|
const handleRowSelect = useCallback(
|
|
505
506
|
(messageId: string, modifiers: SelectionModifiers): boolean => {
|
|
506
507
|
if (modifiers.shiftKey) {
|
|
@@ -47,7 +47,11 @@ import { useOrganizeJob } from "@/hooks/useOrganizeJob";
|
|
|
47
47
|
import { useOrganizeWiden } from "@/hooks/useOrganizeWiden";
|
|
48
48
|
import { useRulePreview } from "@/hooks/useRulePreview";
|
|
49
49
|
import { useSelectedSubjects } from "@/hooks/useSelectedSubjects";
|
|
50
|
-
import type {
|
|
50
|
+
import type {
|
|
51
|
+
BulkActionProgress,
|
|
52
|
+
BulkActionTarget,
|
|
53
|
+
BulkRunOutcome,
|
|
54
|
+
} from "@/lib/bulk-actions";
|
|
51
55
|
import { NO_JUNK_FOLDER_REASON } from "@/lib/junk-destination";
|
|
52
56
|
import { useListHeaderChrome } from "@/lib/list-header-chrome";
|
|
53
57
|
import { useMailContext } from "@/lib/mail-context";
|
|
@@ -66,16 +70,11 @@ import {
|
|
|
66
70
|
import { searchRuleAccountId } from "@/lib/organize/search-to-rule";
|
|
67
71
|
import type { OrganizeMatchPredicate } from "@/lib/organize/sender-fallback";
|
|
68
72
|
import { useWizardEntryValue, useWizardStep } from "@/lib/wizard-history";
|
|
73
|
+
import type { WizardSelectionMessage } from "@/lib/wizard-selection";
|
|
69
74
|
import { organizeRunState } from "./organize-run-state";
|
|
70
75
|
|
|
71
76
|
const EMPTY_DRAFT: WizardDraft = { clauses: [], matchOperator: "any" };
|
|
72
77
|
|
|
73
|
-
/** A ticked row, as the wizard's samples and its clause prefill read it. */
|
|
74
|
-
export interface WizardSelectionMessage extends WizardMessage {
|
|
75
|
-
/** Sender address — the widen's literal fallback and the prefill match on it. */
|
|
76
|
-
email: string;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
78
|
interface SelectionWizardSessionProps extends SelectionWizardHostProps {
|
|
80
79
|
/** The step the URL holds. The session is mounted only while there is one. */
|
|
81
80
|
step: StepId;
|
|
@@ -307,7 +306,14 @@ function SelectionWizardSession({
|
|
|
307
306
|
OrganizeScope | undefined
|
|
308
307
|
>(undefined);
|
|
309
308
|
const [bulkRun, setBulkRun] = useState<
|
|
310
|
-
| {
|
|
309
|
+
| {
|
|
310
|
+
matched: number;
|
|
311
|
+
/** What the run was sent, so a retry of what it missed keeps each
|
|
312
|
+
* id's account. Empty for an escalated run, which has no id list. */
|
|
313
|
+
sent: readonly BulkActionTarget[];
|
|
314
|
+
outcome?: BulkRunOutcome;
|
|
315
|
+
failureReason?: string;
|
|
316
|
+
}
|
|
311
317
|
| undefined
|
|
312
318
|
>(undefined);
|
|
313
319
|
// The predicate the create chains its pass to. Undefined when the rule cannot
|
|
@@ -326,6 +332,16 @@ function SelectionWizardSession({
|
|
|
326
332
|
() => selection.map((message) => message.id),
|
|
327
333
|
[selection],
|
|
328
334
|
);
|
|
335
|
+
// What a bulk run over the ticked rows covers, each row still naming its
|
|
336
|
+
// account so the run can batch per account.
|
|
337
|
+
const bulkTargets = useMemo<BulkActionTarget[]>(
|
|
338
|
+
() =>
|
|
339
|
+
selection.map((message) => ({
|
|
340
|
+
id: message.id,
|
|
341
|
+
accountId: message.accountId,
|
|
342
|
+
})),
|
|
343
|
+
[selection],
|
|
344
|
+
);
|
|
329
345
|
const senders = useMemo(
|
|
330
346
|
() => selection.map((message) => message.email).filter(Boolean),
|
|
331
347
|
[selection],
|
|
@@ -586,19 +602,22 @@ function SelectionWizardSession({
|
|
|
586
602
|
}, [blockedReason, current, steps, goToStep]);
|
|
587
603
|
|
|
588
604
|
const runBulk = useCallback(
|
|
589
|
-
async (
|
|
605
|
+
async (targets: readonly BulkActionTarget[]) => {
|
|
590
606
|
const action = bulkActionFor(verb, named.moveMailboxId, junkMailboxId);
|
|
591
607
|
if (!action) {
|
|
592
608
|
setBulkRun({
|
|
593
|
-
matched:
|
|
609
|
+
matched: targets.length,
|
|
610
|
+
sent: targets,
|
|
594
611
|
failureReason: noDestinationReason(verb),
|
|
595
612
|
});
|
|
596
613
|
return;
|
|
597
614
|
}
|
|
598
|
-
setBulkRun({ matched:
|
|
599
|
-
const outcome = await runAction(action,
|
|
600
|
-
setBulkRun({ matched:
|
|
601
|
-
if (walkedAway.current)
|
|
615
|
+
setBulkRun({ matched: targets.length, sent: targets });
|
|
616
|
+
const outcome = await runAction(action, targets);
|
|
617
|
+
setBulkRun({ matched: targets.length, sent: targets, outcome });
|
|
618
|
+
if (walkedAway.current) {
|
|
619
|
+
onRunEnded?.(action.kind, targets.length, outcome);
|
|
620
|
+
}
|
|
602
621
|
},
|
|
603
622
|
[verb, named.moveMailboxId, junkMailboxId, runAction, onRunEnded],
|
|
604
623
|
);
|
|
@@ -612,13 +631,14 @@ function SelectionWizardSession({
|
|
|
612
631
|
if (!action) {
|
|
613
632
|
setBulkRun({
|
|
614
633
|
matched: escalated.total,
|
|
634
|
+
sent: [],
|
|
615
635
|
failureReason: noDestinationReason(verb),
|
|
616
636
|
});
|
|
617
637
|
return;
|
|
618
638
|
}
|
|
619
|
-
setBulkRun({ matched: escalated.total });
|
|
639
|
+
setBulkRun({ matched: escalated.total, sent: [] });
|
|
620
640
|
const outcome = await escalated.run(action);
|
|
621
|
-
setBulkRun({ matched: escalated.total, outcome });
|
|
641
|
+
setBulkRun({ matched: escalated.total, sent: [], outcome });
|
|
622
642
|
if (walkedAway.current) {
|
|
623
643
|
onRunEnded?.(action.kind, escalated.total, outcome);
|
|
624
644
|
}
|
|
@@ -670,7 +690,13 @@ function SelectionWizardSession({
|
|
|
670
690
|
startJob(organizeDraft);
|
|
671
691
|
return;
|
|
672
692
|
}
|
|
673
|
-
|
|
693
|
+
// A widened match is resolved by the account's own preview, so every id it
|
|
694
|
+
// returned belongs to the account the wizard is scoped to.
|
|
695
|
+
void runBulk(
|
|
696
|
+
scope === "just-these"
|
|
697
|
+
? bulkTargets
|
|
698
|
+
: matchedIds.map((id) => ({ id, accountId })),
|
|
699
|
+
);
|
|
674
700
|
}, [
|
|
675
701
|
escalated,
|
|
676
702
|
runEscalated,
|
|
@@ -678,7 +704,8 @@ function SelectionWizardSession({
|
|
|
678
704
|
named,
|
|
679
705
|
anchorMessageId,
|
|
680
706
|
verb,
|
|
681
|
-
|
|
707
|
+
accountId,
|
|
708
|
+
bulkTargets,
|
|
682
709
|
matchedIds,
|
|
683
710
|
createFilterAsync,
|
|
684
711
|
startJob,
|
|
@@ -850,8 +877,16 @@ function SelectionWizardSession({
|
|
|
850
877
|
sendCommit();
|
|
851
878
|
return;
|
|
852
879
|
}
|
|
853
|
-
const outstanding = bulkRun?.outcome?.failedIds ?? [];
|
|
854
|
-
|
|
880
|
+
const outstanding = new Set(bulkRun?.outcome?.failedIds ?? []);
|
|
881
|
+
// A run hands back ids, and each one's account came from the batch it was
|
|
882
|
+
// sent in — so a retry re-sends the targets the run was given, filtered to
|
|
883
|
+
// what it never reached.
|
|
884
|
+
const sent = bulkRun?.sent ?? bulkTargets;
|
|
885
|
+
void runBulk(
|
|
886
|
+
outstanding.size > 0
|
|
887
|
+
? sent.filter((target) => outstanding.has(target.id))
|
|
888
|
+
: sent,
|
|
889
|
+
);
|
|
855
890
|
};
|
|
856
891
|
|
|
857
892
|
// Cancel rewinds the entries the wizard owns and leaves the selection where
|