@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,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The minimal data needed to open a conversation. A list view normally opens the
|
|
3
|
+
* thread it already has loaded, but a semantic "Related" search hit can point at a
|
|
4
|
+
* message that isn't in the loaded list at all. Such a hit still carries its
|
|
5
|
+
* `threadId` and `mailboxId`, and `ConversationView` fetches by `threadId`, so we
|
|
6
|
+
* can open it directly from those alone — no dependency on the loaded list.
|
|
7
|
+
*/
|
|
8
|
+
import type {
|
|
9
|
+
RemitImapMessageAuthenticity,
|
|
10
|
+
RemitImapThreadMessageResponse,
|
|
11
|
+
} from "@remit/api-http-client/types.gen.ts";
|
|
12
|
+
|
|
13
|
+
export interface ConversationTarget {
|
|
14
|
+
threadId: string;
|
|
15
|
+
mailboxId: string;
|
|
16
|
+
subject?: string;
|
|
17
|
+
messageId?: string;
|
|
18
|
+
authenticity?: RemitImapMessageAuthenticity;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function threadToConversationTarget(
|
|
22
|
+
thread: RemitImapThreadMessageResponse,
|
|
23
|
+
): ConversationTarget {
|
|
24
|
+
return {
|
|
25
|
+
threadId: thread.threadId,
|
|
26
|
+
mailboxId: thread.mailboxId,
|
|
27
|
+
subject: thread.subject,
|
|
28
|
+
messageId: thread.messageId,
|
|
29
|
+
authenticity: thread.authenticity,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Resolve the conversation to open. Prefer the fully loaded thread (it carries
|
|
35
|
+
* the subject, authenticity and read state the reading pane wants); otherwise
|
|
36
|
+
* fall back to the `threadId` + `mailboxId` carried in the URL by a tapped
|
|
37
|
+
* "Related" hit so a message outside the loaded list still opens.
|
|
38
|
+
*/
|
|
39
|
+
export function buildConversationTarget(
|
|
40
|
+
selectedThread: RemitImapThreadMessageResponse | undefined,
|
|
41
|
+
fallback: {
|
|
42
|
+
messageId?: string;
|
|
43
|
+
threadId?: string;
|
|
44
|
+
mailboxId?: string;
|
|
45
|
+
},
|
|
46
|
+
): ConversationTarget | undefined {
|
|
47
|
+
if (selectedThread) return threadToConversationTarget(selectedThread);
|
|
48
|
+
// Require the messageId too: it's always set alongside the threadId when a
|
|
49
|
+
// result is tapped, and gating on it means clearing `selectedMessageId` (e.g.
|
|
50
|
+
// pressing Back) closes the conversation even if a stale threadId lingers.
|
|
51
|
+
if (fallback.messageId && fallback.threadId && fallback.mailboxId) {
|
|
52
|
+
return {
|
|
53
|
+
threadId: fallback.threadId,
|
|
54
|
+
mailboxId: fallback.mailboxId,
|
|
55
|
+
messageId: fallback.messageId,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { RemitImapMessageCategory } from "@remit/api-http-client/types.gen.ts";
|
|
2
|
+
import type { ThreadCategory } from "@remit/ui";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Map an API message category to a kit display category.
|
|
6
|
+
*
|
|
7
|
+
* The API category is total (RFC 032 Tier 2) and carries `uncategorized` for
|
|
8
|
+
* messages that are metadata-synced but not yet body-classified. The kit's
|
|
9
|
+
* display unions (`ThreadCategory`, `EmailRenderCategory`, badge `MessageCategory`)
|
|
10
|
+
* have no `uncategorized` member — there is nothing to show for "not yet
|
|
11
|
+
* classified", so it collapses to `personal`, the classifier's own fallback and
|
|
12
|
+
* the display default that never renders a badge. Pre-migration rows that still
|
|
13
|
+
* read `undefined` collapse the same way.
|
|
14
|
+
*/
|
|
15
|
+
export function toDisplayCategory(
|
|
16
|
+
category: RemitImapMessageCategory | undefined,
|
|
17
|
+
): ThreadCategory {
|
|
18
|
+
if (category === undefined || category === "uncategorized") return "personal";
|
|
19
|
+
return category;
|
|
20
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import assert from "node:assert";
|
|
2
|
+
import { describe, test } from "node:test";
|
|
3
|
+
import type {
|
|
4
|
+
RemitImapOutboxMessageResponse,
|
|
5
|
+
RemitImapThreadMessageResponse,
|
|
6
|
+
} from "@remit/api-http-client/types.gen.ts";
|
|
7
|
+
import { groupDraftSections } from "./drafts.js";
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Fixtures
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
function outboxMessage(
|
|
14
|
+
overrides: Partial<RemitImapOutboxMessageResponse> &
|
|
15
|
+
Pick<RemitImapOutboxMessageResponse, "outboxMessageId" | "accountId">,
|
|
16
|
+
): RemitImapOutboxMessageResponse {
|
|
17
|
+
return {
|
|
18
|
+
fromAddress: "me@example.com",
|
|
19
|
+
toAddresses: ["alice@example.com"],
|
|
20
|
+
status: "draft",
|
|
21
|
+
createdAt: 1_000_000,
|
|
22
|
+
updatedAt: 1_000_000,
|
|
23
|
+
...overrides,
|
|
24
|
+
} as RemitImapOutboxMessageResponse;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function threadMessage(
|
|
28
|
+
overrides: Partial<RemitImapThreadMessageResponse> &
|
|
29
|
+
Pick<
|
|
30
|
+
RemitImapThreadMessageResponse,
|
|
31
|
+
"threadMessageId" | "threadId" | "messageId" | "accountConfigId"
|
|
32
|
+
>,
|
|
33
|
+
): RemitImapThreadMessageResponse {
|
|
34
|
+
return {
|
|
35
|
+
mailboxId: "mb-drafts",
|
|
36
|
+
sentDate: 1_000_000,
|
|
37
|
+
isRead: false,
|
|
38
|
+
hasAttachment: false,
|
|
39
|
+
hasStars: false,
|
|
40
|
+
isDeleted: false,
|
|
41
|
+
...overrides,
|
|
42
|
+
} as RemitImapThreadMessageResponse;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// groupDraftSections
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
describe("groupDraftSections", () => {
|
|
50
|
+
test("returns empty array when both sources are empty", () => {
|
|
51
|
+
const sections = groupDraftSections({
|
|
52
|
+
outboxMessages: [],
|
|
53
|
+
accountId: "acc-1",
|
|
54
|
+
imapThreads: [],
|
|
55
|
+
});
|
|
56
|
+
assert.deepStrictEqual(sections, []);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("returns only Remit-drafts section when there are no IMAP threads", () => {
|
|
60
|
+
const draft = outboxMessage({
|
|
61
|
+
outboxMessageId: "d1",
|
|
62
|
+
accountId: "acc-1",
|
|
63
|
+
status: "draft",
|
|
64
|
+
});
|
|
65
|
+
const sections = groupDraftSections({
|
|
66
|
+
outboxMessages: [draft],
|
|
67
|
+
accountId: "acc-1",
|
|
68
|
+
imapThreads: [],
|
|
69
|
+
});
|
|
70
|
+
assert.strictEqual(sections.length, 1);
|
|
71
|
+
assert.strictEqual(sections[0].id, "remit-drafts");
|
|
72
|
+
assert.strictEqual(sections[0].threads.length, 1);
|
|
73
|
+
assert.strictEqual(sections[0].threads[0].id, "d1");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("returns only IMAP-drafts section when there are no Remit drafts", () => {
|
|
77
|
+
const thread = threadMessage({
|
|
78
|
+
threadMessageId: "tm1",
|
|
79
|
+
threadId: "t1",
|
|
80
|
+
messageId: "m1",
|
|
81
|
+
accountConfigId: "acc-1",
|
|
82
|
+
});
|
|
83
|
+
const sections = groupDraftSections({
|
|
84
|
+
outboxMessages: [],
|
|
85
|
+
accountId: "acc-1",
|
|
86
|
+
imapThreads: [thread],
|
|
87
|
+
});
|
|
88
|
+
assert.strictEqual(sections.length, 1);
|
|
89
|
+
assert.strictEqual(sections[0].id, "imap-drafts");
|
|
90
|
+
assert.strictEqual(sections[0].threads.length, 1);
|
|
91
|
+
assert.strictEqual(sections[0].threads[0].id, "m1");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("returns both sections when both sources have rows", () => {
|
|
95
|
+
const draft = outboxMessage({
|
|
96
|
+
outboxMessageId: "d1",
|
|
97
|
+
accountId: "acc-1",
|
|
98
|
+
status: "draft",
|
|
99
|
+
});
|
|
100
|
+
const thread = threadMessage({
|
|
101
|
+
threadMessageId: "tm1",
|
|
102
|
+
threadId: "t1",
|
|
103
|
+
messageId: "m1",
|
|
104
|
+
accountConfigId: "acc-1",
|
|
105
|
+
});
|
|
106
|
+
const sections = groupDraftSections({
|
|
107
|
+
outboxMessages: [draft],
|
|
108
|
+
accountId: "acc-1",
|
|
109
|
+
imapThreads: [thread],
|
|
110
|
+
});
|
|
111
|
+
assert.strictEqual(sections.length, 2);
|
|
112
|
+
assert.deepStrictEqual(
|
|
113
|
+
sections.map((s) => s.id),
|
|
114
|
+
["remit-drafts", "imap-drafts"],
|
|
115
|
+
);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("per-account filter: only drafts for the given accountId appear", () => {
|
|
119
|
+
const draftOwner = outboxMessage({
|
|
120
|
+
outboxMessageId: "d1",
|
|
121
|
+
accountId: "acc-1",
|
|
122
|
+
status: "draft",
|
|
123
|
+
});
|
|
124
|
+
const draftOther = outboxMessage({
|
|
125
|
+
outboxMessageId: "d2",
|
|
126
|
+
accountId: "acc-2",
|
|
127
|
+
status: "draft",
|
|
128
|
+
});
|
|
129
|
+
const sections = groupDraftSections({
|
|
130
|
+
outboxMessages: [draftOwner, draftOther],
|
|
131
|
+
accountId: "acc-1",
|
|
132
|
+
imapThreads: [],
|
|
133
|
+
});
|
|
134
|
+
assert.strictEqual(sections.length, 1);
|
|
135
|
+
assert.strictEqual(sections[0].threads.length, 1);
|
|
136
|
+
assert.strictEqual(sections[0].threads[0].id, "d1");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("status filter: non-draft statuses are excluded from Remit section", () => {
|
|
140
|
+
const draft = outboxMessage({
|
|
141
|
+
outboxMessageId: "d1",
|
|
142
|
+
accountId: "acc-1",
|
|
143
|
+
status: "draft",
|
|
144
|
+
});
|
|
145
|
+
const queued = outboxMessage({
|
|
146
|
+
outboxMessageId: "q1",
|
|
147
|
+
accountId: "acc-1",
|
|
148
|
+
status: "queued",
|
|
149
|
+
});
|
|
150
|
+
const failed = outboxMessage({
|
|
151
|
+
outboxMessageId: "f1",
|
|
152
|
+
accountId: "acc-1",
|
|
153
|
+
status: "failed",
|
|
154
|
+
});
|
|
155
|
+
const blocked = outboxMessage({
|
|
156
|
+
outboxMessageId: "b1",
|
|
157
|
+
accountId: "acc-1",
|
|
158
|
+
status: "blocked",
|
|
159
|
+
});
|
|
160
|
+
const sending = outboxMessage({
|
|
161
|
+
outboxMessageId: "s1",
|
|
162
|
+
accountId: "acc-1",
|
|
163
|
+
status: "sending",
|
|
164
|
+
});
|
|
165
|
+
const sections = groupDraftSections({
|
|
166
|
+
outboxMessages: [draft, queued, failed, blocked, sending],
|
|
167
|
+
accountId: "acc-1",
|
|
168
|
+
imapThreads: [],
|
|
169
|
+
});
|
|
170
|
+
assert.strictEqual(sections.length, 1);
|
|
171
|
+
// Only the draft should appear
|
|
172
|
+
assert.strictEqual(sections[0].threads.length, 1);
|
|
173
|
+
assert.strictEqual(sections[0].threads[0].id, "d1");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("empty sections are omitted when status filter removes all drafts", () => {
|
|
177
|
+
const queued = outboxMessage({
|
|
178
|
+
outboxMessageId: "q1",
|
|
179
|
+
accountId: "acc-1",
|
|
180
|
+
status: "queued",
|
|
181
|
+
});
|
|
182
|
+
const sections = groupDraftSections({
|
|
183
|
+
outboxMessages: [queued],
|
|
184
|
+
accountId: "acc-1",
|
|
185
|
+
imapThreads: [],
|
|
186
|
+
});
|
|
187
|
+
assert.deepStrictEqual(sections, []);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("section labels match spec", () => {
|
|
191
|
+
const draft = outboxMessage({
|
|
192
|
+
outboxMessageId: "d1",
|
|
193
|
+
accountId: "acc-1",
|
|
194
|
+
status: "draft",
|
|
195
|
+
});
|
|
196
|
+
const thread = threadMessage({
|
|
197
|
+
threadMessageId: "tm1",
|
|
198
|
+
threadId: "t1",
|
|
199
|
+
messageId: "m1",
|
|
200
|
+
accountConfigId: "acc-1",
|
|
201
|
+
});
|
|
202
|
+
const sections = groupDraftSections({
|
|
203
|
+
outboxMessages: [draft],
|
|
204
|
+
accountId: "acc-1",
|
|
205
|
+
imapThreads: [thread],
|
|
206
|
+
});
|
|
207
|
+
assert.deepStrictEqual(
|
|
208
|
+
sections.map((s) => s.label),
|
|
209
|
+
["Not yet sent (Remit)", "On the server"],
|
|
210
|
+
);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("Remit-drafts section row id equals outboxMessageId", () => {
|
|
214
|
+
const draft = outboxMessage({
|
|
215
|
+
outboxMessageId: "outbox-abc",
|
|
216
|
+
accountId: "acc-1",
|
|
217
|
+
status: "draft",
|
|
218
|
+
});
|
|
219
|
+
const sections = groupDraftSections({
|
|
220
|
+
outboxMessages: [draft],
|
|
221
|
+
accountId: "acc-1",
|
|
222
|
+
imapThreads: [],
|
|
223
|
+
});
|
|
224
|
+
assert.strictEqual(sections[0].threads[0].id, "outbox-abc");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("IMAP-drafts section row id equals messageId", () => {
|
|
228
|
+
const thread = threadMessage({
|
|
229
|
+
threadMessageId: "tm1",
|
|
230
|
+
threadId: "t1",
|
|
231
|
+
messageId: "msg-xyz",
|
|
232
|
+
accountConfigId: "acc-1",
|
|
233
|
+
});
|
|
234
|
+
const sections = groupDraftSections({
|
|
235
|
+
outboxMessages: [],
|
|
236
|
+
accountId: "acc-1",
|
|
237
|
+
imapThreads: [thread],
|
|
238
|
+
});
|
|
239
|
+
assert.strictEqual(sections[0].threads[0].id, "msg-xyz");
|
|
240
|
+
});
|
|
241
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drafts view grouping logic.
|
|
3
|
+
*
|
|
4
|
+
* Pure function: takes Remit outbox drafts and IMAP thread rows and returns
|
|
5
|
+
* two labeled sections:
|
|
6
|
+
*
|
|
7
|
+
* 1. Not yet sent (Remit) — outbox rows with status === "draft" belonging to
|
|
8
|
+
* the account that owns the open \Drafts mailbox.
|
|
9
|
+
* 2. On the server — IMAP \Drafts thread rows already loaded for the mailbox.
|
|
10
|
+
*
|
|
11
|
+
* Sections with no rows are omitted entirely (mirrors groupBriefSections).
|
|
12
|
+
*
|
|
13
|
+
* --- Dedup note ---
|
|
14
|
+
* Remit draft rows and IMAP \Drafts messages are rendered in both sections
|
|
15
|
+
* without client-side dedup. There is currently no IMAP APPEND-as-draft path
|
|
16
|
+
* and no IMAP uid / Message-ID back-reference on the outbox model, so the two
|
|
17
|
+
* sets are distinct objects with no shared key. If a future change starts
|
|
18
|
+
* appending Remit drafts into IMAP \Drafts the dedup key would be the RFC822
|
|
19
|
+
* `Message-ID` header. Until that ships, rendering both sections faithfully is
|
|
20
|
+
* the correct behaviour (issue #505).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type {
|
|
24
|
+
RemitImapOutboxMessageResponse,
|
|
25
|
+
RemitImapThreadMessageResponse,
|
|
26
|
+
} from "@remit/api-http-client/types.gen.ts";
|
|
27
|
+
import type { ThreadRowData, ThreadSection } from "@remit/ui";
|
|
28
|
+
import { formatEmailDate } from "./format.js";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Map a Remit outbox draft to the ThreadRowData shape used by remit-ui row
|
|
32
|
+
* components.
|
|
33
|
+
*/
|
|
34
|
+
export function toDraftRowData(
|
|
35
|
+
draft: RemitImapOutboxMessageResponse,
|
|
36
|
+
): ThreadRowData {
|
|
37
|
+
const recipients = draft.toAddresses.join(", ");
|
|
38
|
+
return {
|
|
39
|
+
id: draft.outboxMessageId,
|
|
40
|
+
accountId: draft.accountId,
|
|
41
|
+
fromName: draft.fromName ?? draft.fromAddress ?? "Me",
|
|
42
|
+
fromEmail: draft.fromAddress ?? "",
|
|
43
|
+
subject: draft.subject ?? "(No subject)",
|
|
44
|
+
snippet: recipients ? `To: ${recipients}` : "(No recipients)",
|
|
45
|
+
timeLabel: formatEmailDate(draft.updatedAt),
|
|
46
|
+
isRead: true,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Map a RemitImapThreadMessageResponse to the ThreadRowData shape used by
|
|
52
|
+
* remit-ui row body components. Mirrors toThreadRowData in lib/brief.ts.
|
|
53
|
+
*/
|
|
54
|
+
export function toImapDraftRowData(
|
|
55
|
+
thread: RemitImapThreadMessageResponse,
|
|
56
|
+
): ThreadRowData {
|
|
57
|
+
return {
|
|
58
|
+
id: thread.messageId,
|
|
59
|
+
accountId: thread.accountConfigId,
|
|
60
|
+
fromName: thread.fromName ?? thread.fromEmail ?? "Unknown",
|
|
61
|
+
fromEmail: thread.fromEmail ?? "",
|
|
62
|
+
subject: thread.subject ?? "(No subject)",
|
|
63
|
+
snippet: thread.snippet ?? "",
|
|
64
|
+
timeLabel: formatEmailDate(thread.sentDate),
|
|
65
|
+
isRead: thread.isRead,
|
|
66
|
+
hasAttachment: thread.hasAttachment,
|
|
67
|
+
starred:
|
|
68
|
+
thread.star != null && thread.star !== "none" && thread.hasStars === true,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface GroupDraftSectionsInput {
|
|
73
|
+
/** All outbox messages. Will be filtered to status === "draft" and accountId. */
|
|
74
|
+
outboxMessages: RemitImapOutboxMessageResponse[];
|
|
75
|
+
/** The accountId that owns the open \Drafts mailbox. */
|
|
76
|
+
accountId: string;
|
|
77
|
+
/** Thread rows already loaded for the \Drafts mailbox (IMAP). */
|
|
78
|
+
imapThreads: RemitImapThreadMessageResponse[];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Group outbox drafts and IMAP thread rows into the two-section Drafts view.
|
|
83
|
+
*
|
|
84
|
+
* @param input.outboxMessages Full outbox list (will be filtered here).
|
|
85
|
+
* @param input.accountId Owner account of the open \Drafts mailbox.
|
|
86
|
+
* @param input.imapThreads IMAP \Drafts threads already loaded.
|
|
87
|
+
* @returns Array of ThreadSection — empty sections omitted.
|
|
88
|
+
*/
|
|
89
|
+
export function groupDraftSections({
|
|
90
|
+
outboxMessages,
|
|
91
|
+
accountId,
|
|
92
|
+
imapThreads,
|
|
93
|
+
}: GroupDraftSectionsInput): ThreadSection[] {
|
|
94
|
+
// Only outbox rows with status === "draft" for this account. failed/blocked
|
|
95
|
+
// rows live in the Outbox view and must never appear in Drafts (issue #505).
|
|
96
|
+
const remitDrafts = outboxMessages
|
|
97
|
+
.filter((m) => m.status === "draft" && m.accountId === accountId)
|
|
98
|
+
.map(toDraftRowData);
|
|
99
|
+
|
|
100
|
+
const imapDrafts = imapThreads.map(toImapDraftRowData);
|
|
101
|
+
|
|
102
|
+
const sections: ThreadSection[] = [];
|
|
103
|
+
|
|
104
|
+
if (remitDrafts.length > 0) {
|
|
105
|
+
sections.push({
|
|
106
|
+
id: "remit-drafts",
|
|
107
|
+
label: "Not yet sent (Remit)",
|
|
108
|
+
threads: remitDrafts,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (imapDrafts.length > 0) {
|
|
113
|
+
sections.push({
|
|
114
|
+
id: "imap-drafts",
|
|
115
|
+
label: "On the server",
|
|
116
|
+
threads: imapDrafts,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return sections;
|
|
121
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { ApiError } from "./api";
|
|
4
|
+
import {
|
|
5
|
+
getErrorStatus,
|
|
6
|
+
hasHttpStatus,
|
|
7
|
+
isAbortError,
|
|
8
|
+
isNetworkError,
|
|
9
|
+
isServerError,
|
|
10
|
+
shouldEscalate,
|
|
11
|
+
} from "./error-classifier";
|
|
12
|
+
|
|
13
|
+
const abortError = (): Error => {
|
|
14
|
+
const error = new Error("aborted");
|
|
15
|
+
error.name = "AbortError";
|
|
16
|
+
return error;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
describe("getErrorStatus", () => {
|
|
20
|
+
it("reads status off an ApiError", () => {
|
|
21
|
+
assert.equal(getErrorStatus(new ApiError("boom", 503)), 503);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("reads a numeric status off a plain object", () => {
|
|
25
|
+
assert.equal(getErrorStatus({ status: 500 }), 500);
|
|
26
|
+
assert.equal(getErrorStatus({ statusCode: 404 }), 404);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("returns undefined when no status is present", () => {
|
|
30
|
+
assert.equal(getErrorStatus(new Error("network")), undefined);
|
|
31
|
+
assert.equal(getErrorStatus("nope"), undefined);
|
|
32
|
+
assert.equal(getErrorStatus(null), undefined);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("isServerError", () => {
|
|
37
|
+
it("is true for any 5xx", () => {
|
|
38
|
+
assert.equal(isServerError(new ApiError("boom", 500)), true);
|
|
39
|
+
assert.equal(isServerError(new ApiError("boom", 502)), true);
|
|
40
|
+
assert.equal(isServerError({ status: 599 }), true);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("is false for a 4xx", () => {
|
|
44
|
+
assert.equal(isServerError(new ApiError("not found", 404)), false);
|
|
45
|
+
assert.equal(isServerError(new ApiError("forbidden", 403)), false);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("is false for a statusless error", () => {
|
|
49
|
+
assert.equal(isServerError(new TypeError("Failed to fetch")), false);
|
|
50
|
+
assert.equal(isServerError(undefined), false);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("hasHttpStatus", () => {
|
|
55
|
+
it("is true when the error carries a status", () => {
|
|
56
|
+
assert.equal(hasHttpStatus(new ApiError("boom", 500)), true);
|
|
57
|
+
assert.equal(hasHttpStatus({ statusCode: 404 }), true);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("is false for a statusless error", () => {
|
|
61
|
+
assert.equal(hasHttpStatus(new TypeError("Failed to fetch")), false);
|
|
62
|
+
assert.equal(hasHttpStatus(null), false);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("isAbortError", () => {
|
|
67
|
+
it("is true for an AbortError", () => {
|
|
68
|
+
assert.equal(isAbortError(abortError()), true);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("is false for anything else", () => {
|
|
72
|
+
assert.equal(isAbortError(new ApiError("boom", 500)), false);
|
|
73
|
+
assert.equal(isAbortError(new TypeError("Failed to fetch")), false);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("isNetworkError", () => {
|
|
78
|
+
it("is true for a statusless transport failure", () => {
|
|
79
|
+
assert.equal(isNetworkError(new TypeError("Failed to fetch")), true);
|
|
80
|
+
assert.equal(isNetworkError(new Error("network down")), true);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("is false for an aborted request (its own category)", () => {
|
|
84
|
+
assert.equal(isNetworkError(abortError()), false);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("is false when the error carries a status", () => {
|
|
88
|
+
assert.equal(isNetworkError(new ApiError("boom", 500)), false);
|
|
89
|
+
assert.equal(isNetworkError(new ApiError("not found", 404)), false);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("shouldEscalate (the fail-fast decision table — #1059)", () => {
|
|
94
|
+
it("escalates a 5xx by default", () => {
|
|
95
|
+
assert.equal(shouldEscalate(new ApiError("boom", 500)), true);
|
|
96
|
+
assert.equal(shouldEscalate(new ApiError("boom", 503)), true);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("escalates a 5xx EVEN when the call site marked it soft (rule 2 wins)", () => {
|
|
100
|
+
assert.equal(
|
|
101
|
+
shouldEscalate(new ApiError("boom", 500), { softError: true }),
|
|
102
|
+
true,
|
|
103
|
+
);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("escalates a 4xx by DEFAULT (no opt-out)", () => {
|
|
107
|
+
assert.equal(shouldEscalate(new ApiError("not found", 404)), true);
|
|
108
|
+
assert.equal(shouldEscalate(new ApiError("forbidden", 403)), true);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("does NOT escalate a 4xx the call site opted out of via meta.softError", () => {
|
|
112
|
+
assert.equal(
|
|
113
|
+
shouldEscalate(new ApiError("not found", 404), { softError: true }),
|
|
114
|
+
false,
|
|
115
|
+
);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("does NOT escalate an aborted/cancelled request", () => {
|
|
119
|
+
assert.equal(shouldEscalate(abortError()), false);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("does NOT escalate a statusless network/offline blip", () => {
|
|
123
|
+
assert.equal(shouldEscalate(new TypeError("Failed to fetch")), false);
|
|
124
|
+
assert.equal(shouldEscalate(new Error("network down")), false);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("a statusless error is soft regardless of meta", () => {
|
|
128
|
+
assert.equal(
|
|
129
|
+
shouldEscalate(new TypeError("Failed to fetch"), { softError: false }),
|
|
130
|
+
false,
|
|
131
|
+
);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { ApiError } from "./api";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Extract an HTTP status code from a thrown error, regardless of which client
|
|
5
|
+
* raised it.
|
|
6
|
+
*
|
|
7
|
+
* Two client shapes coexist in the web client:
|
|
8
|
+
* - the hand-written `api` wrapper throws `ApiError` (carries `.status`);
|
|
9
|
+
* - the generated `@remit/api-http-client` (hey-api) throws the parsed JSON error
|
|
10
|
+
* body. A response interceptor (see `lib/client.ts`) re-wraps those as
|
|
11
|
+
* `ApiError` too, so in practice both paths carry `.status`. As a belt-and-
|
|
12
|
+
* braces fallback we also read a numeric `status`/`statusCode` off a plain
|
|
13
|
+
* object.
|
|
14
|
+
*/
|
|
15
|
+
export const getErrorStatus = (error: unknown): number | undefined => {
|
|
16
|
+
if (error instanceof ApiError) return error.status;
|
|
17
|
+
if (error && typeof error === "object") {
|
|
18
|
+
const candidate = error as { status?: unknown; statusCode?: unknown };
|
|
19
|
+
if (typeof candidate.status === "number") return candidate.status;
|
|
20
|
+
if (typeof candidate.statusCode === "number") return candidate.statusCode;
|
|
21
|
+
}
|
|
22
|
+
return undefined;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** The error carries a real HTTP status — our API answered. */
|
|
26
|
+
export const hasHttpStatus = (error: unknown): boolean =>
|
|
27
|
+
getErrorStatus(error) !== undefined;
|
|
28
|
+
|
|
29
|
+
/** A genuine first-party server failure: an HTTP 5xx. */
|
|
30
|
+
export const isServerError = (error: unknown): boolean => {
|
|
31
|
+
const status = getErrorStatus(error);
|
|
32
|
+
return status !== undefined && status >= 500 && status <= 599;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A deliberately cancelled request (route change, React Query cancellation).
|
|
37
|
+
* Never a failure.
|
|
38
|
+
*/
|
|
39
|
+
export const isAbortError = (error: unknown): boolean => {
|
|
40
|
+
if (
|
|
41
|
+
typeof DOMException !== "undefined" &&
|
|
42
|
+
error instanceof DOMException &&
|
|
43
|
+
error.name === "AbortError"
|
|
44
|
+
) {
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
if (
|
|
48
|
+
error &&
|
|
49
|
+
typeof error === "object" &&
|
|
50
|
+
"name" in error &&
|
|
51
|
+
(error as { name?: unknown }).name === "AbortError"
|
|
52
|
+
) {
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
return false;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A statusless transport/network failure (`TypeError: Failed to fetch` from a
|
|
60
|
+
* wifi drop, tab wake, captive portal, or a background refetch): the request
|
|
61
|
+
* never reached a server, so it is environmental, not a proven first-party
|
|
62
|
+
* failure. Excludes deliberate aborts (also statusless, but their own category).
|
|
63
|
+
*/
|
|
64
|
+
export const isNetworkError = (error: unknown): boolean =>
|
|
65
|
+
!isAbortError(error) && !hasHttpStatus(error);
|
|
66
|
+
|
|
67
|
+
const isSoftErrorMeta = (meta: Record<string, unknown> | undefined): boolean =>
|
|
68
|
+
meta?.softError === true;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The single fail-fast decision: should this error escalate to the full-screen
|
|
72
|
+
* fatal overlay? Default is YES — a non-2xx must never silently vanish.
|
|
73
|
+
*
|
|
74
|
+
* The contract (issue #1059):
|
|
75
|
+
* 1. DEFAULT = escalate.
|
|
76
|
+
* 2. A 5xx (500–599) ALWAYS escalates — no opt-out, even on a background
|
|
77
|
+
* refetch, even when the call site marked `meta.softError`. Our API
|
|
78
|
+
* answered "I'm broken"; that is never benign.
|
|
79
|
+
* 3. The ONLY soft (do-NOT-escalate) exemptions:
|
|
80
|
+
* a. aborts / cancellations — never a failure;
|
|
81
|
+
* b. statusless network/offline errors — environmental, recovered by React
|
|
82
|
+
* Query's reconnect/retry;
|
|
83
|
+
* c. a non-5xx error on a query/mutation that opted out via
|
|
84
|
+
* `meta.softError === true` — the call site owns that error's UX
|
|
85
|
+
* (e.g. a 404 empty state, a 4xx "Reconnect" banner).
|
|
86
|
+
*/
|
|
87
|
+
export const shouldEscalate = (
|
|
88
|
+
error: unknown,
|
|
89
|
+
meta?: Record<string, unknown>,
|
|
90
|
+
): boolean => {
|
|
91
|
+
if (isServerError(error)) return true;
|
|
92
|
+
if (isAbortError(error)) return false;
|
|
93
|
+
if (isNetworkError(error)) return false;
|
|
94
|
+
if (isSoftErrorMeta(meta)) return false;
|
|
95
|
+
return true;
|
|
96
|
+
};
|