@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 +1 -1
- package/src/components/layout/MailTopBar.tsx +26 -1
- package/src/components/mail/DailyBrief.tsx +33 -0
- package/src/components/mail/MailListHeader.tsx +9 -0
- package/src/components/mail/MailViewChrome.tsx +4 -0
- package/src/components/mail/MailboxPane.tsx +40 -0
- package/src/hooks/useRefreshControl.ts +279 -0
- package/src/lib/mail-freshness.test.ts +116 -0
- package/src/lib/mail-freshness.tsx +201 -0
- package/src/routes/mail.tsx +145 -138
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
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
|
+
}
|
package/src/routes/mail.tsx
CHANGED
|
@@ -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
|
-
{
|
|
341
|
-
|
|
342
|
-
<
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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
|
-
|
|
486
|
+
<Outlet />
|
|
487
|
+
</MailFreshnessProvider>
|
|
481
488
|
</MailContext.Provider>
|
|
482
489
|
);
|
|
483
490
|
}
|