@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 +1 -1
- package/src/components/mail/BriefPane.tsx +34 -5
- package/src/components/mail/FlaggedPane.tsx +30 -5
- package/src/components/mail/MailboxPane.tsx +62 -11
- package/src/components/mail/MessageList.tsx +196 -78
- package/src/components/mail/MessageListItem.tsx +60 -13
- package/src/components/mail/MessageToolbar.render.test.ts +29 -1
- package/src/components/mail/MessageToolbar.tsx +20 -28
- package/src/components/mail/SwipeableMessageRow.tsx +8 -0
- package/src/components/ui/ConfirmDialog.tsx +9 -7
- package/src/hooks/useTriageKeyboard.ts +13 -8
- package/src/lib/keymap-dispatch.test.ts +78 -1
- package/src/lib/keymap-dispatch.ts +64 -7
- package/src/lib/keymap.ts +23 -0
- package/src/lib/list-focus.test.ts +25 -0
- package/src/lib/list-focus.ts +24 -0
- package/src/routes/mail.tsx +8 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
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 {
|
|
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={
|
|
193
|
-
intelligenceOpen={
|
|
194
|
-
|
|
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={
|
|
137
|
-
intelligenceOpen={
|
|
138
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1047
|
-
|
|
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={
|
|
1077
|
-
|
|
1127
|
+
intelligenceOpen={canToggleIntelligence && intelligenceOpen}
|
|
1128
|
+
canToggleIntelligence={canToggleIntelligence}
|
|
1078
1129
|
onToggleIntelligence={onToggleIntelligence}
|
|
1079
1130
|
onReply={hasThread ? onToolbarReply : undefined}
|
|
1080
1131
|
onReplyAll={hasThread ? onToolbarReplyAll : undefined}
|