@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.187",
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<WizardSelectionMessage[]>(
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<WizardSelectionMessage[]>(
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 { useIntelligenceSurface } from "@/hooks/useIntelligenceSurface";
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, intelligenceOpen, onToggleIntelligence } =
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
- if (!intelligenceOpen) onToggleIntelligence();
696
- }, [intelligenceOpen, onToggleIntelligence]);
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 ? onToggleIntelligence : undefined,
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 { BulkActionProgress, BulkRunOutcome } from "@/lib/bulk-actions";
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
- | { matched: number; outcome?: BulkRunOutcome; failureReason?: string }
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 (ids: readonly string[]) => {
605
+ async (targets: readonly BulkActionTarget[]) => {
590
606
  const action = bulkActionFor(verb, named.moveMailboxId, junkMailboxId);
591
607
  if (!action) {
592
608
  setBulkRun({
593
- matched: ids.length,
609
+ matched: targets.length,
610
+ sent: targets,
594
611
  failureReason: noDestinationReason(verb),
595
612
  });
596
613
  return;
597
614
  }
598
- setBulkRun({ matched: ids.length });
599
- const outcome = await runAction(action, [...ids]);
600
- setBulkRun({ matched: ids.length, outcome });
601
- if (walkedAway.current) onRunEnded?.(action.kind, ids.length, outcome);
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
- void runBulk(scope === "just-these" ? messageIds : matchedIds);
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
- messageIds,
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
- void runBulk(outstanding.length > 0 ? outstanding : messageIds);
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