@remit/web-client 0.0.13 → 0.0.15
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/better-auth-config.ts +4 -1
- package/src/components/compose/ComposeForm.tsx +3 -0
- package/src/components/mail/IntelligencePane.tsx +10 -8
- package/src/components/mail/MailboxPane.tsx +53 -2
- package/src/components/mail/MessageActionMenu.tsx +18 -25
- package/src/components/mail/MessageDetail.tsx +1 -0
- package/src/components/mail/MessageList.tsx +196 -78
- package/src/components/mail/MessageListItem.tsx +60 -13
- package/src/components/mail/SwipeableMessageRow.tsx +8 -0
- package/src/components/ui/ConfirmDialog.tsx +9 -7
- package/src/components/ui/ErrorBanner.tsx +6 -3
- package/src/components/ui/ErrorBannerProvider.render.test.ts +85 -0
- package/src/components/ui/ErrorBannerProvider.tsx +18 -0
- package/src/components/ui/error-banners.ts +9 -0
- package/src/hooks/useDeleteMessages.ts +14 -21
- package/src/hooks/useIntelligenceData.ts +14 -3
- package/src/hooks/useMarkAsRead.ts +14 -23
- package/src/hooks/useMessageBodyContent.ts +2 -1
- package/src/hooks/useMoveMessages.ts +14 -21
- package/src/hooks/useStaleAccountSync.test.ts +22 -1
- package/src/hooks/useToggleStar.test.ts +31 -0
- package/src/hooks/useToggleStar.ts +27 -31
- package/src/hooks/useTriageKeyboard.ts +13 -8
- package/src/hooks/useUpdateAddressFlags.ts +3 -1
- package/src/lib/api.ts +3 -1
- package/src/lib/client.ts +3 -0
- package/src/lib/error-classifier.test.ts +156 -8
- package/src/lib/error-classifier.ts +49 -9
- 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/lib/network-error.ts +58 -0
- package/src/lib/query-error-handler.test.ts +6 -2
- package/src/lib/query-escalation.integration.test.ts +3 -2
- package/src/lib/sender-address.test.ts +60 -0
- package/src/lib/sender-address.ts +37 -0
- package/src/lib/thread-cache.test.ts +65 -0
- package/src/lib/thread-cache.ts +76 -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.15",
|
|
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": {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createAuthClient } from "better-auth/react";
|
|
2
|
+
import { taggedFetch } from "../lib/network-error";
|
|
2
3
|
import { getRuntimeConfig } from "../runtime-config";
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -51,7 +52,9 @@ const decodeExp = (token: string): number => {
|
|
|
51
52
|
};
|
|
52
53
|
|
|
53
54
|
const requestToken = async (): Promise<string | null> => {
|
|
54
|
-
const res = await
|
|
55
|
+
const res = await taggedFetch("/api/auth/token", {
|
|
56
|
+
credentials: "include",
|
|
57
|
+
});
|
|
55
58
|
if (!res.ok) return null;
|
|
56
59
|
const body: unknown = await res.json();
|
|
57
60
|
if (
|
|
@@ -419,6 +419,7 @@ export const ComposeForm = ({
|
|
|
419
419
|
pushError({
|
|
420
420
|
title: "Couldn't save draft",
|
|
421
421
|
detail: formatErrorDetail(saveError) ?? "Saving the draft failed.",
|
|
422
|
+
error: saveError,
|
|
422
423
|
});
|
|
423
424
|
}, [saveError, pushError]);
|
|
424
425
|
|
|
@@ -541,6 +542,7 @@ export const ComposeForm = ({
|
|
|
541
542
|
pushError({
|
|
542
543
|
title: "Couldn't send message",
|
|
543
544
|
detail: formatErrorDetail(error) ?? "Saving the draft failed.",
|
|
545
|
+
error,
|
|
544
546
|
});
|
|
545
547
|
return null;
|
|
546
548
|
});
|
|
@@ -562,6 +564,7 @@ export const ComposeForm = ({
|
|
|
562
564
|
(createdThisAttempt
|
|
563
565
|
? "The draft was saved but the send request failed. Try again from the Outbox."
|
|
564
566
|
: "The send request failed. Try again."),
|
|
567
|
+
error,
|
|
565
568
|
});
|
|
566
569
|
return null;
|
|
567
570
|
});
|
|
@@ -313,15 +313,17 @@ function WiredPanel({
|
|
|
313
313
|
[updateFlags],
|
|
314
314
|
);
|
|
315
315
|
|
|
316
|
-
//
|
|
317
|
-
//
|
|
318
|
-
//
|
|
316
|
+
// Every per-sender flag toggle PATCHes the sender's address row, so none of
|
|
317
|
+
// them can be serviced until that row resolves. Leave them unwired until it
|
|
318
|
+
// does: the panel renders an unwired quick action as disabled, so the flow is
|
|
319
|
+
// never reachable in a state where a click can only fail (issue #51).
|
|
320
|
+
const canUpdateFlags = addressId !== undefined;
|
|
319
321
|
const actions: IntelligenceQuickActions = {
|
|
320
|
-
onToggleVip: handleToggleVip,
|
|
321
|
-
onToggleMute: handleToggleMute,
|
|
322
|
-
onToggleBlock: handleToggleBlock,
|
|
323
|
-
onToggleUnsubscribe: handleToggleUnsubscribe,
|
|
324
|
-
onReclassify: () => setReclassifyOpen(true),
|
|
322
|
+
onToggleVip: canUpdateFlags ? handleToggleVip : undefined,
|
|
323
|
+
onToggleMute: canUpdateFlags ? handleToggleMute : undefined,
|
|
324
|
+
onToggleBlock: canUpdateFlags ? handleToggleBlock : undefined,
|
|
325
|
+
onToggleUnsubscribe: canUpdateFlags ? handleToggleUnsubscribe : undefined,
|
|
326
|
+
onReclassify: canUpdateFlags ? () => setReclassifyOpen(true) : undefined,
|
|
325
327
|
onNotSpam: spamAction === "notSpam" ? handleNotSpam : undefined,
|
|
326
328
|
onMarkSpam: spamAction === "markSpam" ? handleMarkSpam : undefined,
|
|
327
329
|
};
|
|
@@ -43,6 +43,7 @@ import { useNavigate, useSearch } from "@tanstack/react-router";
|
|
|
43
43
|
import {
|
|
44
44
|
createContext,
|
|
45
45
|
type ReactNode,
|
|
46
|
+
type RefObject,
|
|
46
47
|
useCallback,
|
|
47
48
|
useContext,
|
|
48
49
|
useEffect,
|
|
@@ -57,7 +58,10 @@ import { Drawer } from "@/components/layout/Drawer";
|
|
|
57
58
|
import { ConversationView } from "@/components/mail/ConversationView";
|
|
58
59
|
import { DraftsView } from "@/components/mail/DraftsView";
|
|
59
60
|
import { IntelligencePane } from "@/components/mail/IntelligencePane";
|
|
60
|
-
import {
|
|
61
|
+
import {
|
|
62
|
+
MessageList,
|
|
63
|
+
type MessageListCommands,
|
|
64
|
+
} from "@/components/mail/MessageList";
|
|
61
65
|
import { MessageToolbar } from "@/components/mail/MessageToolbar";
|
|
62
66
|
import { PullToRefresh } from "@/components/mail/PullToRefresh";
|
|
63
67
|
import { SpamRescue } from "@/components/mail/SpamRescue";
|
|
@@ -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]);
|
|
@@ -764,8 +791,29 @@ function MailboxPaneProvider({
|
|
|
764
791
|
const showIntelligence = isDesktop && intelligenceOpen && hasThread;
|
|
765
792
|
|
|
766
793
|
useTriageKeyboard({
|
|
767
|
-
|
|
794
|
+
// A modal owns the keyboard outright. Suspending the layer is what keeps a
|
|
795
|
+
// second Delete press from reaching a delete while the confirmation for
|
|
796
|
+
// the first one is still on screen.
|
|
797
|
+
enabled: !composeState.isOpen && !listBlocksKeyboard,
|
|
768
798
|
handlers: {
|
|
799
|
+
// List navigation and selection, registered only while a list is
|
|
800
|
+
// mounted to serve them. An unregistered action is never
|
|
801
|
+
// preventDefault-ed, so with no list the browser keeps Enter, Space and
|
|
802
|
+
// ⌘A — select-all-text in the reading pane still works.
|
|
803
|
+
...(hasList
|
|
804
|
+
? {
|
|
805
|
+
focusNext: () => listCommandsRef.current?.focusNext(),
|
|
806
|
+
focusPrevious: () => listCommandsRef.current?.focusPrevious(),
|
|
807
|
+
focusFirst: () => listCommandsRef.current?.focusFirst(),
|
|
808
|
+
focusLast: () => listCommandsRef.current?.focusLast(),
|
|
809
|
+
openFocused: () => listCommandsRef.current?.openFocused(),
|
|
810
|
+
toggleSelect: () => listCommandsRef.current?.toggleSelect(),
|
|
811
|
+
extendSelectDown: () => listCommandsRef.current?.extendSelectDown(),
|
|
812
|
+
extendSelectUp: () => listCommandsRef.current?.extendSelectUp(),
|
|
813
|
+
selectAll: () => listCommandsRef.current?.selectAll(),
|
|
814
|
+
toggleDensity: () => listCommandsRef.current?.toggleDensity(),
|
|
815
|
+
}
|
|
816
|
+
: {}),
|
|
769
817
|
back: goBack,
|
|
770
818
|
reply: triageReply,
|
|
771
819
|
replyAll: triageReplyAll,
|
|
@@ -831,6 +879,7 @@ function MailboxPaneProvider({
|
|
|
831
879
|
hasMore: hasNextPage,
|
|
832
880
|
isLoadingMore: isFetchingNextPage,
|
|
833
881
|
onTriageContextChange: handleTriageContextChange,
|
|
882
|
+
listCommandsRef,
|
|
834
883
|
onRetry: () => refetch(),
|
|
835
884
|
toolbarComposeRequest,
|
|
836
885
|
onToolbarReply: handleToolbarReply,
|
|
@@ -884,6 +933,7 @@ function MailboxList() {
|
|
|
884
933
|
isSpamFolder,
|
|
885
934
|
rescueCandidates,
|
|
886
935
|
onTriageContextChange,
|
|
936
|
+
listCommandsRef,
|
|
887
937
|
onRetry,
|
|
888
938
|
filterCategory,
|
|
889
939
|
filterAttributes,
|
|
@@ -973,6 +1023,7 @@ function MailboxList() {
|
|
|
973
1023
|
listTitle={listTitle}
|
|
974
1024
|
hideHeader
|
|
975
1025
|
onTriageContextChange={onTriageContextChange}
|
|
1026
|
+
commandsRef={listCommandsRef}
|
|
976
1027
|
/>
|
|
977
1028
|
);
|
|
978
1029
|
|
|
@@ -27,6 +27,7 @@ import { formatErrorDetail } from "@/components/ui/error-banners";
|
|
|
27
27
|
import { useDeleteMessages } from "@/hooks/useDeleteMessages";
|
|
28
28
|
import { useMoveMessages } from "@/hooks/useMoveMessages";
|
|
29
29
|
import { useToggleTrusted } from "@/hooks/useToggleTrusted";
|
|
30
|
+
import { patchThreadListCache, type ThreadListCache } from "@/lib/thread-cache";
|
|
30
31
|
import { MoveToTrigger } from "./MoveToTrigger";
|
|
31
32
|
|
|
32
33
|
interface ThreadMessagesData {
|
|
@@ -34,15 +35,12 @@ interface ThreadMessagesData {
|
|
|
34
35
|
[key: string]: unknown;
|
|
35
36
|
}
|
|
36
37
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
pages: ThreadsListPage[];
|
|
44
|
-
pageParams: Array<string | undefined>;
|
|
45
|
-
}
|
|
38
|
+
/**
|
|
39
|
+
* Either shape a thread list/search query caches — the infinite mailbox list
|
|
40
|
+
* or a single-shot page. `setQueriesData` matches by key prefix, so the
|
|
41
|
+
* optimistic updater sees both.
|
|
42
|
+
*/
|
|
43
|
+
type ThreadsListData = ThreadListCache;
|
|
46
44
|
|
|
47
45
|
interface SnapshotEntry<T> {
|
|
48
46
|
queryKey: readonly unknown[];
|
|
@@ -169,26 +167,20 @@ export const MessageActionMenu = ({
|
|
|
169
167
|
},
|
|
170
168
|
);
|
|
171
169
|
|
|
172
|
-
const patchListData = (old:
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
? { ...item, isRead: isReadNext }
|
|
181
|
-
: item,
|
|
182
|
-
),
|
|
183
|
-
})),
|
|
184
|
-
};
|
|
185
|
-
};
|
|
170
|
+
const patchListData = (old: unknown) =>
|
|
171
|
+
patchThreadListCache(old, (items) =>
|
|
172
|
+
items.map((item) =>
|
|
173
|
+
targetIds.has(item.messageId)
|
|
174
|
+
? { ...item, isRead: isReadNext }
|
|
175
|
+
: item,
|
|
176
|
+
),
|
|
177
|
+
);
|
|
186
178
|
|
|
187
|
-
queryClient.setQueriesData
|
|
179
|
+
queryClient.setQueriesData(
|
|
188
180
|
{ queryKey: threadsListPrefix },
|
|
189
181
|
patchListData,
|
|
190
182
|
);
|
|
191
|
-
queryClient.setQueriesData
|
|
183
|
+
queryClient.setQueriesData(
|
|
192
184
|
{ queryKey: threadsSearchPrefix },
|
|
193
185
|
patchListData,
|
|
194
186
|
);
|
|
@@ -214,6 +206,7 @@ export const MessageActionMenu = ({
|
|
|
214
206
|
pushError({
|
|
215
207
|
title: isReadNext ? "Couldn't mark as read" : "Couldn't mark as unread",
|
|
216
208
|
detail: formatErrorDetail(err),
|
|
209
|
+
error: err,
|
|
217
210
|
});
|
|
218
211
|
},
|
|
219
212
|
onSettled: (_data, _err, _vars, context) => {
|