@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.
Files changed (41) hide show
  1. package/package.json +1 -1
  2. package/src/auth/better-auth-config.ts +4 -1
  3. package/src/components/compose/ComposeForm.tsx +3 -0
  4. package/src/components/mail/IntelligencePane.tsx +10 -8
  5. package/src/components/mail/MailboxPane.tsx +53 -2
  6. package/src/components/mail/MessageActionMenu.tsx +18 -25
  7. package/src/components/mail/MessageDetail.tsx +1 -0
  8. package/src/components/mail/MessageList.tsx +196 -78
  9. package/src/components/mail/MessageListItem.tsx +60 -13
  10. package/src/components/mail/SwipeableMessageRow.tsx +8 -0
  11. package/src/components/ui/ConfirmDialog.tsx +9 -7
  12. package/src/components/ui/ErrorBanner.tsx +6 -3
  13. package/src/components/ui/ErrorBannerProvider.render.test.ts +85 -0
  14. package/src/components/ui/ErrorBannerProvider.tsx +18 -0
  15. package/src/components/ui/error-banners.ts +9 -0
  16. package/src/hooks/useDeleteMessages.ts +14 -21
  17. package/src/hooks/useIntelligenceData.ts +14 -3
  18. package/src/hooks/useMarkAsRead.ts +14 -23
  19. package/src/hooks/useMessageBodyContent.ts +2 -1
  20. package/src/hooks/useMoveMessages.ts +14 -21
  21. package/src/hooks/useStaleAccountSync.test.ts +22 -1
  22. package/src/hooks/useToggleStar.test.ts +31 -0
  23. package/src/hooks/useToggleStar.ts +27 -31
  24. package/src/hooks/useTriageKeyboard.ts +13 -8
  25. package/src/hooks/useUpdateAddressFlags.ts +3 -1
  26. package/src/lib/api.ts +3 -1
  27. package/src/lib/client.ts +3 -0
  28. package/src/lib/error-classifier.test.ts +156 -8
  29. package/src/lib/error-classifier.ts +49 -9
  30. package/src/lib/keymap-dispatch.test.ts +78 -1
  31. package/src/lib/keymap-dispatch.ts +64 -7
  32. package/src/lib/keymap.ts +23 -0
  33. package/src/lib/list-focus.test.ts +25 -0
  34. package/src/lib/list-focus.ts +24 -0
  35. package/src/lib/network-error.ts +58 -0
  36. package/src/lib/query-error-handler.test.ts +6 -2
  37. package/src/lib/query-escalation.integration.test.ts +3 -2
  38. package/src/lib/sender-address.test.ts +60 -0
  39. package/src/lib/sender-address.ts +37 -0
  40. package/src/lib/thread-cache.test.ts +65 -0
  41. 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.13",
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 fetch("/api/auth/token", { credentials: "include" });
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
- // Per-sender flag toggles are always wired. They stay active even before the
317
- // address record resolves: `updateFlags` surfaces feedback when `addressId`
318
- // is missing rather than letting the button look active but do nothing.
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 { MessageList } from "@/components/mail/MessageList";
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
- enabled: !composeState.isOpen,
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
- interface ThreadsListPage {
38
- items: RemitImapThreadMessageResponse[];
39
- [key: string]: unknown;
40
- }
41
-
42
- interface ThreadsListData {
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: ThreadsListData | undefined) => {
173
- if (!old) return old;
174
- return {
175
- ...old,
176
- pages: old.pages.map((page) => ({
177
- ...page,
178
- items: page.items.map((item) =>
179
- targetIds.has(item.messageId)
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<ThreadsListData>(
179
+ queryClient.setQueriesData(
188
180
  { queryKey: threadsListPrefix },
189
181
  patchListData,
190
182
  );
191
- queryClient.setQueriesData<ThreadsListData>(
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) => {
@@ -74,6 +74,7 @@ export const MessageDetail = ({ messageId }: MessageDetailProps) => {
74
74
  pushError({
75
75
  title: "Couldn't mark message as read",
76
76
  detail: formatErrorDetail(error),
77
+ error,
77
78
  });
78
79
  },
79
80
  });