@remit/web-client 0.0.1

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 (298) hide show
  1. package/harness/build.mjs +84 -0
  2. package/harness/index.html +37 -0
  3. package/harness/vite-preset.ts +29 -0
  4. package/index.html +47 -0
  5. package/package.json +147 -0
  6. package/public/config.js +5 -0
  7. package/public/locales/de/common.json +1 -0
  8. package/public/locales/de/errors.json +1 -0
  9. package/public/locales/de/mail.json +13 -0
  10. package/public/locales/de/settings.json +1 -0
  11. package/public/locales/en/common.json +26 -0
  12. package/public/locales/en/errors.json +1 -0
  13. package/public/locales/en/mail.json +69 -0
  14. package/public/locales/en/settings.json +1 -0
  15. package/public/locales/fr/common.json +1 -0
  16. package/public/locales/fr/errors.json +1 -0
  17. package/public/locales/fr/mail.json +13 -0
  18. package/public/locales/fr/settings.json +1 -0
  19. package/public/locales/nl/common.json +1 -0
  20. package/public/locales/nl/errors.json +1 -0
  21. package/public/locales/nl/mail.json +13 -0
  22. package/public/locales/nl/settings.json +1 -0
  23. package/src/auth/AccountMenu.tsx +58 -0
  24. package/src/auth/AccountSession.tsx +19 -0
  25. package/src/auth/BetterAuthShell.tsx +198 -0
  26. package/src/auth/account-menu-mode.test.ts +64 -0
  27. package/src/auth/account-menu-mode.ts +28 -0
  28. package/src/auth/amplify-config.test.ts +158 -0
  29. package/src/auth/amplify-config.ts +60 -0
  30. package/src/auth/auth-error.test.ts +109 -0
  31. package/src/auth/auth-error.ts +107 -0
  32. package/src/auth/auth-interceptor.test.ts +86 -0
  33. package/src/auth/auth-interceptor.ts +23 -0
  34. package/src/auth/auth.css +334 -0
  35. package/src/auth/better-auth/BetterAuthAccount.tsx +25 -0
  36. package/src/auth/better-auth-config.ts +85 -0
  37. package/src/auth/better-auth-provider.tsx +20 -0
  38. package/src/auth/cognito/CognitoAccount.tsx +48 -0
  39. package/src/auth/cognito/CognitoShell.tsx +266 -0
  40. package/src/auth/cognito/cognito-token.test.ts +110 -0
  41. package/src/auth/cognito/cognito-token.ts +47 -0
  42. package/src/auth/cognito/cognito.css +21 -0
  43. package/src/auth/cognito-provider.tsx +22 -0
  44. package/src/auth/combined-provider.tsx +42 -0
  45. package/src/auth/composition.test.ts +136 -0
  46. package/src/auth/provider.tsx +63 -0
  47. package/src/auth/sign-out-visibility.test.ts +40 -0
  48. package/src/auth/sign-out-visibility.ts +10 -0
  49. package/src/auth/sign-up-disabled.test.ts +23 -0
  50. package/src/components/compose/AddressField.tsx +237 -0
  51. package/src/components/compose/ComposeBody.tsx +36 -0
  52. package/src/components/compose/ComposeForm.tsx +676 -0
  53. package/src/components/compose/ComposeProvider.tsx +133 -0
  54. package/src/components/compose/ComposeSmtpMissingBanner.tsx +49 -0
  55. package/src/components/compose/FromSelector.tsx +67 -0
  56. package/src/components/compose/FullCompose.tsx +82 -0
  57. package/src/components/compose/InlineCompose.tsx +29 -0
  58. package/src/components/compose/MobileComposeSheet.tsx +152 -0
  59. package/src/components/compose/PlateEditor.tsx +86 -0
  60. package/src/components/compose/PlateToolbar.tsx +110 -0
  61. package/src/components/compose/SubjectField.tsx +19 -0
  62. package/src/components/compose/draft-discard-banner.test.ts +76 -0
  63. package/src/components/compose/index.ts +6 -0
  64. package/src/components/compose/sanitize-quote-html.ts +23 -0
  65. package/src/components/layout/AppShellSkeleton.tsx +53 -0
  66. package/src/components/layout/ComposeFab.tsx +86 -0
  67. package/src/components/layout/Drawer.tsx +105 -0
  68. package/src/components/mail/AutoMovedIndicator.tsx +48 -0
  69. package/src/components/mail/BlockedImagesNotice.render.test.ts +65 -0
  70. package/src/components/mail/BlockedImagesNotice.tsx +41 -0
  71. package/src/components/mail/BriefPane.tsx +289 -0
  72. package/src/components/mail/ConversationView.tsx +462 -0
  73. package/src/components/mail/DailyBrief.tsx +451 -0
  74. package/src/components/mail/DraftsView.tsx +279 -0
  75. package/src/components/mail/FlaggedList.tsx +170 -0
  76. package/src/components/mail/FlaggedPane.tsx +217 -0
  77. package/src/components/mail/IntelligencePane.test.ts +123 -0
  78. package/src/components/mail/IntelligencePane.tsx +441 -0
  79. package/src/components/mail/MailListHeader.tsx +187 -0
  80. package/src/components/mail/MailNav.tsx +70 -0
  81. package/src/components/mail/MailSidebarAdapter.tsx +351 -0
  82. package/src/components/mail/MailViewChrome.tsx +95 -0
  83. package/src/components/mail/MailboxPane.tsx +1219 -0
  84. package/src/components/mail/MessageActionMenu.tsx +368 -0
  85. package/src/components/mail/MessageBody.tsx +207 -0
  86. package/src/components/mail/MessageBodyErrorBanner.render.test.ts +85 -0
  87. package/src/components/mail/MessageBodyErrorBanner.test.ts +86 -0
  88. package/src/components/mail/MessageBodyErrorBanner.tsx +90 -0
  89. package/src/components/mail/MessageCard.tsx +401 -0
  90. package/src/components/mail/MessageDetail.tsx +153 -0
  91. package/src/components/mail/MessageList.tsx +804 -0
  92. package/src/components/mail/MessageListItem.tsx +267 -0
  93. package/src/components/mail/MessageToolbar.render.test.ts +57 -0
  94. package/src/components/mail/MessageToolbar.tsx +166 -0
  95. package/src/components/mail/MobileMessageBar.tsx +142 -0
  96. package/src/components/mail/MoveToTrigger.tsx +250 -0
  97. package/src/components/mail/NavMenuButton.tsx +25 -0
  98. package/src/components/mail/OutboxPane.tsx +531 -0
  99. package/src/components/mail/PullToRefresh.tsx +24 -0
  100. package/src/components/mail/RawMessageView.tsx +84 -0
  101. package/src/components/mail/ReadingPaneEmpty.render.test.ts +42 -0
  102. package/src/components/mail/SelectionToolbar.tsx +139 -0
  103. package/src/components/mail/SpamRescue.render.test.ts +35 -0
  104. package/src/components/mail/SpamRescue.tsx +138 -0
  105. package/src/components/mail/SwipeableMessageRow.tsx +148 -0
  106. package/src/components/mail/message-body-error-banner-content.ts +87 -0
  107. package/src/components/mail/organize/OrganizeDialog.render.test.ts +37 -0
  108. package/src/components/mail/organize/OrganizeDialog.tsx +83 -0
  109. package/src/components/mail/organize/OrganizePanel.render.test.ts +102 -0
  110. package/src/components/mail/organize/OrganizePanel.tsx +344 -0
  111. package/src/components/onboarding/OnboardingWizard.tsx +1547 -0
  112. package/src/components/settings/AccountFormPanel.test.ts +72 -0
  113. package/src/components/settings/AccountFormPanel.tsx +942 -0
  114. package/src/components/settings/DangerZone.tsx +30 -0
  115. package/src/components/settings/DeleteAccountDialog.render.test.ts +47 -0
  116. package/src/components/settings/DeleteAccountDialog.tsx +224 -0
  117. package/src/components/settings/FiltersList.render.test.ts +71 -0
  118. package/src/components/settings/FiltersList.tsx +93 -0
  119. package/src/components/settings/account-form-helpers.test.ts +172 -0
  120. package/src/components/settings/account-form-helpers.ts +105 -0
  121. package/src/components/ui/AppVersion.tsx +50 -0
  122. package/src/components/ui/BugReportButton.tsx +45 -0
  123. package/src/components/ui/ConfirmDialog.tsx +125 -0
  124. package/src/components/ui/DropdownMenu.tsx +105 -0
  125. package/src/components/ui/EmptyState.tsx +11 -0
  126. package/src/components/ui/ErrorBanner.tsx +88 -0
  127. package/src/components/ui/ErrorBannerProvider.tsx +77 -0
  128. package/src/components/ui/ErrorBannerStack.tsx +34 -0
  129. package/src/components/ui/ErrorState.tsx +80 -0
  130. package/src/components/ui/FatalErrorOverlay.render.test.ts +66 -0
  131. package/src/components/ui/FatalErrorOverlay.tsx +151 -0
  132. package/src/components/ui/KeyboardShortcutsModal.tsx +120 -0
  133. package/src/components/ui/SlidePanel.tsx +72 -0
  134. package/src/components/ui/error-banners.test.ts +207 -0
  135. package/src/components/ui/error-banners.ts +111 -0
  136. package/src/hooks/queries/keys.ts +38 -0
  137. package/src/hooks/useArchiveMailbox.ts +104 -0
  138. package/src/hooks/useAutoMovedBadge.ts +77 -0
  139. package/src/hooks/useCurrentMailboxName.ts +99 -0
  140. package/src/hooks/useDebouncedValue.ts +12 -0
  141. package/src/hooks/useDeleteMessages.test.ts +103 -0
  142. package/src/hooks/useDeleteMessages.ts +226 -0
  143. package/src/hooks/useFilters.ts +112 -0
  144. package/src/hooks/useIntelligenceData.test.ts +269 -0
  145. package/src/hooks/useIntelligenceData.ts +284 -0
  146. package/src/hooks/useIsDark.ts +27 -0
  147. package/src/hooks/useKeyboardNavigation.ts +192 -0
  148. package/src/hooks/useLayoutTier.test.ts +47 -0
  149. package/src/hooks/useLayoutTier.ts +44 -0
  150. package/src/hooks/useLongPress.test.ts +291 -0
  151. package/src/hooks/useLongPress.ts +87 -0
  152. package/src/hooks/useMailboxAccount.ts +62 -0
  153. package/src/hooks/useMailboxNameIndex.ts +32 -0
  154. package/src/hooks/useMarkAsRead.test.ts +165 -0
  155. package/src/hooks/useMarkAsRead.ts +319 -0
  156. package/src/hooks/useMediaQuery.ts +39 -0
  157. package/src/hooks/useMessageBodyContent.test.ts +370 -0
  158. package/src/hooks/useMessageBodyContent.ts +380 -0
  159. package/src/hooks/useMoveMessages.test.ts +63 -0
  160. package/src/hooks/useMoveMessages.ts +221 -0
  161. package/src/hooks/useOrganizeJob.ts +90 -0
  162. package/src/hooks/useOrganizePreview.ts +40 -0
  163. package/src/hooks/useRescueCandidates.test.ts +217 -0
  164. package/src/hooks/useRescueCandidates.ts +48 -0
  165. package/src/hooks/useSaveDraft.ts +118 -0
  166. package/src/hooks/useSelection.test.ts +75 -0
  167. package/src/hooks/useSelection.ts +211 -0
  168. package/src/hooks/useSemanticSearch.ts +94 -0
  169. package/src/hooks/useSignature.ts +69 -0
  170. package/src/hooks/useStaleAccountSync.test.ts +211 -0
  171. package/src/hooks/useStaleAccountSync.ts +246 -0
  172. package/src/hooks/useSwipeNavigation.test.ts +35 -0
  173. package/src/hooks/useSwipeNavigation.ts +99 -0
  174. package/src/hooks/useToggleStar.ts +187 -0
  175. package/src/hooks/useToggleTrusted.test.ts +102 -0
  176. package/src/hooks/useToggleTrusted.ts +161 -0
  177. package/src/hooks/useTriageKeyboard.ts +103 -0
  178. package/src/hooks/useTriggerSync.test.ts +24 -0
  179. package/src/hooks/useTriggerSync.ts +58 -0
  180. package/src/hooks/useUpdateAddressFlags.ts +125 -0
  181. package/src/hooks/useVisualViewport.test.ts +127 -0
  182. package/src/hooks/useVisualViewport.ts +51 -0
  183. package/src/index.css +19 -0
  184. package/src/lib/account-order.test.ts +84 -0
  185. package/src/lib/account-order.ts +13 -0
  186. package/src/lib/adjacent-message.test.ts +43 -0
  187. package/src/lib/adjacent-message.ts +20 -0
  188. package/src/lib/api.ts +82 -0
  189. package/src/lib/app-info.ts +15 -0
  190. package/src/lib/auto-moved.test.ts +140 -0
  191. package/src/lib/auto-moved.ts +68 -0
  192. package/src/lib/autodiscovery.test.ts +94 -0
  193. package/src/lib/autodiscovery.ts +354 -0
  194. package/src/lib/brief.test.ts +366 -0
  195. package/src/lib/brief.ts +180 -0
  196. package/src/lib/bug-report.test.ts +213 -0
  197. package/src/lib/bug-report.ts +176 -0
  198. package/src/lib/client.ts +36 -0
  199. package/src/lib/console-errors.test.ts +64 -0
  200. package/src/lib/console-errors.ts +92 -0
  201. package/src/lib/conversation-target.test.ts +68 -0
  202. package/src/lib/conversation-target.ts +59 -0
  203. package/src/lib/display-category.ts +20 -0
  204. package/src/lib/drafts.test.ts +241 -0
  205. package/src/lib/drafts.ts +121 -0
  206. package/src/lib/error-classifier.test.ts +133 -0
  207. package/src/lib/error-classifier.ts +96 -0
  208. package/src/lib/fatal-error.test.ts +97 -0
  209. package/src/lib/fatal-error.ts +165 -0
  210. package/src/lib/folder-roles.test.ts +113 -0
  211. package/src/lib/folder-roles.ts +112 -0
  212. package/src/lib/format.test.ts +115 -0
  213. package/src/lib/format.ts +198 -0
  214. package/src/lib/i18n.ts +36 -0
  215. package/src/lib/intelligence-pref.test.ts +57 -0
  216. package/src/lib/intelligence-pref.ts +25 -0
  217. package/src/lib/keymap-dispatch.test.ts +178 -0
  218. package/src/lib/keymap-dispatch.ts +187 -0
  219. package/src/lib/keymap.test.ts +49 -0
  220. package/src/lib/keymap.ts +178 -0
  221. package/src/lib/mail-context.ts +64 -0
  222. package/src/lib/mail-route.test.ts +80 -0
  223. package/src/lib/mail-route.ts +44 -0
  224. package/src/lib/message-body-source.test.ts +98 -0
  225. package/src/lib/message-body-source.ts +48 -0
  226. package/src/lib/move-targets.test.ts +195 -0
  227. package/src/lib/move-targets.ts +74 -0
  228. package/src/lib/onboarding-completion.test.ts +64 -0
  229. package/src/lib/onboarding-completion.ts +23 -0
  230. package/src/lib/organize/filter-status.test.ts +99 -0
  231. package/src/lib/organize/filter-status.ts +86 -0
  232. package/src/lib/organize/organize-copy.test.ts +93 -0
  233. package/src/lib/organize/organize-copy.ts +61 -0
  234. package/src/lib/organize/organize-model.test.ts +104 -0
  235. package/src/lib/organize/organize-model.ts +105 -0
  236. package/src/lib/organize/organize-poll.test.ts +37 -0
  237. package/src/lib/organize/organize-poll.ts +28 -0
  238. package/src/lib/outbox-status.test.ts +64 -0
  239. package/src/lib/outbox-status.ts +75 -0
  240. package/src/lib/plate-serializer.ts +164 -0
  241. package/src/lib/provider-presets.test.ts +96 -0
  242. package/src/lib/provider-presets.ts +81 -0
  243. package/src/lib/query-error-handler.test.ts +144 -0
  244. package/src/lib/query-error-handler.ts +34 -0
  245. package/src/lib/query-escalation.integration.test.ts +138 -0
  246. package/src/lib/recent-searches.test.ts +53 -0
  247. package/src/lib/recent-searches.ts +34 -0
  248. package/src/lib/rescue-candidates.test.ts +94 -0
  249. package/src/lib/rescue-candidates.ts +35 -0
  250. package/src/lib/rescue-telemetry.test.ts +75 -0
  251. package/src/lib/rescue-telemetry.ts +55 -0
  252. package/src/lib/rum-adapter.test.ts +299 -0
  253. package/src/lib/rum-adapter.ts +107 -0
  254. package/src/lib/saved-searches.test.ts +80 -0
  255. package/src/lib/saved-searches.ts +52 -0
  256. package/src/lib/search-pending.test.ts +61 -0
  257. package/src/lib/search-pending.ts +24 -0
  258. package/src/lib/search-query.test.ts +35 -0
  259. package/src/lib/search-query.ts +11 -0
  260. package/src/lib/search-result.test.ts +160 -0
  261. package/src/lib/search-result.ts +102 -0
  262. package/src/lib/search-schemas.ts +34 -0
  263. package/src/lib/search-token-index.test.ts +112 -0
  264. package/src/lib/search-token-index.ts +56 -0
  265. package/src/lib/search-tokens.test.ts +218 -0
  266. package/src/lib/search-tokens.ts +133 -0
  267. package/src/lib/telemetry-context.tsx +9 -0
  268. package/src/lib/telemetry.ts +17 -0
  269. package/src/lib/theme-preference.ts +46 -0
  270. package/src/lib/theme.ts +33 -0
  271. package/src/lib/utils.ts +4 -0
  272. package/src/main.tsx +10 -0
  273. package/src/routeTree.gen.ts +392 -0
  274. package/src/router.test.ts +81 -0
  275. package/src/router.tsx +31 -0
  276. package/src/routes/__root.tsx +69 -0
  277. package/src/routes/index.tsx +7 -0
  278. package/src/routes/mail/$mailboxId.tsx +27 -0
  279. package/src/routes/mail/flagged.tsx +37 -0
  280. package/src/routes/mail/index.tsx +41 -0
  281. package/src/routes/mail/outbox.tsx +13 -0
  282. package/src/routes/mail.tsx +413 -0
  283. package/src/routes/onboarding.tsx +35 -0
  284. package/src/routes/settings/-accounts.test.ts +65 -0
  285. package/src/routes/settings/accounts.tsx +542 -0
  286. package/src/routes/settings/advanced.tsx +55 -0
  287. package/src/routes/settings/appearance.tsx +140 -0
  288. package/src/routes/settings/filters.tsx +141 -0
  289. package/src/routes/settings/folders.tsx +216 -0
  290. package/src/routes/settings/index.tsx +5 -0
  291. package/src/routes/settings/senders.tsx +466 -0
  292. package/src/routes/settings/suggested-vips.tsx +10 -0
  293. package/src/routes/settings.tsx +66 -0
  294. package/src/runtime-config.ts +75 -0
  295. package/src/shell/index.tsx +98 -0
  296. package/src/types/index.ts +73 -0
  297. package/src/vite-env.d.ts +4 -0
  298. package/vite.base.ts +43 -0
@@ -0,0 +1,319 @@
1
+ import {
2
+ mailboxOperationsListMailboxesQueryKey,
3
+ messageBulkOperationsUpdateFlagsMutation,
4
+ threadDetailOperationsListThreadMessagesQueryKey,
5
+ threadOperationsListThreadsQueryKey,
6
+ threadOperationsSearchThreadsQueryKey,
7
+ } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
8
+ import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
9
+ import { useMutation, useQueryClient } from "@tanstack/react-query";
10
+ import { useCallback, useEffect, useMemo, useRef } from "react";
11
+ import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
12
+ import { formatErrorDetail } from "@/components/ui/error-banners";
13
+
14
+ interface UseMarkAsReadOptions {
15
+ messages: RemitImapThreadMessageResponse[];
16
+ expandedIds: Set<string>;
17
+ threadId: string;
18
+ mailboxId: string;
19
+ accountId?: string;
20
+ }
21
+
22
+ interface ThreadMessagesData {
23
+ items: RemitImapThreadMessageResponse[];
24
+ [key: string]: unknown;
25
+ }
26
+
27
+ interface ThreadsListPage {
28
+ items: RemitImapThreadMessageResponse[];
29
+ [key: string]: unknown;
30
+ }
31
+
32
+ interface ThreadsListData {
33
+ pages: ThreadsListPage[];
34
+ pageParams: Array<string | undefined>;
35
+ }
36
+
37
+ interface SnapshotEntry<T> {
38
+ queryKey: readonly unknown[];
39
+ data: T;
40
+ }
41
+
42
+ interface MarkAsReadContext {
43
+ threadMessagesPrefix: readonly unknown[];
44
+ threadsListPrefix: readonly unknown[];
45
+ threadsSearchPrefix: readonly unknown[];
46
+ previousThreadMessages: SnapshotEntry<ThreadMessagesData>[];
47
+ previousThreadsList: SnapshotEntry<ThreadsListData>[];
48
+ }
49
+
50
+ const setReadOnItems = (
51
+ items: RemitImapThreadMessageResponse[],
52
+ messageIds: Set<string>,
53
+ isRead: boolean,
54
+ ): RemitImapThreadMessageResponse[] =>
55
+ items.map((item) =>
56
+ messageIds.has(item.messageId) ? { ...item, isRead } : item,
57
+ );
58
+
59
+ /**
60
+ * Pure helper: pick the message IDs that should be marked as read.
61
+ *
62
+ * A message is eligible when it is currently unread, currently expanded,
63
+ * and has not already been marked (or is mid-flight) during this thread
64
+ * view. Extracted so the policy can be exercised without rendering React.
65
+ */
66
+ export const selectMessagesToMarkRead = (
67
+ messages: RemitImapThreadMessageResponse[],
68
+ expandedIds: Set<string>,
69
+ alreadyMarked: Set<string>,
70
+ pending: Set<string>,
71
+ ): string[] =>
72
+ messages
73
+ .filter(
74
+ (m) =>
75
+ !m.isRead &&
76
+ expandedIds.has(m.threadMessageId) &&
77
+ !alreadyMarked.has(m.messageId) &&
78
+ !pending.has(m.messageId),
79
+ )
80
+ .map((m) => m.messageId);
81
+
82
+ export const useMarkAsRead = ({
83
+ messages,
84
+ expandedIds,
85
+ threadId,
86
+ mailboxId,
87
+ accountId,
88
+ }: UseMarkAsReadOptions) => {
89
+ const queryClient = useQueryClient();
90
+ const { pushError } = useErrorBanners();
91
+ const markedAsReadRef = useRef<Set<string>>(new Set());
92
+ const pendingRef = useRef<Set<string>>(new Set());
93
+
94
+ const { mutate: markAsRead } = useMutation({
95
+ ...messageBulkOperationsUpdateFlagsMutation(),
96
+ onMutate: async (variables): Promise<MarkAsReadContext> => {
97
+ const messageIds = new Set(variables.body.messageIds ?? []);
98
+ const isRead = variables.body.isRead ?? true;
99
+
100
+ const threadMessagesPrefix =
101
+ threadDetailOperationsListThreadMessagesQueryKey({
102
+ path: { threadId },
103
+ });
104
+ const threadsListPrefix = threadOperationsListThreadsQueryKey({
105
+ path: { mailboxId },
106
+ });
107
+ const threadsSearchPrefix = threadOperationsSearchThreadsQueryKey({
108
+ path: { mailboxId },
109
+ });
110
+
111
+ await Promise.all([
112
+ queryClient.cancelQueries({ queryKey: threadMessagesPrefix }),
113
+ queryClient.cancelQueries({ queryKey: threadsListPrefix }),
114
+ queryClient.cancelQueries({ queryKey: threadsSearchPrefix }),
115
+ ]);
116
+
117
+ const previousThreadMessages = queryClient
118
+ .getQueriesData<ThreadMessagesData>({ queryKey: threadMessagesPrefix })
119
+ .filter(
120
+ (entry): entry is [readonly unknown[], ThreadMessagesData] =>
121
+ entry[1] !== undefined,
122
+ )
123
+ .map(([queryKey, data]) => ({ queryKey, data }));
124
+
125
+ const previousThreadsList = queryClient
126
+ .getQueriesData<ThreadsListData>({ queryKey: threadsListPrefix })
127
+ .concat(
128
+ queryClient.getQueriesData<ThreadsListData>({
129
+ queryKey: threadsSearchPrefix,
130
+ }),
131
+ )
132
+ .filter(
133
+ (entry): entry is [readonly unknown[], ThreadsListData] =>
134
+ entry[1] !== undefined,
135
+ )
136
+ .map(([queryKey, data]) => ({ queryKey, data }));
137
+
138
+ queryClient.setQueriesData<ThreadMessagesData>(
139
+ { queryKey: threadMessagesPrefix },
140
+ (old) => {
141
+ if (!old) return old;
142
+ return {
143
+ ...old,
144
+ items: setReadOnItems(old.items, messageIds, isRead),
145
+ };
146
+ },
147
+ );
148
+
149
+ const patchListData = (old: ThreadsListData | undefined) => {
150
+ if (!old) return old;
151
+ return {
152
+ ...old,
153
+ pages: old.pages.map((page) => ({
154
+ ...page,
155
+ items: setReadOnItems(page.items, messageIds, isRead),
156
+ })),
157
+ };
158
+ };
159
+
160
+ queryClient.setQueriesData<ThreadsListData>(
161
+ { queryKey: threadsListPrefix },
162
+ patchListData,
163
+ );
164
+ queryClient.setQueriesData<ThreadsListData>(
165
+ { queryKey: threadsSearchPrefix },
166
+ patchListData,
167
+ );
168
+
169
+ return {
170
+ threadMessagesPrefix,
171
+ threadsListPrefix,
172
+ threadsSearchPrefix,
173
+ previousThreadMessages,
174
+ previousThreadsList,
175
+ };
176
+ },
177
+ onSuccess: (_data, variables) => {
178
+ const messageIds = variables.body.messageIds ?? [];
179
+ for (const id of messageIds) {
180
+ markedAsReadRef.current.add(id);
181
+ pendingRef.current.delete(id);
182
+ }
183
+ },
184
+ onError: (error, variables, context) => {
185
+ const messageIds = variables.body.messageIds ?? [];
186
+ for (const id of messageIds) {
187
+ pendingRef.current.delete(id);
188
+ }
189
+ if (context) {
190
+ for (const entry of context.previousThreadMessages) {
191
+ queryClient.setQueryData(entry.queryKey, entry.data);
192
+ }
193
+ for (const entry of context.previousThreadsList) {
194
+ queryClient.setQueryData(entry.queryKey, entry.data);
195
+ }
196
+ }
197
+ const isRead = variables.body.isRead ?? true;
198
+ pushError({
199
+ title: isRead ? "Couldn't mark as read" : "Couldn't mark as unread",
200
+ detail: formatErrorDetail(error),
201
+ });
202
+ },
203
+ onSettled: (_data, _err, _vars, context) => {
204
+ if (!context) return;
205
+ queryClient.invalidateQueries({ queryKey: context.threadMessagesPrefix });
206
+ queryClient.invalidateQueries({ queryKey: context.threadsListPrefix });
207
+ queryClient.invalidateQueries({ queryKey: context.threadsSearchPrefix });
208
+ // Refresh the sidebar mailbox list so the unread badge picks up the
209
+ // next backend `unseenCount` (which is owned by IMAP sync).
210
+ if (accountId) {
211
+ queryClient.invalidateQueries({
212
+ queryKey: mailboxOperationsListMailboxesQueryKey({
213
+ path: { accountId },
214
+ }),
215
+ });
216
+ }
217
+ },
218
+ });
219
+
220
+ const markMessagesRead = useCallback(
221
+ (messageIds: string[]) => {
222
+ const idsToMark = messageIds.filter(
223
+ (id) => !markedAsReadRef.current.has(id) && !pendingRef.current.has(id),
224
+ );
225
+ if (idsToMark.length === 0) return;
226
+
227
+ for (const id of idsToMark) {
228
+ pendingRef.current.add(id);
229
+ }
230
+
231
+ markAsRead({
232
+ body: {
233
+ messageIds: idsToMark,
234
+ isRead: true,
235
+ },
236
+ });
237
+ },
238
+ [markAsRead],
239
+ );
240
+
241
+ const eligibleIds = useMemo(
242
+ () =>
243
+ selectMessagesToMarkRead(
244
+ messages,
245
+ expandedIds,
246
+ markedAsReadRef.current,
247
+ pendingRef.current,
248
+ ),
249
+ [messages, expandedIds],
250
+ );
251
+
252
+ // Mark as read immediately when a message is expanded — match Gmail
253
+ // behaviour. The previous implementation used a 10s delay plus an
254
+ // "all expanded" gate that meant most reads silently never fired.
255
+ useEffect(() => {
256
+ if (eligibleIds.length === 0) return;
257
+ markMessagesRead(eligibleIds);
258
+ }, [eligibleIds, markMessagesRead]);
259
+
260
+ // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally clears state on threadId change; adding threadId to deps causes stale closure issues
261
+ useEffect(() => {
262
+ markedAsReadRef.current.clear();
263
+ pendingRef.current.clear();
264
+ }, [threadId]);
265
+ };
266
+
267
+ /**
268
+ * Standalone hook that exposes a `toggleReadFor` function to mark a set of
269
+ * messages as read or unread. Designed for multi-select mark-read (PR 1)
270
+ * and swipe-to-read (PR 2) use cases that operate outside a thread view.
271
+ */
272
+ export const useToggleReadFor = (options: {
273
+ mailboxId: string;
274
+ accountId?: string;
275
+ }) => {
276
+ const { mailboxId, accountId } = options;
277
+ const queryClient = useQueryClient();
278
+ const { pushError } = useErrorBanners();
279
+
280
+ const { mutate, isPending } = useMutation({
281
+ ...messageBulkOperationsUpdateFlagsMutation(),
282
+ onError: (error, variables) => {
283
+ const isRead = variables.body.isRead ?? true;
284
+ pushError({
285
+ title: isRead ? "Couldn't mark as read" : "Couldn't mark as unread",
286
+ detail: formatErrorDetail(error),
287
+ });
288
+ },
289
+ onSettled: () => {
290
+ queryClient.invalidateQueries({
291
+ queryKey: threadOperationsListThreadsQueryKey({
292
+ path: { mailboxId },
293
+ }),
294
+ });
295
+ queryClient.invalidateQueries({
296
+ queryKey: threadOperationsSearchThreadsQueryKey({
297
+ path: { mailboxId },
298
+ }),
299
+ });
300
+ if (accountId) {
301
+ queryClient.invalidateQueries({
302
+ queryKey: mailboxOperationsListMailboxesQueryKey({
303
+ path: { accountId },
304
+ }),
305
+ });
306
+ }
307
+ },
308
+ });
309
+
310
+ const toggleReadFor = useCallback(
311
+ (messageIds: string[], isRead: boolean) => {
312
+ if (messageIds.length === 0) return;
313
+ mutate({ body: { messageIds, isRead } });
314
+ },
315
+ [mutate],
316
+ );
317
+
318
+ return { toggleReadFor, isPending };
319
+ };
@@ -0,0 +1,39 @@
1
+ import { useEffect, useState } from "react";
2
+
3
+ /**
4
+ * Subscribes to a CSS media query and returns its current `matches` value.
5
+ * SSR-safe: returns `false` on the server / initial render before hydration.
6
+ *
7
+ * Common breakpoint shortcuts (mobile-first):
8
+ * useMediaQuery("(min-width: 768px)") // Tailwind md and up
9
+ * useMediaQuery("(min-width: 1024px)") // Tailwind lg and up
10
+ */
11
+ export const useMediaQuery = (query: string): boolean => {
12
+ const [matches, setMatches] = useState(() => {
13
+ if (typeof window === "undefined" || !window.matchMedia) return false;
14
+ return window.matchMedia(query).matches;
15
+ });
16
+
17
+ useEffect(() => {
18
+ if (typeof window === "undefined" || !window.matchMedia) return;
19
+ const mql = window.matchMedia(query);
20
+ const handler = (event: MediaQueryListEvent) => setMatches(event.matches);
21
+ // Sync state on mount in case the SSR/initial render disagreed.
22
+ setMatches(mql.matches);
23
+ mql.addEventListener("change", handler);
24
+ return () => mql.removeEventListener("change", handler);
25
+ }, [query]);
26
+
27
+ return matches;
28
+ };
29
+
30
+ /**
31
+ * True at desktop widths (Tailwind `lg:` and up, ≥1024px). Below this the app
32
+ * renders the single-pane mobile layout: tablet portrait (768px) and phones
33
+ * both fall under here, so they get the stacked list → reading flow with
34
+ * drawer chrome instead of the cramped three-pane desktop grid (#682).
35
+ *
36
+ * The CSS-gated mobile chrome (Drawer, ComposeFab) uses `lg:hidden` to match
37
+ * this boundary — keep them in sync if the breakpoint changes.
38
+ */
39
+ export const useIsDesktop = (): boolean => useMediaQuery("(min-width: 1024px)");