@remit/web-client 0.0.123 → 0.0.124

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.123",
3
+ "version": "0.0.124",
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": {
@@ -7,13 +7,16 @@
7
7
  * what wording is not decided here.
8
8
  */
9
9
  import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
10
- import { ShellTopBar, shortcutHintForAction } from "@remit/ui";
10
+ import { RefreshButton, ShellTopBar, shortcutHintForAction } from "@remit/ui";
11
11
  import { useNavigate } from "@tanstack/react-router";
12
+ import { useMemo } from "react";
12
13
  import { AccountMenu } from "@/auth/AccountMenu";
13
14
  import { useGlobalCompose } from "@/hooks/useComposeTarget";
15
+ import { useRefreshControl } from "@/hooks/useRefreshControl";
14
16
  import { useSearchScope } from "@/hooks/useSearchScope";
15
17
  import { openBugReport } from "@/lib/bug-report";
16
18
  import { useMailContext } from "@/lib/mail-context";
19
+ import { useMailFreshness } from "@/lib/mail-freshness";
17
20
 
18
21
  interface MailTopBarProps {
19
22
  accounts: RemitImapAccountResponse[];
@@ -30,6 +33,19 @@ export function MailTopBar({ accounts }: MailTopBarProps) {
30
33
  ? [{ id: scope.chip.id, label: scope.chip.label, tone: "scope" as const }]
31
34
  : undefined;
32
35
 
36
+ // Every connected account — the global refresh's whole point, distinct from
37
+ // the account-scoped controls on the inbox and brief headers.
38
+ const allAccountIds = useMemo(
39
+ () => accounts.map((account) => account.accountId),
40
+ [accounts],
41
+ );
42
+ const { hasNewMail } = useMailFreshness();
43
+ const {
44
+ state: refreshState,
45
+ errorMessage: refreshError,
46
+ refresh,
47
+ } = useRefreshControl(allAccountIds);
48
+
33
49
  return (
34
50
  <ShellTopBar
35
51
  search={{
@@ -45,6 +61,15 @@ export function MailTopBar({ accounts }: MailTopBarProps) {
45
61
  onReportBug={openBugReport}
46
62
  onOpenSettings={() => navigate({ to: "/settings/accounts" })}
47
63
  composeShortcut={shortcutHintForAction("compose")}
64
+ refreshControl={
65
+ <RefreshButton
66
+ state={refreshState}
67
+ onRefresh={refresh}
68
+ label="Refresh all accounts"
69
+ errorMessage={refreshError}
70
+ hasUpdate={hasNewMail(allAccountIds)}
71
+ />
72
+ }
48
73
  account={<AccountMenu />}
49
74
  />
50
75
  );
@@ -46,6 +46,7 @@ import {
46
46
  KeyboardHintBar,
47
47
  matchesBriefFilters,
48
48
  partitionSpamResults,
49
+ RefreshButton,
49
50
  type SearchResult,
50
51
  SelectionTopBar,
51
52
  SpamResultsOffer,
@@ -69,6 +70,7 @@ import {
69
70
  } from "@/hooks/useInitialSyncProgress";
70
71
  import { useLabelList } from "@/hooks/useLabels";
71
72
  import { useIsDesktop } from "@/hooks/useMediaQuery";
73
+ import { useRefreshControl } from "@/hooks/useRefreshControl";
72
74
  import { useSearchTokenContext } from "@/hooks/useSearchTokenContext";
73
75
  import { useSemanticSearch } from "@/hooks/useSemanticSearch";
74
76
  import type { TriageContextUpdate } from "@/hooks/useTriageLayer";
@@ -84,6 +86,7 @@ import {
84
86
  import { isServerError } from "@/lib/error-classifier";
85
87
  import type { ListHeaderChrome } from "@/lib/list-header-chrome";
86
88
  import { useMailContext } from "@/lib/mail-context";
89
+ import { useMailFreshness } from "@/lib/mail-freshness";
87
90
  import { relatedSearchResults, rowToSearchResult } from "@/lib/search-result";
88
91
  import { parseSearchTokens } from "@/lib/search-tokens";
89
92
  import { spamOfferForResults } from "@/lib/spam-offer";
@@ -581,6 +584,35 @@ export function DailyBrief({
581
584
  [unseenByAccount],
582
585
  );
583
586
 
587
+ // Every non-muted account the brief aggregates — refreshing it means
588
+ // refreshing all of them, same as the accounts the "caught up" reading
589
+ // above already spans.
590
+ const refreshAccountIds = useMemo(
591
+ () => nonMuted.map((account) => account.accountId),
592
+ [nonMuted],
593
+ );
594
+ const { hasNewMail } = useMailFreshness();
595
+ const {
596
+ state: refreshState,
597
+ errorMessage: refreshError,
598
+ refresh: onRefreshBrief,
599
+ } = useRefreshControl(refreshAccountIds, { onSettled: () => refetch() });
600
+ // Memoized: this element is a dep of `MailListHeader`'s own `chrome` memo
601
+ // (via the `refreshControl` prop), so a fresh element identity every render
602
+ // would defeat that memo and re-render every chrome consumer with it.
603
+ const refreshControl = useMemo(
604
+ () => (
605
+ <RefreshButton
606
+ state={refreshState}
607
+ onRefresh={onRefreshBrief}
608
+ label="Refresh daily brief"
609
+ errorMessage={refreshError}
610
+ hasUpdate={hasNewMail(refreshAccountIds)}
611
+ />
612
+ ),
613
+ [refreshState, onRefreshBrief, refreshError, hasNewMail, refreshAccountIds],
614
+ );
615
+
584
616
  // The phone search takeover renders the account/free-text-narrowed rows,
585
617
  // further narrowed by the same category and attribute chips the list applies.
586
618
  const searchResults = useMemo<SearchResult[]>(
@@ -791,6 +823,7 @@ export function DailyBrief({
791
823
  // it is on the mailbox route (#212) — the two-engine panel stays for
792
824
  // the typing/uncommitted state only.
793
825
  searchResultsInBody: true,
826
+ refreshControl,
794
827
  }}
795
828
  rows={filteredRows}
796
829
  >
@@ -139,6 +139,12 @@ export interface MailListHeaderProps {
139
139
  * leaves this unset and keeps the panel for every query.
140
140
  */
141
141
  searchResultsInBody?: boolean;
142
+ /**
143
+ * The refresh control for this view — the mailbox route and the brief pass
144
+ * one, scoped to the account(s) they show; a view with nothing account-scoped
145
+ * to refresh (Starred) leaves it unset.
146
+ */
147
+ refreshControl?: ReactNode;
142
148
  }
143
149
 
144
150
  export function MailListHeader({
@@ -157,6 +163,7 @@ export function MailListHeader({
157
163
  searchResultsLabel = "Top matches",
158
164
  relatedResultsLabel = "Related",
159
165
  searchResultsInBody = false,
166
+ refreshControl,
160
167
  }: MailListHeaderProps) {
161
168
  const {
162
169
  accounts,
@@ -441,6 +448,7 @@ export function MailListHeader({
441
448
  {unreadCount.toLocaleString()} unread
442
449
  </span>
443
450
  <FilterToggle />
451
+ {refreshControl}
444
452
  </>
445
453
  ),
446
454
  searchSlot: ownsSearch && !searchExpanded && (
@@ -492,6 +500,7 @@ export function MailListHeader({
492
500
  chromeResults,
493
501
  makeFilterAction,
494
502
  searchConversion,
503
+ refreshControl,
495
504
  ],
496
505
  );
497
506
 
@@ -59,6 +59,8 @@ interface MailViewChromeProps {
59
59
  * over its own already query-narrowed rows.
60
60
  */
61
61
  searchResultsInBody?: boolean;
62
+ /** The view's own refresh control, scoped to the account it shows. */
63
+ refreshControl?: ReactNode;
62
64
  }
63
65
 
64
66
  export function MailViewChrome({
@@ -81,6 +83,7 @@ export function MailViewChrome({
81
83
  searchResultsLabel,
82
84
  relatedResultsLabel,
83
85
  searchResultsInBody,
86
+ refreshControl,
84
87
  }: MailViewChromeProps) {
85
88
  // A query owns the pane: the filter chrome and the search's own affordance
86
89
  // narrow the same list from the same place, so the filter sheet stands down
@@ -116,6 +119,7 @@ export function MailViewChrome({
116
119
  searchResultsLabel={searchResultsLabel}
117
120
  relatedResultsLabel={relatedResultsLabel}
118
121
  searchResultsInBody={searchResultsInBody}
122
+ refreshControl={refreshControl}
119
123
  >
120
124
  {/* One shell either way: the children's parent must not change when a
121
125
  query starts, or everything under it — including the header's own
@@ -30,6 +30,7 @@ import {
30
30
  inboxFilterConfig,
31
31
  type MessageListFilter,
32
32
  ReadingPaneEmpty,
33
+ RefreshButton,
33
34
  type RescueCandidate,
34
35
  type SearchResult,
35
36
  useAppShellLayout,
@@ -87,6 +88,7 @@ import { useLayoutTier } from "@/hooks/useLayoutTier";
87
88
  import { useMailboxAccount } from "@/hooks/useMailboxAccount";
88
89
  import { useToggleReadFor } from "@/hooks/useMarkAsRead";
89
90
  import { useMoveMessages } from "@/hooks/useMoveMessages";
91
+ import { useRefreshControl } from "@/hooks/useRefreshControl";
90
92
  import { useRescueCandidates } from "@/hooks/useRescueCandidates";
91
93
  import { useSearchTokenContext } from "@/hooks/useSearchTokenContext";
92
94
  import { useSemanticSearch } from "@/hooks/useSemanticSearch";
@@ -110,6 +112,7 @@ import {
110
112
  } from "@/lib/inbox-filters";
111
113
  import { readIntelligencePref } from "@/lib/intelligence-pref";
112
114
  import { useMailContext } from "@/lib/mail-context";
115
+ import { useMailFreshness } from "@/lib/mail-freshness";
113
116
  import { isRescueCandidate } from "@/lib/rescue-candidates";
114
117
  import { recordRescueSentToJunk } from "@/lib/rescue-telemetry";
115
118
  import {
@@ -953,6 +956,42 @@ function MailboxList() {
953
956
  const listTitle = mailboxName ?? "Inbox";
954
957
  const preset = useMemo(() => inboxFilterConfig(), []);
955
958
 
959
+ // The account owning this folder — undefined for the instant before
960
+ // `useMailboxAccount` resolves it, which simply means there is nothing to
961
+ // refresh yet.
962
+ const refreshAccountIds = useMemo(
963
+ () => (mailboxAccountId ? [mailboxAccountId] : []),
964
+ [mailboxAccountId],
965
+ );
966
+ const { hasNewMail } = useMailFreshness();
967
+ const {
968
+ state: refreshState,
969
+ errorMessage: refreshError,
970
+ refresh: onRefreshMailbox,
971
+ } = useRefreshControl(refreshAccountIds, { onSettled: onRetry });
972
+ // Memoized: this element is a dep of `MailListHeader`'s own `chrome` memo
973
+ // (via the `refreshControl` prop), so a fresh element identity every render
974
+ // would defeat that memo and re-render every chrome consumer with it.
975
+ const refreshControl = useMemo(
976
+ () => (
977
+ <RefreshButton
978
+ state={refreshState}
979
+ onRefresh={onRefreshMailbox}
980
+ label={`Refresh ${listTitle}`}
981
+ errorMessage={refreshError}
982
+ hasUpdate={hasNewMail(refreshAccountIds)}
983
+ />
984
+ ),
985
+ [
986
+ refreshState,
987
+ onRefreshMailbox,
988
+ listTitle,
989
+ refreshError,
990
+ hasNewMail,
991
+ refreshAccountIds,
992
+ ],
993
+ );
994
+
956
995
  const searchResults = useMemo(
957
996
  () =>
958
997
  threads.map((thread) => threadToSearchResult(thread, resultFolderIndex)),
@@ -1084,6 +1123,7 @@ function MailboxList() {
1084
1123
  // the "Select all N matching" escalation are reachable on desktop (#212).
1085
1124
  // The typing/uncommitted state still shows the two-engine panel.
1086
1125
  searchResultsInBody
1126
+ refreshControl={refreshControl}
1087
1127
  >
1088
1128
  {body}
1089
1129
  </MailViewChrome>
@@ -0,0 +1,279 @@
1
+ import {
2
+ mailboxOperationsListMailboxesQueryKey,
3
+ syncOperationsGetSyncStatusOptions,
4
+ } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
5
+ import { syncOperationsTriggerSync } from "@remit/api-http-client/sdk.gen.ts";
6
+ import type { RemitImapMailboxSyncProgress } from "@remit/api-http-client/types.gen.ts";
7
+ import type { RefreshControlState } from "@remit/ui";
8
+ import { type QueryClient, useQueryClient } from "@tanstack/react-query";
9
+ import { useCallback, useEffect, useRef, useState } from "react";
10
+ import { isSyncingPhase } from "@/hooks/useInitialSyncProgress";
11
+ import { shouldEscalate } from "@/lib/error-classifier";
12
+ import { reportFatalError } from "@/lib/fatal-error";
13
+ import { useMailFreshness } from "@/lib/mail-freshness";
14
+ import { useTelemetry } from "@/lib/telemetry-context";
15
+
16
+ export type { RefreshControlState };
17
+
18
+ /** How often the wait polls an account's own sync status once a round is
19
+ * enqueued — fast enough to feel responsive, far below the once-a-minute
20
+ * background cadence, and bounded by {@link REFRESH_TIMEOUT_MS} either way. */
21
+ export const REFRESH_POLL_MS = 2000;
22
+
23
+ /** A refresh that gets no answer must terminate in a stated failure rather
24
+ * than spin forever — this is that bound. */
25
+ export const REFRESH_TIMEOUT_MS = 45_000;
26
+
27
+ const sleep = (ms: number): Promise<void> =>
28
+ new Promise((resolve) => setTimeout(resolve, ms));
29
+
30
+ const messageFor = (error: unknown): string =>
31
+ error instanceof Error && error.message
32
+ ? error.message
33
+ : "Something went wrong";
34
+
35
+ const escalateIfFatal = (error: unknown): void => {
36
+ if (shouldEscalate(error, { softError: true })) reportFatalError(error);
37
+ };
38
+
39
+ const maxLastSynced = (
40
+ mailboxes: readonly RemitImapMailboxSyncProgress[],
41
+ ): number =>
42
+ mailboxes.reduce(
43
+ (max, mailbox) => Math.max(max, mailbox.lastSyncedAt ?? 0),
44
+ 0,
45
+ );
46
+
47
+ /**
48
+ * `getSyncStatus` for one account, always as a real network read: `staleTime:
49
+ * 0` means even a reading `MailFreshnessProvider` fetched a moment ago is
50
+ * treated as stale. Sharing the query key means every fetch here also lands
51
+ * in the same cache `MailFreshnessProvider` reads, so acknowledging a refresh
52
+ * (see `useMailFreshness().acknowledge`) re-baselines from data this wait
53
+ * itself just fetched — never a round-old cached reading.
54
+ *
55
+ * Routed through `queryClient.fetchQuery` rather than the raw SDK call, so a
56
+ * failure here already reaches the global `QueryCache` error sink
57
+ * (`main.tsx` wires `handleQueryCacheError`) on the same terms as every other
58
+ * query — a manual `escalateIfFatal` on top would double-report the same
59
+ * error. `syncOperationsTriggerSync` below is the one call in this file that
60
+ * genuinely needs it: it's a raw SDK call outside React Query, so nothing
61
+ * else escalates it.
62
+ */
63
+ const fetchStatus = (queryClient: QueryClient, accountId: string) =>
64
+ queryClient.fetchQuery({
65
+ ...syncOperationsGetSyncStatusOptions({ path: { accountId } }),
66
+ staleTime: 0,
67
+ });
68
+
69
+ interface AccountOutcome {
70
+ accountId: string;
71
+ message?: string;
72
+ }
73
+
74
+ /**
75
+ * Poll one account's sync status until a round that started after
76
+ * `baselineMaxLastSynced` was captured has settled, or `deadline` passes.
77
+ *
78
+ * `POST /sync` only enqueues the round (the worker runs it later), so the
79
+ * very first status read after triggering can still show the *previous*
80
+ * round's phase — reading that as "settled" would report success, or a
81
+ * stale failed phase, before the new round ever ran (#582 review). This
82
+ * waits for positive evidence the triggered round actually happened: either
83
+ * the phase was observed mid-flight, or some mailbox's `lastSyncedAt`
84
+ * advanced past the baseline taken before the trigger.
85
+ *
86
+ * Read-only (`getSyncStatus`, no IMAP call, no queue write), so waiting costs
87
+ * a handful of cheap GETs — never a refetch of the account's own message
88
+ * list.
89
+ */
90
+ const waitForSettled = async (
91
+ queryClient: QueryClient,
92
+ accountId: string,
93
+ baselineMaxLastSynced: number,
94
+ deadline: number,
95
+ ): Promise<AccountOutcome | undefined> => {
96
+ let observedInProgress = false;
97
+ for (;;) {
98
+ const outcome = await fetchStatus(queryClient, accountId)
99
+ .then((data) => ({ ok: true as const, data }))
100
+ .catch((error: unknown) => ({ ok: false as const, error }));
101
+ if (!outcome.ok) {
102
+ return { accountId, message: messageFor(outcome.error) };
103
+ }
104
+ const { syncPhase, mailboxes } = outcome.data;
105
+ if (isSyncingPhase(syncPhase)) {
106
+ observedInProgress = true;
107
+ } else {
108
+ const confirmed =
109
+ observedInProgress ||
110
+ maxLastSynced(mailboxes ?? []) > baselineMaxLastSynced;
111
+ if (confirmed) {
112
+ if (syncPhase === "error") {
113
+ return { accountId, message: "Sync failed for this account" };
114
+ }
115
+ return undefined;
116
+ }
117
+ }
118
+ if (Date.now() >= deadline) {
119
+ return { accountId, message: "Refresh is taking longer than usual" };
120
+ }
121
+ await sleep(REFRESH_POLL_MS);
122
+ }
123
+ };
124
+
125
+ interface AccountResult {
126
+ accountId: string;
127
+ /** The trigger itself was accepted — the mailbox-list invalidation and
128
+ * `onSettled`/`acknowledge` calls run for every enqueued account,
129
+ * independent of whether its own round then succeeded. */
130
+ enqueued: boolean;
131
+ ok: boolean;
132
+ message?: string;
133
+ }
134
+
135
+ const refreshOneAccount = async (
136
+ queryClient: QueryClient,
137
+ telemetry: { recordEvent: (name: string) => void },
138
+ accountId: string,
139
+ deadline: number,
140
+ ): Promise<AccountResult> => {
141
+ const baseline = await fetchStatus(queryClient, accountId)
142
+ .then((data) => ({
143
+ ok: true as const,
144
+ maxLastSynced: maxLastSynced(data.mailboxes ?? []),
145
+ }))
146
+ .catch((error: unknown) => ({ ok: false as const, error }));
147
+ if (!baseline.ok) {
148
+ return {
149
+ accountId,
150
+ enqueued: false,
151
+ ok: false,
152
+ message: messageFor(baseline.error),
153
+ };
154
+ }
155
+
156
+ const triggered = await syncOperationsTriggerSync({
157
+ path: { accountId },
158
+ throwOnError: true,
159
+ })
160
+ .then(() => ({ ok: true as const }))
161
+ .catch((error: unknown) => ({ ok: false as const, error }));
162
+ if (!triggered.ok) {
163
+ escalateIfFatal(triggered.error);
164
+ return {
165
+ accountId,
166
+ enqueued: false,
167
+ ok: false,
168
+ message: messageFor(triggered.error),
169
+ };
170
+ }
171
+ telemetry.recordEvent("sync.triggered");
172
+
173
+ const settled = await waitForSettled(
174
+ queryClient,
175
+ accountId,
176
+ baseline.maxLastSynced,
177
+ deadline,
178
+ );
179
+ if (settled)
180
+ return { accountId, enqueued: true, ok: false, message: settled.message };
181
+ return { accountId, enqueued: true, ok: true };
182
+ };
183
+
184
+ export interface UseRefreshControlOptions {
185
+ /** Called once every enqueued account's sync round has settled, alongside
186
+ * the unconditional invalidation of each enqueued account's own
187
+ * mailbox-list query — the caller's chance to invalidate whatever
188
+ * view-specific query (a mailbox's thread list, the brief's unified list)
189
+ * the sync may have changed. */
190
+ onSettled?: () => void;
191
+ }
192
+
193
+ export interface UseRefreshControlResult {
194
+ state: RefreshControlState;
195
+ errorMessage?: string;
196
+ refresh: () => void;
197
+ }
198
+
199
+ /**
200
+ * Drives the shared `RefreshButton` for one or more accounts: triggers a sync
201
+ * for each, waits for the server's own sync-status to settle — not just the
202
+ * enqueue ack — then invalidates the queries the caller names. A manual
203
+ * refresh is an explicit user action, so unlike the background poll it always
204
+ * shows the result; what it must never do is leave the button spinning with
205
+ * no answer.
206
+ */
207
+ export const useRefreshControl = (
208
+ accountIds: readonly string[],
209
+ options: UseRefreshControlOptions = {},
210
+ ): UseRefreshControlResult => {
211
+ const queryClient = useQueryClient();
212
+ const telemetry = useTelemetry();
213
+ const { acknowledge } = useMailFreshness();
214
+ const [state, setState] = useState<RefreshControlState>("idle");
215
+ const [errorMessage, setErrorMessage] = useState<string>();
216
+ const runIdRef = useRef(0);
217
+ const accountIdsRef = useRef(accountIds);
218
+ accountIdsRef.current = accountIds;
219
+ const optionsRef = useRef(options);
220
+ optionsRef.current = options;
221
+
222
+ // A success confirmation is a beat, not a permanent state — it hands the
223
+ // button back to idle on its own so a stale checkmark never lingers.
224
+ useEffect(() => {
225
+ if (state !== "success") return;
226
+ const timer = setTimeout(() => setState("idle"), 2000);
227
+ return () => clearTimeout(timer);
228
+ }, [state]);
229
+
230
+ const refresh = useCallback(() => {
231
+ const ids = accountIdsRef.current;
232
+ if (ids.length === 0) {
233
+ // Reachable before the owning account resolves (a fresh mailbox
234
+ // route) or when every account is muted (the brief) — a click here
235
+ // must say why it did nothing, never sit as a dead button.
236
+ setState("error");
237
+ setErrorMessage("Nothing to refresh yet");
238
+ return;
239
+ }
240
+ const runId = ++runIdRef.current;
241
+ setState("refreshing");
242
+ setErrorMessage(undefined);
243
+
244
+ void (async () => {
245
+ const deadline = Date.now() + REFRESH_TIMEOUT_MS;
246
+ const results = await Promise.all(
247
+ ids.map((accountId) =>
248
+ refreshOneAccount(queryClient, telemetry, accountId, deadline),
249
+ ),
250
+ );
251
+ if (runIdRef.current !== runId) return;
252
+
253
+ const enqueued = results
254
+ .filter((result) => result.enqueued)
255
+ .map((result) => result.accountId);
256
+ for (const accountId of enqueued) {
257
+ queryClient.invalidateQueries({
258
+ queryKey: mailboxOperationsListMailboxesQueryKey({
259
+ path: { accountId },
260
+ }),
261
+ });
262
+ }
263
+ if (enqueued.length > 0) {
264
+ optionsRef.current.onSettled?.();
265
+ acknowledge(enqueued);
266
+ }
267
+
268
+ const failures = results.filter((result) => !result.ok);
269
+ if (failures.length === 0) {
270
+ setState("success");
271
+ } else {
272
+ setState("error");
273
+ setErrorMessage(failures[0]?.message);
274
+ }
275
+ })();
276
+ }, [acknowledge, queryClient, telemetry]);
277
+
278
+ return { state, errorMessage, refresh };
279
+ };
@@ -0,0 +1,116 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, test } from "node:test";
3
+ import type { RemitImapMailboxSyncProgress } from "@remit/api-http-client/types.gen.ts";
4
+ import type { NavMailboxRole } from "@remit/ui";
5
+ import type { ResultFolderIndex } from "@/lib/result-folder";
6
+ import { hasGrown, totalsFrom } from "./mail-freshness.js";
7
+
8
+ const mailbox = (
9
+ mailboxId: string,
10
+ messagesTotal: number,
11
+ ): RemitImapMailboxSyncProgress => ({
12
+ mailboxId,
13
+ fullPath: mailboxId,
14
+ phase: "complete",
15
+ messagesTotal,
16
+ messagesSynced: messagesTotal,
17
+ });
18
+
19
+ const roles = (
20
+ entries: Record<string, NavMailboxRole | undefined>,
21
+ ): ResultFolderIndex =>
22
+ new Map(
23
+ Object.entries(entries).map(([id, role]) => [id, role ? { role } : {}]),
24
+ );
25
+
26
+ describe("hasGrown", () => {
27
+ test("false when nothing changed", () => {
28
+ const baseline = new Map([["mb-1", 10]]);
29
+ const current = new Map([["mb-1", 10]]);
30
+ assert.equal(hasGrown(baseline, current), false);
31
+ });
32
+
33
+ test("true when a mailbox's total increased", () => {
34
+ const baseline = new Map([["mb-1", 10]]);
35
+ const current = new Map([["mb-1", 11]]);
36
+ assert.equal(hasGrown(baseline, current), true);
37
+ });
38
+
39
+ test("false when a mailbox's total dropped (reads, deletes) — not an arrival", () => {
40
+ const baseline = new Map([["mb-1", 10]]);
41
+ const current = new Map([["mb-1", 9]]);
42
+ assert.equal(hasGrown(baseline, current), false);
43
+ });
44
+
45
+ test("a mailbox missing from the baseline counts from zero", () => {
46
+ const baseline = new Map<string, number>();
47
+ const current = new Map([["mb-1", 1]]);
48
+ assert.equal(hasGrown(baseline, current), true);
49
+ });
50
+
51
+ test("a mailbox with zero new messages is not growth", () => {
52
+ const baseline = new Map<string, number>();
53
+ const current = new Map([["mb-1", 0]]);
54
+ assert.equal(hasGrown(baseline, current), false);
55
+ });
56
+
57
+ test("one mailbox growing is enough even if others are unchanged", () => {
58
+ const baseline = new Map([
59
+ ["mb-1", 10],
60
+ ["mb-2", 5],
61
+ ]);
62
+ const current = new Map([
63
+ ["mb-1", 10],
64
+ ["mb-2", 6],
65
+ ]);
66
+ assert.equal(hasGrown(baseline, current), true);
67
+ });
68
+ });
69
+
70
+ describe("totalsFrom", () => {
71
+ test("keeps a mailbox with no resolved role (a custom folder)", () => {
72
+ const totals = totalsFrom([mailbox("mb-custom", 5)], roles({}));
73
+ assert.deepEqual([...totals], [["mb-custom", 5]]);
74
+ });
75
+
76
+ test("keeps Inbox and Junk — both are places new mail actually arrives", () => {
77
+ const totals = totalsFrom(
78
+ [mailbox("mb-inbox", 5), mailbox("mb-junk", 2)],
79
+ roles({ "mb-inbox": "inbox", "mb-junk": "junk" }),
80
+ );
81
+ assert.deepEqual(new Set(totals.keys()), new Set(["mb-inbox", "mb-junk"]));
82
+ });
83
+
84
+ for (const role of ["sent", "drafts", "trash", "archive"] as const) {
85
+ test(`drops ${role} — its own total growing is the user's doing, not an arrival`, () => {
86
+ const totals = totalsFrom([mailbox("mb-1", 5)], roles({ "mb-1": role }));
87
+ assert.equal(totals.has("mb-1"), false);
88
+ });
89
+ }
90
+
91
+ test("a delete (Inbox shrinks, Trash grows) never reads as growth once Trash is excluded", () => {
92
+ const folders = roles({ "mb-inbox": "inbox", "mb-trash": "trash" });
93
+ const baseline = totalsFrom(
94
+ [mailbox("mb-inbox", 10), mailbox("mb-trash", 3)],
95
+ folders,
96
+ );
97
+ const afterDelete = totalsFrom(
98
+ [mailbox("mb-inbox", 9), mailbox("mb-trash", 4)],
99
+ folders,
100
+ );
101
+ assert.equal(hasGrown(baseline, afterDelete), false);
102
+ });
103
+
104
+ test("real new mail in Inbox still reads as growth alongside an unrelated delete", () => {
105
+ const folders = roles({ "mb-inbox": "inbox", "mb-trash": "trash" });
106
+ const baseline = totalsFrom(
107
+ [mailbox("mb-inbox", 10), mailbox("mb-trash", 3)],
108
+ folders,
109
+ );
110
+ const afterArrivalAndDelete = totalsFrom(
111
+ [mailbox("mb-inbox", 11), mailbox("mb-trash", 4)],
112
+ folders,
113
+ );
114
+ assert.equal(hasGrown(baseline, afterArrivalAndDelete), true);
115
+ });
116
+ });
@@ -0,0 +1,201 @@
1
+ import { syncOperationsGetSyncStatusOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
2
+ import type { RemitImapMailboxSyncProgress } from "@remit/api-http-client/types.gen.ts";
3
+ import type { NavMailboxRole } from "@remit/ui";
4
+ import { useQueries, useQueryClient } from "@tanstack/react-query";
5
+ import {
6
+ createContext,
7
+ type ReactNode,
8
+ useCallback,
9
+ useContext,
10
+ useEffect,
11
+ useMemo,
12
+ useRef,
13
+ useState,
14
+ } from "react";
15
+ import { useMailContext } from "@/lib/mail-context";
16
+ import type { ResultFolderIndex } from "@/lib/result-folder";
17
+
18
+ /**
19
+ * How often the background poll re-reads each open account's sync status.
20
+ * `getSyncStatus` is a DDB read with no IMAP call and no queue write (see
21
+ * typespec Sync Operations), so this is the cheap side of the refresh
22
+ * feature — the once-a-minute check that decides whether anything is worth
23
+ * surfacing, never a refetch of the mailbox or brief content itself.
24
+ */
25
+ export const FRESHNESS_POLL_MS = 60_000;
26
+
27
+ /**
28
+ * Roles whose own total growing is the user's doing, not an arrival: sending
29
+ * grows Sent, deleting or archiving grows Trash/Archive, and a Drafts count is
30
+ * autosaves. Counting any of these as "new mail" would light the dot on a
31
+ * message the user just moved themselves. Junk is kept in — spam is still an
32
+ * arrival, just an unwanted one — and a mailbox with no resolved role (a
33
+ * custom folder, or one a filter files mail into directly) is never excluded.
34
+ */
35
+ const EXCLUDED_GROWTH_ROLES: ReadonlySet<NavMailboxRole> = new Set([
36
+ "sent",
37
+ "drafts",
38
+ "trash",
39
+ "archive",
40
+ ]);
41
+
42
+ type MailboxTotals = ReadonlyMap<string, number>;
43
+
44
+ export const totalsFrom = (
45
+ mailboxes: readonly RemitImapMailboxSyncProgress[],
46
+ resultFolderIndex: ResultFolderIndex,
47
+ ): MailboxTotals => {
48
+ const totals = new Map<string, number>();
49
+ for (const mailbox of mailboxes) {
50
+ const role = resultFolderIndex.get(mailbox.mailboxId)?.role;
51
+ if (role && EXCLUDED_GROWTH_ROLES.has(role)) continue;
52
+ totals.set(mailbox.mailboxId, mailbox.messagesTotal);
53
+ }
54
+ return totals;
55
+ };
56
+
57
+ /**
58
+ * Whether any mailbox's message total grew since the baseline. Pure so the
59
+ * "did mail arrive" call is unit-testable without a DOM or a QueryClient. A
60
+ * mailbox missing from the baseline (new folder, first-ever reading) counts
61
+ * from zero rather than being skipped, so mail landing in a folder created
62
+ * since the last look still counts as an arrival.
63
+ */
64
+ export const hasGrown = (
65
+ baseline: MailboxTotals,
66
+ current: MailboxTotals,
67
+ ): boolean => {
68
+ for (const [mailboxId, total] of current) {
69
+ if (total > (baseline.get(mailboxId) ?? 0)) return true;
70
+ }
71
+ return false;
72
+ };
73
+
74
+ interface MailFreshnessContextValue {
75
+ /** True once any of these accounts has grown since it was last acknowledged. */
76
+ hasNewMail: (accountIds: readonly string[]) => boolean;
77
+ /** Clears the flag for these accounts and re-baselines them from whatever
78
+ * is currently cached — called once a refresh has shown the user current
79
+ * data for them. */
80
+ acknowledge: (accountIds: readonly string[]) => void;
81
+ }
82
+
83
+ const MailFreshnessContext = createContext<MailFreshnessContextValue | null>(
84
+ null,
85
+ );
86
+
87
+ export const useMailFreshness = (): MailFreshnessContextValue => {
88
+ const ctx = useContext(MailFreshnessContext);
89
+ if (!ctx) {
90
+ throw new Error(
91
+ "useMailFreshness must be used inside <MailFreshnessProvider>",
92
+ );
93
+ }
94
+ return ctx;
95
+ };
96
+
97
+ interface MailFreshnessProviderProps {
98
+ accountIds: readonly string[];
99
+ children: ReactNode;
100
+ }
101
+
102
+ /**
103
+ * Watches every open account's sync status once a minute and flags which
104
+ * accounts have grown since they were last shown to the user (#582 clause
105
+ * 4). The flag is sticky until acknowledged — a background tick never
106
+ * invalidates or reorders anything on its own, it only lights the dot on
107
+ * whichever `RefreshButton` reads this context, leaving the choice to load
108
+ * it with the user.
109
+ */
110
+ export function MailFreshnessProvider({
111
+ accountIds,
112
+ children,
113
+ }: MailFreshnessProviderProps) {
114
+ const queryClient = useQueryClient();
115
+ const { resultFolderIndex } = useMailContext();
116
+ const baselineRef = useRef(new Map<string, MailboxTotals>());
117
+ const seededRef = useRef(new Set<string>());
118
+ const [newMail, setNewMail] = useState<ReadonlySet<string>>(new Set());
119
+
120
+ const queries = useQueries({
121
+ queries: accountIds.map((accountId) => ({
122
+ ...syncOperationsGetSyncStatusOptions({ path: { accountId } }),
123
+ refetchInterval: FRESHNESS_POLL_MS,
124
+ staleTime: FRESHNESS_POLL_MS,
125
+ meta: { softError: true },
126
+ })),
127
+ });
128
+ const updatedKey = queries.map((query) => query.dataUpdatedAt).join(",");
129
+ const accountIdsKey = accountIds.join(",");
130
+
131
+ // biome-ignore lint/correctness/useExhaustiveDependencies: updatedKey is the real trigger (each query's own dataUpdatedAt); queries and resultFolderIndex are read fresh each run, not values this effect reacts to on their own.
132
+ useEffect(() => {
133
+ let changed = false;
134
+ const next = new Set(newMail);
135
+ accountIds.forEach((accountId, index) => {
136
+ const data = queries[index]?.data;
137
+ if (!data) return;
138
+ const current = totalsFrom(data.mailboxes ?? [], resultFolderIndex);
139
+ if (!seededRef.current.has(accountId)) {
140
+ seededRef.current.add(accountId);
141
+ baselineRef.current.set(accountId, current);
142
+ return;
143
+ }
144
+ if (next.has(accountId)) return;
145
+ const baseline = baselineRef.current.get(accountId) ?? new Map();
146
+ if (hasGrown(baseline, current)) {
147
+ next.add(accountId);
148
+ changed = true;
149
+ }
150
+ });
151
+ if (changed) setNewMail(next);
152
+ }, [accountIdsKey, updatedKey]);
153
+
154
+ const hasNewMail = useCallback(
155
+ (ids: readonly string[]) => ids.some((id) => newMail.has(id)),
156
+ [newMail],
157
+ );
158
+
159
+ const acknowledge = useCallback(
160
+ (ids: readonly string[]) => {
161
+ setNewMail((prev) => {
162
+ if (ids.every((id) => !prev.has(id))) return prev;
163
+ const next = new Set(prev);
164
+ for (const id of ids) next.delete(id);
165
+ return next;
166
+ });
167
+ for (const accountId of ids) {
168
+ // `useRefreshControl` reads and writes this exact query key through
169
+ // `queryClient.fetchQuery` as its own wait settles, so this is the
170
+ // data that refresh just showed the user — never a round-old
171
+ // cached reading from this provider's own 60s interval.
172
+ const data = queryClient.getQueryData(
173
+ syncOperationsGetSyncStatusOptions({ path: { accountId } }).queryKey,
174
+ ) as
175
+ | { mailboxes?: readonly RemitImapMailboxSyncProgress[] }
176
+ | undefined;
177
+ baselineRef.current.set(
178
+ accountId,
179
+ totalsFrom(data?.mailboxes ?? [], resultFolderIndex),
180
+ );
181
+ seededRef.current.add(accountId);
182
+ }
183
+ },
184
+ [queryClient, resultFolderIndex],
185
+ );
186
+
187
+ // The sync-status queries update on their own cadence (this poll's 60s, or
188
+ // every 3s while `useInitialSyncProgress` shares the same key during an
189
+ // initial sync) — memoized so a tick that changes nothing about the
190
+ // flag set does not re-render every consumer down the tree.
191
+ const value = useMemo(
192
+ () => ({ hasNewMail, acknowledge }),
193
+ [hasNewMail, acknowledge],
194
+ );
195
+
196
+ return (
197
+ <MailFreshnessContext.Provider value={value}>
198
+ {children}
199
+ </MailFreshnessContext.Provider>
200
+ );
201
+ }
@@ -31,6 +31,7 @@ import { useResultFolderIndex } from "@/hooks/useResultFolderIndex";
31
31
  import { useStaleAccountSync } from "@/hooks/useStaleAccountSync";
32
32
  import { writeIntelligencePref } from "@/lib/intelligence-pref";
33
33
  import { MailContext } from "@/lib/mail-context";
34
+ import { MailFreshnessProvider } from "@/lib/mail-freshness";
34
35
  import {
35
36
  isBriefRoute,
36
37
  isFlaggedRoute,
@@ -238,6 +239,10 @@ function MailLayout() {
238
239
  }, []);
239
240
 
240
241
  const accounts = config?.accounts ?? [];
242
+ const accountIds = useMemo(
243
+ () => accounts.map((account) => account.accountId),
244
+ [accounts],
245
+ );
241
246
  const mailboxNameIndex = useMailboxNameIndex(accounts);
242
247
  const resultFolderIndex = useResultFolderIndex(accounts);
243
248
  const accountNameIndex = useMemo(
@@ -337,147 +342,149 @@ function MailLayout() {
337
342
 
338
343
  return (
339
344
  <MailContext.Provider value={mailContextValue}>
340
- {isConfigError ? (
341
- <div className="flex h-full items-center justify-center bg-canvas p-4">
342
- <ErrorState
343
- title="Couldn't load your account"
344
- error={configError}
345
- onRetry={() => {
346
- refetchConfig();
347
- }}
348
- />
349
- </div>
350
- ) : onBriefRoute ? (
351
- // Daily brief (/mail/) — no mailboxId param; same 3-pane layout as a
352
- // mailbox: an open message has an intelligence rail here too (#52).
353
- <BriefPane selectedMessageId={mobileSelectedMessageId}>
354
- {isSinglePane ? (
355
- <AppShellSlotted
356
- nav={navContent}
357
- list={<BriefPane.Phone />}
358
- intelligenceOpen={intelligenceOpen}
359
- overlay={overlayContent}
360
- skeleton={<AppShellSkeleton />}
361
- isLoading={isLoading || hasNoAccounts}
362
- {...navSlideOver}
363
- />
364
- ) : (
365
- <AppShellSlotted
366
- nav={navContent}
367
- topBar={topBar}
368
- list={<BriefPane.List />}
369
- reading={<BriefPane.Reading />}
370
- intelligence={<BriefPane.Intelligence />}
371
- intelligenceOpen={intelligenceOpen}
372
- hasThread={Boolean(mobileSelectedMessageId)}
373
- overlay={overlayContent}
374
- skeleton={<AppShellSkeleton />}
375
- isLoading={isLoading || hasNoAccounts}
376
- {...navSlideOver}
377
- />
378
- )}
379
- </BriefPane>
380
- ) : onFlaggedRoute ? (
381
- // Flagged virtual mailbox (/mail/flagged) — flat starred list across
382
- // accounts; same slots as the brief, intelligence rail included.
383
- <FlaggedPane selectedMessageId={mobileSelectedMessageId}>
384
- {isSinglePane ? (
385
- <AppShellSlotted
386
- nav={navContent}
387
- list={<FlaggedPane.Phone />}
388
- intelligenceOpen={intelligenceOpen}
389
- overlay={overlayContent}
390
- skeleton={<AppShellSkeleton />}
391
- isLoading={isLoading || hasNoAccounts}
392
- {...navSlideOver}
393
- />
394
- ) : (
395
- <AppShellSlotted
396
- nav={navContent}
397
- topBar={topBar}
398
- list={<FlaggedPane.List />}
399
- reading={<FlaggedPane.Reading />}
400
- intelligence={<FlaggedPane.Intelligence />}
401
- intelligenceOpen={intelligenceOpen}
402
- hasThread={Boolean(mobileSelectedMessageId)}
403
- overlay={overlayContent}
404
- skeleton={<AppShellSkeleton />}
405
- isLoading={isLoading || hasNoAccounts}
406
- {...navSlideOver}
407
- />
408
- )}
409
- </FlaggedPane>
410
- ) : mobileMailboxId ? (
411
- // Mailbox view (/mail/$mailboxId) — full 4-pane layout.
412
- <MailboxPane
413
- mailboxId={mobileMailboxId}
414
- selectedMessageId={mobileSelectedMessageId}
415
- >
416
- {isSinglePane ? (
417
- <AppShellSlotted
418
- nav={navContent}
419
- list={<MailboxPane.Phone />}
420
- intelligenceOpen={intelligenceOpen}
421
- overlay={overlayContent}
422
- skeleton={<AppShellSkeleton />}
423
- isLoading={isLoading || hasNoAccounts}
424
- {...navSlideOver}
425
- />
426
- ) : (
427
- <AppShellSlotted
428
- nav={navContent}
429
- topBar={topBar}
430
- list={<MailboxPane.List />}
431
- reading={<MailboxPane.Reading />}
432
- intelligence={<MailboxPane.Intelligence />}
433
- intelligenceOpen={intelligenceOpen}
434
- hasThread={Boolean(mobileSelectedMessageId)}
435
- overlay={overlayContent}
436
- skeleton={<AppShellSkeleton />}
437
- isLoading={isLoading || hasNoAccounts}
438
- {...navSlideOver}
439
- />
440
- )}
441
- </MailboxPane>
442
- ) : onOutboxRoute ? (
443
- // Outbox — 2-pane layout (list + reading, no intelligence).
444
- <OutboxPane>
445
- {isSinglePane ? (
446
- <AppShellSlotted
447
- nav={navContent}
448
- list={<OutboxPane.Phone />}
449
- intelligenceOpen={false}
450
- overlay={overlayContent}
451
- skeleton={<AppShellSkeleton />}
452
- isLoading={isLoading || hasNoAccounts}
453
- {...navSlideOver}
454
- />
455
- ) : (
456
- <AppShellSlotted
457
- nav={navContent}
458
- topBar={topBar}
459
- list={<OutboxPane.List />}
460
- reading={<OutboxPane.Reading />}
461
- intelligenceOpen={false}
462
- overlay={overlayContent}
463
- skeleton={<AppShellSkeleton />}
464
- isLoading={isLoading || hasNoAccounts}
465
- {...navSlideOver}
345
+ <MailFreshnessProvider accountIds={accountIds}>
346
+ {isConfigError ? (
347
+ <div className="flex h-full items-center justify-center bg-canvas p-4">
348
+ <ErrorState
349
+ title="Couldn't load your account"
350
+ error={configError}
351
+ onRetry={() => {
352
+ refetchConfig();
353
+ }}
466
354
  />
467
- )}
468
- </OutboxPane>
469
- ) : (
470
- // Fallback: transitioning or unrecognized route show skeleton.
471
- <AppShellSkeleton />
472
- )}
473
- <KeyboardShortcutsModal
474
- isOpen={showShortcuts}
475
- onClose={() => setShowShortcuts(false)}
476
- />
477
- {/* Outlet is required for TanStack Router to activate child routes.
355
+ </div>
356
+ ) : onBriefRoute ? (
357
+ // Daily brief (/mail/) no mailboxId param; same 3-pane layout as a
358
+ // mailbox: an open message has an intelligence rail here too (#52).
359
+ <BriefPane selectedMessageId={mobileSelectedMessageId}>
360
+ {isSinglePane ? (
361
+ <AppShellSlotted
362
+ nav={navContent}
363
+ list={<BriefPane.Phone />}
364
+ intelligenceOpen={intelligenceOpen}
365
+ overlay={overlayContent}
366
+ skeleton={<AppShellSkeleton />}
367
+ isLoading={isLoading || hasNoAccounts}
368
+ {...navSlideOver}
369
+ />
370
+ ) : (
371
+ <AppShellSlotted
372
+ nav={navContent}
373
+ topBar={topBar}
374
+ list={<BriefPane.List />}
375
+ reading={<BriefPane.Reading />}
376
+ intelligence={<BriefPane.Intelligence />}
377
+ intelligenceOpen={intelligenceOpen}
378
+ hasThread={Boolean(mobileSelectedMessageId)}
379
+ overlay={overlayContent}
380
+ skeleton={<AppShellSkeleton />}
381
+ isLoading={isLoading || hasNoAccounts}
382
+ {...navSlideOver}
383
+ />
384
+ )}
385
+ </BriefPane>
386
+ ) : onFlaggedRoute ? (
387
+ // Flagged virtual mailbox (/mail/flagged) — flat starred list across
388
+ // accounts; same slots as the brief, intelligence rail included.
389
+ <FlaggedPane selectedMessageId={mobileSelectedMessageId}>
390
+ {isSinglePane ? (
391
+ <AppShellSlotted
392
+ nav={navContent}
393
+ list={<FlaggedPane.Phone />}
394
+ intelligenceOpen={intelligenceOpen}
395
+ overlay={overlayContent}
396
+ skeleton={<AppShellSkeleton />}
397
+ isLoading={isLoading || hasNoAccounts}
398
+ {...navSlideOver}
399
+ />
400
+ ) : (
401
+ <AppShellSlotted
402
+ nav={navContent}
403
+ topBar={topBar}
404
+ list={<FlaggedPane.List />}
405
+ reading={<FlaggedPane.Reading />}
406
+ intelligence={<FlaggedPane.Intelligence />}
407
+ intelligenceOpen={intelligenceOpen}
408
+ hasThread={Boolean(mobileSelectedMessageId)}
409
+ overlay={overlayContent}
410
+ skeleton={<AppShellSkeleton />}
411
+ isLoading={isLoading || hasNoAccounts}
412
+ {...navSlideOver}
413
+ />
414
+ )}
415
+ </FlaggedPane>
416
+ ) : mobileMailboxId ? (
417
+ // Mailbox view (/mail/$mailboxId) — full 4-pane layout.
418
+ <MailboxPane
419
+ mailboxId={mobileMailboxId}
420
+ selectedMessageId={mobileSelectedMessageId}
421
+ >
422
+ {isSinglePane ? (
423
+ <AppShellSlotted
424
+ nav={navContent}
425
+ list={<MailboxPane.Phone />}
426
+ intelligenceOpen={intelligenceOpen}
427
+ overlay={overlayContent}
428
+ skeleton={<AppShellSkeleton />}
429
+ isLoading={isLoading || hasNoAccounts}
430
+ {...navSlideOver}
431
+ />
432
+ ) : (
433
+ <AppShellSlotted
434
+ nav={navContent}
435
+ topBar={topBar}
436
+ list={<MailboxPane.List />}
437
+ reading={<MailboxPane.Reading />}
438
+ intelligence={<MailboxPane.Intelligence />}
439
+ intelligenceOpen={intelligenceOpen}
440
+ hasThread={Boolean(mobileSelectedMessageId)}
441
+ overlay={overlayContent}
442
+ skeleton={<AppShellSkeleton />}
443
+ isLoading={isLoading || hasNoAccounts}
444
+ {...navSlideOver}
445
+ />
446
+ )}
447
+ </MailboxPane>
448
+ ) : onOutboxRoute ? (
449
+ // Outbox — 2-pane layout (list + reading, no intelligence).
450
+ <OutboxPane>
451
+ {isSinglePane ? (
452
+ <AppShellSlotted
453
+ nav={navContent}
454
+ list={<OutboxPane.Phone />}
455
+ intelligenceOpen={false}
456
+ overlay={overlayContent}
457
+ skeleton={<AppShellSkeleton />}
458
+ isLoading={isLoading || hasNoAccounts}
459
+ {...navSlideOver}
460
+ />
461
+ ) : (
462
+ <AppShellSlotted
463
+ nav={navContent}
464
+ topBar={topBar}
465
+ list={<OutboxPane.List />}
466
+ reading={<OutboxPane.Reading />}
467
+ intelligenceOpen={false}
468
+ overlay={overlayContent}
469
+ skeleton={<AppShellSkeleton />}
470
+ isLoading={isLoading || hasNoAccounts}
471
+ {...navSlideOver}
472
+ />
473
+ )}
474
+ </OutboxPane>
475
+ ) : (
476
+ // Fallback: transitioning or unrecognized route — show skeleton.
477
+ <AppShellSkeleton />
478
+ )}
479
+ <KeyboardShortcutsModal
480
+ isOpen={showShortcuts}
481
+ onClose={() => setShowShortcuts(false)}
482
+ />
483
+ {/* Outlet is required for TanStack Router to activate child routes.
478
484
  Routes that manage their own rendering (brief, mailbox, outbox) return
479
485
  null from their component — the parent shell owns the layout. */}
480
- <Outlet />
486
+ <Outlet />
487
+ </MailFreshnessProvider>
481
488
  </MailContext.Provider>
482
489
  );
483
490
  }