@remit/web-client 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/harness/build.mjs +84 -0
- package/harness/index.html +37 -0
- package/harness/vite-preset.ts +29 -0
- package/index.html +47 -0
- package/package.json +147 -0
- package/public/config.js +5 -0
- package/public/locales/de/common.json +1 -0
- package/public/locales/de/errors.json +1 -0
- package/public/locales/de/mail.json +13 -0
- package/public/locales/de/settings.json +1 -0
- package/public/locales/en/common.json +26 -0
- package/public/locales/en/errors.json +1 -0
- package/public/locales/en/mail.json +69 -0
- package/public/locales/en/settings.json +1 -0
- package/public/locales/fr/common.json +1 -0
- package/public/locales/fr/errors.json +1 -0
- package/public/locales/fr/mail.json +13 -0
- package/public/locales/fr/settings.json +1 -0
- package/public/locales/nl/common.json +1 -0
- package/public/locales/nl/errors.json +1 -0
- package/public/locales/nl/mail.json +13 -0
- package/public/locales/nl/settings.json +1 -0
- package/src/auth/AccountMenu.tsx +58 -0
- package/src/auth/AccountSession.tsx +19 -0
- package/src/auth/BetterAuthShell.tsx +198 -0
- package/src/auth/account-menu-mode.test.ts +64 -0
- package/src/auth/account-menu-mode.ts +28 -0
- package/src/auth/amplify-config.test.ts +158 -0
- package/src/auth/amplify-config.ts +60 -0
- package/src/auth/auth-error.test.ts +109 -0
- package/src/auth/auth-error.ts +107 -0
- package/src/auth/auth-interceptor.test.ts +86 -0
- package/src/auth/auth-interceptor.ts +23 -0
- package/src/auth/auth.css +334 -0
- package/src/auth/better-auth/BetterAuthAccount.tsx +25 -0
- package/src/auth/better-auth-config.ts +85 -0
- package/src/auth/better-auth-provider.tsx +20 -0
- package/src/auth/cognito/CognitoAccount.tsx +48 -0
- package/src/auth/cognito/CognitoShell.tsx +266 -0
- package/src/auth/cognito/cognito-token.test.ts +110 -0
- package/src/auth/cognito/cognito-token.ts +47 -0
- package/src/auth/cognito/cognito.css +21 -0
- package/src/auth/cognito-provider.tsx +22 -0
- package/src/auth/combined-provider.tsx +42 -0
- package/src/auth/composition.test.ts +136 -0
- package/src/auth/provider.tsx +63 -0
- package/src/auth/sign-out-visibility.test.ts +40 -0
- package/src/auth/sign-out-visibility.ts +10 -0
- package/src/auth/sign-up-disabled.test.ts +23 -0
- package/src/components/compose/AddressField.tsx +237 -0
- package/src/components/compose/ComposeBody.tsx +36 -0
- package/src/components/compose/ComposeForm.tsx +676 -0
- package/src/components/compose/ComposeProvider.tsx +133 -0
- package/src/components/compose/ComposeSmtpMissingBanner.tsx +49 -0
- package/src/components/compose/FromSelector.tsx +67 -0
- package/src/components/compose/FullCompose.tsx +82 -0
- package/src/components/compose/InlineCompose.tsx +29 -0
- package/src/components/compose/MobileComposeSheet.tsx +152 -0
- package/src/components/compose/PlateEditor.tsx +86 -0
- package/src/components/compose/PlateToolbar.tsx +110 -0
- package/src/components/compose/SubjectField.tsx +19 -0
- package/src/components/compose/draft-discard-banner.test.ts +76 -0
- package/src/components/compose/index.ts +6 -0
- package/src/components/compose/sanitize-quote-html.ts +23 -0
- package/src/components/layout/AppShellSkeleton.tsx +53 -0
- package/src/components/layout/ComposeFab.tsx +86 -0
- package/src/components/layout/Drawer.tsx +105 -0
- package/src/components/mail/AutoMovedIndicator.tsx +48 -0
- package/src/components/mail/BlockedImagesNotice.render.test.ts +65 -0
- package/src/components/mail/BlockedImagesNotice.tsx +41 -0
- package/src/components/mail/BriefPane.tsx +289 -0
- package/src/components/mail/ConversationView.tsx +462 -0
- package/src/components/mail/DailyBrief.tsx +451 -0
- package/src/components/mail/DraftsView.tsx +279 -0
- package/src/components/mail/FlaggedList.tsx +170 -0
- package/src/components/mail/FlaggedPane.tsx +217 -0
- package/src/components/mail/IntelligencePane.test.ts +123 -0
- package/src/components/mail/IntelligencePane.tsx +441 -0
- package/src/components/mail/MailListHeader.tsx +187 -0
- package/src/components/mail/MailNav.tsx +70 -0
- package/src/components/mail/MailSidebarAdapter.tsx +351 -0
- package/src/components/mail/MailViewChrome.tsx +95 -0
- package/src/components/mail/MailboxPane.tsx +1219 -0
- package/src/components/mail/MessageActionMenu.tsx +368 -0
- package/src/components/mail/MessageBody.tsx +207 -0
- package/src/components/mail/MessageBodyErrorBanner.render.test.ts +85 -0
- package/src/components/mail/MessageBodyErrorBanner.test.ts +86 -0
- package/src/components/mail/MessageBodyErrorBanner.tsx +90 -0
- package/src/components/mail/MessageCard.tsx +401 -0
- package/src/components/mail/MessageDetail.tsx +153 -0
- package/src/components/mail/MessageList.tsx +804 -0
- package/src/components/mail/MessageListItem.tsx +267 -0
- package/src/components/mail/MessageToolbar.render.test.ts +57 -0
- package/src/components/mail/MessageToolbar.tsx +166 -0
- package/src/components/mail/MobileMessageBar.tsx +142 -0
- package/src/components/mail/MoveToTrigger.tsx +250 -0
- package/src/components/mail/NavMenuButton.tsx +25 -0
- package/src/components/mail/OutboxPane.tsx +531 -0
- package/src/components/mail/PullToRefresh.tsx +24 -0
- package/src/components/mail/RawMessageView.tsx +84 -0
- package/src/components/mail/ReadingPaneEmpty.render.test.ts +42 -0
- package/src/components/mail/SelectionToolbar.tsx +139 -0
- package/src/components/mail/SpamRescue.render.test.ts +35 -0
- package/src/components/mail/SpamRescue.tsx +138 -0
- package/src/components/mail/SwipeableMessageRow.tsx +148 -0
- package/src/components/mail/message-body-error-banner-content.ts +87 -0
- package/src/components/mail/organize/OrganizeDialog.render.test.ts +37 -0
- package/src/components/mail/organize/OrganizeDialog.tsx +83 -0
- package/src/components/mail/organize/OrganizePanel.render.test.ts +102 -0
- package/src/components/mail/organize/OrganizePanel.tsx +344 -0
- package/src/components/onboarding/OnboardingWizard.tsx +1547 -0
- package/src/components/settings/AccountFormPanel.test.ts +72 -0
- package/src/components/settings/AccountFormPanel.tsx +942 -0
- package/src/components/settings/DangerZone.tsx +30 -0
- package/src/components/settings/DeleteAccountDialog.render.test.ts +47 -0
- package/src/components/settings/DeleteAccountDialog.tsx +224 -0
- package/src/components/settings/FiltersList.render.test.ts +71 -0
- package/src/components/settings/FiltersList.tsx +93 -0
- package/src/components/settings/account-form-helpers.test.ts +172 -0
- package/src/components/settings/account-form-helpers.ts +105 -0
- package/src/components/ui/AppVersion.tsx +50 -0
- package/src/components/ui/BugReportButton.tsx +45 -0
- package/src/components/ui/ConfirmDialog.tsx +125 -0
- package/src/components/ui/DropdownMenu.tsx +105 -0
- package/src/components/ui/EmptyState.tsx +11 -0
- package/src/components/ui/ErrorBanner.tsx +88 -0
- package/src/components/ui/ErrorBannerProvider.tsx +77 -0
- package/src/components/ui/ErrorBannerStack.tsx +34 -0
- package/src/components/ui/ErrorState.tsx +80 -0
- package/src/components/ui/FatalErrorOverlay.render.test.ts +66 -0
- package/src/components/ui/FatalErrorOverlay.tsx +151 -0
- package/src/components/ui/KeyboardShortcutsModal.tsx +120 -0
- package/src/components/ui/SlidePanel.tsx +72 -0
- package/src/components/ui/error-banners.test.ts +207 -0
- package/src/components/ui/error-banners.ts +111 -0
- package/src/hooks/queries/keys.ts +38 -0
- package/src/hooks/useArchiveMailbox.ts +104 -0
- package/src/hooks/useAutoMovedBadge.ts +77 -0
- package/src/hooks/useCurrentMailboxName.ts +99 -0
- package/src/hooks/useDebouncedValue.ts +12 -0
- package/src/hooks/useDeleteMessages.test.ts +103 -0
- package/src/hooks/useDeleteMessages.ts +226 -0
- package/src/hooks/useFilters.ts +112 -0
- package/src/hooks/useIntelligenceData.test.ts +269 -0
- package/src/hooks/useIntelligenceData.ts +284 -0
- package/src/hooks/useIsDark.ts +27 -0
- package/src/hooks/useKeyboardNavigation.ts +192 -0
- package/src/hooks/useLayoutTier.test.ts +47 -0
- package/src/hooks/useLayoutTier.ts +44 -0
- package/src/hooks/useLongPress.test.ts +291 -0
- package/src/hooks/useLongPress.ts +87 -0
- package/src/hooks/useMailboxAccount.ts +62 -0
- package/src/hooks/useMailboxNameIndex.ts +32 -0
- package/src/hooks/useMarkAsRead.test.ts +165 -0
- package/src/hooks/useMarkAsRead.ts +319 -0
- package/src/hooks/useMediaQuery.ts +39 -0
- package/src/hooks/useMessageBodyContent.test.ts +370 -0
- package/src/hooks/useMessageBodyContent.ts +380 -0
- package/src/hooks/useMoveMessages.test.ts +63 -0
- package/src/hooks/useMoveMessages.ts +221 -0
- package/src/hooks/useOrganizeJob.ts +90 -0
- package/src/hooks/useOrganizePreview.ts +40 -0
- package/src/hooks/useRescueCandidates.test.ts +217 -0
- package/src/hooks/useRescueCandidates.ts +48 -0
- package/src/hooks/useSaveDraft.ts +118 -0
- package/src/hooks/useSelection.test.ts +75 -0
- package/src/hooks/useSelection.ts +211 -0
- package/src/hooks/useSemanticSearch.ts +94 -0
- package/src/hooks/useSignature.ts +69 -0
- package/src/hooks/useStaleAccountSync.test.ts +211 -0
- package/src/hooks/useStaleAccountSync.ts +246 -0
- package/src/hooks/useSwipeNavigation.test.ts +35 -0
- package/src/hooks/useSwipeNavigation.ts +99 -0
- package/src/hooks/useToggleStar.ts +187 -0
- package/src/hooks/useToggleTrusted.test.ts +102 -0
- package/src/hooks/useToggleTrusted.ts +161 -0
- package/src/hooks/useTriageKeyboard.ts +103 -0
- package/src/hooks/useTriggerSync.test.ts +24 -0
- package/src/hooks/useTriggerSync.ts +58 -0
- package/src/hooks/useUpdateAddressFlags.ts +125 -0
- package/src/hooks/useVisualViewport.test.ts +127 -0
- package/src/hooks/useVisualViewport.ts +51 -0
- package/src/index.css +19 -0
- package/src/lib/account-order.test.ts +84 -0
- package/src/lib/account-order.ts +13 -0
- package/src/lib/adjacent-message.test.ts +43 -0
- package/src/lib/adjacent-message.ts +20 -0
- package/src/lib/api.ts +82 -0
- package/src/lib/app-info.ts +15 -0
- package/src/lib/auto-moved.test.ts +140 -0
- package/src/lib/auto-moved.ts +68 -0
- package/src/lib/autodiscovery.test.ts +94 -0
- package/src/lib/autodiscovery.ts +354 -0
- package/src/lib/brief.test.ts +366 -0
- package/src/lib/brief.ts +180 -0
- package/src/lib/bug-report.test.ts +213 -0
- package/src/lib/bug-report.ts +176 -0
- package/src/lib/client.ts +36 -0
- package/src/lib/console-errors.test.ts +64 -0
- package/src/lib/console-errors.ts +92 -0
- package/src/lib/conversation-target.test.ts +68 -0
- package/src/lib/conversation-target.ts +59 -0
- package/src/lib/display-category.ts +20 -0
- package/src/lib/drafts.test.ts +241 -0
- package/src/lib/drafts.ts +121 -0
- package/src/lib/error-classifier.test.ts +133 -0
- package/src/lib/error-classifier.ts +96 -0
- package/src/lib/fatal-error.test.ts +97 -0
- package/src/lib/fatal-error.ts +165 -0
- package/src/lib/folder-roles.test.ts +113 -0
- package/src/lib/folder-roles.ts +112 -0
- package/src/lib/format.test.ts +115 -0
- package/src/lib/format.ts +198 -0
- package/src/lib/i18n.ts +36 -0
- package/src/lib/intelligence-pref.test.ts +57 -0
- package/src/lib/intelligence-pref.ts +25 -0
- package/src/lib/keymap-dispatch.test.ts +178 -0
- package/src/lib/keymap-dispatch.ts +187 -0
- package/src/lib/keymap.test.ts +49 -0
- package/src/lib/keymap.ts +178 -0
- package/src/lib/mail-context.ts +64 -0
- package/src/lib/mail-route.test.ts +80 -0
- package/src/lib/mail-route.ts +44 -0
- package/src/lib/message-body-source.test.ts +98 -0
- package/src/lib/message-body-source.ts +48 -0
- package/src/lib/move-targets.test.ts +195 -0
- package/src/lib/move-targets.ts +74 -0
- package/src/lib/onboarding-completion.test.ts +64 -0
- package/src/lib/onboarding-completion.ts +23 -0
- package/src/lib/organize/filter-status.test.ts +99 -0
- package/src/lib/organize/filter-status.ts +86 -0
- package/src/lib/organize/organize-copy.test.ts +93 -0
- package/src/lib/organize/organize-copy.ts +61 -0
- package/src/lib/organize/organize-model.test.ts +104 -0
- package/src/lib/organize/organize-model.ts +105 -0
- package/src/lib/organize/organize-poll.test.ts +37 -0
- package/src/lib/organize/organize-poll.ts +28 -0
- package/src/lib/outbox-status.test.ts +64 -0
- package/src/lib/outbox-status.ts +75 -0
- package/src/lib/plate-serializer.ts +164 -0
- package/src/lib/provider-presets.test.ts +96 -0
- package/src/lib/provider-presets.ts +81 -0
- package/src/lib/query-error-handler.test.ts +144 -0
- package/src/lib/query-error-handler.ts +34 -0
- package/src/lib/query-escalation.integration.test.ts +138 -0
- package/src/lib/recent-searches.test.ts +53 -0
- package/src/lib/recent-searches.ts +34 -0
- package/src/lib/rescue-candidates.test.ts +94 -0
- package/src/lib/rescue-candidates.ts +35 -0
- package/src/lib/rescue-telemetry.test.ts +75 -0
- package/src/lib/rescue-telemetry.ts +55 -0
- package/src/lib/rum-adapter.test.ts +299 -0
- package/src/lib/rum-adapter.ts +107 -0
- package/src/lib/saved-searches.test.ts +80 -0
- package/src/lib/saved-searches.ts +52 -0
- package/src/lib/search-pending.test.ts +61 -0
- package/src/lib/search-pending.ts +24 -0
- package/src/lib/search-query.test.ts +35 -0
- package/src/lib/search-query.ts +11 -0
- package/src/lib/search-result.test.ts +160 -0
- package/src/lib/search-result.ts +102 -0
- package/src/lib/search-schemas.ts +34 -0
- package/src/lib/search-token-index.test.ts +112 -0
- package/src/lib/search-token-index.ts +56 -0
- package/src/lib/search-tokens.test.ts +218 -0
- package/src/lib/search-tokens.ts +133 -0
- package/src/lib/telemetry-context.tsx +9 -0
- package/src/lib/telemetry.ts +17 -0
- package/src/lib/theme-preference.ts +46 -0
- package/src/lib/theme.ts +33 -0
- package/src/lib/utils.ts +4 -0
- package/src/main.tsx +10 -0
- package/src/routeTree.gen.ts +392 -0
- package/src/router.test.ts +81 -0
- package/src/router.tsx +31 -0
- package/src/routes/__root.tsx +69 -0
- package/src/routes/index.tsx +7 -0
- package/src/routes/mail/$mailboxId.tsx +27 -0
- package/src/routes/mail/flagged.tsx +37 -0
- package/src/routes/mail/index.tsx +41 -0
- package/src/routes/mail/outbox.tsx +13 -0
- package/src/routes/mail.tsx +413 -0
- package/src/routes/onboarding.tsx +35 -0
- package/src/routes/settings/-accounts.test.ts +65 -0
- package/src/routes/settings/accounts.tsx +542 -0
- package/src/routes/settings/advanced.tsx +55 -0
- package/src/routes/settings/appearance.tsx +140 -0
- package/src/routes/settings/filters.tsx +141 -0
- package/src/routes/settings/folders.tsx +216 -0
- package/src/routes/settings/index.tsx +5 -0
- package/src/routes/settings/senders.tsx +466 -0
- package/src/routes/settings/suggested-vips.tsx +10 -0
- package/src/routes/settings.tsx +66 -0
- package/src/runtime-config.ts +75 -0
- package/src/shell/index.tsx +98 -0
- package/src/types/index.ts +73 -0
- package/src/vite-env.d.ts +4 -0
- package/vite.base.ts +43 -0
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { mailboxOperationsListMailboxesQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
2
|
+
import { syncOperationsTriggerSync } from "@remit/api-http-client/sdk.gen.ts";
|
|
3
|
+
import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
|
|
4
|
+
import { type QueryClient, useQueryClient } from "@tanstack/react-query";
|
|
5
|
+
import pMap from "p-map";
|
|
6
|
+
import { useEffect, useRef } from "react";
|
|
7
|
+
import { shouldEscalate } from "@/lib/error-classifier";
|
|
8
|
+
import { reportFatalError } from "@/lib/fatal-error";
|
|
9
|
+
import { getRuntimeConfig } from "@/runtime-config";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Mailboxes are considered stale 15 minutes after the last successful sync.
|
|
13
|
+
* Exposed for unit tests and possible future tuning.
|
|
14
|
+
*/
|
|
15
|
+
export const STALENESS_THRESHOLD_MS = 15 * 60 * 1000;
|
|
16
|
+
|
|
17
|
+
const DEFAULT_POLL_INTERVAL_SECONDS = 5 * 60;
|
|
18
|
+
|
|
19
|
+
const parsePositiveIntSeconds = (
|
|
20
|
+
raw: string | undefined,
|
|
21
|
+
fallback: number,
|
|
22
|
+
): number => {
|
|
23
|
+
if (!raw) return fallback;
|
|
24
|
+
const parsed = Number.parseInt(raw, 10);
|
|
25
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Client-side online-poll interval (#1251): while an account's mail is open,
|
|
30
|
+
* the tab re-triggers the same sync the pull-to-refresh path uses on this
|
|
31
|
+
* cadence — the replacement for the server's removed "online tier".
|
|
32
|
+
*/
|
|
33
|
+
export const POLL_INTERVAL_MS =
|
|
34
|
+
parsePositiveIntSeconds(
|
|
35
|
+
getRuntimeConfig().mailboxPollIntervalSeconds,
|
|
36
|
+
DEFAULT_POLL_INTERVAL_SECONDS,
|
|
37
|
+
) * 1000;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Bounded concurrency for the per-poll fan-out across open accounts —
|
|
41
|
+
* mirrors `SCHEDULER_ENQUEUE_CONCURRENCY` in
|
|
42
|
+
* remit-imap-worker/src/scheduler/config.ts (never an unbounded
|
|
43
|
+
* `Promise.all`, see doc/rules/coding-standards.md). A browser tab realistically
|
|
44
|
+
* has a handful of accounts open at once, so this is a ceiling, not a tuned
|
|
45
|
+
* throughput target.
|
|
46
|
+
*/
|
|
47
|
+
export const POLL_CONCURRENCY = 5;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Module-level guard: which accounts have already had a background sync
|
|
51
|
+
* fired in this browser tab's session. Survives layout re-mounts (route
|
|
52
|
+
* changes) so re-mounting MailLayout does not spam SQS.
|
|
53
|
+
*/
|
|
54
|
+
const triggeredAccountIds = new Set<string>();
|
|
55
|
+
|
|
56
|
+
interface AccountLike {
|
|
57
|
+
accountId: string;
|
|
58
|
+
lastSyncAt?: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Pure selector: given a list of accounts and the current time, return
|
|
63
|
+
* the accountIds whose mailbox-list is stale and should be refreshed.
|
|
64
|
+
* `null`/`undefined` `lastSyncAt` is treated as stale unconditionally.
|
|
65
|
+
*/
|
|
66
|
+
export const selectStaleAccountIds = (
|
|
67
|
+
accounts: AccountLike[],
|
|
68
|
+
now: number,
|
|
69
|
+
thresholdMs: number = STALENESS_THRESHOLD_MS,
|
|
70
|
+
): string[] =>
|
|
71
|
+
accounts
|
|
72
|
+
.filter((account) => {
|
|
73
|
+
const last = account.lastSyncAt;
|
|
74
|
+
if (last === undefined || last === null) return true;
|
|
75
|
+
return now - last > thresholdMs;
|
|
76
|
+
})
|
|
77
|
+
.map((account) => account.accountId);
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Pure predicate: has at least one full poll interval elapsed since the last
|
|
81
|
+
* poll? Shared by the recurring timer and the visibility-regain catch-up
|
|
82
|
+
* check so both use the exact same due-ness rule.
|
|
83
|
+
*/
|
|
84
|
+
export const hasPollIntervalElapsed = (
|
|
85
|
+
now: number,
|
|
86
|
+
lastPollAt: number,
|
|
87
|
+
intervalMs: number = POLL_INTERVAL_MS,
|
|
88
|
+
): boolean => now - lastPollAt >= intervalMs;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Pure selector: drop any accountId already mid-flight so a poll tick never
|
|
92
|
+
* stacks a second request onto one still in progress.
|
|
93
|
+
*/
|
|
94
|
+
export const selectPollableAccountIds = (
|
|
95
|
+
accountIds: string[],
|
|
96
|
+
inFlight: ReadonlySet<string>,
|
|
97
|
+
): string[] => accountIds.filter((id) => !inFlight.has(id));
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Test-only helpers. Not exported from the public hook surface; consumers
|
|
101
|
+
* should never reach into the module-level Set directly.
|
|
102
|
+
*/
|
|
103
|
+
export const __resetStaleAccountSyncGuard = (): void => {
|
|
104
|
+
triggeredAccountIds.clear();
|
|
105
|
+
};
|
|
106
|
+
export const __peekStaleAccountSyncGuard = (): ReadonlySet<string> =>
|
|
107
|
+
triggeredAccountIds;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Handle a failed background mailbox-sync trigger. This is a best-effort, direct
|
|
111
|
+
* SDK probe (not a React Query call), so we apply the same fail-fast decision by
|
|
112
|
+
* hand with the `softError` meta the call site owns: its non-5xx failures (the
|
|
113
|
+
* account's own 4xx, a statusless connectivity blip) stay soft — we drop the
|
|
114
|
+
* per-account guard so a later remount can retry, then log and move on. But a
|
|
115
|
+
* 5xx is OUR API broken, and per the contract (#1059) that always escalates to
|
|
116
|
+
* the full-screen overlay — even from a background trigger.
|
|
117
|
+
*/
|
|
118
|
+
export const handleBackgroundSyncFailure = (
|
|
119
|
+
accountId: string,
|
|
120
|
+
error: unknown,
|
|
121
|
+
): void => {
|
|
122
|
+
triggeredAccountIds.delete(accountId);
|
|
123
|
+
if (shouldEscalate(error, { softError: true })) {
|
|
124
|
+
reportFatalError(error);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
console.warn("[remit] background mailbox sync failed", {
|
|
128
|
+
accountId,
|
|
129
|
+
error,
|
|
130
|
+
});
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Trigger the same sync the pull-to-refresh path uses and invalidate the
|
|
135
|
+
* mailbox-list query on success. `throwOnError: true` so a server failure
|
|
136
|
+
* rejects instead of silently resolving with `{ error }` (the default),
|
|
137
|
+
* which would let a 500 look like a successful sync and invalidate queries
|
|
138
|
+
* anyway.
|
|
139
|
+
*/
|
|
140
|
+
const runBackgroundSync = (
|
|
141
|
+
accountId: string,
|
|
142
|
+
queryClient: QueryClient,
|
|
143
|
+
): Promise<void> =>
|
|
144
|
+
syncOperationsTriggerSync({ path: { accountId }, throwOnError: true }).then(
|
|
145
|
+
() => {
|
|
146
|
+
queryClient.invalidateQueries({
|
|
147
|
+
queryKey: mailboxOperationsListMailboxesQueryKey({
|
|
148
|
+
path: { accountId },
|
|
149
|
+
}),
|
|
150
|
+
});
|
|
151
|
+
},
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Auto-trigger a background mailbox-list sync for every stale account the
|
|
156
|
+
* first time MailLayout mounts in a session, then keep it fresh with a
|
|
157
|
+
* recurring online poll for as long as the mail app stays open (#1251) —
|
|
158
|
+
* the client-side replacement for the server's removed "online" sync tier.
|
|
159
|
+
* Fire-and-forget: does not block render, and silently logs failures (this
|
|
160
|
+
* is a best-effort background refresh — the user-visible UI for triggering
|
|
161
|
+
* is the "Refresh mailboxes" button in Settings).
|
|
162
|
+
*/
|
|
163
|
+
export const useStaleAccountSync = (
|
|
164
|
+
accounts: RemitImapAccountResponse[],
|
|
165
|
+
): void => {
|
|
166
|
+
const queryClient = useQueryClient();
|
|
167
|
+
// Hold the latest accounts in a ref so both effects can read them without
|
|
168
|
+
// re-running on every render; they re-run only when the joined accountId
|
|
169
|
+
// list actually changes (account added / removed).
|
|
170
|
+
const accountsRef = useRef(accounts);
|
|
171
|
+
accountsRef.current = accounts;
|
|
172
|
+
const stableKey = accounts.map((a) => a.accountId).join(",");
|
|
173
|
+
|
|
174
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: stableKey is a serialized account list used to trigger re-sync on account changes; adding it would cause issues without also referencing accountsRef
|
|
175
|
+
useEffect(() => {
|
|
176
|
+
const now = Date.now();
|
|
177
|
+
const stale = selectStaleAccountIds(accountsRef.current, now);
|
|
178
|
+
const fresh = stale.filter((id) => !triggeredAccountIds.has(id));
|
|
179
|
+
if (fresh.length === 0) return;
|
|
180
|
+
|
|
181
|
+
for (const accountId of fresh) {
|
|
182
|
+
triggeredAccountIds.add(accountId);
|
|
183
|
+
runBackgroundSync(accountId, queryClient).catch((err: unknown) => {
|
|
184
|
+
handleBackgroundSyncFailure(accountId, err);
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}, [stableKey, queryClient]);
|
|
188
|
+
|
|
189
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: stableKey re-runs the poll loop when the account list changes; accountsRef supplies the live list each tick
|
|
190
|
+
useEffect(() => {
|
|
191
|
+
let lastPollAt = Date.now();
|
|
192
|
+
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
193
|
+
const inFlight = new Set<string>();
|
|
194
|
+
|
|
195
|
+
const poll = () => {
|
|
196
|
+
lastPollAt = Date.now();
|
|
197
|
+
const ids = selectPollableAccountIds(
|
|
198
|
+
accountsRef.current.map((a) => a.accountId),
|
|
199
|
+
inFlight,
|
|
200
|
+
);
|
|
201
|
+
// Mark every selected id in flight before dispatch (not as pMap
|
|
202
|
+
// starts each one) so a concurrency-queued id still counts as
|
|
203
|
+
// "already polling" for the next tick's `selectPollableAccountIds`
|
|
204
|
+
// check — no stacked requests even while waiting for a free slot.
|
|
205
|
+
for (const accountId of ids) inFlight.add(accountId);
|
|
206
|
+
void pMap(
|
|
207
|
+
ids,
|
|
208
|
+
(accountId) =>
|
|
209
|
+
runBackgroundSync(accountId, queryClient)
|
|
210
|
+
.catch((err: unknown) => {
|
|
211
|
+
handleBackgroundSyncFailure(accountId, err);
|
|
212
|
+
})
|
|
213
|
+
.finally(() => {
|
|
214
|
+
inFlight.delete(accountId);
|
|
215
|
+
}),
|
|
216
|
+
{ concurrency: POLL_CONCURRENCY },
|
|
217
|
+
);
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const tick = () => {
|
|
221
|
+
if (document.visibilityState === "visible") {
|
|
222
|
+
poll();
|
|
223
|
+
timeoutId = setTimeout(tick, POLL_INTERVAL_MS);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
// Hidden: don't reschedule. `handleVisibilityChange` resumes the loop
|
|
227
|
+
// (with an immediate catch-up poll) once the tab is visible again.
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const handleVisibilityChange = () => {
|
|
231
|
+
if (document.visibilityState !== "visible") return;
|
|
232
|
+
if (!hasPollIntervalElapsed(Date.now(), lastPollAt)) return;
|
|
233
|
+
if (timeoutId !== undefined) clearTimeout(timeoutId);
|
|
234
|
+
poll();
|
|
235
|
+
timeoutId = setTimeout(tick, POLL_INTERVAL_MS);
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
timeoutId = setTimeout(tick, POLL_INTERVAL_MS);
|
|
239
|
+
document.addEventListener("visibilitychange", handleVisibilityChange);
|
|
240
|
+
|
|
241
|
+
return () => {
|
|
242
|
+
if (timeoutId !== undefined) clearTimeout(timeoutId);
|
|
243
|
+
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
|
244
|
+
};
|
|
245
|
+
}, [stableKey, queryClient]);
|
|
246
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import assert from "node:assert";
|
|
2
|
+
import { describe, test } from "node:test";
|
|
3
|
+
import { resolveSwipe } from "./useSwipeNavigation.js";
|
|
4
|
+
|
|
5
|
+
const THRESHOLD = 60;
|
|
6
|
+
|
|
7
|
+
describe("resolveSwipe", () => {
|
|
8
|
+
test("classifies a left swipe (next) when dragging right→left", () => {
|
|
9
|
+
assert.strictEqual(resolveSwipe(-80, 0, THRESHOLD).direction, "left");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("classifies a right swipe (previous) when dragging left→right", () => {
|
|
13
|
+
assert.strictEqual(resolveSwipe(80, 0, THRESHOLD).direction, "right");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("ignores a gesture shorter than the threshold", () => {
|
|
17
|
+
assert.strictEqual(resolveSwipe(-40, 0, THRESHOLD).direction, null);
|
|
18
|
+
assert.strictEqual(resolveSwipe(59, 0, THRESHOLD).direction, null);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("ignores a mostly-vertical gesture so it never hijacks scroll", () => {
|
|
22
|
+
// Long horizontal travel but even longer vertical travel: this is a scroll.
|
|
23
|
+
assert.strictEqual(resolveSwipe(70, 200, THRESHOLD).direction, null);
|
|
24
|
+
assert.strictEqual(resolveSwipe(-70, -200, THRESHOLD).direction, null);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("accepts a horizontal swipe with mild vertical drift", () => {
|
|
28
|
+
assert.strictEqual(resolveSwipe(-100, 20, THRESHOLD).direction, "left");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("rejects a diagonal gesture that is not horizontally dominant", () => {
|
|
32
|
+
// dx 70, dy 60: not 1.5x dominant, so treated as ambiguous → no swipe.
|
|
33
|
+
assert.strictEqual(resolveSwipe(70, 60, THRESHOLD).direction, null);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { useCallback, useRef } from "react";
|
|
2
|
+
|
|
3
|
+
export type SwipeDirection = "left" | "right";
|
|
4
|
+
|
|
5
|
+
interface UseSwipeNavigationOptions {
|
|
6
|
+
/**
|
|
7
|
+
* Swipe left (content advances, finger moves right→left): go to the NEXT
|
|
8
|
+
* item — the platform convention for paging through content (photos, mail).
|
|
9
|
+
*/
|
|
10
|
+
onSwipeLeft?: () => void;
|
|
11
|
+
/** Swipe right: go to the PREVIOUS item. */
|
|
12
|
+
onSwipeRight?: () => void;
|
|
13
|
+
/** Minimum horizontal travel (px) before a swipe registers. */
|
|
14
|
+
thresholdPx?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface SwipeHandlers {
|
|
18
|
+
onPointerDown: (e: React.PointerEvent) => void;
|
|
19
|
+
onPointerMove: (e: React.PointerEvent) => void;
|
|
20
|
+
onPointerUp: (e: React.PointerEvent) => void;
|
|
21
|
+
onPointerCancel: () => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const DEFAULT_THRESHOLD = 60;
|
|
25
|
+
/**
|
|
26
|
+
* A gesture only counts as a horizontal swipe when its horizontal travel
|
|
27
|
+
* dominates the vertical. Anything more vertical than this is the user
|
|
28
|
+
* scrolling the message body, and we leave it alone.
|
|
29
|
+
*/
|
|
30
|
+
const HORIZONTAL_DOMINANCE = 1.5;
|
|
31
|
+
|
|
32
|
+
export interface SwipeResolution {
|
|
33
|
+
direction: SwipeDirection | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Pure swipe classifier. Returns the swipe direction, or `null` when the
|
|
38
|
+
* gesture is too short or too vertical to count as a horizontal swipe (so it
|
|
39
|
+
* never hijacks vertical scrolling).
|
|
40
|
+
*/
|
|
41
|
+
export const resolveSwipe = (
|
|
42
|
+
dx: number,
|
|
43
|
+
dy: number,
|
|
44
|
+
thresholdPx: number,
|
|
45
|
+
): SwipeResolution => {
|
|
46
|
+
const absX = Math.abs(dx);
|
|
47
|
+
const absY = Math.abs(dy);
|
|
48
|
+
if (absX < thresholdPx) return { direction: null };
|
|
49
|
+
if (absX < absY * HORIZONTAL_DOMINANCE) return { direction: null };
|
|
50
|
+
return { direction: dx < 0 ? "left" : "right" };
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Detects horizontal swipe gestures from pointer events and routes them to
|
|
55
|
+
* next/previous callbacks. Only reacts to touch/pen pointers so mouse drags on
|
|
56
|
+
* desktop never trigger navigation. Vertical scroll is left untouched: a
|
|
57
|
+
* gesture must travel mostly sideways to register.
|
|
58
|
+
*/
|
|
59
|
+
export const useSwipeNavigation = ({
|
|
60
|
+
onSwipeLeft,
|
|
61
|
+
onSwipeRight,
|
|
62
|
+
thresholdPx = DEFAULT_THRESHOLD,
|
|
63
|
+
}: UseSwipeNavigationOptions): { handlers: SwipeHandlers } => {
|
|
64
|
+
const startRef = useRef<{ x: number; y: number } | null>(null);
|
|
65
|
+
|
|
66
|
+
const onPointerDown = useCallback((e: React.PointerEvent) => {
|
|
67
|
+
if (e.pointerType === "mouse") {
|
|
68
|
+
startRef.current = null;
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
startRef.current = { x: e.clientX, y: e.clientY };
|
|
72
|
+
}, []);
|
|
73
|
+
|
|
74
|
+
const onPointerMove = useCallback(() => {}, []);
|
|
75
|
+
|
|
76
|
+
const onPointerUp = useCallback(
|
|
77
|
+
(e: React.PointerEvent) => {
|
|
78
|
+
const start = startRef.current;
|
|
79
|
+
startRef.current = null;
|
|
80
|
+
if (!start) return;
|
|
81
|
+
const { direction } = resolveSwipe(
|
|
82
|
+
e.clientX - start.x,
|
|
83
|
+
e.clientY - start.y,
|
|
84
|
+
thresholdPx,
|
|
85
|
+
);
|
|
86
|
+
if (direction === "left") onSwipeLeft?.();
|
|
87
|
+
if (direction === "right") onSwipeRight?.();
|
|
88
|
+
},
|
|
89
|
+
[onSwipeLeft, onSwipeRight, thresholdPx],
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
const onPointerCancel = useCallback(() => {
|
|
93
|
+
startRef.current = null;
|
|
94
|
+
}, []);
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
handlers: { onPointerDown, onPointerMove, onPointerUp, onPointerCancel },
|
|
98
|
+
};
|
|
99
|
+
};
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import {
|
|
2
|
+
messageOperationsUpdateMessageFlagsMutation,
|
|
3
|
+
threadDetailOperationsListThreadMessagesQueryKey,
|
|
4
|
+
threadOperationsListThreadsQueryKey,
|
|
5
|
+
threadOperationsSearchThreadsQueryKey,
|
|
6
|
+
} from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
7
|
+
import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
8
|
+
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
9
|
+
import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
|
|
10
|
+
import { formatErrorDetail } from "@/components/ui/error-banners";
|
|
11
|
+
|
|
12
|
+
interface UseToggleStarOptions {
|
|
13
|
+
threadId: string;
|
|
14
|
+
mailboxId: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface ThreadMessagesData {
|
|
18
|
+
items: RemitImapThreadMessageResponse[];
|
|
19
|
+
[key: string]: unknown;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface ThreadsListPage {
|
|
23
|
+
items: RemitImapThreadMessageResponse[];
|
|
24
|
+
[key: string]: unknown;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface ThreadsListData {
|
|
28
|
+
pages: ThreadsListPage[];
|
|
29
|
+
pageParams: Array<string | undefined>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface SnapshotEntry<T> {
|
|
33
|
+
queryKey: readonly unknown[];
|
|
34
|
+
data: T;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface ToggleStarContext {
|
|
38
|
+
threadMessagesPrefix: readonly unknown[];
|
|
39
|
+
threadsListPrefix: readonly unknown[];
|
|
40
|
+
threadsSearchPrefix: readonly unknown[];
|
|
41
|
+
previousThreadMessages: SnapshotEntry<ThreadMessagesData>[];
|
|
42
|
+
previousThreadsList: SnapshotEntry<ThreadsListData>[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const toggleStarsInItems = (
|
|
46
|
+
items: RemitImapThreadMessageResponse[],
|
|
47
|
+
messageId: string,
|
|
48
|
+
nextStarred: boolean,
|
|
49
|
+
): RemitImapThreadMessageResponse[] =>
|
|
50
|
+
items.map((item) =>
|
|
51
|
+
item.messageId === messageId ? { ...item, hasStars: nextStarred } : item,
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
export const useToggleStar = ({
|
|
55
|
+
threadId,
|
|
56
|
+
mailboxId,
|
|
57
|
+
}: UseToggleStarOptions) => {
|
|
58
|
+
const queryClient = useQueryClient();
|
|
59
|
+
const { pushError } = useErrorBanners();
|
|
60
|
+
|
|
61
|
+
const { mutate, isPending, variables } = useMutation({
|
|
62
|
+
...messageOperationsUpdateMessageFlagsMutation(),
|
|
63
|
+
onMutate: async (vars): Promise<ToggleStarContext> => {
|
|
64
|
+
const messageId = vars.path.messageId;
|
|
65
|
+
const nextStarred = vars.body.isStarred ?? false;
|
|
66
|
+
|
|
67
|
+
// Use partial-key prefixes so the cancel/snapshot/patch covers the
|
|
68
|
+
// full key (which includes query options like { order: "desc",
|
|
69
|
+
// mailboxId }) without us having to enumerate every variant. React
|
|
70
|
+
// Query matches by prefix on the first key element.
|
|
71
|
+
const threadMessagesPrefix =
|
|
72
|
+
threadDetailOperationsListThreadMessagesQueryKey({
|
|
73
|
+
path: { threadId },
|
|
74
|
+
});
|
|
75
|
+
const threadsListPrefix = threadOperationsListThreadsQueryKey({
|
|
76
|
+
path: { mailboxId },
|
|
77
|
+
});
|
|
78
|
+
const threadsSearchPrefix = threadOperationsSearchThreadsQueryKey({
|
|
79
|
+
path: { mailboxId },
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
await Promise.all([
|
|
83
|
+
queryClient.cancelQueries({ queryKey: threadMessagesPrefix }),
|
|
84
|
+
queryClient.cancelQueries({ queryKey: threadsListPrefix }),
|
|
85
|
+
queryClient.cancelQueries({ queryKey: threadsSearchPrefix }),
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
const previousThreadMessages = queryClient
|
|
89
|
+
.getQueriesData<ThreadMessagesData>({ queryKey: threadMessagesPrefix })
|
|
90
|
+
.filter(
|
|
91
|
+
(entry): entry is [readonly unknown[], ThreadMessagesData] =>
|
|
92
|
+
entry[1] !== undefined,
|
|
93
|
+
)
|
|
94
|
+
.map(([queryKey, data]) => ({ queryKey, data }));
|
|
95
|
+
|
|
96
|
+
const previousThreadsList = queryClient
|
|
97
|
+
.getQueriesData<ThreadsListData>({ queryKey: threadsListPrefix })
|
|
98
|
+
.concat(
|
|
99
|
+
queryClient.getQueriesData<ThreadsListData>({
|
|
100
|
+
queryKey: threadsSearchPrefix,
|
|
101
|
+
}),
|
|
102
|
+
)
|
|
103
|
+
.filter(
|
|
104
|
+
(entry): entry is [readonly unknown[], ThreadsListData] =>
|
|
105
|
+
entry[1] !== undefined,
|
|
106
|
+
)
|
|
107
|
+
.map(([queryKey, data]) => ({ queryKey, data }));
|
|
108
|
+
|
|
109
|
+
queryClient.setQueriesData<ThreadMessagesData>(
|
|
110
|
+
{ queryKey: threadMessagesPrefix },
|
|
111
|
+
(old) => {
|
|
112
|
+
if (!old) return old;
|
|
113
|
+
return {
|
|
114
|
+
...old,
|
|
115
|
+
items: toggleStarsInItems(old.items, messageId, nextStarred),
|
|
116
|
+
};
|
|
117
|
+
},
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
const patchListData = (old: ThreadsListData | undefined) => {
|
|
121
|
+
if (!old) return old;
|
|
122
|
+
return {
|
|
123
|
+
...old,
|
|
124
|
+
pages: old.pages.map((page) => ({
|
|
125
|
+
...page,
|
|
126
|
+
items: toggleStarsInItems(page.items, messageId, nextStarred),
|
|
127
|
+
})),
|
|
128
|
+
};
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
queryClient.setQueriesData<ThreadsListData>(
|
|
132
|
+
{ queryKey: threadsListPrefix },
|
|
133
|
+
patchListData,
|
|
134
|
+
);
|
|
135
|
+
queryClient.setQueriesData<ThreadsListData>(
|
|
136
|
+
{ queryKey: threadsSearchPrefix },
|
|
137
|
+
patchListData,
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
threadMessagesPrefix,
|
|
142
|
+
threadsListPrefix,
|
|
143
|
+
threadsSearchPrefix,
|
|
144
|
+
previousThreadMessages,
|
|
145
|
+
previousThreadsList,
|
|
146
|
+
};
|
|
147
|
+
},
|
|
148
|
+
onError: (err, vars, context) => {
|
|
149
|
+
if (context) {
|
|
150
|
+
for (const entry of context.previousThreadMessages) {
|
|
151
|
+
queryClient.setQueryData(entry.queryKey, entry.data);
|
|
152
|
+
}
|
|
153
|
+
for (const entry of context.previousThreadsList) {
|
|
154
|
+
queryClient.setQueryData(entry.queryKey, entry.data);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const nextStarred = vars.body.isStarred ?? false;
|
|
158
|
+
pushError({
|
|
159
|
+
title: nextStarred
|
|
160
|
+
? "Couldn't star message"
|
|
161
|
+
: "Couldn't unstar message",
|
|
162
|
+
detail: formatErrorDetail(err),
|
|
163
|
+
});
|
|
164
|
+
},
|
|
165
|
+
onSettled: (_data, _err, _vars, context) => {
|
|
166
|
+
if (!context) return;
|
|
167
|
+
queryClient.invalidateQueries({ queryKey: context.threadMessagesPrefix });
|
|
168
|
+
queryClient.invalidateQueries({ queryKey: context.threadsListPrefix });
|
|
169
|
+
queryClient.invalidateQueries({ queryKey: context.threadsSearchPrefix });
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const toggleStar = (messageId: string, currentlyStarred: boolean) => {
|
|
174
|
+
mutate({
|
|
175
|
+
path: { messageId },
|
|
176
|
+
body: {
|
|
177
|
+
isStarred: !currentlyStarred,
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
toggleStar,
|
|
184
|
+
isPending,
|
|
185
|
+
pendingMessageId: isPending ? variables?.path.messageId : undefined,
|
|
186
|
+
};
|
|
187
|
+
};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, test } from "node:test";
|
|
3
|
+
import type {
|
|
4
|
+
RemitImapDescribeMessageResponse,
|
|
5
|
+
RemitImapEnvelopeAddressResponse,
|
|
6
|
+
} from "@remit/api-http-client/types.gen.ts";
|
|
7
|
+
import { patchDescribeMessage, patchEnvelopeAddress } from "./useToggleTrusted";
|
|
8
|
+
|
|
9
|
+
const NOW = 1_700_000_000_000;
|
|
10
|
+
|
|
11
|
+
const makeAddr = (
|
|
12
|
+
overrides: Partial<RemitImapEnvelopeAddressResponse> & {
|
|
13
|
+
addressId: string;
|
|
14
|
+
},
|
|
15
|
+
): RemitImapEnvelopeAddressResponse =>
|
|
16
|
+
({
|
|
17
|
+
normalizedEmail: "x@example.com",
|
|
18
|
+
addressRole: "from",
|
|
19
|
+
addressOrder: 0,
|
|
20
|
+
...overrides,
|
|
21
|
+
}) as RemitImapEnvelopeAddressResponse;
|
|
22
|
+
|
|
23
|
+
describe("patchEnvelopeAddress", () => {
|
|
24
|
+
test("ignores addresses with non-matching addressId", () => {
|
|
25
|
+
const addr = makeAddr({ addressId: "addr-other" });
|
|
26
|
+
const result = patchEnvelopeAddress(addr, "addr-target", true, NOW);
|
|
27
|
+
assert.equal(result, addr);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("sets trusted flag when toggled on", () => {
|
|
31
|
+
const addr = makeAddr({ addressId: "addr-1" });
|
|
32
|
+
const result = patchEnvelopeAddress(addr, "addr-1", true, NOW);
|
|
33
|
+
assert.equal(result.flags?.trusted?.value, true);
|
|
34
|
+
assert.equal(result.flags?.trusted?.setAt, NOW);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("removes trusted flag when toggled off", () => {
|
|
38
|
+
const addr = makeAddr({
|
|
39
|
+
addressId: "addr-1",
|
|
40
|
+
flags: { trusted: { value: true, setAt: 1 } },
|
|
41
|
+
});
|
|
42
|
+
const result = patchEnvelopeAddress(addr, "addr-1", false, NOW);
|
|
43
|
+
assert.equal(result.flags?.trusted, undefined);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("preserves other flags when toggling trust off", () => {
|
|
47
|
+
const addr = makeAddr({
|
|
48
|
+
addressId: "addr-1",
|
|
49
|
+
flags: {
|
|
50
|
+
trusted: { value: true, setAt: 1 },
|
|
51
|
+
vip: { value: true, setAt: 2 },
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
const result = patchEnvelopeAddress(addr, "addr-1", false, NOW);
|
|
55
|
+
assert.equal(result.flags?.vip?.value, true);
|
|
56
|
+
assert.equal(result.flags?.trusted, undefined);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("patchDescribeMessage", () => {
|
|
61
|
+
const baseDescribe = (
|
|
62
|
+
from: RemitImapEnvelopeAddressResponse[],
|
|
63
|
+
): RemitImapDescribeMessageResponse =>
|
|
64
|
+
({
|
|
65
|
+
message: {
|
|
66
|
+
messageId: "m",
|
|
67
|
+
mailboxId: "mb",
|
|
68
|
+
uid: 1,
|
|
69
|
+
rfc822Size: 1,
|
|
70
|
+
internalDate: 1,
|
|
71
|
+
},
|
|
72
|
+
envelope: {
|
|
73
|
+
messageId: "m",
|
|
74
|
+
date: 1,
|
|
75
|
+
from,
|
|
76
|
+
to: [],
|
|
77
|
+
cc: [],
|
|
78
|
+
bcc: [],
|
|
79
|
+
replyTo: [],
|
|
80
|
+
senderTrust: "unknown",
|
|
81
|
+
},
|
|
82
|
+
flags: [],
|
|
83
|
+
bodyParts: [],
|
|
84
|
+
references: [],
|
|
85
|
+
}) as RemitImapDescribeMessageResponse;
|
|
86
|
+
|
|
87
|
+
test("flips only the matching address across all roles", () => {
|
|
88
|
+
const target = makeAddr({ addressId: "a-1" });
|
|
89
|
+
const other = makeAddr({ addressId: "a-2" });
|
|
90
|
+
const data = baseDescribe([target, other]);
|
|
91
|
+
const result = patchDescribeMessage(data, "a-1", true, NOW);
|
|
92
|
+
assert.equal(result.envelope.from[0].flags?.trusted?.value, true);
|
|
93
|
+
assert.equal(result.envelope.from[1].flags?.trusted, undefined);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("rollback semantics: original data is unchanged (immutable patch)", () => {
|
|
97
|
+
const target = makeAddr({ addressId: "a-1" });
|
|
98
|
+
const data = baseDescribe([target]);
|
|
99
|
+
patchDescribeMessage(data, "a-1", true, NOW);
|
|
100
|
+
assert.equal(data.envelope.from[0].flags, undefined);
|
|
101
|
+
});
|
|
102
|
+
});
|