@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,110 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BlockquotePlugin,
|
|
3
|
+
BoldPlugin,
|
|
4
|
+
ItalicPlugin,
|
|
5
|
+
} from "@platejs/basic-nodes/react";
|
|
6
|
+
import { insertLink } from "@platejs/link";
|
|
7
|
+
import { Bold, Italic, Link, Quote, Redo2, Undo2 } from "lucide-react";
|
|
8
|
+
import { useEditorRef, useEditorSelector } from "platejs/react";
|
|
9
|
+
|
|
10
|
+
const ToolbarButton = ({
|
|
11
|
+
isActive,
|
|
12
|
+
onClick,
|
|
13
|
+
children,
|
|
14
|
+
title,
|
|
15
|
+
}: {
|
|
16
|
+
isActive: boolean;
|
|
17
|
+
onClick: () => void;
|
|
18
|
+
children: React.ReactNode;
|
|
19
|
+
title: string;
|
|
20
|
+
}) => (
|
|
21
|
+
<button
|
|
22
|
+
type="button"
|
|
23
|
+
onMouseDown={(e) => {
|
|
24
|
+
e.preventDefault();
|
|
25
|
+
onClick();
|
|
26
|
+
}}
|
|
27
|
+
title={title}
|
|
28
|
+
className={`p-1.5 rounded transition-colors ${
|
|
29
|
+
isActive
|
|
30
|
+
? "text-fg bg-accent-2-soft"
|
|
31
|
+
: "text-fg-muted hover:text-fg hover:bg-surface-raised"
|
|
32
|
+
}`}
|
|
33
|
+
>
|
|
34
|
+
{children}
|
|
35
|
+
</button>
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
export const PlateToolbar = () => {
|
|
39
|
+
const editor = useEditorRef();
|
|
40
|
+
|
|
41
|
+
const isBoldActive = useEditorSelector(
|
|
42
|
+
(editor) => !!editor.api.mark(BoldPlugin.key),
|
|
43
|
+
[],
|
|
44
|
+
);
|
|
45
|
+
const isItalicActive = useEditorSelector(
|
|
46
|
+
(editor) => !!editor.api.mark(ItalicPlugin.key),
|
|
47
|
+
[],
|
|
48
|
+
);
|
|
49
|
+
const isBlockquoteActive = useEditorSelector((editor) => {
|
|
50
|
+
const entry = editor.api.block();
|
|
51
|
+
return entry ? entry[0].type === BlockquotePlugin.key : false;
|
|
52
|
+
}, []);
|
|
53
|
+
|
|
54
|
+
const canUndo = useEditorSelector(
|
|
55
|
+
(editor) => editor.history.undos.length > 0,
|
|
56
|
+
[],
|
|
57
|
+
);
|
|
58
|
+
const canRedo = useEditorSelector(
|
|
59
|
+
(editor) => editor.history.redos.length > 0,
|
|
60
|
+
[],
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<div className="flex items-center gap-0.5 px-3 py-1 border-b border-line">
|
|
65
|
+
<ToolbarButton
|
|
66
|
+
isActive={isBoldActive}
|
|
67
|
+
onClick={() => editor.tf.toggleMark(BoldPlugin.key)}
|
|
68
|
+
title="Bold (Ctrl+B)"
|
|
69
|
+
>
|
|
70
|
+
<Bold className="size-4" />
|
|
71
|
+
</ToolbarButton>
|
|
72
|
+
<ToolbarButton
|
|
73
|
+
isActive={isItalicActive}
|
|
74
|
+
onClick={() => editor.tf.toggleMark(ItalicPlugin.key)}
|
|
75
|
+
title="Italic (Ctrl+I)"
|
|
76
|
+
>
|
|
77
|
+
<Italic className="size-4" />
|
|
78
|
+
</ToolbarButton>
|
|
79
|
+
<ToolbarButton
|
|
80
|
+
isActive={false}
|
|
81
|
+
onClick={() => insertLink(editor, { url: "" })}
|
|
82
|
+
title="Link (Ctrl+K)"
|
|
83
|
+
>
|
|
84
|
+
<Link className="size-4" />
|
|
85
|
+
</ToolbarButton>
|
|
86
|
+
<ToolbarButton
|
|
87
|
+
isActive={isBlockquoteActive}
|
|
88
|
+
onClick={() => editor.tf.toggleBlock(BlockquotePlugin.key)}
|
|
89
|
+
title="Blockquote"
|
|
90
|
+
>
|
|
91
|
+
<Quote className="size-4" />
|
|
92
|
+
</ToolbarButton>
|
|
93
|
+
<div className="mx-1.5 h-4 w-px bg-line" />
|
|
94
|
+
<ToolbarButton
|
|
95
|
+
isActive={false}
|
|
96
|
+
onClick={() => editor.undo()}
|
|
97
|
+
title="Undo (Ctrl+Z)"
|
|
98
|
+
>
|
|
99
|
+
<Undo2 className={`size-4 ${!canUndo ? "opacity-40" : ""}`} />
|
|
100
|
+
</ToolbarButton>
|
|
101
|
+
<ToolbarButton
|
|
102
|
+
isActive={false}
|
|
103
|
+
onClick={() => editor.redo()}
|
|
104
|
+
title="Redo (Ctrl+Y)"
|
|
105
|
+
>
|
|
106
|
+
<Redo2 className={`size-4 ${!canRedo ? "opacity-40" : ""}`} />
|
|
107
|
+
</ToolbarButton>
|
|
108
|
+
</div>
|
|
109
|
+
);
|
|
110
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
interface SubjectFieldProps {
|
|
2
|
+
value: string;
|
|
3
|
+
onChange: (value: string) => void;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export const SubjectField = ({ value, onChange }: SubjectFieldProps) => (
|
|
7
|
+
<div className="flex items-start gap-2">
|
|
8
|
+
{/* biome-ignore lint/a11y/noLabelWithoutControl: label is visually adjacent to the sibling input; static id risks duplicates */}
|
|
9
|
+
<label className="text-sm text-fg-muted shrink-0 w-12 pt-1.5">Subj:</label>
|
|
10
|
+
<input
|
|
11
|
+
type="text"
|
|
12
|
+
value={value}
|
|
13
|
+
onChange={(e) => onChange(e.target.value)}
|
|
14
|
+
className="flex-1 px-2 py-1.5 border rounded-md bg-canvas text-sm"
|
|
15
|
+
placeholder="Subject"
|
|
16
|
+
data-subject-field
|
|
17
|
+
/>
|
|
18
|
+
</div>
|
|
19
|
+
);
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Draft-discard must surface a banner on a soft failure.
|
|
3
|
+
*
|
|
4
|
+
* `ComposeForm.handleDiscard` closes the compose dialog optimistically, then
|
|
5
|
+
* fires the delete mutation. Before #741 the delete had no `onError`, so a soft
|
|
6
|
+
* 4xx (409/404 "the draft is already gone") closed the dialog as if it
|
|
7
|
+
* succeeded — silent discard. The fix adds an `onError` that pushes a banner via
|
|
8
|
+
* `buildMutationErrorBanner("Couldn't discard draft", …)`, mirroring the
|
|
9
|
+
* outbox-delete pattern. A fatal 5xx still escalates globally; this pins the
|
|
10
|
+
* non-fatal branch.
|
|
11
|
+
*
|
|
12
|
+
* Exercising the real mutation `onError` headlessly (no DOM) via the same
|
|
13
|
+
* `MutationCache` wiring `ComposeForm` uses through `QueryClient`.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import assert from "node:assert/strict";
|
|
17
|
+
import { describe, it } from "node:test";
|
|
18
|
+
import { MutationCache, QueryClient } from "@tanstack/react-query";
|
|
19
|
+
import { ApiError } from "../../lib/api.js";
|
|
20
|
+
import {
|
|
21
|
+
buildMutationErrorBanner,
|
|
22
|
+
type PushErrorInput,
|
|
23
|
+
} from "../ui/error-banners.js";
|
|
24
|
+
|
|
25
|
+
describe("draft discard surfaces a banner on a soft failure (#741)", () => {
|
|
26
|
+
const buildDiscardMutation = (
|
|
27
|
+
pushError: (input: PushErrorInput) => void,
|
|
28
|
+
fail: () => Promise<never>,
|
|
29
|
+
) => {
|
|
30
|
+
const client = new QueryClient({
|
|
31
|
+
mutationCache: new MutationCache(),
|
|
32
|
+
});
|
|
33
|
+
return client.getMutationCache().build(client, {
|
|
34
|
+
mutationFn: fail,
|
|
35
|
+
onError: (error: unknown) => {
|
|
36
|
+
pushError(
|
|
37
|
+
buildMutationErrorBanner(
|
|
38
|
+
"Couldn't discard draft",
|
|
39
|
+
"The draft wasn't discarded.",
|
|
40
|
+
error,
|
|
41
|
+
),
|
|
42
|
+
);
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
it("pushes a 'Couldn't discard draft' banner on a 409 already-gone", async () => {
|
|
48
|
+
const pushed: PushErrorInput[] = [];
|
|
49
|
+
const mutation = buildDiscardMutation(
|
|
50
|
+
(input) => pushed.push(input),
|
|
51
|
+
async () => {
|
|
52
|
+
throw new ApiError("Draft already deleted", 409);
|
|
53
|
+
},
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
await mutation.execute(undefined).catch(() => {});
|
|
57
|
+
|
|
58
|
+
assert.equal(pushed.length, 1);
|
|
59
|
+
assert.equal(pushed[0].title, "Couldn't discard draft");
|
|
60
|
+
assert.equal(pushed[0].detail, "Draft already deleted");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("falls back to a human detail when the error carries no message", async () => {
|
|
64
|
+
const pushed: PushErrorInput[] = [];
|
|
65
|
+
const mutation = buildDiscardMutation(
|
|
66
|
+
(input) => pushed.push(input),
|
|
67
|
+
async () => {
|
|
68
|
+
throw {};
|
|
69
|
+
},
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
await mutation.execute(undefined).catch(() => {});
|
|
73
|
+
|
|
74
|
+
assert.equal(pushed[0].detail, "The draft wasn't discarded.");
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { ComposeForm } from "./ComposeForm.js";
|
|
2
|
+
export type { ComposeMode, ComposeState } from "./ComposeProvider.js";
|
|
3
|
+
export { ComposeProvider, useCompose } from "./ComposeProvider.js";
|
|
4
|
+
export { FullCompose } from "./FullCompose.js";
|
|
5
|
+
export { InlineCompose } from "./InlineCompose.js";
|
|
6
|
+
export { MobileComposeSheet } from "./MobileComposeSheet.js";
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import DOMPurify from "dompurify";
|
|
2
|
+
|
|
3
|
+
const QUOTE_ALLOWED_TAGS = [
|
|
4
|
+
"p",
|
|
5
|
+
"br",
|
|
6
|
+
"strong",
|
|
7
|
+
"b",
|
|
8
|
+
"em",
|
|
9
|
+
"i",
|
|
10
|
+
"a",
|
|
11
|
+
"blockquote",
|
|
12
|
+
"ul",
|
|
13
|
+
"ol",
|
|
14
|
+
"li",
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
const QUOTE_ALLOWED_ATTR = ["href"];
|
|
18
|
+
|
|
19
|
+
export const sanitizeQuoteHtml = (html: string): string =>
|
|
20
|
+
DOMPurify.sanitize(html, {
|
|
21
|
+
ALLOWED_TAGS: QUOTE_ALLOWED_TAGS,
|
|
22
|
+
ALLOWED_ATTR: QUOTE_ALLOWED_ATTR,
|
|
23
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AppShellSkeleton — structural placeholder shown on cold load.
|
|
3
|
+
*
|
|
4
|
+
* Renders the inbox chrome (nav rail + list rows) immediately, before the
|
|
5
|
+
* auth session hydrates and the config query resolves, so the user sees the
|
|
6
|
+
* shape of the app instead of a blank screen or a "Loading..." string. The
|
|
7
|
+
* real list components swap in once their own data arrives.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const NavRailSkeleton = () => (
|
|
11
|
+
<div className="hidden h-full w-[17%] min-w-40 max-w-64 shrink-0 flex-col gap-1 border-r border-line p-3 md:flex">
|
|
12
|
+
<div className="mb-3 h-6 w-24 rounded bg-surface-sunken" />
|
|
13
|
+
{Array.from({ length: 6 }).map((_, i) => (
|
|
14
|
+
<div
|
|
15
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: skeleton rows have no stable id
|
|
16
|
+
key={i}
|
|
17
|
+
className="flex items-center gap-2 py-1.5"
|
|
18
|
+
>
|
|
19
|
+
<div className="size-5 shrink-0 rounded bg-surface-sunken" />
|
|
20
|
+
<div className="h-3.5 flex-1 rounded bg-surface-sunken" />
|
|
21
|
+
</div>
|
|
22
|
+
))}
|
|
23
|
+
</div>
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
const ListRowsSkeleton = () => (
|
|
27
|
+
<div className="min-w-0 flex-1">
|
|
28
|
+
{Array.from({ length: 8 }).map((_, i) => (
|
|
29
|
+
<div
|
|
30
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: skeleton rows have no stable id
|
|
31
|
+
key={i}
|
|
32
|
+
className="flex items-start gap-3 border-b border-line py-2 pl-5 pr-4"
|
|
33
|
+
>
|
|
34
|
+
<div className="mt-0.5 size-7 shrink-0 rounded-full bg-surface-sunken" />
|
|
35
|
+
<div className="flex-1 space-y-1.5">
|
|
36
|
+
<div className="flex justify-between gap-2">
|
|
37
|
+
<div className="h-3.5 w-28 rounded bg-surface-sunken" />
|
|
38
|
+
<div className="h-3 w-12 rounded bg-surface-sunken" />
|
|
39
|
+
</div>
|
|
40
|
+
<div className="h-3.5 w-48 rounded bg-surface-sunken" />
|
|
41
|
+
<div className="h-3 w-full rounded bg-surface-sunken" />
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
))}
|
|
45
|
+
</div>
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
export const AppShellSkeleton = () => (
|
|
49
|
+
<div className="flex h-dvh animate-pulse bg-canvas" aria-hidden="true">
|
|
50
|
+
<NavRailSkeleton />
|
|
51
|
+
<ListRowsSkeleton />
|
|
52
|
+
</div>
|
|
53
|
+
);
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { useLocation, useNavigate } from "@tanstack/react-router";
|
|
2
|
+
import { Pencil } from "lucide-react";
|
|
3
|
+
import { startTransition } from "react";
|
|
4
|
+
import { useCompose } from "@/components/compose/ComposeProvider";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Returns true when `pathname` is `/mail/<id>` for any id other than
|
|
8
|
+
* the reserved `outbox` segment. That route owns a `FullCompose` mount
|
|
9
|
+
* point, so the FAB can open compose in-place. From every other route
|
|
10
|
+
* the FAB has to send the user there first.
|
|
11
|
+
*/
|
|
12
|
+
const isOnMailboxRoute = (pathname: string): boolean =>
|
|
13
|
+
/^\/mail\/(?!outbox)[^/?]+/.test(pathname);
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Primary mobile surfaces where the FAB belongs: anywhere under
|
|
17
|
+
* `/mail` or under `/settings`. The bare `/` route (sign-in / OAuth
|
|
18
|
+
* landing) is intentionally excluded — compose has no useful target
|
|
19
|
+
* before the user has an account.
|
|
20
|
+
*/
|
|
21
|
+
const isOnPrimaryMobileRoute = (pathname: string): boolean =>
|
|
22
|
+
pathname.startsWith("/mail") || pathname.startsWith("/settings");
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Floating Action Button for composing a new message. Mobile-only.
|
|
26
|
+
*
|
|
27
|
+
* Layout follows Material 3: 56×56 surface, 16px from the right and
|
|
28
|
+
* bottom edges (plus the iOS safe-area inset). Hidden when any of:
|
|
29
|
+
* - Viewport is `≥ md` (desktop has compose in the sidebar / etc.).
|
|
30
|
+
* - The compose surface is already open.
|
|
31
|
+
* - The user is reading a thread (`?selectedMessageId=…`) — the
|
|
32
|
+
* conversation's Reply/Forward action bar covers that workflow.
|
|
33
|
+
* - The user is not on a primary mobile route (`/mail` or
|
|
34
|
+
* `/settings`).
|
|
35
|
+
*
|
|
36
|
+
* From routes that don't host a `FullCompose` (everything except
|
|
37
|
+
* `/mail/<mailboxId>` — i.e. `/mail`, `/mail/outbox`, `/settings/*`)
|
|
38
|
+
* tapping the FAB opens compose state and navigates to `/mail`, which
|
|
39
|
+
* the index loader redirects to the preferred mailbox (PR #138). The
|
|
40
|
+
* compose state survives the route transition because `ComposeProvider`
|
|
41
|
+
* lives in `__root.tsx`, so the destination route mounts straight into
|
|
42
|
+
* compose.
|
|
43
|
+
*/
|
|
44
|
+
export const ComposeFab = () => {
|
|
45
|
+
const { state, openCompose } = useCompose();
|
|
46
|
+
const location = useLocation();
|
|
47
|
+
const navigate = useNavigate();
|
|
48
|
+
|
|
49
|
+
const search = location.search as Record<string, unknown> | undefined;
|
|
50
|
+
const isReadingThread =
|
|
51
|
+
typeof search?.selectedMessageId === "string" &&
|
|
52
|
+
search.selectedMessageId.length > 0;
|
|
53
|
+
|
|
54
|
+
if (
|
|
55
|
+
!isOnPrimaryMobileRoute(location.pathname) ||
|
|
56
|
+
state.isOpen ||
|
|
57
|
+
isReadingThread
|
|
58
|
+
)
|
|
59
|
+
return null;
|
|
60
|
+
|
|
61
|
+
const handleClick = () => {
|
|
62
|
+
// Wrap in startTransition so the suspension that ComposeForm
|
|
63
|
+
// triggers on first mount (lazy chunks, queries) doesn't
|
|
64
|
+
// snap the surrounding chrome into a route-level fallback.
|
|
65
|
+
startTransition(() => {
|
|
66
|
+
openCompose({ mode: "new" });
|
|
67
|
+
});
|
|
68
|
+
if (isOnMailboxRoute(location.pathname)) return;
|
|
69
|
+
navigate({ to: "/mail" });
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<button
|
|
74
|
+
type="button"
|
|
75
|
+
onClick={handleClick}
|
|
76
|
+
aria-label="Compose new message"
|
|
77
|
+
className="lg:hidden fixed right-4 z-30 h-14 w-14 rounded-full bg-accent text-accent-fg shadow-lg flex items-center justify-center hover:opacity-90 active:scale-95 transition-all"
|
|
78
|
+
style={{
|
|
79
|
+
// 16px breathing room above the iOS home-indicator inset.
|
|
80
|
+
bottom: "calc(env(safe-area-inset-bottom, 0) + 1rem)",
|
|
81
|
+
}}
|
|
82
|
+
>
|
|
83
|
+
<Pencil className="size-6" />
|
|
84
|
+
</button>
|
|
85
|
+
);
|
|
86
|
+
};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { X } from "lucide-react";
|
|
2
|
+
import { type ReactNode, useEffect, useRef } from "react";
|
|
3
|
+
import { cn } from "@/lib/utils";
|
|
4
|
+
|
|
5
|
+
interface DrawerProps {
|
|
6
|
+
isOpen: boolean;
|
|
7
|
+
onClose: () => void;
|
|
8
|
+
children: ReactNode;
|
|
9
|
+
ariaLabel?: string;
|
|
10
|
+
side?: "left" | "right";
|
|
11
|
+
widthClassName?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Modal navigation drawer. Slides in from the side with a scrim behind.
|
|
16
|
+
* Dismissed by scrim tap, escape key, or the close button. Focus moves into
|
|
17
|
+
* the drawer on open and returns to the previously focused element on close.
|
|
18
|
+
*/
|
|
19
|
+
export const Drawer = ({
|
|
20
|
+
isOpen,
|
|
21
|
+
onClose,
|
|
22
|
+
children,
|
|
23
|
+
ariaLabel = "Navigation",
|
|
24
|
+
side = "left",
|
|
25
|
+
widthClassName = "w-[80vw] max-w-[320px]",
|
|
26
|
+
}: DrawerProps) => {
|
|
27
|
+
const drawerRef = useRef<HTMLDivElement>(null);
|
|
28
|
+
const previouslyFocusedRef = useRef<HTMLElement | null>(null);
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
if (!isOpen) return;
|
|
32
|
+
|
|
33
|
+
previouslyFocusedRef.current = document.activeElement as HTMLElement | null;
|
|
34
|
+
|
|
35
|
+
const handleKey = (event: KeyboardEvent) => {
|
|
36
|
+
if (event.key === "Escape") {
|
|
37
|
+
event.preventDefault();
|
|
38
|
+
onClose();
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
document.addEventListener("keydown", handleKey);
|
|
43
|
+
|
|
44
|
+
// Move focus into the drawer
|
|
45
|
+
const focusable = drawerRef.current?.querySelector<HTMLElement>(
|
|
46
|
+
"button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])",
|
|
47
|
+
);
|
|
48
|
+
focusable?.focus();
|
|
49
|
+
|
|
50
|
+
// Lock body scroll
|
|
51
|
+
const previousOverflow = document.body.style.overflow;
|
|
52
|
+
document.body.style.overflow = "hidden";
|
|
53
|
+
|
|
54
|
+
return () => {
|
|
55
|
+
document.removeEventListener("keydown", handleKey);
|
|
56
|
+
document.body.style.overflow = previousOverflow;
|
|
57
|
+
previouslyFocusedRef.current?.focus();
|
|
58
|
+
};
|
|
59
|
+
}, [isOpen, onClose]);
|
|
60
|
+
|
|
61
|
+
if (!isOpen) return null;
|
|
62
|
+
|
|
63
|
+
const sideClasses =
|
|
64
|
+
side === "left"
|
|
65
|
+
? "left-0 border-r animate-in slide-in-from-left duration-150"
|
|
66
|
+
: "right-0 border-l animate-in slide-in-from-right duration-150";
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<div
|
|
70
|
+
className="fixed inset-0 z-50 lg:hidden"
|
|
71
|
+
role="dialog"
|
|
72
|
+
aria-modal="true"
|
|
73
|
+
aria-label={ariaLabel}
|
|
74
|
+
>
|
|
75
|
+
{/* Scrim */}
|
|
76
|
+
<button
|
|
77
|
+
type="button"
|
|
78
|
+
aria-label="Close menu"
|
|
79
|
+
onClick={onClose}
|
|
80
|
+
className="absolute inset-0 bg-black/40 animate-in fade-in duration-150 cursor-default"
|
|
81
|
+
/>
|
|
82
|
+
{/* Drawer panel */}
|
|
83
|
+
<div
|
|
84
|
+
ref={drawerRef}
|
|
85
|
+
className={cn(
|
|
86
|
+
"absolute top-0 bottom-0 bg-canvas border-line shadow-xl flex flex-col",
|
|
87
|
+
widthClassName,
|
|
88
|
+
sideClasses,
|
|
89
|
+
)}
|
|
90
|
+
>
|
|
91
|
+
<div className="flex items-center justify-end h-12 px-2 border-b border-line shrink-0">
|
|
92
|
+
<button
|
|
93
|
+
type="button"
|
|
94
|
+
onClick={onClose}
|
|
95
|
+
className="p-2 rounded-md hover:bg-surface-raised transition-colors min-h-11 min-w-11 inline-flex items-center justify-center"
|
|
96
|
+
aria-label="Close menu"
|
|
97
|
+
>
|
|
98
|
+
<X className="size-5" />
|
|
99
|
+
</button>
|
|
100
|
+
</div>
|
|
101
|
+
<div className="flex-1 overflow-y-auto">{children}</div>
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
104
|
+
);
|
|
105
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { RemitImapAutoMovedInfo } from "@remit/api-http-client/types.gen.ts";
|
|
2
|
+
import { AutoMovedBadge } from "@remit/ui";
|
|
3
|
+
import { useAutoMovedBadge } from "@/hooks/useAutoMovedBadge";
|
|
4
|
+
|
|
5
|
+
interface AutoMovedIndicatorProps {
|
|
6
|
+
accountId: string | undefined;
|
|
7
|
+
messageId: string;
|
|
8
|
+
threadId: string;
|
|
9
|
+
mailboxId: string;
|
|
10
|
+
autoMoved: RemitImapAutoMovedInfo | undefined;
|
|
11
|
+
/** `md` (reading view) adds the inline Undo action; `sm` (list row) is icon + label only. */
|
|
12
|
+
size?: "sm" | "md";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Renders the "auto-moved by Remit" badge only while the move is still in
|
|
17
|
+
* effect (current mailbox matches the verdict's implied destination) — see
|
|
18
|
+
* `useAutoMovedBadge`. Mount this only when `autoMoved` is present on the row
|
|
19
|
+
* (callers gate with `thread.autoMoved &&`) so rows without a move never pay
|
|
20
|
+
* for the Inbox/Junk mailbox lookups.
|
|
21
|
+
*/
|
|
22
|
+
export function AutoMovedIndicator({
|
|
23
|
+
accountId,
|
|
24
|
+
messageId,
|
|
25
|
+
threadId,
|
|
26
|
+
mailboxId,
|
|
27
|
+
autoMoved,
|
|
28
|
+
size = "sm",
|
|
29
|
+
}: AutoMovedIndicatorProps) {
|
|
30
|
+
const badge = useAutoMovedBadge({
|
|
31
|
+
accountId,
|
|
32
|
+
messageId,
|
|
33
|
+
threadId,
|
|
34
|
+
mailboxId,
|
|
35
|
+
autoMoved,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
if (!badge.show) return null;
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<AutoMovedBadge
|
|
42
|
+
label={badge.label}
|
|
43
|
+
size={size}
|
|
44
|
+
onUndo={badge.onUndo}
|
|
45
|
+
undoLabel={badge.isUndoing ? "Undoing…" : "Undo"}
|
|
46
|
+
/>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React-render smoke test for `BlockedImagesNotice` — the privacy "images
|
|
3
|
+
* blocked" bar extracted from `MessageBody`. Pins the never-disable-buttons
|
|
4
|
+
* rule (#943): the "Always trust" button stays active while the trust toggle
|
|
5
|
+
* is in flight. It must NOT render `disabled`; the in-flight state is reflected
|
|
6
|
+
* via `aria-busy` and a pending label instead, so the control stays focusable
|
|
7
|
+
* and clickable (the handler no-ops the second click on the consumer side).
|
|
8
|
+
*
|
|
9
|
+
* Uses `react-dom/server`'s `renderToString` so the assertion runs without
|
|
10
|
+
* jsdom/happy-dom (no new deps), matching `MessageBodyErrorBanner.render.test`.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import assert from "node:assert/strict";
|
|
14
|
+
import { describe, it } from "node:test";
|
|
15
|
+
import { createElement } from "react";
|
|
16
|
+
import { renderToString } from "react-dom/server";
|
|
17
|
+
import { BlockedImagesNotice } from "./BlockedImagesNotice";
|
|
18
|
+
|
|
19
|
+
const noop = () => undefined;
|
|
20
|
+
|
|
21
|
+
const render = (
|
|
22
|
+
overrides: Partial<Parameters<typeof BlockedImagesNotice>[0]>,
|
|
23
|
+
) =>
|
|
24
|
+
renderToString(
|
|
25
|
+
createElement(BlockedImagesNotice, {
|
|
26
|
+
blockedImageCount: 3,
|
|
27
|
+
canAlwaysTrust: true,
|
|
28
|
+
isTrustPending: false,
|
|
29
|
+
onLoadOnce: noop,
|
|
30
|
+
onAlwaysTrust: noop,
|
|
31
|
+
...overrides,
|
|
32
|
+
}) as never,
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
const alwaysTrustButton = (html: string): string | undefined =>
|
|
36
|
+
html.match(/<button\b[^>]*>(?:Always trust|Trusting…)<\/button>/)?.[0];
|
|
37
|
+
|
|
38
|
+
describe("BlockedImagesNotice — never-disable the Always trust button (#943)", () => {
|
|
39
|
+
it("renders the Always trust button active when not pending", () => {
|
|
40
|
+
const html = render({ isTrustPending: false });
|
|
41
|
+
const btn = alwaysTrustButton(html);
|
|
42
|
+
assert.ok(btn, "Always trust button should render");
|
|
43
|
+
assert.doesNotMatch(btn, /\bdisabled\b/);
|
|
44
|
+
assert.match(btn, /Always trust/);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("keeps the Always trust button NOT disabled while pending, reflecting state via aria-busy + label", () => {
|
|
48
|
+
const html = render({ isTrustPending: true });
|
|
49
|
+
const btn = alwaysTrustButton(html);
|
|
50
|
+
assert.ok(btn, "Always trust button should still render while pending");
|
|
51
|
+
assert.doesNotMatch(
|
|
52
|
+
btn,
|
|
53
|
+
/\bdisabled\b/,
|
|
54
|
+
"never disable the button — pending must not gray it out (#943)",
|
|
55
|
+
);
|
|
56
|
+
assert.match(btn, /aria-busy="true"/);
|
|
57
|
+
assert.match(btn, /Trusting…/);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("hides the Always trust button when the sender can't be trusted", () => {
|
|
61
|
+
const html = render({ canAlwaysTrust: false });
|
|
62
|
+
assert.equal(alwaysTrustButton(html), undefined);
|
|
63
|
+
assert.match(html, /<button\b[^>]*>Load once<\/button>/);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
interface BlockedImagesNoticeProps {
|
|
2
|
+
blockedImageCount: number;
|
|
3
|
+
canAlwaysTrust: boolean;
|
|
4
|
+
isTrustPending: boolean;
|
|
5
|
+
onLoadOnce: () => void;
|
|
6
|
+
onAlwaysTrust: () => void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const BlockedImagesNotice = ({
|
|
10
|
+
blockedImageCount,
|
|
11
|
+
canAlwaysTrust,
|
|
12
|
+
isTrustPending,
|
|
13
|
+
onLoadOnce,
|
|
14
|
+
onAlwaysTrust,
|
|
15
|
+
}: BlockedImagesNoticeProps) => (
|
|
16
|
+
<div className="mb-3 flex items-center justify-between rounded-md bg-surface-sunken/50 px-3 py-2 text-sm">
|
|
17
|
+
<span className="text-fg-muted">
|
|
18
|
+
{blockedImageCount} image{blockedImageCount > 1 ? "s" : ""} blocked for
|
|
19
|
+
privacy
|
|
20
|
+
</span>
|
|
21
|
+
<div className="flex items-center gap-3">
|
|
22
|
+
<button
|
|
23
|
+
type="button"
|
|
24
|
+
onClick={onLoadOnce}
|
|
25
|
+
className="text-accent hover:underline"
|
|
26
|
+
>
|
|
27
|
+
Load once
|
|
28
|
+
</button>
|
|
29
|
+
{canAlwaysTrust && (
|
|
30
|
+
<button
|
|
31
|
+
type="button"
|
|
32
|
+
onClick={onAlwaysTrust}
|
|
33
|
+
aria-busy={isTrustPending}
|
|
34
|
+
className="text-accent hover:underline aria-busy:opacity-50"
|
|
35
|
+
>
|
|
36
|
+
{isTrustPending ? "Trusting…" : "Always trust"}
|
|
37
|
+
</button>
|
|
38
|
+
)}
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
);
|