@remit/web-client 0.0.14 → 0.0.16

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.14",
3
+ "version": "0.0.16",
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": {
@@ -14,7 +14,11 @@
14
14
  */
15
15
  import { unifiedThreadOperationsListAllThreadsOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
16
16
  import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
17
- import { ReadingPaneEmpty, type SearchResult } from "@remit/ui";
17
+ import {
18
+ ReadingPaneEmpty,
19
+ type SearchResult,
20
+ useAppShellLayout,
21
+ } from "@remit/ui";
18
22
  import { useQuery } from "@tanstack/react-query";
19
23
  import { useNavigate, useSearch } from "@tanstack/react-router";
20
24
  import {
@@ -184,14 +188,20 @@ function BriefList() {
184
188
  */
185
189
  function BriefReading() {
186
190
  const { conversation } = useBriefPane();
187
- const { onToggleIntelligence } = useMailContext();
191
+ const { intelligenceOpen, onToggleIntelligence } = useMailContext();
192
+ // The rail's own width gate, not the shell tier: between 1024 and 1280 the
193
+ // reading pane is mounted but the rail is not, so "enabled" would promise an
194
+ // open that cannot happen.
195
+ const railFits = useAppShellLayout()?.showIntelligencePane ?? false;
196
+ const hasThread = Boolean(conversation);
197
+ const canToggleIntelligence = railFits && hasThread;
188
198
 
189
199
  return (
190
200
  <section className="flex h-full w-full min-w-0 flex-col bg-canvas">
191
201
  <MessageToolbar
192
- hasThread={Boolean(conversation)}
193
- intelligenceOpen={false}
194
- showIntelligenceToggle={false}
202
+ hasThread={hasThread}
203
+ intelligenceOpen={canToggleIntelligence && intelligenceOpen}
204
+ canToggleIntelligence={canToggleIntelligence}
195
205
  onToggleIntelligence={onToggleIntelligence}
196
206
  />
197
207
  <div className="min-h-0 flex-1 overflow-hidden">
@@ -211,6 +221,24 @@ function BriefReading() {
211
221
  );
212
222
  }
213
223
 
224
+ /**
225
+ * Intelligence pane: IntelligencePane for the thread open in the brief.
226
+ * Mount in the `intelligence` slot of `AppShellSlotted`. Only rendered ≥ 1280px.
227
+ */
228
+ function BriefIntelligence() {
229
+ const { selectedThread } = useBriefPane();
230
+ const { onToggleIntelligence } = useMailContext();
231
+
232
+ return (
233
+ <IntelligencePane
234
+ onClose={onToggleIntelligence}
235
+ thread={selectedThread}
236
+ mailboxId={selectedThread?.mailboxId}
237
+ accountId={selectedThread?.accountId}
238
+ />
239
+ );
240
+ }
241
+
214
242
  /**
215
243
  * Phone view: ConversationView when thread is open, or the DailyBrief list.
216
244
  */
@@ -272,6 +300,7 @@ function BriefPhone() {
272
300
  const BriefPane = Object.assign(BriefPaneProvider, {
273
301
  List: BriefList,
274
302
  Reading: BriefReading,
303
+ Intelligence: BriefIntelligence,
275
304
  Phone: BriefPhone,
276
305
  });
277
306
 
@@ -17,7 +17,7 @@
17
17
  */
18
18
  import { unifiedThreadOperationsListAllThreadsOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
19
19
  import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
20
- import { ReadingPaneEmpty } from "@remit/ui";
20
+ import { ReadingPaneEmpty, useAppShellLayout } from "@remit/ui";
21
21
  import { useQuery } from "@tanstack/react-query";
22
22
  import { useNavigate } from "@tanstack/react-router";
23
23
  import {
@@ -128,14 +128,20 @@ function FlaggedListSlot() {
128
128
  */
129
129
  function FlaggedReading() {
130
130
  const { selectedThread } = useFlaggedPane();
131
- const { onToggleIntelligence } = useMailContext();
131
+ const { intelligenceOpen, onToggleIntelligence } = useMailContext();
132
+ // The rail's own width gate, not the shell tier: between 1024 and 1280 the
133
+ // reading pane is mounted but the rail is not, so "enabled" would promise an
134
+ // open that cannot happen.
135
+ const railFits = useAppShellLayout()?.showIntelligencePane ?? false;
136
+ const hasThread = Boolean(selectedThread);
137
+ const canToggleIntelligence = railFits && hasThread;
132
138
 
133
139
  return (
134
140
  <section className="flex h-full w-full min-w-0 flex-col bg-canvas">
135
141
  <MessageToolbar
136
- hasThread={Boolean(selectedThread)}
137
- intelligenceOpen={false}
138
- showIntelligenceToggle={false}
142
+ hasThread={hasThread}
143
+ intelligenceOpen={canToggleIntelligence && intelligenceOpen}
144
+ canToggleIntelligence={canToggleIntelligence}
139
145
  onToggleIntelligence={onToggleIntelligence}
140
146
  />
141
147
  <div className="min-h-0 flex-1 overflow-hidden">
@@ -154,6 +160,24 @@ function FlaggedReading() {
154
160
  );
155
161
  }
156
162
 
163
+ /**
164
+ * Intelligence pane: IntelligencePane for the open thread.
165
+ * Mount in the `intelligence` slot of `AppShellSlotted`. Only rendered ≥ 1280px.
166
+ */
167
+ function FlaggedIntelligence() {
168
+ const { selectedThread } = useFlaggedPane();
169
+ const { onToggleIntelligence } = useMailContext();
170
+
171
+ return (
172
+ <IntelligencePane
173
+ onClose={onToggleIntelligence}
174
+ thread={selectedThread}
175
+ mailboxId={selectedThread?.mailboxId}
176
+ accountId={selectedThread?.accountId}
177
+ />
178
+ );
179
+ }
180
+
157
181
  /** Phone view: ConversationView when a thread is open, else the flat list. */
158
182
  function FlaggedPhone() {
159
183
  const { selectedThread, onCloseThread } = useFlaggedPane();
@@ -200,6 +224,7 @@ function FlaggedPhone() {
200
224
  const FlaggedPane = Object.assign(FlaggedPaneProvider, {
201
225
  List: FlaggedListSlot,
202
226
  Reading: FlaggedReading,
227
+ Intelligence: FlaggedIntelligence,
203
228
  Phone: FlaggedPhone,
204
229
  });
205
230
 
@@ -32,6 +32,7 @@ import {
32
32
  ReadingPaneEmpty,
33
33
  type RescueCandidate,
34
34
  type SearchResult,
35
+ useAppShellLayout,
35
36
  } from "@remit/ui";
36
37
  import {
37
38
  keepPreviousData,
@@ -43,6 +44,7 @@ import { useNavigate, useSearch } from "@tanstack/react-router";
43
44
  import {
44
45
  createContext,
45
46
  type ReactNode,
47
+ type RefObject,
46
48
  useCallback,
47
49
  useContext,
48
50
  useEffect,
@@ -57,7 +59,10 @@ import { Drawer } from "@/components/layout/Drawer";
57
59
  import { ConversationView } from "@/components/mail/ConversationView";
58
60
  import { DraftsView } from "@/components/mail/DraftsView";
59
61
  import { IntelligencePane } from "@/components/mail/IntelligencePane";
60
- import { MessageList } from "@/components/mail/MessageList";
62
+ import {
63
+ MessageList,
64
+ type MessageListCommands,
65
+ } from "@/components/mail/MessageList";
61
66
  import { MessageToolbar } from "@/components/mail/MessageToolbar";
62
67
  import { PullToRefresh } from "@/components/mail/PullToRefresh";
63
68
  import { SpamRescue } from "@/components/mail/SpamRescue";
@@ -175,7 +180,6 @@ interface MailboxPaneContextValue {
175
180
  onSelectFilterCategory: (id: string) => void;
176
181
  onToggleFilterAttribute: (id: string) => void;
177
182
  onClearFilters: () => void;
178
- showIntelligence: boolean;
179
183
  intelligenceOpen: boolean;
180
184
  onToggleIntelligence: () => void;
181
185
  // List actions
@@ -189,7 +193,11 @@ interface MailboxPaneContextValue {
189
193
  onTriageContextChange: (ctx: {
190
194
  focusedMessageId: string | undefined;
191
195
  selectedIds: string[];
196
+ hasList: boolean;
197
+ blocksKeyboard: boolean;
192
198
  }) => void;
199
+ /** Where the list publishes the commands the keyboard layer drives. */
200
+ listCommandsRef: RefObject<MessageListCommands | null>;
193
201
  onRetry: () => void;
194
202
  // Toolbar / reading pane actions
195
203
  toolbarComposeRequest: ComposeMode | null;
@@ -418,13 +426,25 @@ function MailboxPaneProvider({
418
426
  undefined,
419
427
  );
420
428
  const [triageSelectedIds, setTriageSelectedIds] = useState<string[]>([]);
429
+ // The mounted message list publishes its navigation/selection commands here.
430
+ // Null whenever no list is mounted (reading-only phone view, drafts) — the
431
+ // keyboard layer then simply has nothing to drive.
432
+ const listCommandsRef = useRef<MessageListCommands | null>(null);
433
+ // Whether a message list is mounted and serving commands, and whether it has
434
+ // a modal that owns the keyboard. Both drive which keys this route claims.
435
+ const [hasList, setHasList] = useState(false);
436
+ const [listBlocksKeyboard, setListBlocksKeyboard] = useState(false);
421
437
  const handleTriageContextChange = useCallback(
422
438
  (context: {
423
439
  focusedMessageId: string | undefined;
424
440
  selectedIds: string[];
441
+ hasList: boolean;
442
+ blocksKeyboard: boolean;
425
443
  }) => {
426
444
  setTriageFocusedId(context.focusedMessageId);
427
445
  setTriageSelectedIds(context.selectedIds);
446
+ setHasList(context.hasList);
447
+ setListBlocksKeyboard(context.blocksKeyboard);
428
448
  },
429
449
  [],
430
450
  );
@@ -583,7 +603,10 @@ function MailboxPaneProvider({
583
603
  closeCompose,
584
604
  ]);
585
605
 
606
+ // Esc unwinds one step at a time: an active selection first, then the open
607
+ // thread.
586
608
  const goBack = useCallback(() => {
609
+ if (listCommandsRef.current?.clearSelection()) return;
587
610
  if (selectedMessageId) {
588
611
  navigate({
589
612
  to: "/mail/$mailboxId",
@@ -670,7 +693,11 @@ function MailboxPaneProvider({
670
693
  return messageIdsForFocusedThread(focusedThread);
671
694
  }, [triageSelectedIds, messageIdsForFocusedThread, focusedThread]);
672
695
 
696
+ // Prefer the list's delete: it confirms the move to Trash and then places the
697
+ // cursor on a surviving row. Falls back to the reading pane's message when no
698
+ // list is mounted or nothing in it is targeted.
673
699
  const triageDeleteAction = useCallback(() => {
700
+ if (listCommandsRef.current?.requestDelete()) return;
674
701
  const ids = triageTargetMessageIds();
675
702
  if (ids.length > 0) triageDelete(ids);
676
703
  }, [triageTargetMessageIds, triageDelete]);
@@ -755,17 +782,36 @@ function MailboxPaneProvider({
755
782
  }
756
783
  }, [normalizedSearchQuery, mailboxType, telemetry]);
757
784
 
758
- const hasThread = Boolean(selectedThread);
759
785
  const hasRemitDraftOpen =
760
786
  isDraftsMailbox &&
761
787
  composeState.isOpen &&
762
788
  !!composeState.outboxMessageId &&
763
789
  !selectedThread;
764
- const showIntelligence = isDesktop && intelligenceOpen && hasThread;
765
790
 
766
791
  useTriageKeyboard({
767
- enabled: !composeState.isOpen,
792
+ // A modal owns the keyboard outright. Suspending the layer is what keeps a
793
+ // second Delete press from reaching a delete while the confirmation for
794
+ // the first one is still on screen.
795
+ enabled: !composeState.isOpen && !listBlocksKeyboard,
768
796
  handlers: {
797
+ // List navigation and selection, registered only while a list is
798
+ // mounted to serve them. An unregistered action is never
799
+ // preventDefault-ed, so with no list the browser keeps Enter, Space and
800
+ // ⌘A — select-all-text in the reading pane still works.
801
+ ...(hasList
802
+ ? {
803
+ focusNext: () => listCommandsRef.current?.focusNext(),
804
+ focusPrevious: () => listCommandsRef.current?.focusPrevious(),
805
+ focusFirst: () => listCommandsRef.current?.focusFirst(),
806
+ focusLast: () => listCommandsRef.current?.focusLast(),
807
+ openFocused: () => listCommandsRef.current?.openFocused(),
808
+ toggleSelect: () => listCommandsRef.current?.toggleSelect(),
809
+ extendSelectDown: () => listCommandsRef.current?.extendSelectDown(),
810
+ extendSelectUp: () => listCommandsRef.current?.extendSelectUp(),
811
+ selectAll: () => listCommandsRef.current?.selectAll(),
812
+ toggleDensity: () => listCommandsRef.current?.toggleDensity(),
813
+ }
814
+ : {}),
769
815
  back: goBack,
770
816
  reply: triageReply,
771
817
  replyAll: triageReplyAll,
@@ -820,7 +866,6 @@ function MailboxPaneProvider({
820
866
  onSelectFilterCategory,
821
867
  onToggleFilterAttribute,
822
868
  onClearFilters,
823
- showIntelligence,
824
869
  intelligenceOpen,
825
870
  onToggleIntelligence,
826
871
  onDeleteMessages: handleDeleteMessages,
@@ -831,6 +876,7 @@ function MailboxPaneProvider({
831
876
  hasMore: hasNextPage,
832
877
  isLoadingMore: isFetchingNextPage,
833
878
  onTriageContextChange: handleTriageContextChange,
879
+ listCommandsRef,
834
880
  onRetry: () => refetch(),
835
881
  toolbarComposeRequest,
836
882
  onToolbarReply: handleToolbarReply,
@@ -884,6 +930,7 @@ function MailboxList() {
884
930
  isSpamFolder,
885
931
  rescueCandidates,
886
932
  onTriageContextChange,
933
+ listCommandsRef,
887
934
  onRetry,
888
935
  filterCategory,
889
936
  filterAttributes,
@@ -973,6 +1020,7 @@ function MailboxList() {
973
1020
  listTitle={listTitle}
974
1021
  hideHeader
975
1022
  onTriageContextChange={onTriageContextChange}
1023
+ commandsRef={listCommandsRef}
976
1024
  />
977
1025
  );
978
1026
 
@@ -1030,7 +1078,7 @@ function MailboxReading() {
1030
1078
  selectedThread,
1031
1079
  conversation,
1032
1080
  hasRemitDraftOpen,
1033
- showIntelligence,
1081
+ intelligenceOpen,
1034
1082
  onToggleIntelligence,
1035
1083
  toolbarComposeRequest,
1036
1084
  onToolbarReply,
@@ -1043,9 +1091,12 @@ function MailboxReading() {
1043
1091
  onToolbarMove,
1044
1092
  composeState,
1045
1093
  } = useMailboxPane();
1046
- const tier = useLayoutTier();
1047
- const isDesktop = tier === "desktop";
1094
+ // The rail's own width gate, not the shell tier: between 1024 and 1280 the
1095
+ // reading pane is mounted but the rail is not, so "enabled" would promise an
1096
+ // open that cannot happen.
1097
+ const railFits = useAppShellLayout()?.showIntelligencePane ?? false;
1048
1098
  const hasThread = Boolean(conversation);
1099
+ const canToggleIntelligence = railFits && hasThread;
1049
1100
 
1050
1101
  const detailPane =
1051
1102
  composeState.isOpen && !conversation ? (
@@ -1073,8 +1124,8 @@ function MailboxReading() {
1073
1124
  <section className="flex h-full w-full min-w-0 flex-col bg-canvas">
1074
1125
  <MessageToolbar
1075
1126
  hasThread={hasThread}
1076
- intelligenceOpen={showIntelligence}
1077
- showIntelligenceToggle={isDesktop && hasThread}
1127
+ intelligenceOpen={canToggleIntelligence && intelligenceOpen}
1128
+ canToggleIntelligence={canToggleIntelligence}
1078
1129
  onToggleIntelligence={onToggleIntelligence}
1079
1130
  onReply={hasThread ? onToolbarReply : undefined}
1080
1131
  onReplyAll={hasThread ? onToolbarReplyAll : undefined}