@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,86 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { BodyFetchError } from "@/hooks/useMessageBodyContent";
|
|
4
|
+
import {
|
|
5
|
+
contentForReason,
|
|
6
|
+
extractFallbackDetail,
|
|
7
|
+
extractReason,
|
|
8
|
+
} from "./message-body-error-banner-content";
|
|
9
|
+
|
|
10
|
+
describe("contentForReason — diagnostic banner copy per failure mode (#401)", () => {
|
|
11
|
+
it("renders a session-expired title for auth failures (Lambda@Edge 401/403 with x-remit-403-reason header)", () => {
|
|
12
|
+
const content = contentForReason("auth", "ignored fallback");
|
|
13
|
+
assert.match(content.title, /session/i);
|
|
14
|
+
assert.match(content.detail, /sign in/i);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("renders a body-missing-in-storage title for bare S3 403/404 (no edge header — OAC blocks list so missing keys 403)", () => {
|
|
18
|
+
const content = contentForReason("body-missing", "ignored fallback");
|
|
19
|
+
assert.match(content.title, /missing in storage/i);
|
|
20
|
+
assert.match(content.detail, /reconcile|sync|support/i);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("renders a generic title for other 4xx/5xx and falls back to the error's own message in the detail", () => {
|
|
24
|
+
const content = contentForReason("generic", "Network connection lost");
|
|
25
|
+
assert.match(content.title, /couldn.?t load/i);
|
|
26
|
+
assert.equal(content.detail, "Network connection lost");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("renders a generic-but-safe title for content-type-mismatch (the actual reason is too technical for the user, the security guard already refused to render)", () => {
|
|
30
|
+
const content = contentForReason("content-type-mismatch", "ignored");
|
|
31
|
+
assert.match(content.title, /couldn.?t load/i);
|
|
32
|
+
assert.match(content.detail, /content type|unexpected/i);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("renders a generic title for the spa-shell-leak regression guard (#310) and flags it as an infrastructure bug", () => {
|
|
36
|
+
const content = contentForReason("spa-shell-leak", "ignored");
|
|
37
|
+
assert.match(content.title, /couldn.?t load/i);
|
|
38
|
+
assert.match(content.detail, /shell|infrastructure|report/i);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("never reuses the auth copy for body-missing — these must be visibly distinct so the user knows which failure they're facing (#401 acceptance)", () => {
|
|
42
|
+
const auth = contentForReason("auth", "x");
|
|
43
|
+
const missing = contentForReason("body-missing", "x");
|
|
44
|
+
assert.notEqual(auth.title, missing.title);
|
|
45
|
+
assert.notEqual(auth.detail, missing.detail);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("extractReason — pulls the discriminated reason off a BodyFetchError, falls back to generic", () => {
|
|
50
|
+
it("returns the reason from a BodyFetchError instance", () => {
|
|
51
|
+
const err = new BodyFetchError("body-missing", "missing", 403);
|
|
52
|
+
assert.equal(extractReason(err), "body-missing");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("returns the reason from a BodyFetchError(auth)", () => {
|
|
56
|
+
const err = new BodyFetchError("auth", "Invalid id_token", 401);
|
|
57
|
+
assert.equal(extractReason(err), "auth");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("falls back to generic for a plain Error (e.g. a network error before fetch returned)", () => {
|
|
61
|
+
assert.equal(extractReason(new Error("offline")), "generic");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("falls back to generic for unknown shapes (null, undefined, strings)", () => {
|
|
65
|
+
assert.equal(extractReason(undefined), "generic");
|
|
66
|
+
assert.equal(extractReason(null), "generic");
|
|
67
|
+
assert.equal(extractReason("just a string"), "generic");
|
|
68
|
+
assert.equal(extractReason(42), "generic");
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("extractFallbackDetail — used only when contentForReason picks the generic branch", () => {
|
|
73
|
+
it("returns an Error.message", () => {
|
|
74
|
+
assert.equal(extractFallbackDetail(new Error("boom")), "boom");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("returns a string as-is", () => {
|
|
78
|
+
assert.equal(extractFallbackDetail("network error"), "network error");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("returns a stable generic message for other shapes (never the raw object, never undefined)", () => {
|
|
82
|
+
const detail = extractFallbackDetail({ status: 502 });
|
|
83
|
+
assert.equal(typeof detail, "string");
|
|
84
|
+
assert.ok(detail.length > 0);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { AlertCircle, X } from "lucide-react";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { useAuthProvider } from "@/auth/provider";
|
|
4
|
+
import {
|
|
5
|
+
contentForReason,
|
|
6
|
+
extractFallbackDetail,
|
|
7
|
+
extractReason,
|
|
8
|
+
} from "./message-body-error-banner-content";
|
|
9
|
+
|
|
10
|
+
interface MessageBodyErrorBannerProps {
|
|
11
|
+
error: unknown;
|
|
12
|
+
onRetry?: () => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Inline alert banner for a body-fetch failure. Renders variant-specific copy
|
|
17
|
+
* for auth-expired vs. body-missing-in-storage 403s (issue #401), with a
|
|
18
|
+
* Retry button (always) and a Sign-in button (auth variant only, and only
|
|
19
|
+
* when a session is active to sign out of). Dismissible — once dismissed, the
|
|
20
|
+
* message body area falls back to an empty state until Retry is clicked.
|
|
21
|
+
*
|
|
22
|
+
* The sign-in affordance goes through the composed auth provider's `Account`
|
|
23
|
+
* render-prop: it renders only when there is a session to sign out of, so no
|
|
24
|
+
* identity SDK hook fires in a build (or a local-dev run) that has none.
|
|
25
|
+
*
|
|
26
|
+
* Uses the same destructive colour palette as `ErrorState(variant="inline")`
|
|
27
|
+
* so it looks at home in the message body slot.
|
|
28
|
+
*/
|
|
29
|
+
export const MessageBodyErrorBanner = ({
|
|
30
|
+
error,
|
|
31
|
+
onRetry,
|
|
32
|
+
}: MessageBodyErrorBannerProps) => {
|
|
33
|
+
const reason = extractReason(error);
|
|
34
|
+
const fallback = extractFallbackDetail(error);
|
|
35
|
+
const { title, detail } = contentForReason(reason, fallback);
|
|
36
|
+
const [dismissed, setDismissed] = useState(false);
|
|
37
|
+
const { Account } = useAuthProvider();
|
|
38
|
+
|
|
39
|
+
if (dismissed) return null;
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<div
|
|
43
|
+
role="alert"
|
|
44
|
+
data-testid="message-body-error-banner"
|
|
45
|
+
data-reason={reason}
|
|
46
|
+
className="flex items-start gap-2 rounded-md border border-danger/40 bg-danger-soft px-3 py-2 text-sm"
|
|
47
|
+
>
|
|
48
|
+
<AlertCircle
|
|
49
|
+
className="size-4 shrink-0 text-danger mt-0.5"
|
|
50
|
+
aria-hidden="true"
|
|
51
|
+
/>
|
|
52
|
+
<div className="flex-1 min-w-0">
|
|
53
|
+
<p className="font-medium text-danger">{title}</p>
|
|
54
|
+
<p className="text-fg-muted mt-1 break-words">{detail}</p>
|
|
55
|
+
</div>
|
|
56
|
+
<div className="flex shrink-0 items-center gap-3">
|
|
57
|
+
{reason === "auth" && (
|
|
58
|
+
<Account>
|
|
59
|
+
{({ signOut }) => (
|
|
60
|
+
<button
|
|
61
|
+
type="button"
|
|
62
|
+
onClick={() => signOut()}
|
|
63
|
+
className="text-sm font-medium text-accent hover:underline"
|
|
64
|
+
>
|
|
65
|
+
Sign in again
|
|
66
|
+
</button>
|
|
67
|
+
)}
|
|
68
|
+
</Account>
|
|
69
|
+
)}
|
|
70
|
+
{onRetry && (
|
|
71
|
+
<button
|
|
72
|
+
type="button"
|
|
73
|
+
onClick={onRetry}
|
|
74
|
+
className="text-sm font-medium text-accent hover:underline"
|
|
75
|
+
>
|
|
76
|
+
Retry
|
|
77
|
+
</button>
|
|
78
|
+
)}
|
|
79
|
+
<button
|
|
80
|
+
type="button"
|
|
81
|
+
onClick={() => setDismissed(true)}
|
|
82
|
+
aria-label="Dismiss message body error"
|
|
83
|
+
className="rounded-md p-1 text-fg-muted hover:bg-surface-raised hover:text-fg transition-colors"
|
|
84
|
+
>
|
|
85
|
+
<X className="size-4" aria-hidden="true" />
|
|
86
|
+
</button>
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
);
|
|
90
|
+
};
|
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
import { messageOperationsDescribeMessageOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
2
|
+
import type {
|
|
3
|
+
RemitImapDescribeMessageResponse,
|
|
4
|
+
RemitImapThreadMessageResponse,
|
|
5
|
+
} from "@remit/api-http-client/types.gen.ts";
|
|
6
|
+
import type { ThreadMessageData } from "@remit/ui";
|
|
7
|
+
import {
|
|
8
|
+
AddressList,
|
|
9
|
+
CollapsedMessage,
|
|
10
|
+
ExpandedMessage,
|
|
11
|
+
} from "@remit/ui";
|
|
12
|
+
import { useQuery } from "@tanstack/react-query";
|
|
13
|
+
import { BadgeCheck, Paperclip, Star } from "lucide-react";
|
|
14
|
+
import { useState } from "react";
|
|
15
|
+
import { EmptyState } from "@/components/ui/EmptyState";
|
|
16
|
+
import { ErrorState } from "@/components/ui/ErrorState";
|
|
17
|
+
import { isMessageNotFoundError } from "@/components/ui/error-banners";
|
|
18
|
+
import { toDisplayCategory } from "@/lib/display-category";
|
|
19
|
+
import { formatDatePreset } from "@/lib/format";
|
|
20
|
+
import { cn } from "@/lib/utils";
|
|
21
|
+
import { AutoMovedIndicator } from "./AutoMovedIndicator";
|
|
22
|
+
import { MessageActionMenu } from "./MessageActionMenu";
|
|
23
|
+
import { MessageBody } from "./MessageBody";
|
|
24
|
+
import { MobileMessageBar } from "./MobileMessageBar";
|
|
25
|
+
import { RawMessageView } from "./RawMessageView";
|
|
26
|
+
|
|
27
|
+
const TrustedSenderBadge = () => (
|
|
28
|
+
<BadgeCheck
|
|
29
|
+
className="inline-block size-4 ml-1 -mt-0.5 text-positive align-middle"
|
|
30
|
+
aria-label="Trusted sender"
|
|
31
|
+
data-testid="trusted-sender-badge"
|
|
32
|
+
/>
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Builds the kit's `ThreadMessageData` view shape from an IMAP thread message.
|
|
37
|
+
* The kit reading rows own the row layout; MessageCard supplies the data and
|
|
38
|
+
* injects its interactivity through slots — so the row rhythm has one source of
|
|
39
|
+
* truth and can't drift from Storybook (#945).
|
|
40
|
+
*/
|
|
41
|
+
const toThreadMessageData = (
|
|
42
|
+
threadMessage: RemitImapThreadMessageResponse,
|
|
43
|
+
dateLabel: string,
|
|
44
|
+
): ThreadMessageData => ({
|
|
45
|
+
id: threadMessage.messageId,
|
|
46
|
+
fromName: threadMessage.fromName ?? threadMessage.fromEmail ?? "?",
|
|
47
|
+
fromEmail: threadMessage.fromEmail ?? "",
|
|
48
|
+
toLabel: "",
|
|
49
|
+
dateLabel,
|
|
50
|
+
snippet: threadMessage.snippet || "",
|
|
51
|
+
bodyHtml: "",
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const StarButton = ({
|
|
55
|
+
isStarred,
|
|
56
|
+
onToggleStar,
|
|
57
|
+
isStarPending,
|
|
58
|
+
hoverReveal,
|
|
59
|
+
}: {
|
|
60
|
+
isStarred: boolean;
|
|
61
|
+
onToggleStar: () => void;
|
|
62
|
+
isStarPending?: boolean;
|
|
63
|
+
hoverReveal?: boolean;
|
|
64
|
+
}) => (
|
|
65
|
+
<button
|
|
66
|
+
type="button"
|
|
67
|
+
onClick={(e) => {
|
|
68
|
+
e.stopPropagation();
|
|
69
|
+
onToggleStar();
|
|
70
|
+
}}
|
|
71
|
+
disabled={isStarPending}
|
|
72
|
+
aria-label={isStarred ? "Remove star" : "Add star"}
|
|
73
|
+
className={cn(
|
|
74
|
+
"shrink-0 p-0.5 rounded transition-colors",
|
|
75
|
+
isStarred
|
|
76
|
+
? "text-warning"
|
|
77
|
+
: cn(
|
|
78
|
+
"text-fg-subtle hover:text-warning",
|
|
79
|
+
hoverReveal &&
|
|
80
|
+
"opacity-0 group-hover:opacity-100 focus:opacity-100",
|
|
81
|
+
),
|
|
82
|
+
isStarPending && "opacity-50",
|
|
83
|
+
)}
|
|
84
|
+
>
|
|
85
|
+
<Star className={cn("size-3.5", isStarred && "fill-current")} />
|
|
86
|
+
</button>
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
interface MessageCardProps {
|
|
90
|
+
threadMessage: RemitImapThreadMessageResponse;
|
|
91
|
+
isExpanded: boolean;
|
|
92
|
+
isFocused?: boolean;
|
|
93
|
+
onToggle: () => void;
|
|
94
|
+
onToggleStar: () => void;
|
|
95
|
+
isStarPending?: boolean;
|
|
96
|
+
/**
|
|
97
|
+
* Owning account for the thread's mailbox. Forwarded to
|
|
98
|
+
* `MessageActionMenu` so the per-message Move trigger can scope its
|
|
99
|
+
* folder picker to the right account. Optional because the resolver
|
|
100
|
+
* is async — when omitted the Move trigger is hidden until the lookup
|
|
101
|
+
* completes.
|
|
102
|
+
*/
|
|
103
|
+
accountId?: string;
|
|
104
|
+
/**
|
|
105
|
+
* Single-pane mobile reading: the expanded card owns a per-message
|
|
106
|
+
* `MobileMessageBar` (reply / star / move / delete / overflow) instead of
|
|
107
|
+
* the desktop star indicator + kebab, so nothing is duplicated against the
|
|
108
|
+
* shell. Reply verbs are supplied by the conversation.
|
|
109
|
+
*/
|
|
110
|
+
mobile?: boolean;
|
|
111
|
+
onReply?: () => void;
|
|
112
|
+
onReplyAll?: () => void;
|
|
113
|
+
onForward?: () => void;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const CollapsedCard = ({
|
|
117
|
+
threadMessage,
|
|
118
|
+
isFocused,
|
|
119
|
+
onToggle,
|
|
120
|
+
onToggleStar,
|
|
121
|
+
isStarPending,
|
|
122
|
+
}: {
|
|
123
|
+
threadMessage: RemitImapThreadMessageResponse;
|
|
124
|
+
isFocused?: boolean;
|
|
125
|
+
onToggle: () => void;
|
|
126
|
+
onToggleStar: () => void;
|
|
127
|
+
isStarPending?: boolean;
|
|
128
|
+
}) => {
|
|
129
|
+
const date = formatDatePreset(threadMessage.sentDate, "datetime");
|
|
130
|
+
const message = toThreadMessageData(threadMessage, date);
|
|
131
|
+
|
|
132
|
+
return (
|
|
133
|
+
<CollapsedMessage
|
|
134
|
+
message={message}
|
|
135
|
+
onClick={onToggle}
|
|
136
|
+
isFocused={isFocused}
|
|
137
|
+
isUnread={!threadMessage.isRead}
|
|
138
|
+
trailing={
|
|
139
|
+
<>
|
|
140
|
+
{threadMessage.hasAttachment && (
|
|
141
|
+
<Paperclip className="size-3 shrink-0 text-fg-subtle" />
|
|
142
|
+
)}
|
|
143
|
+
<StarButton
|
|
144
|
+
isStarred={threadMessage.hasStars}
|
|
145
|
+
onToggleStar={onToggleStar}
|
|
146
|
+
isStarPending={isStarPending}
|
|
147
|
+
hoverReveal
|
|
148
|
+
/>
|
|
149
|
+
<span
|
|
150
|
+
data-testid="message-date"
|
|
151
|
+
className="shrink-0 text-2xs text-fg-subtle tabular-nums"
|
|
152
|
+
>
|
|
153
|
+
{date}
|
|
154
|
+
</span>
|
|
155
|
+
</>
|
|
156
|
+
}
|
|
157
|
+
/>
|
|
158
|
+
);
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const ExpandedCard = ({
|
|
162
|
+
threadMessage,
|
|
163
|
+
messageData,
|
|
164
|
+
isLoading,
|
|
165
|
+
isError,
|
|
166
|
+
error,
|
|
167
|
+
onRetry,
|
|
168
|
+
isFocused,
|
|
169
|
+
onToggle,
|
|
170
|
+
onToggleStar,
|
|
171
|
+
isStarPending,
|
|
172
|
+
accountId,
|
|
173
|
+
showRaw,
|
|
174
|
+
onToggleRaw,
|
|
175
|
+
mobile,
|
|
176
|
+
onReply,
|
|
177
|
+
onReplyAll,
|
|
178
|
+
onForward,
|
|
179
|
+
}: {
|
|
180
|
+
threadMessage: RemitImapThreadMessageResponse;
|
|
181
|
+
messageData?: RemitImapDescribeMessageResponse;
|
|
182
|
+
isLoading: boolean;
|
|
183
|
+
isError: boolean;
|
|
184
|
+
error: unknown;
|
|
185
|
+
onRetry: () => void;
|
|
186
|
+
isFocused?: boolean;
|
|
187
|
+
onToggle: () => void;
|
|
188
|
+
onToggleStar: () => void;
|
|
189
|
+
isStarPending?: boolean;
|
|
190
|
+
accountId?: string;
|
|
191
|
+
showRaw: boolean;
|
|
192
|
+
onToggleRaw: () => void;
|
|
193
|
+
mobile?: boolean;
|
|
194
|
+
onReply?: () => void;
|
|
195
|
+
onReplyAll?: () => void;
|
|
196
|
+
onForward?: () => void;
|
|
197
|
+
}) => {
|
|
198
|
+
const date = formatDatePreset(threadMessage.sentDate, "long");
|
|
199
|
+
const isUnread = !threadMessage.isRead;
|
|
200
|
+
const isTrusted =
|
|
201
|
+
messageData?.envelope.from[0]?.flags?.trusted?.value === true;
|
|
202
|
+
const fromAddressId = messageData?.envelope.from[0]?.addressId;
|
|
203
|
+
const message = toThreadMessageData(threadMessage, date);
|
|
204
|
+
|
|
205
|
+
const autoMovedNode = threadMessage.autoMoved ? (
|
|
206
|
+
<AutoMovedIndicator
|
|
207
|
+
accountId={accountId}
|
|
208
|
+
messageId={threadMessage.messageId}
|
|
209
|
+
threadId={threadMessage.threadId}
|
|
210
|
+
mailboxId={threadMessage.mailboxId}
|
|
211
|
+
autoMoved={threadMessage.autoMoved}
|
|
212
|
+
size="md"
|
|
213
|
+
/>
|
|
214
|
+
) : null;
|
|
215
|
+
|
|
216
|
+
return (
|
|
217
|
+
<ExpandedMessage
|
|
218
|
+
message={message}
|
|
219
|
+
isFocused={isFocused}
|
|
220
|
+
onHeaderClick={onToggle}
|
|
221
|
+
senderBadge={isTrusted ? <TrustedSenderBadge /> : undefined}
|
|
222
|
+
trailing={
|
|
223
|
+
<span data-testid="message-date" className="text-2xs text-fg-subtle">
|
|
224
|
+
{date}
|
|
225
|
+
</span>
|
|
226
|
+
}
|
|
227
|
+
to={
|
|
228
|
+
messageData ? (
|
|
229
|
+
<AddressList label="To" addresses={messageData.envelope.to} />
|
|
230
|
+
) : null
|
|
231
|
+
}
|
|
232
|
+
indicators={
|
|
233
|
+
autoMovedNode || !mobile ? (
|
|
234
|
+
<div className="flex flex-wrap items-center gap-1.5 mt-0.5">
|
|
235
|
+
{autoMovedNode}
|
|
236
|
+
{!mobile && (
|
|
237
|
+
<>
|
|
238
|
+
<div className="flex items-center justify-end gap-1">
|
|
239
|
+
<StarButton
|
|
240
|
+
isStarred={threadMessage.hasStars}
|
|
241
|
+
onToggleStar={onToggleStar}
|
|
242
|
+
isStarPending={isStarPending}
|
|
243
|
+
/>
|
|
244
|
+
{threadMessage.hasAttachment && (
|
|
245
|
+
<span className="text-fg-subtle p-0.5">
|
|
246
|
+
<Paperclip className="size-3.5" />
|
|
247
|
+
</span>
|
|
248
|
+
)}
|
|
249
|
+
</div>
|
|
250
|
+
{isUnread && (
|
|
251
|
+
// biome-ignore lint/a11y/useAriaPropsSupportedByRole: aria-label on decorative indicator provides useful context for assistive tech
|
|
252
|
+
<span
|
|
253
|
+
className="size-1.5 rounded-full bg-accent"
|
|
254
|
+
aria-label="Unread"
|
|
255
|
+
/>
|
|
256
|
+
)}
|
|
257
|
+
</>
|
|
258
|
+
)}
|
|
259
|
+
</div>
|
|
260
|
+
) : undefined
|
|
261
|
+
}
|
|
262
|
+
actionMenu={
|
|
263
|
+
mobile ? undefined : (
|
|
264
|
+
<MessageActionMenu
|
|
265
|
+
messageId={threadMessage.messageId}
|
|
266
|
+
threadId={threadMessage.threadId}
|
|
267
|
+
mailboxId={threadMessage.mailboxId}
|
|
268
|
+
isRead={threadMessage.isRead}
|
|
269
|
+
accountId={accountId}
|
|
270
|
+
fromAddressId={fromAddressId}
|
|
271
|
+
isTrusted={isTrusted}
|
|
272
|
+
showRaw={showRaw}
|
|
273
|
+
onToggleRaw={onToggleRaw}
|
|
274
|
+
/>
|
|
275
|
+
)
|
|
276
|
+
}
|
|
277
|
+
actionBar={
|
|
278
|
+
mobile ? (
|
|
279
|
+
<MobileMessageBar
|
|
280
|
+
messageId={threadMessage.messageId}
|
|
281
|
+
threadId={threadMessage.threadId}
|
|
282
|
+
mailboxId={threadMessage.mailboxId}
|
|
283
|
+
accountId={accountId}
|
|
284
|
+
isRead={threadMessage.isRead}
|
|
285
|
+
isStarred={threadMessage.hasStars}
|
|
286
|
+
onToggleStar={onToggleStar}
|
|
287
|
+
fromAddressId={fromAddressId}
|
|
288
|
+
isTrusted={isTrusted}
|
|
289
|
+
showRaw={showRaw}
|
|
290
|
+
onToggleRaw={onToggleRaw}
|
|
291
|
+
onReply={onReply}
|
|
292
|
+
onReplyAll={onReplyAll}
|
|
293
|
+
onForward={onForward}
|
|
294
|
+
/>
|
|
295
|
+
) : undefined
|
|
296
|
+
}
|
|
297
|
+
body={
|
|
298
|
+
isLoading ? (
|
|
299
|
+
<div className="mt-3 animate-pulse space-y-2">
|
|
300
|
+
<div className="h-4 bg-surface-sunken rounded w-full" />
|
|
301
|
+
<div className="h-4 bg-surface-sunken rounded w-3/4" />
|
|
302
|
+
<div className="h-4 bg-surface-sunken rounded w-1/2" />
|
|
303
|
+
</div>
|
|
304
|
+
) : isError && isMessageNotFoundError(error) ? (
|
|
305
|
+
<div className="mt-3">
|
|
306
|
+
<EmptyState message="This message has been deleted" />
|
|
307
|
+
</div>
|
|
308
|
+
) : isError ? (
|
|
309
|
+
<div className="mt-3">
|
|
310
|
+
<ErrorState
|
|
311
|
+
variant="inline"
|
|
312
|
+
title="Couldn't load this message"
|
|
313
|
+
error={error}
|
|
314
|
+
onRetry={onRetry}
|
|
315
|
+
/>
|
|
316
|
+
</div>
|
|
317
|
+
) : showRaw ? (
|
|
318
|
+
<div className="mt-3">
|
|
319
|
+
<RawMessageView messageId={threadMessage.messageId} />
|
|
320
|
+
</div>
|
|
321
|
+
) : (
|
|
322
|
+
<div className="mt-3">
|
|
323
|
+
<MessageBody
|
|
324
|
+
bodyParts={messageData?.bodyParts}
|
|
325
|
+
messageId={threadMessage.messageId}
|
|
326
|
+
fromAddressId={messageData?.envelope.from[0]?.addressId}
|
|
327
|
+
isTrusted={isTrusted}
|
|
328
|
+
category={toDisplayCategory(threadMessage.category)}
|
|
329
|
+
/>
|
|
330
|
+
</div>
|
|
331
|
+
)
|
|
332
|
+
}
|
|
333
|
+
/>
|
|
334
|
+
);
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
export const MessageCard = ({
|
|
338
|
+
threadMessage,
|
|
339
|
+
isExpanded,
|
|
340
|
+
isFocused,
|
|
341
|
+
onToggle,
|
|
342
|
+
onToggleStar,
|
|
343
|
+
isStarPending,
|
|
344
|
+
accountId,
|
|
345
|
+
mobile,
|
|
346
|
+
onReply,
|
|
347
|
+
onReplyAll,
|
|
348
|
+
onForward,
|
|
349
|
+
}: MessageCardProps) => {
|
|
350
|
+
const [showRaw, setShowRaw] = useState(false);
|
|
351
|
+
const {
|
|
352
|
+
data: messageData,
|
|
353
|
+
isLoading,
|
|
354
|
+
isError,
|
|
355
|
+
error,
|
|
356
|
+
refetch,
|
|
357
|
+
} = useQuery({
|
|
358
|
+
...messageOperationsDescribeMessageOptions({
|
|
359
|
+
path: { messageId: threadMessage.messageId },
|
|
360
|
+
}),
|
|
361
|
+
enabled: isExpanded,
|
|
362
|
+
// A 404 (row deleted / mid-refresh) renders the inline "deleted" empty
|
|
363
|
+
// state below — opt it out of the global fatal overlay. A 5xx still
|
|
364
|
+
// escalates globally (meta.softError is ignored for 5xx — #1059).
|
|
365
|
+
meta: { softError: true },
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
if (!isExpanded) {
|
|
369
|
+
return (
|
|
370
|
+
<CollapsedCard
|
|
371
|
+
threadMessage={threadMessage}
|
|
372
|
+
isFocused={isFocused}
|
|
373
|
+
onToggle={onToggle}
|
|
374
|
+
onToggleStar={onToggleStar}
|
|
375
|
+
isStarPending={isStarPending}
|
|
376
|
+
/>
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return (
|
|
381
|
+
<ExpandedCard
|
|
382
|
+
threadMessage={threadMessage}
|
|
383
|
+
messageData={messageData}
|
|
384
|
+
isLoading={isLoading}
|
|
385
|
+
isError={isError}
|
|
386
|
+
error={error}
|
|
387
|
+
onRetry={() => refetch()}
|
|
388
|
+
isFocused={isFocused}
|
|
389
|
+
onToggle={onToggle}
|
|
390
|
+
onToggleStar={onToggleStar}
|
|
391
|
+
isStarPending={isStarPending}
|
|
392
|
+
accountId={accountId}
|
|
393
|
+
showRaw={showRaw}
|
|
394
|
+
onToggleRaw={() => setShowRaw((prev) => !prev)}
|
|
395
|
+
mobile={mobile}
|
|
396
|
+
onReply={onReply}
|
|
397
|
+
onReplyAll={onReplyAll}
|
|
398
|
+
onForward={onForward}
|
|
399
|
+
/>
|
|
400
|
+
);
|
|
401
|
+
};
|