@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,368 @@
|
|
|
1
|
+
import {
|
|
2
|
+
messageBulkOperationsUpdateFlagsMutation,
|
|
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 { useNavigate, useSearch } from "@tanstack/react-router";
|
|
10
|
+
import {
|
|
11
|
+
BadgeCheck,
|
|
12
|
+
Check,
|
|
13
|
+
Code,
|
|
14
|
+
MailOpen,
|
|
15
|
+
MoreVertical,
|
|
16
|
+
Trash2,
|
|
17
|
+
} from "lucide-react";
|
|
18
|
+
import { useCallback } from "react";
|
|
19
|
+
import {
|
|
20
|
+
DropdownMenu,
|
|
21
|
+
DropdownMenuItem,
|
|
22
|
+
DropdownMenuSeparator,
|
|
23
|
+
} from "@/components/ui/DropdownMenu";
|
|
24
|
+
import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
|
|
25
|
+
import { ErrorState } from "@/components/ui/ErrorState";
|
|
26
|
+
import { formatErrorDetail } from "@/components/ui/error-banners";
|
|
27
|
+
import { useDeleteMessages } from "@/hooks/useDeleteMessages";
|
|
28
|
+
import { useMoveMessages } from "@/hooks/useMoveMessages";
|
|
29
|
+
import { useToggleTrusted } from "@/hooks/useToggleTrusted";
|
|
30
|
+
import { MoveToTrigger } from "./MoveToTrigger";
|
|
31
|
+
|
|
32
|
+
interface ThreadMessagesData {
|
|
33
|
+
items: RemitImapThreadMessageResponse[];
|
|
34
|
+
[key: string]: unknown;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface ThreadsListPage {
|
|
38
|
+
items: RemitImapThreadMessageResponse[];
|
|
39
|
+
[key: string]: unknown;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface ThreadsListData {
|
|
43
|
+
pages: ThreadsListPage[];
|
|
44
|
+
pageParams: Array<string | undefined>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface SnapshotEntry<T> {
|
|
48
|
+
queryKey: readonly unknown[];
|
|
49
|
+
data: T;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface MarkUnreadContext {
|
|
53
|
+
threadMessagesPrefix: readonly unknown[];
|
|
54
|
+
threadsListPrefix: readonly unknown[];
|
|
55
|
+
threadsSearchPrefix: readonly unknown[];
|
|
56
|
+
previousThreadMessages: SnapshotEntry<ThreadMessagesData>[];
|
|
57
|
+
previousThreadsList: SnapshotEntry<ThreadsListData>[];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface MessageActionMenuProps {
|
|
61
|
+
messageId: string;
|
|
62
|
+
threadId: string;
|
|
63
|
+
mailboxId: string;
|
|
64
|
+
isRead: boolean;
|
|
65
|
+
/**
|
|
66
|
+
* Account that owns this message's mailbox. When provided the
|
|
67
|
+
* Move-to-folder trigger is rendered next to the overflow; when
|
|
68
|
+
* omitted (e.g. the surrounding view hasn't resolved the account
|
|
69
|
+
* yet) the trigger is hidden so we never present a picker scoped to
|
|
70
|
+
* the wrong account.
|
|
71
|
+
*/
|
|
72
|
+
accountId?: string;
|
|
73
|
+
/**
|
|
74
|
+
* The From-address `addressId` for the message. When omitted (e.g. the
|
|
75
|
+
* envelope has no parseable From) the "Trusted sender" toggle is
|
|
76
|
+
* disabled with an explanatory tooltip.
|
|
77
|
+
*/
|
|
78
|
+
fromAddressId?: string;
|
|
79
|
+
/**
|
|
80
|
+
* Whether the From-address is currently flagged as trusted.
|
|
81
|
+
*/
|
|
82
|
+
isTrusted?: boolean;
|
|
83
|
+
/**
|
|
84
|
+
* Whether the body area is currently showing the raw RFC822/MIME source
|
|
85
|
+
* instead of the rendered body. Controls the label/icon of the toggle.
|
|
86
|
+
*/
|
|
87
|
+
showRaw?: boolean;
|
|
88
|
+
/**
|
|
89
|
+
* Toggle between the rendered body and the raw source. Owned by the
|
|
90
|
+
* parent card so the body area and this menu stay in sync.
|
|
91
|
+
*/
|
|
92
|
+
onToggleRaw?: () => void;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export const MessageActionMenu = ({
|
|
96
|
+
messageId,
|
|
97
|
+
threadId,
|
|
98
|
+
mailboxId,
|
|
99
|
+
isRead,
|
|
100
|
+
accountId,
|
|
101
|
+
fromAddressId,
|
|
102
|
+
isTrusted = false,
|
|
103
|
+
showRaw = false,
|
|
104
|
+
onToggleRaw,
|
|
105
|
+
}: MessageActionMenuProps) => {
|
|
106
|
+
const queryClient = useQueryClient();
|
|
107
|
+
const navigate = useNavigate();
|
|
108
|
+
const { pushError } = useErrorBanners();
|
|
109
|
+
const { selectedMessageId } = useSearch({ strict: false }) as {
|
|
110
|
+
selectedMessageId?: string;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const { mutate: updateFlags, isPending: isUpdatingFlags } = useMutation({
|
|
114
|
+
...messageBulkOperationsUpdateFlagsMutation(),
|
|
115
|
+
onMutate: async (variables): Promise<MarkUnreadContext> => {
|
|
116
|
+
const isReadNext = variables.body.isRead ?? true;
|
|
117
|
+
const targetIds = new Set(variables.body.messageIds ?? []);
|
|
118
|
+
|
|
119
|
+
const threadMessagesPrefix =
|
|
120
|
+
threadDetailOperationsListThreadMessagesQueryKey({
|
|
121
|
+
path: { threadId },
|
|
122
|
+
});
|
|
123
|
+
const threadsListPrefix = threadOperationsListThreadsQueryKey({
|
|
124
|
+
path: { mailboxId },
|
|
125
|
+
});
|
|
126
|
+
const threadsSearchPrefix = threadOperationsSearchThreadsQueryKey({
|
|
127
|
+
path: { mailboxId },
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
await Promise.all([
|
|
131
|
+
queryClient.cancelQueries({ queryKey: threadMessagesPrefix }),
|
|
132
|
+
queryClient.cancelQueries({ queryKey: threadsListPrefix }),
|
|
133
|
+
queryClient.cancelQueries({ queryKey: threadsSearchPrefix }),
|
|
134
|
+
]);
|
|
135
|
+
|
|
136
|
+
const previousThreadMessages = queryClient
|
|
137
|
+
.getQueriesData<ThreadMessagesData>({ queryKey: threadMessagesPrefix })
|
|
138
|
+
.filter(
|
|
139
|
+
(entry): entry is [readonly unknown[], ThreadMessagesData] =>
|
|
140
|
+
entry[1] !== undefined,
|
|
141
|
+
)
|
|
142
|
+
.map(([queryKey, data]) => ({ queryKey, data }));
|
|
143
|
+
|
|
144
|
+
const previousThreadsList = queryClient
|
|
145
|
+
.getQueriesData<ThreadsListData>({ queryKey: threadsListPrefix })
|
|
146
|
+
.concat(
|
|
147
|
+
queryClient.getQueriesData<ThreadsListData>({
|
|
148
|
+
queryKey: threadsSearchPrefix,
|
|
149
|
+
}),
|
|
150
|
+
)
|
|
151
|
+
.filter(
|
|
152
|
+
(entry): entry is [readonly unknown[], ThreadsListData] =>
|
|
153
|
+
entry[1] !== undefined,
|
|
154
|
+
)
|
|
155
|
+
.map(([queryKey, data]) => ({ queryKey, data }));
|
|
156
|
+
|
|
157
|
+
queryClient.setQueriesData<ThreadMessagesData>(
|
|
158
|
+
{ queryKey: threadMessagesPrefix },
|
|
159
|
+
(old) => {
|
|
160
|
+
if (!old) return old;
|
|
161
|
+
return {
|
|
162
|
+
...old,
|
|
163
|
+
items: old.items.map((item) =>
|
|
164
|
+
targetIds.has(item.messageId)
|
|
165
|
+
? { ...item, isRead: isReadNext }
|
|
166
|
+
: item,
|
|
167
|
+
),
|
|
168
|
+
};
|
|
169
|
+
},
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
const patchListData = (old: ThreadsListData | undefined) => {
|
|
173
|
+
if (!old) return old;
|
|
174
|
+
return {
|
|
175
|
+
...old,
|
|
176
|
+
pages: old.pages.map((page) => ({
|
|
177
|
+
...page,
|
|
178
|
+
items: page.items.map((item) =>
|
|
179
|
+
targetIds.has(item.messageId)
|
|
180
|
+
? { ...item, isRead: isReadNext }
|
|
181
|
+
: item,
|
|
182
|
+
),
|
|
183
|
+
})),
|
|
184
|
+
};
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
queryClient.setQueriesData<ThreadsListData>(
|
|
188
|
+
{ queryKey: threadsListPrefix },
|
|
189
|
+
patchListData,
|
|
190
|
+
);
|
|
191
|
+
queryClient.setQueriesData<ThreadsListData>(
|
|
192
|
+
{ queryKey: threadsSearchPrefix },
|
|
193
|
+
patchListData,
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
threadMessagesPrefix,
|
|
198
|
+
threadsListPrefix,
|
|
199
|
+
threadsSearchPrefix,
|
|
200
|
+
previousThreadMessages,
|
|
201
|
+
previousThreadsList,
|
|
202
|
+
};
|
|
203
|
+
},
|
|
204
|
+
onError: (err, variables, context) => {
|
|
205
|
+
if (context) {
|
|
206
|
+
for (const entry of context.previousThreadMessages) {
|
|
207
|
+
queryClient.setQueryData(entry.queryKey, entry.data);
|
|
208
|
+
}
|
|
209
|
+
for (const entry of context.previousThreadsList) {
|
|
210
|
+
queryClient.setQueryData(entry.queryKey, entry.data);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
const isReadNext = variables.body.isRead ?? true;
|
|
214
|
+
pushError({
|
|
215
|
+
title: isReadNext ? "Couldn't mark as read" : "Couldn't mark as unread",
|
|
216
|
+
detail: formatErrorDetail(err),
|
|
217
|
+
});
|
|
218
|
+
},
|
|
219
|
+
onSettled: (_data, _err, _vars, context) => {
|
|
220
|
+
if (!context) return;
|
|
221
|
+
queryClient.invalidateQueries({
|
|
222
|
+
queryKey: context.threadMessagesPrefix,
|
|
223
|
+
});
|
|
224
|
+
queryClient.invalidateQueries({ queryKey: context.threadsListPrefix });
|
|
225
|
+
queryClient.invalidateQueries({
|
|
226
|
+
queryKey: context.threadsSearchPrefix,
|
|
227
|
+
});
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// Deselect the thread before the server response arrives if the message
|
|
232
|
+
// being deleted is the one currently routed to. Mirrors the bulk-delete
|
|
233
|
+
// flow on the mailbox route — without this, the URL keeps a stale
|
|
234
|
+
// `selectedMessageId` after the row vanishes from the list.
|
|
235
|
+
const handleAfterOptimisticRemove = useCallback(
|
|
236
|
+
(removedIds: string[]) => {
|
|
237
|
+
if (!selectedMessageId) return;
|
|
238
|
+
if (!removedIds.includes(selectedMessageId)) return;
|
|
239
|
+
navigate({
|
|
240
|
+
to: "/mail/$mailboxId",
|
|
241
|
+
params: { mailboxId },
|
|
242
|
+
search: (prev: Record<string, unknown>) => ({
|
|
243
|
+
...prev,
|
|
244
|
+
selectedMessageId: undefined,
|
|
245
|
+
}),
|
|
246
|
+
});
|
|
247
|
+
},
|
|
248
|
+
[selectedMessageId, mailboxId, navigate],
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
const { deleteMessages, isPending: isDeleting } = useDeleteMessages({
|
|
252
|
+
mailboxId,
|
|
253
|
+
threadId,
|
|
254
|
+
onAfterOptimisticRemove: handleAfterOptimisticRemove,
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
const { moveMessages, isPending: isMoving } = useMoveMessages({
|
|
258
|
+
mailboxId,
|
|
259
|
+
threadId,
|
|
260
|
+
accountId,
|
|
261
|
+
onAfterOptimisticRemove: handleAfterOptimisticRemove,
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
const {
|
|
265
|
+
toggleTrusted,
|
|
266
|
+
isPending: isUpdatingTrusted,
|
|
267
|
+
error: trustedError,
|
|
268
|
+
reset: resetTrustedError,
|
|
269
|
+
} = useToggleTrusted({ messageId });
|
|
270
|
+
|
|
271
|
+
const handleMarkAsUnread = () => {
|
|
272
|
+
updateFlags({
|
|
273
|
+
body: {
|
|
274
|
+
messageIds: [messageId],
|
|
275
|
+
isRead: false,
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
const handleDelete = () => {
|
|
281
|
+
deleteMessages([messageId]);
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
const handleToggleTrusted = () => {
|
|
285
|
+
if (!fromAddressId) return;
|
|
286
|
+
toggleTrusted(fromAddressId, isTrusted);
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
const isDisabled =
|
|
290
|
+
isUpdatingFlags || isDeleting || isMoving || isUpdatingTrusted;
|
|
291
|
+
const trustedItemDisabled = isDisabled || !fromAddressId;
|
|
292
|
+
|
|
293
|
+
const handleMove = useCallback(
|
|
294
|
+
(destinationMailboxId: string) => {
|
|
295
|
+
moveMessages([messageId], destinationMailboxId);
|
|
296
|
+
},
|
|
297
|
+
[moveMessages, messageId],
|
|
298
|
+
);
|
|
299
|
+
const trustedItemTitle = !fromAddressId
|
|
300
|
+
? "Sender has no parseable address"
|
|
301
|
+
: undefined;
|
|
302
|
+
|
|
303
|
+
return (
|
|
304
|
+
<div className="flex flex-col items-end gap-1">
|
|
305
|
+
<div className="flex items-center gap-1">
|
|
306
|
+
{accountId && (
|
|
307
|
+
<MoveToTrigger
|
|
308
|
+
accountId={accountId}
|
|
309
|
+
currentMailboxId={mailboxId}
|
|
310
|
+
onMove={handleMove}
|
|
311
|
+
disabled={isDisabled}
|
|
312
|
+
label="Move this message"
|
|
313
|
+
/>
|
|
314
|
+
)}
|
|
315
|
+
<DropdownMenu trigger={<MoreVertical className="size-4" />}>
|
|
316
|
+
<DropdownMenuItem
|
|
317
|
+
onClick={handleToggleTrusted}
|
|
318
|
+
disabled={trustedItemDisabled}
|
|
319
|
+
>
|
|
320
|
+
{isTrusted ? (
|
|
321
|
+
<BadgeCheck className="size-4 text-positive" />
|
|
322
|
+
) : (
|
|
323
|
+
<Check className="size-4 opacity-0" />
|
|
324
|
+
)}
|
|
325
|
+
<span title={trustedItemTitle}>Trusted sender</span>
|
|
326
|
+
</DropdownMenuItem>
|
|
327
|
+
{isRead && (
|
|
328
|
+
<DropdownMenuItem
|
|
329
|
+
onClick={handleMarkAsUnread}
|
|
330
|
+
disabled={isDisabled}
|
|
331
|
+
>
|
|
332
|
+
<MailOpen className="size-4" />
|
|
333
|
+
Mark as unread
|
|
334
|
+
</DropdownMenuItem>
|
|
335
|
+
)}
|
|
336
|
+
{onToggleRaw && (
|
|
337
|
+
<DropdownMenuItem onClick={onToggleRaw}>
|
|
338
|
+
<Code className="size-4" />
|
|
339
|
+
{showRaw ? "Show formatted" : "Show raw email"}
|
|
340
|
+
</DropdownMenuItem>
|
|
341
|
+
)}
|
|
342
|
+
<DropdownMenuSeparator />
|
|
343
|
+
<DropdownMenuItem
|
|
344
|
+
onClick={handleDelete}
|
|
345
|
+
disabled={isDisabled}
|
|
346
|
+
destructive
|
|
347
|
+
>
|
|
348
|
+
<Trash2 className="size-4" />
|
|
349
|
+
Delete
|
|
350
|
+
</DropdownMenuItem>
|
|
351
|
+
</DropdownMenu>
|
|
352
|
+
</div>
|
|
353
|
+
{trustedError && (
|
|
354
|
+
<div className="w-64">
|
|
355
|
+
<ErrorState
|
|
356
|
+
variant="inline"
|
|
357
|
+
title="Couldn't update trust"
|
|
358
|
+
error={trustedError}
|
|
359
|
+
onRetry={() => {
|
|
360
|
+
resetTrustedError();
|
|
361
|
+
handleToggleTrusted();
|
|
362
|
+
}}
|
|
363
|
+
/>
|
|
364
|
+
</div>
|
|
365
|
+
)}
|
|
366
|
+
</div>
|
|
367
|
+
);
|
|
368
|
+
};
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildCidResolver,
|
|
3
|
+
type CidResolver,
|
|
4
|
+
type EmailRenderCategory,
|
|
5
|
+
MessageBodyView,
|
|
6
|
+
} from "@remit/ui";
|
|
7
|
+
import { useMemo, useState } from "react";
|
|
8
|
+
import { useIsDark } from "@/hooks/useIsDark";
|
|
9
|
+
import { useMessageBodyContent } from "@/hooks/useMessageBodyContent";
|
|
10
|
+
import { useToggleTrusted } from "@/hooks/useToggleTrusted";
|
|
11
|
+
import { cn } from "@/lib/utils";
|
|
12
|
+
import { BlockedImagesNotice } from "./BlockedImagesNotice";
|
|
13
|
+
import { MessageBodyErrorBanner } from "./MessageBodyErrorBanner";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Subset of the OpenAPI `BodyPartResponse` consumed by this component for
|
|
17
|
+
* inline-image / attachment URL resolution and the body fetcher. Pinning a
|
|
18
|
+
* structural subset avoids a hard import on the generated types so this file
|
|
19
|
+
* compiles before `make` runs in fresh checkouts.
|
|
20
|
+
*/
|
|
21
|
+
export interface MessageBodyPart {
|
|
22
|
+
bodyPartId: string;
|
|
23
|
+
mediaType: string;
|
|
24
|
+
mediaSubtype: string;
|
|
25
|
+
contentId?: string;
|
|
26
|
+
contentUrl: string;
|
|
27
|
+
disposition?: string;
|
|
28
|
+
dispositionFilename?: string;
|
|
29
|
+
isMultipart: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface MessageBodyProps {
|
|
33
|
+
/**
|
|
34
|
+
* Body parts from `describeMessage`. The component picks the first
|
|
35
|
+
* `text/html` part (fallback `text/plain`) and fetches its `contentUrl`
|
|
36
|
+
* from CloudFront. `cid:CONTENT_ID` references in HTML resolve to other
|
|
37
|
+
* parts' `contentUrl` via the same list (#224 PR 3).
|
|
38
|
+
*/
|
|
39
|
+
bodyParts?: readonly MessageBodyPart[];
|
|
40
|
+
/**
|
|
41
|
+
* Local-render fallback for callers that don't have BodyParts (Outbox
|
|
42
|
+
* drafts, Compose preview). When `bodyParts` is provided, these are
|
|
43
|
+
* ignored — the component fetches via CloudFront instead.
|
|
44
|
+
*/
|
|
45
|
+
html?: string;
|
|
46
|
+
text?: string;
|
|
47
|
+
/**
|
|
48
|
+
* Whether the From-address has the `trusted` flag set. Trusted senders
|
|
49
|
+
* auto-load images. The "Load images" bar is suppressed entirely for
|
|
50
|
+
* trusted senders.
|
|
51
|
+
*/
|
|
52
|
+
isTrusted?: boolean;
|
|
53
|
+
/**
|
|
54
|
+
* The current message id. Required for the body-content query cache key
|
|
55
|
+
* and by `useToggleTrusted` for the optimistic cache patch.
|
|
56
|
+
*/
|
|
57
|
+
messageId?: string;
|
|
58
|
+
/**
|
|
59
|
+
* The From-address `addressId`. When omitted (e.g. the envelope has no
|
|
60
|
+
* parseable From) the "Always trust" button is hidden.
|
|
61
|
+
*/
|
|
62
|
+
fromAddressId?: string;
|
|
63
|
+
/**
|
|
64
|
+
* Message category (personal/newsletter/marketing/…). Together with
|
|
65
|
+
* `hasAuthorBackground` this determines whether to apply the framed
|
|
66
|
+
* newsletter treatment or the plain-email normalization CSS.
|
|
67
|
+
*/
|
|
68
|
+
category?: EmailRenderCategory;
|
|
69
|
+
/**
|
|
70
|
+
* Extra classes for the `.message-body` wrapper. The single-message
|
|
71
|
+
* reading view (`MessageDetail`) passes `px-4` here so the body shares
|
|
72
|
+
* the header's horizontal inset (#729). `MessageCard` provides its own
|
|
73
|
+
* `px-5` inset via the surrounding card and leaves this unset.
|
|
74
|
+
*/
|
|
75
|
+
className?: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const LoadingSkeleton = () => (
|
|
79
|
+
// biome-ignore lint/a11y/useSemanticElements: <div> with role="status" preserves block layout; <output> is inline
|
|
80
|
+
<div
|
|
81
|
+
className="animate-pulse space-y-2"
|
|
82
|
+
role="status"
|
|
83
|
+
aria-label="Loading message body"
|
|
84
|
+
>
|
|
85
|
+
<div className="h-4 bg-surface-sunken rounded w-full" />
|
|
86
|
+
<div className="h-4 bg-surface-sunken rounded w-11/12" />
|
|
87
|
+
<div className="h-4 bg-surface-sunken rounded w-3/4" />
|
|
88
|
+
<div className="h-4 bg-surface-sunken rounded w-5/6" />
|
|
89
|
+
</div>
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
const EmptyBody = () => (
|
|
93
|
+
<p className="text-fg-muted text-sm italic">
|
|
94
|
+
This message has no body content.
|
|
95
|
+
</p>
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
export const MessageBody = ({
|
|
99
|
+
bodyParts,
|
|
100
|
+
html,
|
|
101
|
+
text,
|
|
102
|
+
isTrusted = false,
|
|
103
|
+
messageId,
|
|
104
|
+
fromAddressId,
|
|
105
|
+
category,
|
|
106
|
+
className,
|
|
107
|
+
}: MessageBodyProps) => {
|
|
108
|
+
const [allowImagesOnce, setAllowImagesOnce] = useState(false);
|
|
109
|
+
const allowImages = isTrusted || allowImagesOnce;
|
|
110
|
+
const isDark = useIsDark();
|
|
111
|
+
// `useToggleTrusted` surfaces its own failure (banner + rollback) and a fatal
|
|
112
|
+
// 5xx escalates globally — no consumer-side error effect needed here.
|
|
113
|
+
const { toggleTrusted, isPending: isTrustPending } = useToggleTrusted({
|
|
114
|
+
messageId: messageId ?? "",
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const hasParts = !!bodyParts && bodyParts.length > 0;
|
|
118
|
+
const {
|
|
119
|
+
picked,
|
|
120
|
+
data: fetched,
|
|
121
|
+
isLoading: isBodyLoading,
|
|
122
|
+
isError: isBodyError,
|
|
123
|
+
error: bodyError,
|
|
124
|
+
refetch: refetchBody,
|
|
125
|
+
} = useMessageBodyContent({
|
|
126
|
+
messageId,
|
|
127
|
+
bodyParts,
|
|
128
|
+
enabled: hasParts,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const resolveCid: CidResolver = useMemo(
|
|
132
|
+
() => buildCidResolver(bodyParts ?? []),
|
|
133
|
+
[bodyParts],
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
const renderedHtml =
|
|
137
|
+
hasParts && fetched?.kind === "html" ? fetched.body : html;
|
|
138
|
+
const renderedText =
|
|
139
|
+
hasParts && fetched?.kind === "text" ? fetched.body : text;
|
|
140
|
+
|
|
141
|
+
if (hasParts) {
|
|
142
|
+
if (isBodyLoading) {
|
|
143
|
+
return (
|
|
144
|
+
<div className={cn("message-body", className)}>
|
|
145
|
+
<LoadingSkeleton />
|
|
146
|
+
</div>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
if (isBodyError) {
|
|
150
|
+
return (
|
|
151
|
+
<div className={cn("message-body", className)}>
|
|
152
|
+
<MessageBodyErrorBanner
|
|
153
|
+
error={bodyError}
|
|
154
|
+
onRetry={() => refetchBody()}
|
|
155
|
+
/>
|
|
156
|
+
</div>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
if (!picked) {
|
|
160
|
+
return (
|
|
161
|
+
<div className={cn("message-body", className)}>
|
|
162
|
+
<EmptyBody />
|
|
163
|
+
</div>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
} else if (!html && !text) {
|
|
167
|
+
return (
|
|
168
|
+
<div className={cn("message-body", className)}>
|
|
169
|
+
<EmptyBody />
|
|
170
|
+
</div>
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const canAlwaysTrust = Boolean(fromAddressId && messageId);
|
|
175
|
+
const handleAlwaysTrust = () => {
|
|
176
|
+
if (!fromAddressId || isTrustPending) return;
|
|
177
|
+
setAllowImagesOnce(true);
|
|
178
|
+
toggleTrusted(fromAddressId, false);
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
// The sanitize → classify → sandboxed-iframe rendering lives in the kit's
|
|
182
|
+
// `MessageBodyView` (the single source of truth, shared with Storybook's
|
|
183
|
+
// reading panes — #940). This component keeps the data orchestration:
|
|
184
|
+
// bodyParts → html/text, the allow-images-once trust state, and the
|
|
185
|
+
// privacy "images blocked" bar (it touches app trust state, so it stays
|
|
186
|
+
// here and is injected via `renderBlockedNotice`).
|
|
187
|
+
return (
|
|
188
|
+
<MessageBodyView
|
|
189
|
+
className={className}
|
|
190
|
+
html={renderedHtml}
|
|
191
|
+
text={renderedText}
|
|
192
|
+
isDark={isDark}
|
|
193
|
+
category={category}
|
|
194
|
+
allowImages={allowImages}
|
|
195
|
+
resolveCid={resolveCid}
|
|
196
|
+
renderBlockedNotice={(blockedImageCount) => (
|
|
197
|
+
<BlockedImagesNotice
|
|
198
|
+
blockedImageCount={blockedImageCount}
|
|
199
|
+
canAlwaysTrust={canAlwaysTrust}
|
|
200
|
+
isTrustPending={isTrustPending}
|
|
201
|
+
onLoadOnce={() => setAllowImagesOnce(true)}
|
|
202
|
+
onAlwaysTrust={handleAlwaysTrust}
|
|
203
|
+
/>
|
|
204
|
+
)}
|
|
205
|
+
/>
|
|
206
|
+
);
|
|
207
|
+
};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React-render smoke test for `MessageBodyErrorBanner`. Pure helpers are
|
|
3
|
+
* covered by `MessageBodyErrorBanner.test.ts`; this file proves the component
|
|
4
|
+
* itself renders without crashing in the two surfaces that matter:
|
|
5
|
+
*
|
|
6
|
+
* 1. local-dev (no Cognito, no `Authenticator.Provider` in scope) — the
|
|
7
|
+
* previous version of this component called `useAuthenticator` at the
|
|
8
|
+
* top of render, which throws `USE_AUTHENTICATOR_ERROR` outside the
|
|
9
|
+
* provider and would crash the whole MessageBody subtree on the first
|
|
10
|
+
* body-fetch failure. The fix gates the hook-using subcomponent behind
|
|
11
|
+
* `isCognitoConfigured()`. This test pins that behaviour.
|
|
12
|
+
* 2. body-missing variant — no sign-in CTA regardless of config, so it
|
|
13
|
+
* should also render fine without the provider.
|
|
14
|
+
*
|
|
15
|
+
* Uses `react-dom/server`'s `renderToString` so the assertion runs without
|
|
16
|
+
* pulling in jsdom/happy-dom (no new deps).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import assert from "node:assert/strict";
|
|
20
|
+
import { describe, it } from "node:test";
|
|
21
|
+
import { createElement } from "react";
|
|
22
|
+
import { renderToString } from "react-dom/server";
|
|
23
|
+
import { BodyFetchError } from "@/hooks/useMessageBodyContent";
|
|
24
|
+
import { MessageBodyErrorBanner } from "./MessageBodyErrorBanner";
|
|
25
|
+
|
|
26
|
+
const renderBanner = (error: unknown, onRetry?: () => void): string =>
|
|
27
|
+
renderToString(
|
|
28
|
+
createElement(MessageBodyErrorBanner, { error, onRetry }) as never,
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The sign-in CTA is rendered as a `<button>` whose text is "Sign in again".
|
|
33
|
+
* The string "Sign in again" also appears as the prefix of the auth-variant
|
|
34
|
+
* detail copy, so the assertion must look for the BUTTON tag specifically,
|
|
35
|
+
* not the bare substring.
|
|
36
|
+
*/
|
|
37
|
+
const hasSignInButton = (html: string): boolean =>
|
|
38
|
+
/<button\b[^>]*>Sign in again<\/button>/.test(html);
|
|
39
|
+
|
|
40
|
+
describe("MessageBodyErrorBanner renders without an Authenticator.Provider in scope (#401 review)", () => {
|
|
41
|
+
it("renders the body-missing variant without throwing — local-dev / production both reach this branch", () => {
|
|
42
|
+
const html = renderBanner(
|
|
43
|
+
new BodyFetchError("body-missing", "missing", 403),
|
|
44
|
+
);
|
|
45
|
+
assert.match(html, /Message body is missing in storage/);
|
|
46
|
+
assert.match(html, /data-reason="body-missing"/);
|
|
47
|
+
assert.equal(
|
|
48
|
+
hasSignInButton(html),
|
|
49
|
+
false,
|
|
50
|
+
"sign-in CTA must not appear for body-missing — only the auth variant",
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("renders the auth variant in local-dev WITHOUT mounting the sign-in CTA — `useAuthenticator` would throw outside `Authenticator.Provider` and crash the subtree", () => {
|
|
55
|
+
// Default test env: `globalThis.__REMIT_CONFIG__` is unset, so
|
|
56
|
+
// `isCognitoConfigured()` returns false — same shape as `AuthShell`
|
|
57
|
+
// running without the provider. If `MessageBodyErrorBanner` calls
|
|
58
|
+
// `useAuthenticator` unconditionally again, this `renderToString`
|
|
59
|
+
// call throws.
|
|
60
|
+
const html = renderBanner(
|
|
61
|
+
new BodyFetchError("auth", "Invalid id_token", 401),
|
|
62
|
+
);
|
|
63
|
+
assert.match(html, /Your session expired/);
|
|
64
|
+
assert.match(html, /data-reason="auth"/);
|
|
65
|
+
assert.equal(
|
|
66
|
+
hasSignInButton(html),
|
|
67
|
+
false,
|
|
68
|
+
"sign-in CTA must be suppressed when Cognito is not configured (no Authenticator.Provider in the tree) — pre-fix regression: the button rendered and `useAuthenticator` crashed at render",
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("renders the generic variant for unknown errors without crashing", () => {
|
|
73
|
+
const html = renderBanner(new Error("network down"));
|
|
74
|
+
assert.match(html, /Couldn.+t load message body/);
|
|
75
|
+
assert.match(html, /network down/);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("renders a Retry button when onRetry is supplied (every variant)", () => {
|
|
79
|
+
const html = renderBanner(
|
|
80
|
+
new BodyFetchError("body-missing", "missing", 403),
|
|
81
|
+
() => undefined,
|
|
82
|
+
);
|
|
83
|
+
assert.match(html, /<button\b[^>]*>Retry<\/button>/);
|
|
84
|
+
});
|
|
85
|
+
});
|