@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,133 @@
|
|
|
1
|
+
import {
|
|
2
|
+
outboxDetailOperationsGetOutboxMessageOptions,
|
|
3
|
+
outboxOperationsListOutboxMessagesQueryKey,
|
|
4
|
+
} from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
5
|
+
import type {
|
|
6
|
+
RemitImapAccountResponse,
|
|
7
|
+
RemitImapDescribeMessageResponse,
|
|
8
|
+
} from "@remit/api-http-client/types.gen.ts";
|
|
9
|
+
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
10
|
+
import {
|
|
11
|
+
createContext,
|
|
12
|
+
useCallback,
|
|
13
|
+
useContext,
|
|
14
|
+
useEffect,
|
|
15
|
+
useMemo,
|
|
16
|
+
useRef,
|
|
17
|
+
useState,
|
|
18
|
+
} from "react";
|
|
19
|
+
export type ComposeMode = "reply" | "reply_all" | "forward" | "new";
|
|
20
|
+
|
|
21
|
+
export interface ComposeState {
|
|
22
|
+
isOpen: boolean;
|
|
23
|
+
mode: ComposeMode;
|
|
24
|
+
account?: RemitImapAccountResponse;
|
|
25
|
+
sourceMessage?: RemitImapDescribeMessageResponse;
|
|
26
|
+
threadId?: string;
|
|
27
|
+
mailboxId?: string;
|
|
28
|
+
outboxMessageId?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface ComposeContextValue {
|
|
32
|
+
state: ComposeState;
|
|
33
|
+
openCompose: (params: Omit<ComposeState, "isOpen">) => void;
|
|
34
|
+
closeCompose: () => void;
|
|
35
|
+
setOutboxMessageId: (id: string) => void;
|
|
36
|
+
startSendPolling: (outboxMessageId: string) => void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const ComposeContext = createContext<ComposeContextValue | undefined>(
|
|
40
|
+
undefined,
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
const INITIAL_STATE: ComposeState = {
|
|
44
|
+
isOpen: false,
|
|
45
|
+
mode: "new",
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const POLL_INTERVAL_MS = 2000;
|
|
49
|
+
const MAX_POLL_DURATION_MS = 60_000;
|
|
50
|
+
|
|
51
|
+
export const ComposeProvider = ({
|
|
52
|
+
children,
|
|
53
|
+
}: {
|
|
54
|
+
children: React.ReactNode;
|
|
55
|
+
}) => {
|
|
56
|
+
const [state, setState] = useState<ComposeState>(INITIAL_STATE);
|
|
57
|
+
const [pollingMessageId, setPollingMessageId] = useState<
|
|
58
|
+
string | undefined
|
|
59
|
+
>();
|
|
60
|
+
const startedAtRef = useRef(0);
|
|
61
|
+
const queryClient = useQueryClient();
|
|
62
|
+
|
|
63
|
+
const { data: polledMessage } = useQuery({
|
|
64
|
+
...outboxDetailOperationsGetOutboxMessageOptions({
|
|
65
|
+
path: { outboxMessageId: pollingMessageId ?? "" },
|
|
66
|
+
}),
|
|
67
|
+
enabled: !!pollingMessageId,
|
|
68
|
+
refetchInterval: (query) => {
|
|
69
|
+
const status = query.state.data?.status;
|
|
70
|
+
if (status === "sent" || status === "failed" || status === "blocked")
|
|
71
|
+
return false;
|
|
72
|
+
if (Date.now() - startedAtRef.current > MAX_POLL_DURATION_MS)
|
|
73
|
+
return false;
|
|
74
|
+
return POLL_INTERVAL_MS;
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
if (!polledMessage || !pollingMessageId) return;
|
|
80
|
+
|
|
81
|
+
const terminal =
|
|
82
|
+
polledMessage.status === "sent" ||
|
|
83
|
+
polledMessage.status === "failed" ||
|
|
84
|
+
polledMessage.status === "blocked";
|
|
85
|
+
|
|
86
|
+
if (terminal) {
|
|
87
|
+
setPollingMessageId(undefined);
|
|
88
|
+
queryClient.invalidateQueries({
|
|
89
|
+
queryKey: outboxOperationsListOutboxMessagesQueryKey(),
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}, [polledMessage, pollingMessageId, queryClient]);
|
|
93
|
+
|
|
94
|
+
const openCompose = useCallback((params: Omit<ComposeState, "isOpen">) => {
|
|
95
|
+
setState({ ...params, isOpen: true });
|
|
96
|
+
}, []);
|
|
97
|
+
|
|
98
|
+
const closeCompose = useCallback(() => {
|
|
99
|
+
setState(INITIAL_STATE);
|
|
100
|
+
}, []);
|
|
101
|
+
|
|
102
|
+
const setOutboxMessageId = useCallback((id: string) => {
|
|
103
|
+
setState((prev) => ({ ...prev, outboxMessageId: id }));
|
|
104
|
+
}, []);
|
|
105
|
+
|
|
106
|
+
const startSendPolling = useCallback((outboxMessageId: string) => {
|
|
107
|
+
startedAtRef.current = Date.now();
|
|
108
|
+
setPollingMessageId(outboxMessageId);
|
|
109
|
+
}, []);
|
|
110
|
+
|
|
111
|
+
const value = useMemo(
|
|
112
|
+
() => ({
|
|
113
|
+
state,
|
|
114
|
+
openCompose,
|
|
115
|
+
closeCompose,
|
|
116
|
+
setOutboxMessageId,
|
|
117
|
+
startSendPolling,
|
|
118
|
+
}),
|
|
119
|
+
[state, openCompose, closeCompose, setOutboxMessageId, startSendPolling],
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<ComposeContext.Provider value={value}>{children}</ComposeContext.Provider>
|
|
124
|
+
);
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
export const useCompose = (): ComposeContextValue => {
|
|
128
|
+
const context = useContext(ComposeContext);
|
|
129
|
+
if (!context) {
|
|
130
|
+
throw new Error("useCompose must be used within a ComposeProvider");
|
|
131
|
+
}
|
|
132
|
+
return context;
|
|
133
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { useNavigate } from "@tanstack/react-router";
|
|
2
|
+
import { AlertTriangle, ArrowRight } from "lucide-react";
|
|
3
|
+
|
|
4
|
+
interface ComposeSmtpMissingBannerProps {
|
|
5
|
+
accountId: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Non-dismissible banner shown above the compose form when the selected
|
|
10
|
+
* account has no SMTP host configured. Pairs with disabling the Send
|
|
11
|
+
* button so the user has a single, factual explanation of why sending
|
|
12
|
+
* is blocked. See issue #196.
|
|
13
|
+
*/
|
|
14
|
+
export const ComposeSmtpMissingBanner = ({
|
|
15
|
+
accountId,
|
|
16
|
+
}: ComposeSmtpMissingBannerProps) => {
|
|
17
|
+
const navigate = useNavigate();
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<div
|
|
21
|
+
role="alert"
|
|
22
|
+
data-testid="compose-smtp-missing-banner"
|
|
23
|
+
className="flex items-start gap-3 border-b border-warning/50 bg-warning/10 px-3 py-2"
|
|
24
|
+
>
|
|
25
|
+
<AlertTriangle
|
|
26
|
+
className="size-5 shrink-0 mt-0.5 text-warning"
|
|
27
|
+
aria-hidden="true"
|
|
28
|
+
/>
|
|
29
|
+
<div className="flex-1 min-w-0">
|
|
30
|
+
<p className="text-sm font-medium text-warning">
|
|
31
|
+
This account can't send mail until SMTP is configured.
|
|
32
|
+
</p>
|
|
33
|
+
<button
|
|
34
|
+
type="button"
|
|
35
|
+
onClick={() => {
|
|
36
|
+
navigate({
|
|
37
|
+
to: "/settings/accounts",
|
|
38
|
+
search: { editAccountId: accountId, focusSmtp: true },
|
|
39
|
+
});
|
|
40
|
+
}}
|
|
41
|
+
className="mt-1 inline-flex items-center gap-1 text-xs font-medium text-warning hover:underline"
|
|
42
|
+
>
|
|
43
|
+
Configure SMTP
|
|
44
|
+
<ArrowRight className="size-3" aria-hidden="true" />
|
|
45
|
+
</button>
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
);
|
|
49
|
+
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { configOperationsGetConfigOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
2
|
+
import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
|
|
3
|
+
import { useQuery } from "@tanstack/react-query";
|
|
4
|
+
import { useEffect } from "react";
|
|
5
|
+
|
|
6
|
+
interface FromSelectorProps {
|
|
7
|
+
selectedAccountId?: string;
|
|
8
|
+
onSelect: (account: RemitImapAccountResponse) => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const FromSelector = ({
|
|
12
|
+
selectedAccountId,
|
|
13
|
+
onSelect,
|
|
14
|
+
}: FromSelectorProps) => {
|
|
15
|
+
const { data: config } = useQuery({
|
|
16
|
+
...configOperationsGetConfigOptions(),
|
|
17
|
+
staleTime: Infinity,
|
|
18
|
+
});
|
|
19
|
+
const accounts = config?.accounts ?? [];
|
|
20
|
+
|
|
21
|
+
// Auto-select the only account when none is selected
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
if (!selectedAccountId && accounts.length === 1 && accounts[0]) {
|
|
24
|
+
onSelect(accounts[0]);
|
|
25
|
+
}
|
|
26
|
+
}, [selectedAccountId, accounts, onSelect]);
|
|
27
|
+
|
|
28
|
+
if (accounts.length <= 1) {
|
|
29
|
+
const account = accounts[0];
|
|
30
|
+
if (!account) return null;
|
|
31
|
+
return (
|
|
32
|
+
<div className="flex items-start gap-2">
|
|
33
|
+
{/* biome-ignore lint/a11y/noLabelWithoutControl: decorative label for a read-only value, not a form control */}
|
|
34
|
+
<label className="text-sm text-fg-muted shrink-0 w-12 pt-1.5">
|
|
35
|
+
From:
|
|
36
|
+
</label>
|
|
37
|
+
<div className="text-sm py-1.5">{account.email}</div>
|
|
38
|
+
</div>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<div className="flex items-start gap-2">
|
|
44
|
+
<label
|
|
45
|
+
htmlFor="from-account-selector"
|
|
46
|
+
className="text-sm text-fg-muted shrink-0 w-12 pt-1.5"
|
|
47
|
+
>
|
|
48
|
+
From:
|
|
49
|
+
</label>
|
|
50
|
+
<select
|
|
51
|
+
id="from-account-selector"
|
|
52
|
+
value={selectedAccountId ?? ""}
|
|
53
|
+
onChange={(e) => {
|
|
54
|
+
const account = accounts.find((a) => a.accountId === e.target.value);
|
|
55
|
+
if (account) onSelect(account);
|
|
56
|
+
}}
|
|
57
|
+
className="flex-1 px-2 py-1.5 border rounded-md bg-canvas text-sm"
|
|
58
|
+
>
|
|
59
|
+
{accounts.map((account) => (
|
|
60
|
+
<option key={account.accountId} value={account.accountId}>
|
|
61
|
+
{account.email}
|
|
62
|
+
</option>
|
|
63
|
+
))}
|
|
64
|
+
</select>
|
|
65
|
+
</div>
|
|
66
|
+
);
|
|
67
|
+
};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { configOperationsGetConfigOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
2
|
+
import { useQuery } from "@tanstack/react-query";
|
|
3
|
+
import { X } from "lucide-react";
|
|
4
|
+
import { ErrorState } from "@/components/ui/ErrorState";
|
|
5
|
+
import { useIsDesktop } from "@/hooks/useMediaQuery";
|
|
6
|
+
import { ComposeForm } from "./ComposeForm";
|
|
7
|
+
import { useCompose } from "./ComposeProvider";
|
|
8
|
+
import { MobileComposeSheet } from "./MobileComposeSheet";
|
|
9
|
+
|
|
10
|
+
const MODE_LABELS: Record<string, string> = {
|
|
11
|
+
reply: "Reply",
|
|
12
|
+
reply_all: "Reply All",
|
|
13
|
+
forward: "Forward",
|
|
14
|
+
new: "New Message",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const FullCompose = () => {
|
|
18
|
+
const { state, closeCompose } = useCompose();
|
|
19
|
+
const isDesktop = useIsDesktop();
|
|
20
|
+
const {
|
|
21
|
+
isError: isConfigError,
|
|
22
|
+
error: configError,
|
|
23
|
+
refetch: refetchConfig,
|
|
24
|
+
} = useQuery({
|
|
25
|
+
...configOperationsGetConfigOptions(),
|
|
26
|
+
staleTime: Infinity,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
if (!isDesktop) return <MobileComposeSheet />;
|
|
30
|
+
|
|
31
|
+
if (!state.isOpen) return null;
|
|
32
|
+
|
|
33
|
+
const title = MODE_LABELS[state.mode] ?? "New Message";
|
|
34
|
+
|
|
35
|
+
const header = (
|
|
36
|
+
<header className="flex items-center justify-between gap-2 px-4 py-3 border-b border-line shrink-0">
|
|
37
|
+
<div className="flex items-center gap-2 min-w-0">
|
|
38
|
+
<h2 className="text-lg font-semibold truncate">{title}</h2>
|
|
39
|
+
</div>
|
|
40
|
+
<button
|
|
41
|
+
type="button"
|
|
42
|
+
onClick={closeCompose}
|
|
43
|
+
className="p-2 rounded-md hover:bg-surface-raised transition-colors min-h-11 min-w-11 inline-flex items-center justify-center"
|
|
44
|
+
aria-label="Close"
|
|
45
|
+
>
|
|
46
|
+
<X className="size-5" />
|
|
47
|
+
</button>
|
|
48
|
+
</header>
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
if (isConfigError) {
|
|
52
|
+
return (
|
|
53
|
+
<div className="h-full flex flex-col bg-canvas">
|
|
54
|
+
{header}
|
|
55
|
+
<div className="flex-1 flex items-center justify-center p-4">
|
|
56
|
+
<ErrorState
|
|
57
|
+
title="Couldn't load compose"
|
|
58
|
+
error={configError}
|
|
59
|
+
onRetry={() => {
|
|
60
|
+
refetchConfig();
|
|
61
|
+
}}
|
|
62
|
+
/>
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<div className="flex h-full min-h-0 flex-col bg-canvas">
|
|
70
|
+
{header}
|
|
71
|
+
<div className="min-h-0 flex-1 overflow-hidden">
|
|
72
|
+
<ComposeForm
|
|
73
|
+
key={state.outboxMessageId ?? "new"}
|
|
74
|
+
mode={state.mode}
|
|
75
|
+
account={state.account}
|
|
76
|
+
sourceMessage={state.sourceMessage}
|
|
77
|
+
onClose={closeCompose}
|
|
78
|
+
/>
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
);
|
|
82
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
RemitImapAccountResponse,
|
|
3
|
+
RemitImapDescribeMessageResponse,
|
|
4
|
+
} from "@remit/api-http-client/types.gen.ts";
|
|
5
|
+
import { ComposeForm } from "./ComposeForm";
|
|
6
|
+
import type { ComposeMode } from "./ComposeProvider";
|
|
7
|
+
|
|
8
|
+
interface InlineComposeProps {
|
|
9
|
+
mode: ComposeMode;
|
|
10
|
+
account?: RemitImapAccountResponse;
|
|
11
|
+
sourceMessage?: RemitImapDescribeMessageResponse;
|
|
12
|
+
onClose: () => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const InlineCompose = ({
|
|
16
|
+
mode,
|
|
17
|
+
account,
|
|
18
|
+
sourceMessage,
|
|
19
|
+
onClose,
|
|
20
|
+
}: InlineComposeProps) => (
|
|
21
|
+
<div className="border-t border-line bg-canvas max-h-[400px] flex flex-col">
|
|
22
|
+
<ComposeForm
|
|
23
|
+
mode={mode}
|
|
24
|
+
account={account}
|
|
25
|
+
sourceMessage={sourceMessage}
|
|
26
|
+
onClose={onClose}
|
|
27
|
+
/>
|
|
28
|
+
</div>
|
|
29
|
+
);
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { configOperationsGetConfigOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
2
|
+
import { useQuery } from "@tanstack/react-query";
|
|
3
|
+
import { useCallback, useRef, useState } from "react";
|
|
4
|
+
import { Drawer } from "vaul";
|
|
5
|
+
import { ErrorState } from "@/components/ui/ErrorState";
|
|
6
|
+
import { ComposeForm } from "./ComposeForm";
|
|
7
|
+
import { useCompose } from "./ComposeProvider";
|
|
8
|
+
|
|
9
|
+
const MODE_LABELS: Record<string, string> = {
|
|
10
|
+
reply: "Reply",
|
|
11
|
+
reply_all: "Reply All",
|
|
12
|
+
forward: "Forward",
|
|
13
|
+
new: "New Message",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Returns true when the draft has meaningful content (subject or body
|
|
18
|
+
* typed by the user). Used to gate drag-to-dismiss with a confirmation.
|
|
19
|
+
*/
|
|
20
|
+
const useIsDraftDirty = (): (() => boolean) => {
|
|
21
|
+
const dirtyRef = useRef(false);
|
|
22
|
+
|
|
23
|
+
// We peek at the DOM: if subject has text or the editor has non-empty
|
|
24
|
+
// text content, the draft is dirty. This avoids threading form state
|
|
25
|
+
// back up — ComposeForm owns the source of truth.
|
|
26
|
+
const check = useCallback((): boolean => {
|
|
27
|
+
const subject =
|
|
28
|
+
document.querySelector<HTMLInputElement>("[data-subject-field]")?.value ??
|
|
29
|
+
"";
|
|
30
|
+
const editorEl = document.querySelector("[data-slate-editor]");
|
|
31
|
+
const bodyText = editorEl?.textContent ?? "";
|
|
32
|
+
// Strip signature separator "-- " to avoid false positives
|
|
33
|
+
const cleaned = bodyText.replace(/--\s*/g, "").trim();
|
|
34
|
+
dirtyRef.current = subject.trim().length > 0 || cleaned.length > 0;
|
|
35
|
+
return dirtyRef.current;
|
|
36
|
+
}, []);
|
|
37
|
+
|
|
38
|
+
return check;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export const MobileComposeSheet = () => {
|
|
42
|
+
const { state, closeCompose } = useCompose();
|
|
43
|
+
const isDraftDirty = useIsDraftDirty();
|
|
44
|
+
const [showConfirm, setShowConfirm] = useState(false);
|
|
45
|
+
|
|
46
|
+
const {
|
|
47
|
+
isError: isConfigError,
|
|
48
|
+
error: configError,
|
|
49
|
+
refetch: refetchConfig,
|
|
50
|
+
} = useQuery({
|
|
51
|
+
...configOperationsGetConfigOptions(),
|
|
52
|
+
staleTime: Infinity,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const handleOpenChange = useCallback(
|
|
56
|
+
(open: boolean) => {
|
|
57
|
+
if (open) return;
|
|
58
|
+
// User is trying to dismiss
|
|
59
|
+
if (isDraftDirty()) {
|
|
60
|
+
setShowConfirm(true);
|
|
61
|
+
} else {
|
|
62
|
+
closeCompose();
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
[closeCompose, isDraftDirty],
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const handleConfirmDiscard = useCallback(() => {
|
|
69
|
+
setShowConfirm(false);
|
|
70
|
+
closeCompose();
|
|
71
|
+
}, [closeCompose]);
|
|
72
|
+
|
|
73
|
+
const handleCancelDiscard = useCallback(() => {
|
|
74
|
+
setShowConfirm(false);
|
|
75
|
+
}, []);
|
|
76
|
+
|
|
77
|
+
if (!state.isOpen) return null;
|
|
78
|
+
|
|
79
|
+
const title = MODE_LABELS[state.mode] ?? "New Message";
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<Drawer.Root
|
|
83
|
+
open={state.isOpen}
|
|
84
|
+
onOpenChange={handleOpenChange}
|
|
85
|
+
// Don't auto-dismiss on drag when dirty — we intercept via onOpenChange
|
|
86
|
+
>
|
|
87
|
+
<Drawer.Portal>
|
|
88
|
+
<Drawer.Overlay className="fixed inset-0 z-40 bg-black/40" />
|
|
89
|
+
<Drawer.Content
|
|
90
|
+
className="fixed inset-x-0 bottom-0 z-50 flex flex-col bg-canvas rounded-t-lg"
|
|
91
|
+
style={{ height: "95dvh" }}
|
|
92
|
+
>
|
|
93
|
+
<Drawer.Handle className="mx-auto mt-2 mb-1 h-1.5 w-12 rounded-full bg-fg-muted/30" />
|
|
94
|
+
|
|
95
|
+
<Drawer.Title className="px-4 py-2 text-base font-semibold border-b border-line">
|
|
96
|
+
{title}
|
|
97
|
+
</Drawer.Title>
|
|
98
|
+
|
|
99
|
+
{isConfigError ? (
|
|
100
|
+
<div className="flex-1 flex items-center justify-center p-4">
|
|
101
|
+
<ErrorState
|
|
102
|
+
title="Couldn't load compose"
|
|
103
|
+
error={configError}
|
|
104
|
+
onRetry={() => {
|
|
105
|
+
refetchConfig();
|
|
106
|
+
}}
|
|
107
|
+
/>
|
|
108
|
+
</div>
|
|
109
|
+
) : (
|
|
110
|
+
<div className="flex-1 overflow-hidden">
|
|
111
|
+
<ComposeForm
|
|
112
|
+
mode={state.mode}
|
|
113
|
+
account={state.account}
|
|
114
|
+
sourceMessage={state.sourceMessage}
|
|
115
|
+
onClose={closeCompose}
|
|
116
|
+
/>
|
|
117
|
+
</div>
|
|
118
|
+
)}
|
|
119
|
+
</Drawer.Content>
|
|
120
|
+
</Drawer.Portal>
|
|
121
|
+
|
|
122
|
+
{/* Confirmation dialog when dismissing dirty draft */}
|
|
123
|
+
{showConfirm && (
|
|
124
|
+
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50">
|
|
125
|
+
<div className="mx-4 w-full max-w-sm rounded-xl bg-canvas p-6 shadow-xl">
|
|
126
|
+
<h3 className="text-lg font-semibold">Discard draft?</h3>
|
|
127
|
+
<p className="mt-2 text-sm text-fg-muted">
|
|
128
|
+
Your message has unsaved content. Are you sure you want to discard
|
|
129
|
+
it?
|
|
130
|
+
</p>
|
|
131
|
+
<div className="mt-4 flex justify-end gap-2">
|
|
132
|
+
<button
|
|
133
|
+
type="button"
|
|
134
|
+
onClick={handleCancelDiscard}
|
|
135
|
+
className="rounded-md px-4 py-2 text-sm font-medium hover:bg-surface-raised transition-colors"
|
|
136
|
+
>
|
|
137
|
+
Keep editing
|
|
138
|
+
</button>
|
|
139
|
+
<button
|
|
140
|
+
type="button"
|
|
141
|
+
onClick={handleConfirmDiscard}
|
|
142
|
+
className="rounded-md bg-danger px-4 py-2 text-sm font-medium text-canvas hover:bg-danger/90 transition-colors"
|
|
143
|
+
>
|
|
144
|
+
Discard
|
|
145
|
+
</button>
|
|
146
|
+
</div>
|
|
147
|
+
</div>
|
|
148
|
+
</div>
|
|
149
|
+
)}
|
|
150
|
+
</Drawer.Root>
|
|
151
|
+
);
|
|
152
|
+
};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { AutoformatPlugin } from "@platejs/autoformat";
|
|
2
|
+
import {
|
|
3
|
+
BlockquotePlugin,
|
|
4
|
+
BoldPlugin,
|
|
5
|
+
ItalicPlugin,
|
|
6
|
+
} from "@platejs/basic-nodes/react";
|
|
7
|
+
import { LinkPlugin } from "@platejs/link/react";
|
|
8
|
+
import type { Value } from "platejs";
|
|
9
|
+
import { PlateContent, usePlateEditor } from "platejs/react";
|
|
10
|
+
import { useEffect } from "react";
|
|
11
|
+
|
|
12
|
+
export interface PlateEditorProps {
|
|
13
|
+
initialValue?: Value;
|
|
14
|
+
onChange?: (value: Value) => void;
|
|
15
|
+
onSubmit?: () => void;
|
|
16
|
+
autoFocus?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const EMPTY_VALUE: Value = [{ type: "p", children: [{ text: "" }] }];
|
|
20
|
+
|
|
21
|
+
export const COMPOSE_PLUGINS = [
|
|
22
|
+
BoldPlugin,
|
|
23
|
+
ItalicPlugin,
|
|
24
|
+
LinkPlugin,
|
|
25
|
+
BlockquotePlugin,
|
|
26
|
+
AutoformatPlugin.configure({
|
|
27
|
+
options: {
|
|
28
|
+
rules: [
|
|
29
|
+
{
|
|
30
|
+
match: "**",
|
|
31
|
+
mode: "mark" as const,
|
|
32
|
+
type: "bold",
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
match: "*",
|
|
36
|
+
mode: "mark" as const,
|
|
37
|
+
type: "italic",
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
match: "> ",
|
|
41
|
+
mode: "block" as const,
|
|
42
|
+
type: "blockquote",
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
enableUndoOnDelete: true,
|
|
46
|
+
},
|
|
47
|
+
}),
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
export const usePlateComposeEditor = (initialValue?: Value) =>
|
|
51
|
+
usePlateEditor({
|
|
52
|
+
plugins: COMPOSE_PLUGINS,
|
|
53
|
+
value: initialValue ?? EMPTY_VALUE,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
export const PlateEditorContent = ({
|
|
57
|
+
onSubmit,
|
|
58
|
+
autoFocus,
|
|
59
|
+
editor,
|
|
60
|
+
}: Omit<PlateEditorProps, "initialValue" | "onChange"> & {
|
|
61
|
+
editor: ReturnType<typeof usePlateComposeEditor>;
|
|
62
|
+
}) => {
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (autoFocus) {
|
|
65
|
+
const timer = setTimeout(() => {
|
|
66
|
+
editor.tf.focus();
|
|
67
|
+
}, 0);
|
|
68
|
+
return () => clearTimeout(timer);
|
|
69
|
+
}
|
|
70
|
+
}, [autoFocus, editor]);
|
|
71
|
+
|
|
72
|
+
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
73
|
+
if ((e.metaKey || e.ctrlKey) && e.key === "Enter" && onSubmit) {
|
|
74
|
+
e.preventDefault();
|
|
75
|
+
onSubmit();
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<PlateContent
|
|
81
|
+
className="w-full px-3 py-2 bg-canvas text-sm outline-none min-h-[120px] [&_blockquote]:pl-3 [&_blockquote]:border-l-2 [&_blockquote]:border-fg-subtle/30 [&_blockquote]:text-fg-muted [&_a]:text-accent [&_a]:underline"
|
|
82
|
+
placeholder="Write your message..."
|
|
83
|
+
onKeyDown={handleKeyDown}
|
|
84
|
+
/>
|
|
85
|
+
);
|
|
86
|
+
};
|